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:
@@ -81,6 +81,53 @@ test('Registrieren, Set anlegen, Wunschliste teilen und reservieren', async ({ p
|
||||
await visitorContext.close();
|
||||
});
|
||||
|
||||
test('Einladung: zweiter Elternteil bekommt ein eigenes Konto', async ({ page, browser }) => {
|
||||
// The admin from the first test is still the only account, so log in.
|
||||
await page.goto('/anmelden');
|
||||
await page.getByLabel('E-Mail').fill('test@bricktrack.local');
|
||||
await page.getByLabel('Passwort').fill('smoketest-passwort');
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Hallo Florian' })).toBeVisible();
|
||||
|
||||
// --- the admin issues an invitation --------------------------------------
|
||||
await page.goto('/verwaltung/einladungen');
|
||||
await page.getByRole('button', { name: 'Einladung erstellen' }).click();
|
||||
await page.getByRole('button', { name: 'Erstellen' }).click();
|
||||
|
||||
const inviteUrl = await page
|
||||
.getByRole('textbox', { name: 'Einladungslink' })
|
||||
.inputValue();
|
||||
expect(inviteUrl).toContain('/einladung/');
|
||||
await page.getByRole('button', { name: 'Fertig' }).click();
|
||||
await expect(page.getByText('Offen').first()).toBeVisible();
|
||||
|
||||
// --- the invited person registers ----------------------------------------
|
||||
const guestContext = await browser.newContext();
|
||||
const guest = await guestContext.newPage();
|
||||
await guest.goto(inviteUrl);
|
||||
await expect(guest.getByRole('button', { name: 'Konto anlegen' })).toBeVisible();
|
||||
|
||||
await guest.getByLabel('Name').fill('Mama');
|
||||
await guest.getByLabel('E-Mail').fill('mama@bricktrack.local');
|
||||
await guest.getByLabel('Passwort').fill('zweites-passwort');
|
||||
await guest.getByRole('button', { name: 'Konto anlegen' }).click();
|
||||
|
||||
await expect(guest.getByRole('heading', { name: 'Hallo Mama' })).toBeVisible();
|
||||
|
||||
// A member is not an admin: no invitation management for them.
|
||||
await guest.goto('/verwaltung/einladungen');
|
||||
await expect(guest).toHaveURL(/\/$|\/anmelden/);
|
||||
|
||||
// --- the link is single use ----------------------------------------------
|
||||
const secondTry = await browser.newContext();
|
||||
const secondPage = await secondTry.newPage();
|
||||
await secondPage.goto(inviteUrl);
|
||||
await expect(secondPage.getByText('Einladung ungültig')).toBeVisible();
|
||||
|
||||
await guestContext.close();
|
||||
await secondTry.close();
|
||||
});
|
||||
|
||||
test('Ohne Anmeldung fuehrt jeder geschuetzte Pfad zur Anmeldung', async ({ page }) => {
|
||||
await page.goto('/sammlung');
|
||||
await expect(page).toHaveURL(/\/anmelden|\/registrieren/);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { requireUser } from '@/lib/session';
|
||||
export const metadata: Metadata = { title: 'Verwaltung' };
|
||||
|
||||
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireUser();
|
||||
const user = await requireUser();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -15,7 +15,7 @@ export default async function VerwaltungLayout({ children }: { children: React.R
|
||||
Stammdaten, die im Katalog und in der Sammlung ausgewählt werden.
|
||||
</p>
|
||||
</div>
|
||||
<VerwaltungNav />
|
||||
<VerwaltungNav isAdmin={user.role === 'admin'} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { InviteForm } from '@/components/auth/invite-form';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { findUsableInvite } from '@/lib/invites';
|
||||
import { getSession } from '@/lib/session';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Einladung',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) {
|
||||
if (await getSession()) redirect('/');
|
||||
|
||||
const { token } = await params;
|
||||
const invitation = findUsableInvite(token);
|
||||
|
||||
if (!invitation) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Einladung ungültig</CardTitle>
|
||||
<CardDescription>
|
||||
Dieser Link ist abgelaufen oder wurde schon benutzt. Lass dir einfach einen neuen
|
||||
schicken.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/anmelden" className="text-sm underline underline-offset-4">
|
||||
Zur Anmeldung
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Konto anlegen</CardTitle>
|
||||
<CardDescription>
|
||||
Du wurdest zu Bricktrack eingeladen. Der Link gilt noch bis zum{' '}
|
||||
{formatDate(invitation.expiresAt)}.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InviteForm token={token} email={invitation.email} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { registerWithInvite } from '@/app/(auth)/einladung/[token]/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 10;
|
||||
|
||||
export function InviteForm({ token, email }: { token: string; email: string | null }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await registerWithInvite({ ...input, token });
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Konto angelegt. Willkommen bei Bricktrack!');
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Field label="Name" htmlFor="name" required>
|
||||
<Input id="name" name="name" autoComplete="name" required maxLength={80} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="E-Mail"
|
||||
htmlFor="email"
|
||||
required
|
||||
hint={email ? 'Diese Einladung gilt für genau diese Adresse.' : undefined}
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
defaultValue={email ?? ''}
|
||||
readOnly={Boolean(email)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Passwort"
|
||||
htmlFor="password"
|
||||
required
|
||||
hint={`Mindestens ${MIN_PASSWORD_LENGTH} Zeichen. Einen Passkey kannst du danach unter „Konto“ hinterlegen.`}
|
||||
>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={MIN_PASSWORD_LENGTH}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||
Konto anlegen
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useTransition } from 'react';
|
||||
import { Check, Copy, Loader2, Mail, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
createInviteAction,
|
||||
deleteInviteAction,
|
||||
} from '@/app/(app)/verwaltung/einladungen/actions';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Field } from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { NativeSelect } from '@/components/ui/native-select';
|
||||
|
||||
export type InviteListRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: 'admin' | 'member';
|
||||
expiresAt: Date;
|
||||
usedAt: Date | null;
|
||||
usedByName: string | null;
|
||||
state: 'open' | 'used' | 'expired';
|
||||
};
|
||||
|
||||
export function InviteList({ invites }: { invites: InviteListRow[] }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [link, setLink] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createInviteAction(input);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
setCreateOpen(false);
|
||||
setLink(`${window.location.origin}/einladung/${result.data.token}`);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteInviteAction(id);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Einladung gelöscht.');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
if (!link) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
setCopied(true);
|
||||
toast.success('Link kopiert.');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button size="lg" className="w-full sm:w-auto" onClick={() => setCreateOpen(true)}>
|
||||
<Plus />
|
||||
Einladung erstellen
|
||||
</Button>
|
||||
|
||||
{invites.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
Noch keine Einladung. Damit bekommt zum Beispiel der zweite Elternteil ein eigenes
|
||||
Konto – ohne dass die Registrierung für alle offen steht.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{invites.map((item) => (
|
||||
<li key={item.id} className="flex items-center gap-3 p-3">
|
||||
<Mail className="size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{item.email ?? 'Für beliebige Adresse'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{item.state === 'used'
|
||||
? `Verwendet von ${item.usedByName ?? 'jemandem'} am ${formatDate(item.usedAt)}`
|
||||
: `Gültig bis ${formatDate(item.expiresAt)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={
|
||||
item.state === 'open' ? 'default' : item.state === 'used' ? 'secondary' : 'outline'
|
||||
}
|
||||
>
|
||||
{item.state === 'open' ? 'Offen' : item.state === 'used' ? 'Verwendet' : 'Abgelaufen'}
|
||||
</Badge>
|
||||
{item.role === 'admin' ? <Badge variant="outline">Admin</Badge> : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Einladung löschen"
|
||||
onClick={() => remove(item.id)}
|
||||
disabled={pending}
|
||||
>
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Einladung erstellen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Du bekommst einen Link, den du weitergibst. Er funktioniert genau einmal.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form id="invite-form" onSubmit={submit} className="space-y-4">
|
||||
<Field
|
||||
label="E-Mail"
|
||||
htmlFor="invite-email"
|
||||
hint="Optional. Wenn gesetzt, funktioniert der Link nur mit dieser Adresse."
|
||||
>
|
||||
<Input id="invite-email" name="email" type="email" inputMode="email" maxLength={160} />
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Rolle" htmlFor="invite-role">
|
||||
<NativeSelect id="invite-role" name="role" defaultValue="member">
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
|
||||
<Field label="Gültig für" htmlFor="invite-days">
|
||||
<NativeSelect id="invite-days" name="validDays" defaultValue="14">
|
||||
<option value="1">1 Tag</option>
|
||||
<option value="7">7 Tage</option>
|
||||
<option value="14">14 Tage</option>
|
||||
<option value="30">30 Tage</option>
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={pending}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" form="invite-form" disabled={pending}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Erstellen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={link !== null} onOpenChange={(open) => !open && setLink(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Einladungslink</DialogTitle>
|
||||
<DialogDescription>
|
||||
Der Link wird nur jetzt angezeigt – in der Datenbank liegt nur sein Hash. Kopiere
|
||||
ihn und schick ihn weiter.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
readOnly
|
||||
aria-label="Einladungslink"
|
||||
value={link ?? ''}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={copy}>
|
||||
{copied ? <Check className="text-emerald-600" /> : <Copy />}
|
||||
Link kopieren
|
||||
</Button>
|
||||
<Button onClick={() => setLink(null)}>Fertig</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+12
-4
@@ -6,6 +6,7 @@ import { nextCookies } from 'better-auth/next-js';
|
||||
import { count } from 'drizzle-orm';
|
||||
import { db, schema } from '@/db';
|
||||
import { env } from '@/env';
|
||||
import { currentInviteGrant } from './invites';
|
||||
|
||||
/**
|
||||
* Returns true while the instance has no user at all.
|
||||
@@ -90,15 +91,22 @@ export const auth = betterAuth({
|
||||
user: {
|
||||
create: {
|
||||
before: async (user) => {
|
||||
const firstRun = isFirstRun();
|
||||
if (!firstRun) {
|
||||
// Phase 2 opens this up again for invited users.
|
||||
// The very first account owns the instance.
|
||||
if (isFirstRun()) {
|
||||
return { data: { ...user, role: 'admin' as const } };
|
||||
}
|
||||
|
||||
// Everyone after that needs an invitation. The grant is set by the
|
||||
// server action that verified the link token, never by the client.
|
||||
const grant = currentInviteGrant();
|
||||
if (!grant) {
|
||||
throw new APIError('FORBIDDEN', {
|
||||
message:
|
||||
'Die Registrierung ist geschlossen. Bitte lass dir vom Administrator einen Einladungslink schicken.',
|
||||
});
|
||||
}
|
||||
return { data: { ...user, role: 'admin' as const } };
|
||||
|
||||
return { data: { ...user, role: grant.role } };
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import 'server-only';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import { and, desc, eq, isNull, gt } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { invite, user } from '@/db/schema';
|
||||
import type { UserRole } from '@/db/types';
|
||||
import { createToken, hashToken } from './tokens';
|
||||
|
||||
/**
|
||||
* Invitations.
|
||||
*
|
||||
* After the first user, sign-up is closed. An admin issues a link; the
|
||||
* plaintext token only ever lives in that link, the database keeps its
|
||||
* SHA-256 hash.
|
||||
*/
|
||||
|
||||
export type InviteGrant = { inviteId: string; role: UserRole };
|
||||
|
||||
/**
|
||||
* Marks the current call stack as "covered by a valid invitation".
|
||||
*
|
||||
* better-auth's user-create hook has no way of receiving extra arguments, and
|
||||
* the sign-up payload must not be trusted with a role. AsyncLocalStorage keeps
|
||||
* the grant tied to the one server action that verified the token - it can
|
||||
* neither leak into another request nor be forged from the client.
|
||||
*/
|
||||
const grantStorage = new AsyncLocalStorage<InviteGrant>();
|
||||
|
||||
export function withInviteGrant<T>(grant: InviteGrant, fn: () => Promise<T>): Promise<T> {
|
||||
return grantStorage.run(grant, fn);
|
||||
}
|
||||
|
||||
export function currentInviteGrant(): InviteGrant | undefined {
|
||||
return grantStorage.getStore();
|
||||
}
|
||||
|
||||
export const INVITE_VALID_DAYS = 14;
|
||||
|
||||
export function createInvite(input: {
|
||||
createdByUserId: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
validDays?: number;
|
||||
}): { id: string; token: string; expiresAt: Date } {
|
||||
const token = createToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + (input.validDays ?? INVITE_VALID_DAYS) * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
db.insert(invite)
|
||||
.values({
|
||||
id,
|
||||
tokenHash: hashToken(token),
|
||||
email: input.email,
|
||||
role: input.role,
|
||||
createdByUserId: input.createdByUserId,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
.run();
|
||||
|
||||
return { id, token, expiresAt };
|
||||
}
|
||||
|
||||
export type UsableInvite = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
/** Returns the invite behind a link token, or null if it cannot be used. */
|
||||
export function findUsableInvite(token: string): UsableInvite | null {
|
||||
if (!token) return null;
|
||||
|
||||
const row = db
|
||||
.select({
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
})
|
||||
.from(invite)
|
||||
.where(
|
||||
and(
|
||||
eq(invite.tokenHash, hashToken(token)),
|
||||
isNull(invite.usedAt),
|
||||
gt(invite.expiresAt, new Date()),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export function markInviteUsed(inviteId: string, userId: string): void {
|
||||
db.update(invite)
|
||||
.set({ usedAt: new Date(), usedByUserId: userId })
|
||||
.where(eq(invite.id, inviteId))
|
||||
.run();
|
||||
}
|
||||
|
||||
export function deleteInvite(inviteId: string): void {
|
||||
db.delete(invite).where(eq(invite.id, inviteId)).run();
|
||||
}
|
||||
|
||||
export type InviteRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
role: UserRole;
|
||||
expiresAt: Date;
|
||||
usedAt: Date | null;
|
||||
usedByName: string | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
export function listInvites(): InviteRow[] {
|
||||
return db
|
||||
.select({
|
||||
id: invite.id,
|
||||
email: invite.email,
|
||||
role: invite.role,
|
||||
expiresAt: invite.expiresAt,
|
||||
usedAt: invite.usedAt,
|
||||
usedByName: user.name,
|
||||
createdAt: invite.createdAt,
|
||||
})
|
||||
.from(invite)
|
||||
.leftJoin(user, eq(user.id, invite.usedByUserId))
|
||||
.orderBy(desc(invite.createdAt))
|
||||
.all();
|
||||
}
|
||||
|
||||
/** An invite is only interesting while it can still be used. */
|
||||
export function inviteState(row: InviteRow): 'used' | 'expired' | 'open' {
|
||||
if (row.usedAt) return 'used';
|
||||
if (row.expiresAt.getTime() <= Date.now()) return 'expired';
|
||||
return 'open';
|
||||
}
|
||||
Reference in New Issue
Block a user