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,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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user