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>
64 lines
2.1 KiB
TypeScript
64 lines
2.1 KiB
TypeScript
'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();
|
|
}
|