- Reworked the tenant admin login page

- Updated the User model to implement Filament’s tenancy contracts
- Seeded a ready-to-use demo tenant (user, tenant, active package, purchase)
- Introduced a branded, translated 403 error page to replace the generic forbidden message for unauthorised admin hits
- Removed the public “Register” links from the marketing header
- hardened join event logic and improved error handling in the guest pwa.
This commit is contained in:
Codex Agent
2025-10-13 12:50:46 +02:00
parent 9394c3171e
commit 64a5411fb9
69 changed files with 5447 additions and 588 deletions

View File

@@ -1,13 +1,12 @@
import React from 'react';
import { NavLink, useParams, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
function TabLink({
to,
children,
isActive
isActive,
}: {
to: string;
children: React.ReactNode;
@@ -31,9 +30,11 @@ function TabLink({
export default function BottomNav() {
const { token } = useParams();
const location = useLocation();
const { event } = useEventData();
if (!token) return null; // Only show bottom nav within event context
const { event, status } = useEventData();
const isReady = status === 'ready' && !!event;
if (!token || !isReady) return null; // Only show bottom nav within event context
const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname;
const locale = event?.default_locale || 'de';

View File

@@ -28,11 +28,11 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
const { event, loading: eventLoading, error: eventError } = useEventData();
const stats = statsContext && statsContext.eventKey === slug ? statsContext : undefined;
const guestName = identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
const { event, status } = useEventData();
const guestName =
identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
if (eventLoading) {
if (status === 'loading') {
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
<div className="font-semibold">Lade Event...</div>
@@ -44,18 +44,13 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
);
}
if (eventError || !event) {
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40">
<div className="font-semibold text-red-600">Event nicht gefunden</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
<SettingsSheet />
</div>
</div>
);
if (status !== 'ready' || !event) {
return null;
}
const stats =
statsContext && statsContext.eventKey === slug ? statsContext : undefined;
const getEventAvatar = (event: any) => {
if (event.type?.icon) {
return (

View File

@@ -1,40 +1,90 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { fetchEvent, EventData } from '../services/eventApi';
import {
fetchEvent,
EventData,
FetchEventError,
FetchEventErrorCode,
} from '../services/eventApi';
export function useEventData() {
type EventDataStatus = 'loading' | 'ready' | 'error';
interface UseEventDataResult {
event: EventData | null;
status: EventDataStatus;
loading: boolean;
error: string | null;
errorCode: FetchEventErrorCode | null;
token: string | null;
}
const NO_TOKEN_ERROR_MESSAGE = 'Es wurde kein Einladungscode übergeben.';
export function useEventData(): UseEventDataResult {
const { token } = useParams<{ token: string }>();
const [event, setEvent] = useState<EventData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<EventDataStatus>(token ? 'loading' : 'error');
const [errorMessage, setErrorMessage] = useState<string | null>(token ? null : NO_TOKEN_ERROR_MESSAGE);
const [errorCode, setErrorCode] = useState<FetchEventErrorCode | null>(token ? null : 'invalid_token');
useEffect(() => {
if (!token) {
setError('No event token provided');
setLoading(false);
setEvent(null);
setStatus('error');
setErrorCode('invalid_token');
setErrorMessage(NO_TOKEN_ERROR_MESSAGE);
return;
}
let cancelled = false;
const loadEvent = async () => {
setStatus('loading');
setErrorCode(null);
setErrorMessage(null);
try {
setLoading(true);
setError(null);
const eventData = await fetchEvent(token);
if (cancelled) {
return;
}
setEvent(eventData);
setStatus('ready');
} catch (err) {
console.error('Failed to load event:', err);
setError(err instanceof Error ? err.message : 'Failed to load event');
} finally {
setLoading(false);
if (cancelled) {
return;
}
setEvent(null);
setStatus('error');
if (err instanceof FetchEventError) {
setErrorCode(err.code);
setErrorMessage(err.message);
} else if (err instanceof Error) {
setErrorCode('unknown');
setErrorMessage(err.message || 'Event konnte nicht geladen werden.');
} else {
setErrorCode('unknown');
setErrorMessage('Event konnte nicht geladen werden.');
}
}
};
loadEvent();
return () => {
cancelled = true;
};
}, [token]);
return {
event,
loading,
error,
status,
loading: status === 'loading',
error: errorMessage,
errorCode,
token: token ?? null,
};
}

View File

@@ -28,6 +28,12 @@ export function usePollStats(eventKey: string | null | undefined) {
headers: { 'Cache-Control': 'no-store' },
});
if (res.status === 304) return;
if (!res.ok) {
if (res.status === 404) {
setData({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
}
return;
}
const json: StatsResponse = await res.json();
setData({
onlineGuests: json.online_guests ?? 0,

View File

@@ -1,7 +1,11 @@
import React from 'react';
import { createBrowserRouter, Outlet, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { createBrowserRouter, Outlet, useParams, Link } from 'react-router-dom';
import Header from './components/Header';
import BottomNav from './components/BottomNav';
import { useEventData } from './hooks/useEventData';
import { AlertTriangle, Loader2 } from 'lucide-react';
import type { FetchEventErrorCode } from './services/eventApi';
import { EventStatsProvider } from './context/EventStatsContext';
import { GuestIdentityProvider } from './context/GuestIdentityContext';
import LandingPage from './pages/LandingPage';
@@ -36,15 +40,7 @@ function HomeLayout() {
return (
<GuestIdentityProvider eventKey={token}>
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
<EventBoundary token={token} />
</GuestIdentityProvider>
);
}
@@ -78,6 +74,30 @@ export const router = createBrowserRouter([
{ path: '*', element: <NotFoundPage /> },
]);
function EventBoundary({ token }: { token: string }) {
const { event, status, error, errorCode } = useEventData();
if (status === 'loading') {
return <EventLoadingView />;
}
if (status === 'error' || !event) {
return <EventErrorView code={errorCode} message={error} />;
}
return (
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
<BottomNav />
</div>
</EventStatsProvider>
);
}
function SetupLayout() {
const { token } = useParams<{ token: string }>();
if (!token) return null;
@@ -93,6 +113,95 @@ function SetupLayout() {
);
}
function EventLoadingView() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 text-center">
<Loader2 className="h-10 w-10 animate-spin text-muted-foreground" aria-hidden />
<div className="space-y-1">
<p className="text-lg font-semibold text-foreground">Wir prüfen deinen Zugang...</p>
<p className="text-sm text-muted-foreground">Einen Moment bitte.</p>
</div>
</div>
);
}
interface EventErrorViewProps {
code: FetchEventErrorCode | null;
message: string | null;
}
function EventErrorView({ code, message }: EventErrorViewProps) {
const content = getErrorContent(code, message);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-red-100 text-red-600">
<AlertTriangle className="h-8 w-8" aria-hidden />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">{content.title}</h2>
<p className="text-sm text-muted-foreground">{content.description}</p>
{content.hint && (
<p className="text-xs text-muted-foreground">{content.hint}</p>
)}
</div>
{content.ctaHref && content.ctaLabel && (
<Button asChild>
<Link to={content.ctaHref}>{content.ctaLabel}</Link>
</Button>
)}
</div>
);
}
function getErrorContent(
code: FetchEventErrorCode | null,
message: string | null,
) {
const base = (fallbackTitle: string, fallbackDescription: string, options?: { ctaLabel?: string; ctaHref?: string; hint?: string }) => ({
title: fallbackTitle,
description: message ?? fallbackDescription,
ctaLabel: options?.ctaLabel,
ctaHref: options?.ctaHref,
hint: options?.hint ?? null,
});
switch (code) {
case 'invalid_token':
return base('Zugriffscode ungültig', 'Der eingegebene Code konnte nicht verifiziert werden.', {
ctaLabel: 'Neuen Code anfordern',
ctaHref: '/event',
});
case 'token_revoked':
return base('Zugriffscode deaktiviert', 'Dieser Code wurde zurückgezogen. Bitte fordere einen neuen Code an.', {
ctaLabel: 'Neuen Code anfordern',
ctaHref: '/event',
});
case 'token_expired':
return base('Zugriffscode abgelaufen', 'Der Code ist nicht mehr gültig. Aktualisiere deinen Code, um fortzufahren.', {
ctaLabel: 'Code aktualisieren',
ctaHref: '/event',
});
case 'token_rate_limited':
return base('Zu viele Versuche', 'Es gab zu viele Eingaben in kurzer Zeit. Warte kurz und versuche es erneut.', {
hint: 'Tipp: Eine erneute Eingabe ist in wenigen Minuten möglich.',
});
case 'event_not_public':
return base('Event nicht öffentlich', 'Dieses Event ist aktuell nicht öffentlich zugänglich.', {
hint: 'Nimm Kontakt mit den Veranstalter:innen auf, um Zugang zu erhalten.',
});
case 'network_error':
return base('Verbindungsproblem', 'Wir konnten keine Verbindung zum Server herstellen. Prüfe deine Internetverbindung und versuche es erneut.');
case 'server_error':
return base('Server nicht erreichbar', 'Der Server reagiert derzeit nicht. Versuche es später erneut.');
default:
return base('Event nicht erreichbar', 'Wir konnten dein Event nicht laden. Bitte versuche es erneut.', {
ctaLabel: 'Zur Code-Eingabe',
ctaHref: '/event',
});
}
}
function SimpleLayout({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="pb-16">

View File

@@ -34,10 +34,138 @@ export interface EventStats {
latestPhotoAt: string | null;
}
export type FetchEventErrorCode =
| 'invalid_token'
| 'token_expired'
| 'token_revoked'
| 'token_rate_limited'
| 'event_not_public'
| 'network_error'
| 'server_error'
| 'unknown';
interface FetchEventErrorOptions {
code: FetchEventErrorCode;
message: string;
status?: number;
}
export class FetchEventError extends Error {
readonly code: FetchEventErrorCode;
readonly status?: number;
constructor({ code, message, status }: FetchEventErrorOptions) {
super(message);
this.name = 'FetchEventError';
this.code = code;
this.status = status;
}
}
const API_ERROR_CODES: FetchEventErrorCode[] = [
'invalid_token',
'token_expired',
'token_revoked',
'token_rate_limited',
'event_not_public',
];
function resolveErrorCode(rawCode: unknown, status: number): FetchEventErrorCode {
if (typeof rawCode === 'string') {
const normalized = rawCode.toLowerCase() as FetchEventErrorCode;
if ((API_ERROR_CODES as string[]).includes(normalized)) {
return normalized;
}
}
if (status === 429) return 'token_rate_limited';
if (status === 404) return 'event_not_public';
if (status === 410) return 'token_expired';
if (status === 401) return 'invalid_token';
if (status === 403) return 'token_revoked';
if (status >= 500) return 'server_error';
return 'unknown';
}
function defaultMessageForCode(code: FetchEventErrorCode): string {
switch (code) {
case 'invalid_token':
return 'Der eingegebene Zugriffscode ist ungültig.';
case 'token_revoked':
return 'Dieser Zugriffscode wurde deaktiviert. Bitte fordere einen neuen Code an.';
case 'token_expired':
return 'Dieser Zugriffscode ist abgelaufen.';
case 'token_rate_limited':
return 'Zu viele Versuche in kurzer Zeit. Bitte warte einen Moment und versuche es erneut.';
case 'event_not_public':
return 'Dieses Event ist nicht öffentlich verfügbar.';
case 'network_error':
return 'Keine Verbindung zum Server. Prüfe deine Internetverbindung und versuche es erneut.';
case 'server_error':
return 'Der Server ist gerade nicht erreichbar. Bitte versuche es später erneut.';
case 'unknown':
default:
return 'Event konnte nicht geladen werden.';
}
}
export async function fetchEvent(eventKey: string): Promise<EventData> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
if (!res.ok) throw new Error('Event fetch failed');
return await res.json();
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
if (!res.ok) {
let apiMessage: string | null = null;
let rawCode: unknown;
try {
const data = await res.json();
rawCode = data?.error?.code ?? data?.code;
const message = data?.error?.message ?? data?.message;
if (typeof message === 'string' && message.trim() !== '') {
apiMessage = message.trim();
}
} catch {
// ignore parse errors and fall back to defaults
}
const code = resolveErrorCode(rawCode, res.status);
const message = apiMessage ?? defaultMessageForCode(code);
throw new FetchEventError({
code,
message,
status: res.status,
});
}
return await res.json();
} catch (error) {
if (error instanceof FetchEventError) {
throw error;
}
if (error instanceof TypeError) {
throw new FetchEventError({
code: 'network_error',
message: defaultMessageForCode('network_error'),
status: 0,
});
}
if (error instanceof Error) {
throw new FetchEventError({
code: 'unknown',
message: error.message || defaultMessageForCode('unknown'),
status: 0,
});
}
throw new FetchEventError({
code: 'unknown',
message: defaultMessageForCode('unknown'),
status: 0,
});
}
}
export async function fetchStats(eventKey: string): Promise<EventStats> {