Phase B1: Anmeldung, Benutzer, Gruppen und Rollen

Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
Florian Wilde
2026-08-14 12:31:16 +02:00
parent 94a373ff48
commit f1cdb3da13
39 changed files with 3076 additions and 64 deletions
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button, Card, Field, Notice, TextInput } from "@/components/ui";
import { ApiError, apiJson } from "@/lib/api-client";
function useSubmit() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (json: unknown) => {
setPending(true);
setError(null);
try {
await apiJson("/api/auth", { method: "POST", json });
// refresh() ist hier entscheidend: Kopfbereich und Startseite werden
// serverseitig je nach Anmeldung unterschiedlich gerendert, und ohne
// Verwerfen des Zwischenspeichers käme die Seite von vor der Anmeldung.
router.replace("/");
router.refresh();
} catch (caught) {
setError(
caught instanceof ApiError
? caught.message
: "Die Anmeldung ist fehlgeschlagen.",
);
setPending(false);
}
};
return { submit, pending, error, router };
}
export function LoginForm() {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void submit({ action: "login", email, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="E-Mail-Adresse" htmlFor="login-email">
<TextInput
id="login-email"
type="email"
autoComplete="username"
autoFocus
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field label="Passwort" htmlFor="login-password">
<TextInput
id="login-password"
type="password"
autoComplete="current-password"
required
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending}>
{pending ? "Wird angemeldet …" : "Anmelden"}
</Button>
</form>
</Card>
);
}
export function SetupForm({ minLength }: { minLength: number }) {
const { submit, pending, error } = useSubmit();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const mismatch = repeat.length > 0 && password !== repeat;
return (
<Card>
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
if (mismatch) return;
void submit({ action: "setup", email, name, password });
}}
>
{error ? <Notice tone="error">{error}</Notice> : null}
<Field label="Name (optional)" htmlFor="setup-name">
<TextInput
id="setup-name"
autoComplete="name"
value={name}
disabled={pending}
onChange={(event) => setName(event.target.value)}
/>
</Field>
<Field label="E-Mail-Adresse" htmlFor="setup-email">
<TextInput
id="setup-email"
type="email"
autoComplete="username"
required
value={email}
disabled={pending}
onChange={(event) => setEmail(event.target.value)}
/>
</Field>
<Field
label="Passwort"
htmlFor="setup-password"
hint={`Mindestens ${minLength} Zeichen.`}
>
<TextInput
id="setup-password"
type="password"
autoComplete="new-password"
required
minLength={minLength}
value={password}
disabled={pending}
onChange={(event) => setPassword(event.target.value)}
/>
</Field>
<Field
label="Passwort wiederholen"
htmlFor="setup-repeat"
error={mismatch ? "Die beiden Passwörter stimmen nicht überein." : null}
>
<TextInput
id="setup-repeat"
type="password"
autoComplete="new-password"
required
invalid={mismatch}
value={repeat}
disabled={pending}
onChange={(event) => setRepeat(event.target.value)}
/>
</Field>
<Button type="submit" disabled={pending || mismatch}>
{pending ? "Wird angelegt …" : "Administrator anlegen"}
</Button>
</form>
</Card>
);
}
export function LogoutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
return (
<button
type="button"
disabled={pending}
onClick={async () => {
setPending(true);
try {
await apiJson("/api/auth", {
method: "POST",
json: { action: "logout" },
});
} finally {
router.replace("/anmelden");
router.refresh();
}
}}
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 disabled:opacity-50"
>
{pending ? "…" : "Abmelden"}
</button>
);
}