feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als
Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig,
Rolle und Aktiv-Status pro Anfrage aus der Datenbank.

Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des
Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser
//fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft
verglichen. Dazu acht weitere Befunde, alle behoben.

274 Tests.

Closes #7

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #21.
This commit is contained in:
2026-09-25 01:35:02 +00:00
co-authored by Claude Opus 5
parent 7a0f875f55
commit 40e95dbe5f
43 changed files with 3426 additions and 56 deletions
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useActionState } from "react";
import { Rolle } from "@/generated/prisma/enums";
import { Button } from "@/components/ui/button";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { benutzerAnlegenAktion, type AktionsErgebnis } from "@/server/actions/benutzer";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Formular fuer ein lokales Konto. Als Client-Komponente, weil Erfolg und Fehler
* ohne Neuladen an Ort und Stelle erscheinen sollen – die Rechtepruefung liegt
* trotzdem ausschliesslich serverseitig in der Aktion.
*/
export function BenutzerAnlegen({ aktiv }: { aktiv: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(benutzerAnlegenAktion, {});
if (!aktiv) {
return (
<p className="text-sm text-nebentext">
Der lokale Login ist abgeschaltet (<code className="mono">AUTH_LOCAL_ENABLED</code>). Neue Konten
entstehen beim ersten Anmelden über den Anbieter von selbst.
</p>
);
}
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">E-Mail</span>
<input type="email" name="email" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Rolle</span>
<select name="rolle" defaultValue={Rolle.TECHNIKER} className={FELD}>
{Object.values(Rolle).map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_TEXT[rolle]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Passwort (mindestens 12 Zeichen)</span>
<input
type="password"
name="passwort"
minLength={12}
required
autoComplete="new-password"
className={FELD}
/>
</label>
</div>
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Konto anlegen"}
</Button>
</form>
);
}