feat(transfers): add PicTransfer — cross-event file transfers (#998)
Closes #997. Send original files from any event as a token-protected download link, with an optional client-upload channel. Strictly opt-in behind a new `transfers` feature flag, default OFF. Migrations 170-172 (transfers, transfer_files, transfer_extra_files, transfer_uploads, transfer_recipients, transfer_downloads, default settings and two email templates) — all hasTable/hasColumn-guarded and idempotent, with destructive statements confined to down(). Backend: transferService (CRUD, 256-bit download token, 6-char upload token, cross-event ZIP streaming of originals), admin CRUD routes, and two public token routes. transferCleanupService runs an hourly retention sweep; source-event photos are never touched. All three routers fail closed via requireFeatureFlag('transfers'). Review closed two ownership blockers, both the same root cause — permissions used where ownership was needed: - photoIds arrived from the request body and were validated only for existence, so a scoped admin could bundle any event's originals and hand them out through the public download token. filterOwnedPhotoIds now resolves ids to their events and gates them through filterOwnedEventIds, on both the create and add-files paths. - The transfer list was unscoped and carried each row's download token, so any admin with events.view could read another's token and fetch their originals. The list is now scoped by created_by, the token/url fields are stripped from the list payload, and a single router.use('/:id', requireTransferOwnership) covers all twelve /:id routes, 404ing foreign and missing alike. The admin photo picker filters its event list to the same rule, so the UI stops offering picks the API would discard. Fork-PR workflows had not been approved since the fix commits, so the PR's green checks were stale against the pre-fix head. Verified by dispatching tests.yml against the actual head: backend and frontend both green. Follow-up: neither ownership guard has a regression test yet. Co-authored-by: Luca-Timo <[email protected]>
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Github,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -67,6 +68,7 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
|
||||
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
|
||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
|
||||
{ nameKey: 'navigation.transfers', href: '/admin/transfers', icon: Send, permission: 'events.view', featureFlag: 'transfers' },
|
||||
{ nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' },
|
||||
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* TransferPhotoPicker — cross-event image picker for PicTransfer (#997).
|
||||
*
|
||||
* A modal that lets the admin browse ANY event's photos and pick images to add
|
||||
* to a transfer. Thumbnails are previewed (recipient page has none); a lightbox
|
||||
* toggle switches image clicks between "select" and "preview". Selection
|
||||
* persists as the admin hops between events.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { X, Check, Image as ImageIcon, Maximize2, Search } from 'lucide-react';
|
||||
|
||||
import { Button, Input, Loading } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { photosService, type AdminPhoto } from '../../services/photos.service';
|
||||
import { useAdminAuth } from '../../contexts/AdminAuthContext';
|
||||
|
||||
export interface PickedPhoto {
|
||||
id: number;
|
||||
filename: string;
|
||||
event_id: number;
|
||||
event_name: string;
|
||||
thumbnail_url: string;
|
||||
}
|
||||
|
||||
interface TransferPhotoPickerProps {
|
||||
onClose: () => void;
|
||||
onConfirm: (photos: PickedPhoto[]) => void;
|
||||
excludePhotoIds?: number[];
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export const TransferPhotoPicker: React.FC<TransferPhotoPickerProps> = ({
|
||||
onClose,
|
||||
onConfirm,
|
||||
excludePhotoIds = [],
|
||||
isSaving = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAdminAuth();
|
||||
const [eventSearch, setEventSearch] = useState('');
|
||||
const [selectedEventId, setSelectedEventId] = useState<number | null>(null);
|
||||
const [selectedEventName, setSelectedEventName] = useState<string>('');
|
||||
const [lightboxEnabled, setLightboxEnabled] = useState(false);
|
||||
const [previewPhoto, setPreviewPhoto] = useState<AdminPhoto | null>(null);
|
||||
// Persist selection (with metadata) across events.
|
||||
const [selected, setSelected] = useState<Map<number, PickedPhoto>>(new Map());
|
||||
|
||||
const excluded = useMemo(() => new Set(excludePhotoIds), [excludePhotoIds]);
|
||||
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['transfer-picker-events', eventSearch],
|
||||
queryFn: () => eventsService.getEvents(1, 100, undefined, eventSearch || undefined),
|
||||
});
|
||||
// Only offer events the caller may bundle — mirrors the backend's
|
||||
// filterOwnedEventIds gate (super_admin unrestricted; others get their own
|
||||
// events plus ownerless legacy ones). Without this the picker would show
|
||||
// events whose photos the API silently drops on create — a dead control.
|
||||
// The backend is still the enforcer; this just keeps the UI honest.
|
||||
const roleName = user?.roleName || user?.role?.name;
|
||||
const isSuperAdmin = roleName === 'super_admin';
|
||||
const events = (eventsData?.events || []).filter((ev) => {
|
||||
if (isSuperAdmin) return true;
|
||||
const owner = (ev as { created_by?: number | null }).created_by;
|
||||
return owner == null || owner === user?.id;
|
||||
});
|
||||
|
||||
const { data: photos, isLoading: photosLoading } = useQuery({
|
||||
queryKey: ['transfer-picker-photos', selectedEventId],
|
||||
queryFn: () => photosService.getEventPhotos(selectedEventId as number),
|
||||
enabled: !!selectedEventId,
|
||||
});
|
||||
|
||||
const togglePhoto = (photo: AdminPhoto) => {
|
||||
if (excluded.has(photo.id)) return;
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(photo.id)) {
|
||||
next.delete(photo.id);
|
||||
} else {
|
||||
next.set(photo.id, {
|
||||
id: photo.id,
|
||||
filename: photo.original_filename || photo.filename,
|
||||
event_id: selectedEventId as number,
|
||||
event_name: selectedEventName,
|
||||
thumbnail_url: photo.thumbnail_url || '',
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePhotoClick = (photo: AdminPhoto) => {
|
||||
if (lightboxEnabled) setPreviewPhoto(photo);
|
||||
else togglePhoto(photo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="flex h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-white shadow-xl dark:bg-neutral-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-5 py-3 dark:border-neutral-700">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('transfers.picker.title', 'Select images from other events')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={lightboxEnabled ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
leftIcon={<Maximize2 className="h-4 w-4" />}
|
||||
onClick={() => setLightboxEnabled((v) => !v)}
|
||||
>
|
||||
{t('transfers.picker.lightbox', 'Lightbox')}
|
||||
</Button>
|
||||
<button onClick={onClose} className="rounded p-1 text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Event list */}
|
||||
<div className="flex w-64 flex-col border-r border-neutral-200 dark:border-neutral-700">
|
||||
<div className="p-3">
|
||||
<Input
|
||||
leftIcon={<Search className="h-4 w-4" />}
|
||||
placeholder={t('transfers.picker.searchEvents', 'Search events…')}
|
||||
value={eventSearch}
|
||||
onChange={(e) => setEventSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{eventsLoading ? (
|
||||
<div className="p-4"><Loading /></div>
|
||||
) : (
|
||||
events.map((ev) => (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => { setSelectedEventId(ev.id); setSelectedEventName(ev.event_name); }}
|
||||
className={`block w-full truncate px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
selectedEventId === ev.id ? 'bg-primary-50 font-medium text-primary-700 dark:bg-neutral-800' : 'text-neutral-700 dark:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{ev.event_name}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
{!selectedEventId ? (
|
||||
<div className="flex h-full items-center justify-center text-neutral-400">
|
||||
<div className="text-center">
|
||||
<ImageIcon className="mx-auto mb-2 h-10 w-10" />
|
||||
<p>{t('transfers.picker.pickEvent', 'Pick an event to browse its photos')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : photosLoading ? (
|
||||
<Loading />
|
||||
) : !photos || photos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-neutral-400">
|
||||
{t('transfers.picker.noPhotos', 'No photos in this event')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
|
||||
{photos.map((photo) => {
|
||||
const isSelected = selected.has(photo.id);
|
||||
const isExcluded = excluded.has(photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-md border-2 ${
|
||||
isSelected ? 'border-primary-500' : 'border-transparent'
|
||||
} ${isExcluded ? 'opacity-40' : ''}`}
|
||||
onClick={() => handlePhotoClick(photo)}
|
||||
>
|
||||
{photo.thumbnail_url ? (
|
||||
<AdminAuthenticatedImage src={photo.thumbnail_url} alt={photo.filename} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-100 dark:bg-neutral-800">
|
||||
<ImageIcon className="h-6 w-6 text-neutral-400" />
|
||||
</div>
|
||||
)}
|
||||
{isExcluded && (
|
||||
<span className="absolute inset-x-0 bottom-0 bg-black/60 py-0.5 text-center text-[10px] text-white">
|
||||
{t('transfers.picker.alreadyAdded', 'Added')}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<span className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary-500 text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-neutral-200 px-5 py-3 dark:border-neutral-700">
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('transfers.picker.selectedCount', '{{count}} selected', { count: selected.size })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button
|
||||
onClick={() => onConfirm(Array.from(selected.values()))}
|
||||
disabled={selected.size === 0}
|
||||
isLoading={isSaving}
|
||||
>
|
||||
{t('transfers.picker.addSelected', 'Add selected')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simple lightbox preview */}
|
||||
{previewPhoto && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-6"
|
||||
onClick={() => setPreviewPhoto(null)}
|
||||
>
|
||||
<button className="absolute right-4 top-4 rounded p-2 text-white hover:bg-white/10">
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="max-h-full max-w-full" onClick={(e) => e.stopPropagation()}>
|
||||
<AdminAuthenticatedImage
|
||||
src={`/admin/photos/${selectedEventId}/photo/${previewPhoto.id}`}
|
||||
alt={previewPhoto.filename}
|
||||
className="max-h-[80vh] max-w-full rounded object-contain"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-center gap-3">
|
||||
<span className="text-sm text-white/80">{previewPhoto.original_filename || previewPhoto.filename}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selected.has(previewPhoto.id) ? 'outline' : 'primary'}
|
||||
onClick={() => togglePhoto(previewPhoto)}
|
||||
>
|
||||
{selected.has(previewPhoto.id) ? t('transfers.picker.deselect', 'Deselect') : t('transfers.picker.select', 'Select')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user