From 52d69e565481ae596687fcf8e8d19e2fc7442d31 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Sep 2026 07:01:55 +0200 Subject: [PATCH] feat(einladungen): admin erzeugt einmal gueltige registrierungslinks Nach dem ersten Nutzer bleibt die Registrierung geschlossen; weitere Konten entstehen nur ueber einen Einladungslink. Der Klartext-Token wird genau einmal angezeigt, in der Datenbank liegt sein SHA-256-Hash. Optional laesst sich eine Einladung an eine E-Mail-Adresse binden, die Rolle und die Gueltigkeit sind waehlbar. Die Freigabe zum Anlegen eines Kontos laeuft ueber AsyncLocalStorage: Der Server-Action, der den Token geprueft hat, umschliesst damit den signUp-Aufruf, und nur in diesem Kontext laesst der user-create-Hook von better-auth die Registrierung zu. Die Rolle kommt aus der Einladung und kann nicht ueber das Formular gesetzt werden. Der Smoke-Test deckt den ganzen Weg ab: Admin erstellt die Einladung, der zweite Elternteil registriert sich damit, bekommt die Rolle "Mitglied" (und wird von der Einladungsverwaltung weggeleitet), und der Link funktioniert kein zweites Mal. Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) --- e2e/smoke.spec.ts | 47 ++++ .../(app)/verwaltung/einladungen/actions.ts | 44 ++++ src/app/(app)/verwaltung/einladungen/page.tsx | 22 ++ src/app/(app)/verwaltung/layout.tsx | 4 +- src/app/(auth)/einladung/[token]/actions.ts | 63 ++++++ src/app/(auth)/einladung/[token]/page.tsx | 54 +++++ src/components/auth/invite-form.tsx | 83 +++++++ src/components/verwaltung/invite-list.tsx | 212 ++++++++++++++++++ src/components/verwaltung/verwaltung-nav.tsx | 12 +- src/lib/auth.ts | 16 +- src/lib/invites.ts | 140 ++++++++++++ 11 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 src/app/(app)/verwaltung/einladungen/actions.ts create mode 100644 src/app/(app)/verwaltung/einladungen/page.tsx create mode 100644 src/app/(auth)/einladung/[token]/actions.ts create mode 100644 src/app/(auth)/einladung/[token]/page.tsx create mode 100644 src/components/auth/invite-form.tsx create mode 100644 src/components/verwaltung/invite-list.tsx create mode 100644 src/lib/invites.ts diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 9a4047f..c3f21cf 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -81,6 +81,53 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p await visitorContext.close(); }); +test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => { + // The admin from the first test is still the only account, so log in. + await page.goto('/anmelden'); + await page.getByLabel('E-Mail').fill('test@bricktrack.local'); + await page.getByLabel('Passwort').fill('smoketest-passwort'); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible(); + + // --- the admin issues an invitation -------------------------------------- + await page.goto('/verwaltung/einladungen'); + await page.getByRole('button', { name: 'Einladung erstellen' }).click(); + await page.getByRole('button', { name: 'Erstellen' }).click(); + + const inviteUrl = await page + .getByRole('textbox', { name: 'Einladungslink' }) + .inputValue(); + expect(inviteUrl).toContain('/einladung/'); + await page.getByRole('button', { name: 'Fertig' }).click(); + await expect(page.getByText('Offen').first()).toBeVisible(); + + // --- the invited person registers ---------------------------------------- + const guestContext = await browser.newContext(); + const guest = await guestContext.newPage(); + await guest.goto(inviteUrl); + await expect(guest.getByRole('button', { name: 'Konto anlegen' })).toBeVisible(); + + await guest.getByLabel('Name').fill('Mama'); + await guest.getByLabel('E-Mail').fill('mama@bricktrack.local'); + await guest.getByLabel('Passwort').fill('zweites-passwort'); + await guest.getByRole('button', { name: 'Konto anlegen' }).click(); + + await expect(guest.getByRole('heading', { name: 'Hallo Mama' })).toBeVisible(); + + // A member is not an admin: no invitation management for them. + await guest.goto('/verwaltung/einladungen'); + await expect(guest).toHaveURL(/\/$|\/anmelden/); + + // --- the link is single use ---------------------------------------------- + const secondTry = await browser.newContext(); + const secondPage = await secondTry.newPage(); + await secondPage.goto(inviteUrl); + await expect(secondPage.getByText('Einladung ungültig')).toBeVisible(); + + await guestContext.close(); + await secondTry.close(); +}); + test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => { await page.goto('/sammlung'); await expect(page).toHaveURL(/\/anmelden|\/registrieren/); diff --git a/src/app/(app)/verwaltung/einladungen/actions.ts b/src/app/(app)/verwaltung/einladungen/actions.ts new file mode 100644 index 0000000..ee9544a --- /dev/null +++ b/src/app/(app)/verwaltung/einladungen/actions.ts @@ -0,0 +1,44 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { actionData, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { createInvite, deleteInvite, INVITE_VALID_DAYS } from '@/lib/invites'; +import { requireAdmin } from '@/lib/session'; +import { optionalText } from '@/lib/validation'; + +const schema = z.object({ + // Optional: when set, the invitation only works for that address. + email: optionalText(160), + role: z.enum(['admin', 'member']).default('member'), + validDays: z.coerce.number().int().min(1).max(90).default(INVITE_VALID_DAYS), +}); + +export async function createInviteAction( + input: unknown, +): Promise> { + const admin = await requireAdmin(); + + const parsed = parseForm(schema, input); + if (!parsed.ok) return parsed; + + const created = createInvite({ + createdByUserId: admin.id, + email: parsed.data.email, + role: parsed.data.role, + validDays: parsed.data.validDays, + }); + + revalidatePath('/verwaltung/einladungen'); + // The plaintext token is returned once and never stored - only its hash is. + return actionData({ token: created.token }); +} + +export async function deleteInviteAction(id: string): Promise { + await requireAdmin(); + + deleteInvite(id); + + revalidatePath('/verwaltung/einladungen'); + return actionOk(); +} diff --git a/src/app/(app)/verwaltung/einladungen/page.tsx b/src/app/(app)/verwaltung/einladungen/page.tsx new file mode 100644 index 0000000..f342e44 --- /dev/null +++ b/src/app/(app)/verwaltung/einladungen/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import { InviteList, type InviteListRow } from '@/components/verwaltung/invite-list'; +import { inviteState, listInvites } from '@/lib/invites'; +import { requireAdmin } from '@/lib/session'; + +export const metadata: Metadata = { title: 'Einladungen' }; + +export default async function EinladungenPage() { + await requireAdmin(); + + const invites: InviteListRow[] = listInvites().map((row) => ({ + id: row.id, + email: row.email, + role: row.role, + expiresAt: row.expiresAt, + usedAt: row.usedAt, + usedByName: row.usedByName, + state: inviteState(row), + })); + + return ; +} diff --git a/src/app/(app)/verwaltung/layout.tsx b/src/app/(app)/verwaltung/layout.tsx index 7a57af8..ae01e31 100644 --- a/src/app/(app)/verwaltung/layout.tsx +++ b/src/app/(app)/verwaltung/layout.tsx @@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session'; export const metadata: Metadata = { title: 'Verwaltung' }; export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) { - await requireUser(); + const user = await requireUser(); return (
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.

- + {children} ); diff --git a/src/app/(auth)/einladung/[token]/actions.ts b/src/app/(auth)/einladung/[token]/actions.ts new file mode 100644 index 0000000..3f39573 --- /dev/null +++ b/src/app/(auth)/einladung/[token]/actions.ts @@ -0,0 +1,63 @@ +'use server'; + +import { headers } from 'next/headers'; +import { z } from 'zod'; +import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result'; +import { auth } from '@/lib/auth'; +import { findUsableInvite, markInviteUsed, withInviteGrant } from '@/lib/invites'; + +const schema = z.object({ + token: z.string().trim().min(1), + name: z.string().trim().min(1, 'Bitte gib deinen Namen an.').max(80), + email: z.email('Bitte gib eine gültige E-Mail-Adresse an.'), + password: z.string().min(10, 'Das Passwort braucht mindestens 10 Zeichen.'), +}); + +/** + * Registration through an invitation link. + * + * The token is checked here, on the server, and only then is the sign-up + * wrapped in a grant that the user-create hook can see. The role comes from + * the invitation, never from the submitted form. + */ +export async function registerWithInvite(input: unknown): Promise { + const parsed = parseForm(schema, input); + if (!parsed.ok) return parsed; + + const invitation = findUsableInvite(parsed.data.token); + if (!invitation) { + return actionError('Diese Einladung ist abgelaufen oder wurde bereits verwendet.'); + } + + if (invitation.email && invitation.email.toLowerCase() !== parsed.data.email.toLowerCase()) { + return actionError(`Diese Einladung gilt für ${invitation.email}.`); + } + + const requestHeaders = await headers(); + + try { + const result = await withInviteGrant( + { inviteId: invitation.id, role: invitation.role }, + () => + auth.api.signUpEmail({ + body: { + name: parsed.data.name, + email: parsed.data.email, + password: parsed.data.password, + }, + headers: requestHeaders, + }), + ); + + markInviteUsed(invitation.id, result.user.id); + } catch (error) { + const message = error instanceof Error ? error.message : ''; + if (message.toLowerCase().includes('exist')) { + return actionError('Zu dieser E-Mail-Adresse gibt es schon ein Konto.'); + } + console.error('[invite] Registrierung fehlgeschlagen', error); + return actionError('Die Registrierung hat nicht geklappt. Bitte versuch es noch einmal.'); + } + + return actionOk(); +} diff --git a/src/app/(auth)/einladung/[token]/page.tsx b/src/app/(auth)/einladung/[token]/page.tsx new file mode 100644 index 0000000..ef65089 --- /dev/null +++ b/src/app/(auth)/einladung/[token]/page.tsx @@ -0,0 +1,54 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; +import { InviteForm } from '@/components/auth/invite-form'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { formatDate } from '@/lib/format'; +import { findUsableInvite } from '@/lib/invites'; +import { getSession } from '@/lib/session'; + +export const metadata: Metadata = { + title: 'Einladung', + robots: { index: false, follow: false }, +}; + +export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) { + if (await getSession()) redirect('/'); + + const { token } = await params; + const invitation = findUsableInvite(token); + + if (!invitation) { + return ( + + + Einladung ungültig + + Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen + schicken. + + + + + Zur Anmeldung + + + + ); + } + + return ( + + + Konto anlegen + + Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '} + {formatDate(invitation.expiresAt)}. + + + + + + + ); +} diff --git a/src/components/auth/invite-form.tsx b/src/components/auth/invite-form.tsx new file mode 100644 index 0000000..27a4321 --- /dev/null +++ b/src/components/auth/invite-form.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { registerWithInvite } from '@/app/(auth)/einladung/[token]/actions'; +import { Button } from '@/components/ui/button'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; + +const MIN_PASSWORD_LENGTH = 10; + +export function InviteForm({ token, email }: { token: string; email: string | null }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [password, setPassword] = useState(''); + + function onSubmit(event: React.FormEvent) { + event.preventDefault(); + const input = Object.fromEntries(new FormData(event.currentTarget).entries()); + + startTransition(async () => { + const result = await registerWithInvite({ ...input, token }); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Konto angelegt. Willkommen bei Bricktrack!'); + router.push('/'); + router.refresh(); + }); + } + + return ( +
+ + + + + + + + + + setPassword(event.target.value)} + /> + + + +
+ ); +} diff --git a/src/components/verwaltung/invite-list.tsx b/src/components/verwaltung/invite-list.tsx new file mode 100644 index 0000000..de7440a --- /dev/null +++ b/src/components/verwaltung/invite-list.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useState, useTransition } from 'react'; +import { Check, Copy, Loader2, Mail, Plus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { + createInviteAction, + deleteInviteAction, +} from '@/app/(app)/verwaltung/einladungen/actions'; +import { formatDate } from '@/lib/format'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Field } from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { NativeSelect } from '@/components/ui/native-select'; + +export type InviteListRow = { + id: string; + email: string | null; + role: 'admin' | 'member'; + expiresAt: Date; + usedAt: Date | null; + usedByName: string | null; + state: 'open' | 'used' | 'expired'; +}; + +export function InviteList({ invites }: { invites: InviteListRow[] }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [createOpen, setCreateOpen] = useState(false); + const [link, setLink] = useState(null); + const [copied, setCopied] = useState(false); + + function submit(event: React.FormEvent) { + event.preventDefault(); + const input = Object.fromEntries(new FormData(event.currentTarget).entries()); + + startTransition(async () => { + const result = await createInviteAction(input); + if (!result.ok) { + toast.error(result.error); + return; + } + setCreateOpen(false); + setLink(`${window.location.origin}/einladung/${result.data.token}`); + router.refresh(); + }); + } + + function remove(id: string) { + startTransition(async () => { + const result = await deleteInviteAction(id); + if (!result.ok) { + toast.error(result.error); + return; + } + toast.success('Einladung gelöscht.'); + router.refresh(); + }); + } + + async function copy() { + if (!link) return; + try { + await navigator.clipboard.writeText(link); + setCopied(true); + toast.success('Link kopiert.'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.'); + } + } + + return ( +
+ + + {invites.length === 0 ? ( +

+ Noch keine Einladung. Damit bekommt zum Beispiel der zweite Elternteil ein eigenes + Konto – ohne dass die Registrierung für alle offen steht. +

+ ) : ( +
    + {invites.map((item) => ( +
  • + +
    +

    + {item.email ?? 'Für beliebige Adresse'} +

    +

    + {item.state === 'used' + ? `Verwendet von ${item.usedByName ?? 'jemandem'} am ${formatDate(item.usedAt)}` + : `Gültig bis ${formatDate(item.expiresAt)}`} +

    +
    + + + {item.state === 'open' ? 'Offen' : item.state === 'used' ? 'Verwendet' : 'Abgelaufen'} + + {item.role === 'admin' ? Admin : null} + + +
  • + ))} +
+ )} + + + + + Einladung erstellen + + Du bekommst einen Link, den du weitergibst. Er funktioniert genau einmal. + + + +
+ + + + +
+ + + + + + + + + + + + + + + +
+
+ + + + + +
+
+ + !open && setLink(null)}> + + + Einladungslink + + Der Link wird nur jetzt angezeigt – in der Datenbank liegt nur sein Hash. Kopiere + ihn und schick ihn weiter. + + + + event.currentTarget.select()} + className="font-mono text-xs" + /> + + + + + + + +
+ ); +} diff --git a/src/components/verwaltung/verwaltung-nav.tsx b/src/components/verwaltung/verwaltung-nav.tsx index fd83275..017ac23 100644 --- a/src/components/verwaltung/verwaltung-nav.tsx +++ b/src/components/verwaltung/verwaltung-nav.tsx @@ -5,17 +5,19 @@ import { usePathname } from 'next/navigation'; import { cn } from 'cn'; const tabs = [ - { href: '/verwaltung/hersteller', label: 'Hersteller' }, - { href: '/verwaltung/themen', label: 'Themen' }, - { href: '/verwaltung/standorte', label: 'Standorte' }, + { href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false }, + { href: '/verwaltung/themen', label: 'Themen', adminOnly: false }, + { href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false }, + { href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true }, ] as const; -export function VerwaltungNav() { +export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) { const pathname = usePathname(); + const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin); return (