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