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
@@ -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();
}