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
+7 -5
View File
@@ -5,17 +5,19 @@ import { usePathname } from 'next/navigation';
import { cn } from 'cn';
const tabs = [
{ href: '/verwaltung/hersteller', label: 'Hersteller' },
{ href: '/verwaltung/themen', label: 'Themen' },
{ href: '/verwaltung/standorte', label: 'Standorte' },
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
{ href: '/verwaltung/einladungen', label: 'Einladungen', adminOnly: true },
] as const;
export function VerwaltungNav() {
export function VerwaltungNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
const visible = tabs.filter((tab) => !tab.adminOnly || isAdmin);
return (
<nav className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1" aria-label="Stammdaten">
{tabs.map((tab) => {
{visible.map((tab) => {
const active = pathname === tab.href;
return (
<Link