feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
import { isAuthError } from '../auth/tokens';
export default function AuthCallbackPage() {
const { completeLogin } = useAuth();
const navigate = useNavigate();
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
completeLogin(params)
.then((redirectTo) => {
navigate(redirectTo ?? '/admin', { replace: true });
})
.catch((err) => {
console.error('[Auth] Callback processing failed', err);
if (isAuthError(err) && err.code === 'token_exchange_failed') {
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
} else if (isAuthError(err) && err.code === 'invalid_state') {
setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
} else {
setError('Unbekannter Fehler beim Login.');
}
});
}, [completeLogin, navigate]);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet ...</span>
{error && <div className="max-w-sm rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { getEvent, getEventStats, toggleEvent, createInviteLink } from '../api';
import { Button } from '@/components/ui/button';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventDetailPage() {
const [sp] = useSearchParams();
@@ -11,35 +12,67 @@ export default function EventDetailPage() {
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
const [invite, setInvite] = React.useState<string | null>(null);
async function load() {
const e = await getEvent(id);
setEv(e);
setStats(await getEventStats(id));
}
React.useEffect(() => { load(); }, [id]);
const load = React.useCallback(async () => {
try {
const event = await getEvent(id);
setEv(event);
setStats(await getEventStats(id));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}, [id]);
React.useEffect(() => {
load();
}, [load]);
async function onToggle() {
const isActive = await toggleEvent(id);
setEv((o: any) => ({ ...(o || {}), is_active: isActive }));
try {
const isActive = await toggleEvent(id);
setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
async function onInvite() {
const link = await createInviteLink(id);
setInvite(link);
try { await navigator.clipboard.writeText(link); } catch {}
try {
const link = await createInviteLink(id);
setInvite(link);
try {
await navigator.clipboard.writeText(link);
} catch {
// clipboard may be unavailable
}
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
if (!ev) return <div className="p-4">Lade</div>;
const joinLink = `${location.origin}/e/${ev.slug}`;
if (!ev) {
return <div className="p-4">Lade ...</div>;
}
const joinLink = `${window.location.origin}/e/${ev.slug}`;
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
return (
<div className="mx-auto max-w-3xl p-4 space-y-4">
<div className="mx-auto max-w-3xl space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={onToggle}>{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}</Button>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>Fotos moderieren</Button>
<Button variant="secondary" onClick={onToggle}>
{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
</Button>
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
Fotos moderieren
</Button>
</div>
</div>
<div className="rounded border p-3 text-sm">
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
<div>Datum: {ev.date ?? '-'}</div>
<div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div>
</div>
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.total ?? 0}</div><div>Fotos</div></div>
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.featured ?? 0}</div><div>Featured</div></div>
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.likes ?? 0}</div><div>Likes gesamt</div></div>
<div className="grid grid-cols-1 gap-3 text-center text-sm sm:grid-cols-3">
<StatCard label="Fotos" value={stats?.total ?? 0} />
<StatCard label="Featured" value={stats?.featured ?? 0} />
<StatCard label="Likes gesamt" value={stats?.likes ?? 0} />
</div>
<div className="rounded border p-3">
<div className="mb-2 text-sm font-medium">Join-Link</div>
<div className="rounded border p-3 text-sm">
<div className="mb-2 font-medium">Join-Link</div>
<div className="mb-2 flex items-center gap-2">
<input className="w-full rounded border p-2 text-sm" value={joinLink} readOnly />
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>Kopieren</Button>
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>
Kopieren
</Button>
</div>
<div className="mb-2 text-sm font-medium">QR</div>
<div className="mb-2 font-medium">QR</div>
<img src={qrUrl} alt="QR" width={200} height={200} className="rounded border" />
<div className="mt-3">
<Button variant="secondary" onClick={onInvite}>Einladungslink erzeugen</Button>
{invite && <div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>}
<Button variant="secondary" onClick={onInvite}>
Einladungslink erzeugen
</Button>
{invite && (
<div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>
)}
</div>
</div>
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="rounded border p-3">
<div className="text-2xl font-semibold">{value}</div>
<div>{label}</div>
</div>
);
}
function renderName(name: any): string {
if (typeof name === 'string') return name;
if (name && (name.de || name.en)) return name.de || name.en;
return JSON.stringify(name);
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { createEvent, updateEvent } from '../api';
import { isAuthError } from '../auth/tokens';
import { useNavigate, useSearchParams } from 'react-router-dom';
export default function EventFormPage() {
@@ -13,10 +14,12 @@ export default function EventFormPage() {
const [date, setDate] = React.useState('');
const [active, setActive] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const isEdit = !!id;
async function save() {
setSaving(true);
setError(null);
try {
if (isEdit) {
await updateEvent(Number(id), { name, slug, date, is_active: active });
@@ -24,21 +27,31 @@ export default function EventFormPage() {
await createEvent({ name, slug, date, is_active: active });
}
nav('/admin/events');
} finally { setSaving(false); }
} catch (e) {
if (!isAuthError(e)) {
setError('Speichern fehlgeschlagen');
}
} finally {
setSaving(false);
}
}
return (
<div className="mx-auto max-w-md p-4 space-y-3">
<div className="mx-auto max-w-md space-y-3 p-4">
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv
</label>
<div className="flex gap-2">
<Button onClick={save} disabled={saving || !name || !slug}>{saving ? 'Speichern…' : 'Speichern'}</Button>
<Button onClick={save} disabled={saving || !name || !slug}>
{saving ? 'Speichern <20>' : 'Speichern'}
</Button>
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { Button } from '@/components/ui/button';
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventPhotosPage() {
const [sp] = useSearchParams();
@@ -9,33 +10,83 @@ export default function EventPhotosPage() {
const [rows, setRows] = React.useState<any[]>([]);
const [loading, setLoading] = React.useState(true);
async function load() {
const load = React.useCallback(async () => {
setLoading(true);
try { setRows(await getEventPhotos(id)); } finally { setLoading(false); }
}
React.useEffect(() => { load(); }, [id]);
try {
setRows(await getEventPhotos(id));
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
} finally {
setLoading(false);
}
}, [id]);
async function onFeature(p: any) { await featurePhoto(p.id); load(); }
async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); }
async function onDelete(p: any) { await deletePhoto(p.id); load(); }
React.useEffect(() => {
load();
}, [load]);
async function onFeature(photo: any) {
try {
await featurePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
async function onUnfeature(photo: any) {
try {
await unfeaturePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
async function onDelete(photo: any) {
try {
await deletePhoto(photo.id);
await load();
} catch (error) {
if (!isAuthError(error)) {
console.error(error);
}
}
}
return (
<div className="mx-auto max-w-5xl p-4">
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
{loading && <div>Lade</div>}
{loading && <div>Lade ...</div>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{rows.map((p) => (
<div key={p.id} className="rounded border p-2">
<img src={p.thumbnail_path || p.file_path} className="mb-2 aspect-square w-full rounded object-cover" />
<img
src={p.thumbnail_path || p.file_path}
className="mb-2 aspect-square w-full rounded object-cover"
alt={p.caption ?? 'Foto'}
/>
<div className="flex items-center justify-between text-sm">
<span> {p.likes_count}</span>
<span>?? {p.likes_count}</span>
<div className="flex gap-1">
{p.is_featured ? (
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>Unfeature</Button>
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>
Unfeature
</Button>
) : (
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>Feature</Button>
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>
Feature
</Button>
)}
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>Löschen</Button>
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
L<EFBFBD>schen
</Button>
</div>
</div>
</div>
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
</div>
);
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { getEvents } from '../api';
import { Button } from '@/components/ui/button';
import { Link, useNavigate } from 'react-router-dom';
import { getEvents } from '../api';
import { isAuthError } from '../auth/tokens';
export default function EventsPage() {
const [rows, setRows] = React.useState<any[]>([]);
@@ -11,7 +12,15 @@ export default function EventsPage() {
React.useEffect(() => {
(async () => {
try { setRows(await getEvents()); } catch (e) { setError('Laden fehlgeschlagen'); } finally { setLoading(false); }
try {
setRows(await getEvents());
} catch (err) {
if (!isAuthError(err)) {
setError('Laden fehlgeschlagen');
}
} finally {
setLoading(false);
}
})();
}, []);
@@ -20,24 +29,32 @@ export default function EventsPage() {
<div className="mb-3 flex items-center justify-between">
<h1 className="text-lg font-semibold">Meine Events</h1>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>Neues Event</Button>
<Link to="/admin/settings"><Button variant="secondary">Einstellungen</Button></Link>
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>
Neues Event
</Button>
<Link to="/admin/settings">
<Button variant="secondary">Einstellungen</Button>
</Link>
</div>
</div>
{loading && <div>Lade</div>}
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
{loading && <div>Lade ...</div>}
{error && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>
)}
<div className="divide-y rounded border">
{rows.map((e) => (
<div key={e.id} className="flex items-center justify-between p-3">
{rows.map((event) => (
<div key={event.id} className="flex items-center justify-between p-3">
<div className="text-sm">
<div className="font-medium">{renderName(e.name)}</div>
<div className="text-muted-foreground">Slug: {e.slug} · Datum: {e.date ?? '-'}</div>
<div className="font-medium">{renderName(event.name)}</div>
<div className="text-muted-foreground">Slug: {event.slug} <EFBFBD> Datum: {event.date ?? '-'}</div>
</div>
<div className="flex items-center gap-2">
<Link to={`/admin/events/view?id=${e.id}`} className="text-sm underline">details</Link>
<Link to={`/admin/events/edit?id=${e.id}`} className="text-sm underline">bearbeiten</Link>
<Link to={`/admin/events/photos?id=${e.id}`} className="text-sm underline">fotos</Link>
<a className="text-sm underline" href={`/e/${e.slug}`} target="_blank">öffnen</a>
<div className="flex items-center gap-2 text-sm underline">
<Link to={`/admin/events/view?id=${event.id}`}>details</Link>
<Link to={`/admin/events/edit?id=${event.id}`}>bearbeiten</Link>
<Link to={`/admin/events/photos?id=${event.id}`}>fotos</Link>
<a href={`/e/${event.slug}`} target="_blank" rel="noreferrer">
<EFBFBD>ffnen
</a>
</div>
</div>
))}

View File

@@ -1,43 +1,61 @@
import React from 'react';
import { login } from '../api';
import { useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { Location, useLocation, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { useAuth } from '../auth/context';
interface LocationState {
from?: Location;
}
export default function LoginPage() {
const nav = useNavigate();
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const { status, login } = useAuth();
const location = useLocation();
const navigate = useNavigate();
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const oauthError = searchParams.get('error');
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const { token } = await login(email, password);
localStorage.setItem('ta_token', token);
nav('/admin', { replace: true });
} catch (err: any) {
setError('Login fehlgeschlagen');
} finally { setLoading(false); }
}
React.useEffect(() => {
if (status === 'authenticated') {
navigate('/admin', { replace: true });
}
}, [status, navigate]);
const redirectTarget = React.useMemo(() => {
const state = location.state as LocationState | null;
if (state?.from) {
const from = state.from;
const search = from.search ?? '';
const hash = from.hash ?? '';
return `${from.pathname}${search}${hash}`;
}
return '/admin';
}, [location.state]);
return (
<div className="mx-auto max-w-sm p-6">
<div className="mb-4 flex items-center justify-between">
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-lg font-semibold">Tenant Admin</h1>
<AppearanceToggleDropdown />
</div>
<form onSubmit={submit} className="space-y-3">
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
<Input placeholder="E-Mail" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<Input placeholder="Passwort" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Button type="submit" disabled={loading || !email || !password} className="w-full">{loading ? 'Bitte warten…' : 'Anmelden'}</Button>
</form>
<div className="space-y-4 text-sm text-muted-foreground">
<p>
Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
wieder zur Admin-Oberfl<EFBFBD>che gebracht.
</p>
{oauthError && (
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
Anmeldung fehlgeschlagen: {oauthError}
</div>
)}
<Button
className="w-full"
disabled={status === 'loading'}
onClick={() => login(redirectTarget)}
>
{status === 'loading' ? 'Bitte warten <20>' : 'Mit Tenant-Account anmelden'}
</Button>
</div>
</div>
);
}

View File

@@ -2,22 +2,34 @@ import React from 'react';
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
import { Button } from '@/components/ui/button';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
export default function SettingsPage() {
const nav = useNavigate();
function logout() {
localStorage.removeItem('ta_token');
nav('/admin/login', { replace: true });
const { user, logout } = useAuth();
function handleLogout() {
logout({ redirect: '/admin/login' });
}
return (
<div className="mx-auto max-w-sm p-6">
<h1 className="mb-4 text-lg font-semibold">Einstellungen</h1>
<div className="mb-4">
<div className="mx-auto max-w-sm space-y-4 p-6">
<div>
<h1 className="text-lg font-semibold">Einstellungen</h1>
{user && (
<p className="mt-1 text-sm text-muted-foreground">
Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
</p>
)}
</div>
<div>
<div className="text-sm font-medium">Darstellung</div>
<AppearanceToggleDropdown />
</div>
<Button variant="destructive" onClick={logout}>Abmelden</Button>
<div className="flex gap-2">
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
</div>
</div>
);
}