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) <noreply@anthropic.com>
This commit is contained in:
unknown
2026-09-07 07:01:55 +02:00
co-authored by Claude Opus 5
parent 220a3303cd
commit 52d69e5654
11 changed files with 686 additions and 11 deletions
+47
View File
@@ -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/);
@@ -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<ActionResult<{ token: string }>> {
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<ActionResult> {
await requireAdmin();
deleteInvite(id);
revalidatePath('/verwaltung/einladungen');
return actionOk();
}
@@ -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 <InviteList invites={invites} />;
}
+2 -2
View File
@@ -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 (
<div className="space-y-6">
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
</p>
</div>
<VerwaltungNav />
<VerwaltungNav isAdmin={user.role === 'admin'} />
{children}
</div>
);
@@ -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<ActionResult> {
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();
}
+54
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Einladung ungültig</CardTitle>
<CardDescription>
Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
schicken.
</CardDescription>
</CardHeader>
<CardContent>
<Link href="/anmelden" className="text-sm underline underline-offset-4">
Zur Anmeldung
</Link>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle>Konto anlegen</CardTitle>
<CardDescription>
Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
{formatDate(invitation.expiresAt)}.
</CardDescription>
</CardHeader>
<CardContent>
<InviteForm token={token} email={invitation.email} />
</CardContent>
</Card>
);
}
+83
View File
@@ -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<HTMLFormElement>) {
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 (
<form onSubmit={onSubmit} className="space-y-4">
<Field label="Name" htmlFor="name" required>
<Input id="name" name="name" autoComplete="name" required maxLength={80} />
</Field>
<Field
label="E-Mail"
htmlFor="email"
required
hint={email ? 'Diese Einladung gilt für genau diese Adresse.' : undefined}
>
<Input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="username"
required
defaultValue={email ?? ''}
readOnly={Boolean(email)}
/>
</Field>
<Field
label="Passwort"
htmlFor="password"
required
hint={`Mindestens ${MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du danach unter „Konto“ hinterlegen.`}
>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={MIN_PASSWORD_LENGTH}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" size="lg" className="w-full" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Konto anlegen
</Button>
</form>
);
}
+212
View File
@@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
function submit(event: React.FormEvent<HTMLFormElement>) {
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 (
<div className="space-y-4">
<Button size="lg" className="w-full sm:w-auto" onClick={() => setCreateOpen(true)}>
<Plus />
Einladung erstellen
</Button>
{invites.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
Noch keine Einladung. Damit bekommt zum Beispiel der zweite Elternteil ein eigenes
Konto – ohne dass die Registrierung für alle offen steht.
</p>
) : (
<ul className="divide-y divide-border rounded-lg border border-border">
{invites.map((item) => (
<li key={item.id} className="flex items-center gap-3 p-3">
<Mail className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{item.email ?? 'Für beliebige Adresse'}
</p>
<p className="text-xs text-muted-foreground">
{item.state === 'used'
? `Verwendet von ${item.usedByName ?? 'jemandem'} am ${formatDate(item.usedAt)}`
: `Gültig bis ${formatDate(item.expiresAt)}`}
</p>
</div>
<Badge
variant={
item.state === 'open' ? 'default' : item.state === 'used' ? 'secondary' : 'outline'
}
>
{item.state === 'open' ? 'Offen' : item.state === 'used' ? 'Verwendet' : 'Abgelaufen'}
</Badge>
{item.role === 'admin' ? <Badge variant="outline">Admin</Badge> : null}
<Button
variant="ghost"
size="icon-sm"
aria-label="Einladung löschen"
onClick={() => remove(item.id)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Einladung erstellen</DialogTitle>
<DialogDescription>
Du bekommst einen Link, den du weitergibst. Er funktioniert genau einmal.
</DialogDescription>
</DialogHeader>
<form id="invite-form" onSubmit={submit} className="space-y-4">
<Field
label="E-Mail"
htmlFor="invite-email"
hint="Optional. Wenn gesetzt, funktioniert der Link nur mit dieser Adresse."
>
<Input id="invite-email" name="email" type="email" inputMode="email" maxLength={160} />
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="Rolle" htmlFor="invite-role">
<NativeSelect id="invite-role" name="role" defaultValue="member">
<option value="member">Mitglied</option>
<option value="admin">Administrator</option>
</NativeSelect>
</Field>
<Field label="Gültig für" htmlFor="invite-days">
<NativeSelect id="invite-days" name="validDays" defaultValue="14">
<option value="1">1 Tag</option>
<option value="7">7 Tage</option>
<option value="14">14 Tage</option>
<option value="30">30 Tage</option>
</NativeSelect>
</Field>
</div>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="invite-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Erstellen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={link !== null} onOpenChange={(open) => !open && setLink(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Einladungslink</DialogTitle>
<DialogDescription>
Der Link wird nur jetzt angezeigt – in der Datenbank liegt nur sein Hash. Kopiere
ihn und schick ihn weiter.
</DialogDescription>
</DialogHeader>
<Input
readOnly
aria-label="Einladungslink"
value={link ?? ''}
onFocus={(event) => event.currentTarget.select()}
className="font-mono text-xs"
/>
<DialogFooter>
<Button variant="outline" onClick={copy}>
{copied ? <Check className="text-emerald-600" /> : <Copy />}
Link kopieren
</Button>
<Button onClick={() => setLink(null)}>Fertig</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+7 -5
View File
@@ -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 (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{tabs.map((tab) => {
{visible.map((tab) => {
const active = pathname === tab.href;
return (
<Link
+12 -4
View File
@@ -6,6 +6,7 @@ import { nextCookies } from 'better-auth/next-js';
import { count } from 'drizzle-orm';
import { db, schema } from '@/db';
import { env } from '@/env';
import { currentInviteGrant } from './invites';
/**
* Returns true while the instance has no user at all.
@@ -90,15 +91,22 @@ export const auth = betterAuth({
user: {
create: {
before: async (user) => {
const firstRun = isFirstRun();
if (!firstRun) {
// Phase 2 opens this up again for invited users.
// The very first account owns the instance.
if (isFirstRun()) {
return { data: { ...user, role: 'admin' as const } };
}
// Everyone after that needs an invitation. The grant is set by the
// server action that verified the link token, never by the client.
const grant = currentInviteGrant();
if (!grant) {
throw new APIError('FORBIDDEN', {
message:
'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.',
});
}
return { data: { ...user, role: 'admin' as const } };
return { data: { ...user, role: grant.role } };
},
},
},
+140
View File
@@ -0,0 +1,140 @@
import 'server-only';
import { AsyncLocalStorage } from 'node:async_hooks';
import { and, desc, eq, isNull, gt } from 'drizzle-orm';
import { db } from '@/db';
import { invite, user } from '@/db/schema';
import type { UserRole } from '@/db/types';
import { createToken, hashToken } from './tokens';
/**
* Invitations.
*
* After the first user, sign-up is closed. An admin issues a link; the
* plaintext token only ever lives in that link, the database keeps its
* SHA-256 hash.
*/
export type InviteGrant = { inviteId: string; role: UserRole };
/**
* Marks the current call stack as "covered by a valid invitation".
*
* better-auth's user-create hook has no way of receiving extra arguments, and
* the sign-up payload must not be trusted with a role. AsyncLocalStorage keeps
* the grant tied to the one server action that verified the token - it can
* neither leak into another request nor be forged from the client.
*/
const grantStorage = new AsyncLocalStorage<InviteGrant>();
export function withInviteGrant<T>(grant: InviteGrant, fn: () => Promise<T>): Promise<T> {
return grantStorage.run(grant, fn);
}
export function currentInviteGrant(): InviteGrant | undefined {
return grantStorage.getStore();
}
export const INVITE_VALID_DAYS = 14;
export function createInvite(input: {
createdByUserId: string;
email: string | null;
role: UserRole;
validDays?: number;
}): { id: string; token: string; expiresAt: Date } {
const token = createToken();
const expiresAt = new Date(
Date.now() + (input.validDays ?? INVITE_VALID_DAYS) * 24 * 60 * 60 * 1000,
);
const id = crypto.randomUUID();
db.insert(invite)
.values({
id,
tokenHash: hashToken(token),
email: input.email,
role: input.role,
createdByUserId: input.createdByUserId,
expiresAt,
createdAt: new Date(),
})
.run();
return { id, token, expiresAt };
}
export type UsableInvite = {
id: string;
email: string | null;
role: UserRole;
expiresAt: Date;
};
/** Returns the invite behind a link token, or null if it cannot be used. */
export function findUsableInvite(token: string): UsableInvite | null {
if (!token) return null;
const row = db
.select({
id: invite.id,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt,
})
.from(invite)
.where(
and(
eq(invite.tokenHash, hashToken(token)),
isNull(invite.usedAt),
gt(invite.expiresAt, new Date()),
),
)
.get();
return row ?? null;
}
export function markInviteUsed(inviteId: string, userId: string): void {
db.update(invite)
.set({ usedAt: new Date(), usedByUserId: userId })
.where(eq(invite.id, inviteId))
.run();
}
export function deleteInvite(inviteId: string): void {
db.delete(invite).where(eq(invite.id, inviteId)).run();
}
export type InviteRow = {
id: string;
email: string | null;
role: UserRole;
expiresAt: Date;
usedAt: Date | null;
usedByName: string | null;
createdAt: Date;
};
export function listInvites(): InviteRow[] {
return db
.select({
id: invite.id,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt,
usedAt: invite.usedAt,
usedByName: user.name,
createdAt: invite.createdAt,
})
.from(invite)
.leftJoin(user, eq(user.id, invite.usedByUserId))
.orderBy(desc(invite.createdAt))
.all();
}
/** An invite is only interesting while it can still be used. */
export function inviteState(row: InviteRow): 'used' | 'expired' | 'open' {
if (row.usedAt) return 'used';
if (row.expiresAt.getTime() <= Date.now()) return 'expired';
return 'open';
}