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
235 lines
7.7 KiB
TypeScript
235 lines
7.7 KiB
TypeScript
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>
|
||
);
|
||
}
|