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

@@ -292,7 +292,7 @@ function PersonalActions({ slug }: { slug: string }) {
}
export default function AchievementsPage() {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const identity = useGuestIdentity();
const [data, setData] = useState<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true);

View File

@@ -12,8 +12,8 @@ import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
export default function GalleryPage() {
const { slug } = useParams();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug!);
const { token: slug } = useParams<{ token?: string }>();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? '');
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -99,6 +99,10 @@ export default function GalleryPage() {
}
}
if (!slug) {
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
}
if (eventLoading) {
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
}

View File

@@ -12,13 +12,13 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
export default function HomePage() {
const { slug } = useParams<{ slug: string }>();
const { token } = useParams<{ token: string }>();
const { name, hydrated } = useGuestIdentity();
const stats = useEventStats();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(slug);
const { completedCount } = useGuestTaskProgress(token);
if (!slug) return null;
if (!token) return null;
const displayName = hydrated && name ? name : 'Gast';
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
@@ -125,7 +125,7 @@ export default function HomePage() {
<EmotionPicker />
<GalleryPreview slug={slug} />
<GalleryPreview slug={token} />
</div>
);
}

View File

@@ -10,28 +10,58 @@ import { readGuestName } from '../context/GuestIdentityContext';
export default function LandingPage() {
const nav = useNavigate();
const [slug, setSlug] = useState('');
const [eventCode, setEventCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
async function join(eventSlug?: string) {
const s = (eventSlug ?? slug).trim();
if (!s) return;
function extractEventKey(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
if (inviteParam) {
return inviteParam;
}
const segments = url.pathname.split('/').filter(Boolean);
const eventIndex = segments.findIndex((segment) => segment === 'e');
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
return decodeURIComponent(segments[eventIndex + 1]);
}
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch {
// Not a URL, treat as raw code
}
return trimmed;
}
async function join(input?: string) {
const provided = input ?? eventCode;
const normalized = extractEventKey(provided);
if (!normalized) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(s)}`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
if (!res.ok) {
setError('Event nicht gefunden oder geschlossen.');
return;
}
const storedName = readGuestName(s);
const data = await res.json();
const targetKey = data.join_token ?? data.slug ?? normalized;
const storedName = readGuestName(targetKey);
if (!storedName) {
nav(`/setup/${encodeURIComponent(s)}`);
nav(`/setup/${encodeURIComponent(targetKey)}`);
} else {
nav(`/e/${encodeURIComponent(s)}`);
nav(`/e/${encodeURIComponent(targetKey)}`);
}
} catch (e) {
console.error('Join request failed', e);
@@ -136,14 +166,14 @@ export default function LandingPage() {
<div className="space-y-2">
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
value={eventCode}
onChange={(event) => setEventCode(event.target.value)}
placeholder="Event-Code eingeben"
disabled={loading}
/>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
disabled={loading || !slug.trim()}
disabled={loading || !eventCode.trim()}
onClick={() => join()}
>
{loading ? 'Pruefe...' : 'Event beitreten'}

View File

@@ -13,12 +13,13 @@ export default function LegalPage() {
if (!page) {
return;
}
const slug = page;
const controller = new AbortController();
async function loadLegal() {
try {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
const res = await fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
});
@@ -45,8 +46,10 @@ export default function LegalPage() {
return () => controller.abort();
}, [page]);
const fallbackTitle = page ? `Rechtliches: ${page}` : 'Rechtliche Informationen';
return (
<Page title={title || `Rechtliches: ${page}` }>
<Page title={title || fallbackTitle}>
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
</Page>
);

View File

@@ -26,11 +26,11 @@ interface Props {
}
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) {
const params = useParams();
const params = useParams<{ token?: string; photoId?: string }>();
const location = useLocation();
const navigate = useNavigate();
const photoId = params.photoId;
const eventSlug = params.slug || slug;
const eventSlug = params.token || slug;
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [loading, setLoading] = useState(true);
@@ -129,9 +129,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
};
// Load task info if photo has task_id and slug is available
// Load task info if photo has task_id and event key is available
React.useEffect(() => {
if (!photo?.task_id || !slug) {
if (!photo?.task_id || !eventSlug) {
setTask(null);
setTaskLoading(false);
return;
@@ -142,7 +142,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
(async () => {
setTaskLoading(true);
try {
const res = await fetch(`/api/v1/events/${slug}/tasks`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`);
if (res.ok) {
const tasks = await res.json();
const foundTask = tasks.find((t: any) => t.id === taskId);
@@ -173,7 +173,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
setTaskLoading(false);
}
})();
}, [photo?.task_id, slug]);
}, [photo?.task_id, eventSlug]);
async function onLike() {
if (liked || !photo) return;

View File

@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
import Header from '../components/Header';
export default function ProfileSetupPage() {
const { slug } = useParams<{ slug: string }>();
const { token } = useParams<{ token: string }>();
const nav = useNavigate();
const { event, loading, error } = useEventData();
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
@@ -17,11 +17,11 @@ export default function ProfileSetupPage() {
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!slug) {
if (!token) {
nav('/');
return;
}
}, [slug, nav]);
}, [token, nav]);
useEffect(() => {
if (hydrated) {
@@ -34,14 +34,14 @@ export default function ProfileSetupPage() {
}
function submitName() {
if (!slug) return;
if (!token) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSubmitting(true);
try {
persistName(trimmedName);
nav(`/e/${slug}`);
nav(`/e/${token}`);
} catch (e) {
console.error('Fehler beim Speichern des Namens:', e);
setSubmitting(false);
@@ -67,7 +67,7 @@ export default function ProfileSetupPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
<Header slug={slug!} />
<Header slug={token!} />
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">

View File

@@ -28,11 +28,12 @@ const TASK_PROGRESS_TARGET = 5;
const TIMER_VIBRATION = [0, 60, 120, 60];
export default function TaskPickerPage() {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(eventKey);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
@@ -48,12 +49,12 @@ export default function TaskPickerPage() {
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!slug) return;
if (!eventKey) return;
setIsFetching(true);
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (Array.isArray(payload)) {
@@ -69,7 +70,7 @@ export default function TaskPickerPage() {
setIsFetching(false);
setLoading(false);
}
}, [slug]);
}, [eventKey]);
React.useEffect(() => {
fetchTasks();
@@ -206,8 +207,9 @@ export default function TaskPickerPage() {
};
const handleStartUpload = () => {
if (!currentTask || !slug) return;
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
if (!currentTask || !eventKey) return;
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleMarkCompleted = () => {

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()}