feat(auth): better-auth mit passwort, passkeys und app-shell
Registrierung des ersten Nutzers macht ihn zum Administrator, danach ist die Registrierung geschlossen (Einladungen folgen in Phase 2). Login per Passwort oder Passkey inklusive Conditional UI, Verwaltung der Passkeys unter "Konto" mit Umbenennen und Loeschen. Rate-Limiting liegt in der Datenbank und ueberlebt Neustarts; Session-Cookies sind httpOnly, sameSite=lax und im Produktivbetrieb secure. Die Client-IP wird hinter dem Reverse Proxy aus x-forwarded-for gelesen. Dazu die mobile-first Grundstruktur: Bottom-Navigation, Header mit Konto-Menue, Healthcheck unter /api/health und ein Dashboard mit den vier Einstiegspunkten. Bedienelemente sind auf 44px Touch-Groesse angehoben. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { z } from 'zod';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
const idSchema = z.object({ id: z.string().min(1) });
|
||||
const renameSchema = idSchema.extend({ name: z.string().trim().min(1).max(60) });
|
||||
|
||||
export type ActionResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function deletePasskeyAction(input: unknown): Promise<ActionResult> {
|
||||
const parsed = idSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: 'Ungültige Anfrage.' };
|
||||
|
||||
try {
|
||||
await auth.api.deletePasskey({
|
||||
body: { id: parsed.data.id },
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: 'Der Passkey konnte nicht gelöscht werden.' };
|
||||
}
|
||||
|
||||
revalidatePath('/konto');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function renamePasskeyAction(input: unknown): Promise<ActionResult> {
|
||||
const parsed = renameSchema.safeParse(input);
|
||||
if (!parsed.success) return { ok: false, error: 'Bitte gib einen Namen an (max. 60 Zeichen).' };
|
||||
|
||||
try {
|
||||
await auth.api.updatePasskey({
|
||||
body: { id: parsed.data.id, name: parsed.data.name },
|
||||
headers: await headers(),
|
||||
});
|
||||
} catch {
|
||||
return { ok: false, error: 'Der Passkey konnte nicht umbenannt werden.' };
|
||||
}
|
||||
|
||||
revalidatePath('/konto');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
import { PasskeyManager, type PasskeyRow } from '@/components/auth/passkey-manager';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = { title: 'Konto' };
|
||||
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUser();
|
||||
|
||||
const passkeys = await auth.api.listPasskeys({ headers: await headers() });
|
||||
const rows: PasskeyRow[] = passkeys.map((passkey) => ({
|
||||
id: passkey.id,
|
||||
name: passkey.name ?? null,
|
||||
createdAt: passkey.createdAt ? new Date(passkey.createdAt) : null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Konto</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profil</CardTitle>
|
||||
<CardDescription>Diese Angaben stammen aus deiner Registrierung.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Name</span>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">E-Mail</span>
|
||||
<span className="truncate font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Rolle</span>
|
||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||
{user.role === 'admin' ? 'Administrator' : 'Mitglied'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Passkeys</CardTitle>
|
||||
<CardDescription>
|
||||
Passkeys sind an diese Domain gebunden und funktionieren nur über HTTPS. Du kannst
|
||||
beliebig viele Geräte hinterlegen.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasskeyManager passkeys={rows} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AppHeader } from '@/components/layout/app-header';
|
||||
import { BottomNav } from '@/components/layout/bottom-nav';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<AppHeader user={user} />
|
||||
{/* Bottom padding keeps the mobile nav from covering the last row. */}
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 pt-4 pb-24 md:pb-10">{children}</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Gift, LayoutGrid, PackageSearch } from 'lucide-react';
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
href: '/katalog',
|
||||
icon: LayoutGrid,
|
||||
title: 'Katalog',
|
||||
description: 'Alle bekannten Sets durchsuchen und neue anlegen.',
|
||||
},
|
||||
{
|
||||
href: '/katalog?besitz=fehlt',
|
||||
icon: PackageSearch,
|
||||
title: 'Fehlende Sets',
|
||||
description: 'Was noch nicht in der Sammlung ist – direkt auf die Wunschliste.',
|
||||
},
|
||||
{
|
||||
href: '/sammlung',
|
||||
icon: Boxes,
|
||||
title: 'Sammlung',
|
||||
description: 'Alles, was tatsächlich da ist – mit Standort und Zustand.',
|
||||
},
|
||||
{
|
||||
href: '/wunschlisten',
|
||||
icon: Gift,
|
||||
title: 'Wunschlisten',
|
||||
description: 'Listen anlegen und per Link mit Familie und Freunden teilen.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Hallo {user.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Was möchtest du tun?</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{shortcuts.map((shortcut) => (
|
||||
<Link key={shortcut.href} href={shortcut.href} className="group">
|
||||
<Card className="h-full transition-colors group-hover:border-primary/40">
|
||||
<CardHeader>
|
||||
<shortcut.icon className="size-6 text-primary" aria-hidden />
|
||||
<CardTitle>{shortcut.title}</CardTitle>
|
||||
<CardDescription>{shortcut.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user