changemaker.lite/admin/src/components/media/AlbumDetailDrawer.tsx
bunker-admin b215cda018 Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile
Deferred findings from the March 27 security audit, plus a bug fix:

MongoDB keyfile (bug fix):
- Generate replica.key on first boot via entrypoint script
- Fixes crash from --auth + --keyFile without an existing keyfile
- Applied to docker-compose.yml, docker-compose.prod.yml, CCP template

I7 — Ticket overselling prevention (reservation pattern):
- Add reservedCount field to TicketTier schema
- Atomically increment reservedCount inside transaction on checkout
- Release reservation on checkout.session.completed (webhook)
- Release reservation on checkout.session.expired (webhook)
- Include reservedCount in availability calculations

I17 — Move refresh token to httpOnly cookie:
- Server sets httpOnly SameSite=Strict cookie on login/register/refresh
- Cookie scoped to /api/auth path, secure in production
- Refresh/logout endpoints read from cookie (with body fallback for compat)
- Frontend no longer stores refreshToken in localStorage
- Auth store simplified: removed refreshToken from state + persistence
- API interceptor uses withCredentials:true for automatic cookie sending
- Updated media-api, media-public-api, QuickJoinPage, volunteer-invite
- Renamed getTokens → getAccessToken across all media components
- Install cookie-parser middleware

L2 — FeatureGate loading state:
- Show Skeleton instead of children while settings are loading
- Prevents briefly exposing disabled feature pages

Bunker Admin
2026-03-27 09:20:26 -06:00

235 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { Drawer, Button, Input, List, Image, message, Tag, Popconfirm, Space, Empty, Grid } from 'antd';
import {
DeleteOutlined,
PictureOutlined,
CrownOutlined,
GlobalOutlined,
} from '@ant-design/icons';
import { mediaApi } from '@/lib/media-api';
import { getAuthCallbacks } from '@/lib/api';
import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string {
const { getAccessToken } = getAuthCallbacks();
const accessToken = getAccessToken();
if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`;
}
interface AlbumDetailDrawerProps {
albumId: number | null;
open: boolean;
onClose: () => void;
onRefresh: () => void;
}
export default function AlbumDetailDrawer({ albumId, open, onClose, onRefresh }: AlbumDetailDrawerProps) {
const [album, setAlbum] = useState<PhotoAlbum | null>(null);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [loading, setLoading] = useState(false);
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
useEffect(() => {
if (albumId && open) {
fetchAlbum();
}
}, [albumId, open]);
const fetchAlbum = async () => {
if (!albumId) return;
setLoading(true);
try {
const { data } = await mediaApi.get(`/albums/${albumId}`);
setAlbum(data);
setTitle(data.title);
setDescription(data.description || '');
} catch {
message.error('Failed to load album');
} finally {
setLoading(false);
}
};
const handleSaveMetadata = async () => {
if (!albumId) return;
try {
await mediaApi.patch(`/albums/${albumId}`, { title, description });
message.success('Album updated');
setEditing(false);
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to update album');
}
};
const handleSetCover = async (photoId: number) => {
if (!albumId) return;
try {
await mediaApi.put(`/albums/${albumId}/cover`, { photoId });
message.success('Cover photo set');
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to set cover photo');
}
};
const handleRemovePhoto = async (photoId: number) => {
if (!albumId) return;
try {
await mediaApi.delete(`/albums/${albumId}/photos/${photoId}`);
message.success('Photo removed from album');
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to remove photo');
}
};
const handlePublish = async () => {
if (!albumId) return;
try {
await mediaApi.post(`/albums/${albumId}/publish`);
message.success('Album and photos published');
fetchAlbum();
onRefresh();
} catch {
message.error('Failed to publish album');
}
};
const handleDeleteAlbum = async () => {
if (!albumId) return;
try {
await mediaApi.delete(`/albums/${albumId}`);
message.success('Album deleted (photos preserved)');
onClose();
onRefresh();
} catch {
message.error('Failed to delete album');
}
};
const photos = album?.photos || [];
return (
<Drawer
title={album?.title || 'Album Detail'}
open={open}
onClose={onClose}
width={isMobile ? '100%' : 600}
loading={loading}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Popconfirm title="Delete album? Photos will be preserved." onConfirm={handleDeleteAlbum}>
<Button danger icon={<DeleteOutlined />}>Delete Album</Button>
</Popconfirm>
<Space>
{!album?.isPublished && (
<Button type="primary" icon={<GlobalOutlined />} onClick={handlePublish}>
Publish Album
</Button>
)}
</Space>
</div>
}
>
{/* Editable title/description */}
<div style={{ marginBottom: 16 }}>
{editing ? (
<Space direction="vertical" style={{ width: '100%' }}>
<Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Album title" />
<Input.TextArea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Description" rows={2} />
<Space>
<Button type="primary" size="small" onClick={handleSaveMetadata}>Save</Button>
<Button size="small" onClick={() => setEditing(false)}>Cancel</Button>
</Space>
</Space>
) : (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<strong>{album?.title}</strong>
{album?.isPublished && <Tag color="green" style={{ marginLeft: 8 }}>Published</Tag>}
</div>
<Button size="small" onClick={() => setEditing(true)}>Edit</Button>
</div>
{album?.description && <div style={{ color: '#999', marginTop: 4 }}>{album.description}</div>}
<div style={{ color: '#666', marginTop: 4, fontSize: 12 }}>
{photos.length} photos · {album?.viewCount || 0} views · {album?.upvoteCount || 0} upvotes
</div>
</div>
)}
</div>
{/* Photo list */}
{photos.length === 0 ? (
<Empty description="No photos in this album" />
) : (
<List
dataSource={photos}
renderItem={(photo: PhotoAlbumItem) => (
<List.Item
key={photo.id}
actions={[
<Button
key="cover"
size="small"
icon={<CrownOutlined />}
onClick={() => handleSetCover(photo.id)}
type={album?.coverPhotoId === photo.id ? 'primary' : 'default'}
>
{album?.coverPhotoId === photo.id ? 'Cover' : 'Set Cover'}
</Button>,
<Popconfirm
key="remove"
title="Remove from album?"
onConfirm={() => handleRemovePhoto(photo.id)}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>,
]}
>
<List.Item.Meta
avatar={
photo.thumbnailUrl ? (
<Image
src={getAuthenticatedUrl(photo.thumbnailUrl)}
width={60}
height={45}
style={{ objectFit: 'cover', borderRadius: 4 }}
preview={false}
/>
) : (
<div style={{ width: 60, height: 45, background: '#1a1a1a', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<PictureOutlined style={{ color: '#555' }} />
</div>
)
}
title={
<span style={{ fontSize: 13 }}>
{photo.title || photo.originalFilename}
</span>
}
description={
<span style={{ fontSize: 11 }}>
{photo.width}×{photo.height} · {photo.format?.toUpperCase()}
{photo.isPublished && <Tag color="green" style={{ marginLeft: 4, fontSize: 10 }}>Published</Tag>}
</span>
}
/>
</List.Item>
)}
/>
)}
</Drawer>
);
}