Added opaque join-token support across backend and frontend: new migration/model/service/endpoints, guest controllers now resolve tokens, and the demo seeder seeds a token. Tenant event details list/manage tokens with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing, storage, uploads, achievements, etc.). Docs TODO updated to reflect completed steps.

This commit is contained in:
Codex Agent
2025-10-12 10:32:37 +02:00
parent d04e234ca0
commit 9394c3171e
73 changed files with 3277 additions and 911 deletions

View File

@@ -55,7 +55,8 @@ const DEFAULT_PREFS: CameraPreferences = {
};
export default function UploadPage() {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
@@ -65,8 +66,8 @@ export default function UploadPage() {
const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed';
const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : 'guestCameraPrefs';
const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed';
const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs';
const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
@@ -148,7 +149,7 @@ export default function UploadPage() {
setLoadingTask(true);
setTaskError(null);
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/tasks`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const tasks = await res.json();
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
@@ -203,15 +204,15 @@ export default function UploadPage() {
return () => {
active = false;
};
}, [slug, taskId, emotionSlug]);
}, [eventKey, taskId, emotionSlug]);
// Check upload limits
useEffect(() => {
if (!slug || !task) return;
if (!eventKey || !task) return;
const checkLimits = async () => {
try {
const pkg = await getEventPackage(slug);
const pkg = await getEventPackage(eventKey);
setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false);
@@ -227,7 +228,7 @@ export default function UploadPage() {
};
checkLimits();
}, [slug, task]);
}, [eventKey, task]);
const stopStream = useCallback(() => {
if (streamRef.current) {
@@ -444,19 +445,19 @@ export default function UploadPage() {
const navigateAfterUpload = useCallback(
(photoId: number | undefined) => {
if (!slug || !task) return;
if (!eventKey || !task) return;
const params = new URLSearchParams();
params.set('uploaded', 'true');
if (task.id) params.set('task', String(task.id));
if (photoId) params.set('photo', String(photoId));
if (emotionSlug) params.set('emotion', emotionSlug);
navigate(`/e/${encodeURIComponent(slug!)}/gallery?${params.toString()}`);
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`);
},
[emotionSlug, navigate, slug, task]
[emotionSlug, navigate, eventKey, task]
);
const handleUsePhoto = useCallback(async () => {
if (!slug || !reviewPhoto || !task || !canUpload) return;
if (!eventKey || !reviewPhoto || !task || !canUpload) return;
setMode('uploading');
setUploadProgress(5);
setUploadError(null);
@@ -470,7 +471,7 @@ export default function UploadPage() {
}, 400);
try {
const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined);
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
setUploadProgress(100);
setStatusMessage('Upload abgeschlossen.');
markCompleted(task.id);
@@ -487,7 +488,7 @@ export default function UploadPage() {
}
setStatusMessage('');
}
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]);
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
@@ -532,7 +533,7 @@ export default function UploadPage() {
if (!supportsCamera && !task) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="px-4 py-6">
<Alert>
<AlertDescription>
@@ -548,7 +549,7 @@ export default function UploadPage() {
if (loadingTask) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
<p className="text-sm text-muted-foreground">Aufgabe und Kamera werden vorbereitet ...</p>
@@ -561,7 +562,7 @@ export default function UploadPage() {
if (!canUpload) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
@@ -629,7 +630,7 @@ export default function UploadPage() {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="relative flex flex-col gap-4 pb-4">
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}