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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user