feat(benutzer): Benutzerverwaltung, Passwort ändern, Audit-Log-Ansicht

Admins verwalten Benutzer (anlegen, Rolle, sperren, Passwort setzen); jeder ändert sein Passwort; das Audit-Log ist filterbar einsehbar. Sperre und Passwortwechsel beenden ältere Sitzungen über Benutzer.sitzungenAb. Der letzte Admin und das eigene Konto sind geschützt.

Closes #015

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:49:47 +02:00
co-authored by Claude Opus 5.5
parent 7f6cf00a0e
commit 99357d59bd
21 changed files with 1292 additions and 22 deletions
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { EINGABE_KLASSE, Feld } from "@/components/formular";
import { Karte } from "@/components/karte";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
import { apiAufruf, ApiFehler } from "@/lib/api-client";
/** Eigenes Passwort ändern. Andere Sitzungen (andere Geräte) enden, diese bleibt angemeldet. */
export function PasswortAendern() {
const [werte, setWerte] = useState({ alt: "", neu: "", wiederholt: "" });
const [meldung, setMeldung] = useState<{
art: "erledigt" | "fehler";
text: string;
felder?: Record<string, string>;
} | null>(null);
const abweichend = werte.wiederholt !== "" && werte.neu !== werte.wiederholt;
async function speichern(ereignis: React.FormEvent) {
ereignis.preventDefault();
try {
await apiAufruf("POST", "/api/v1/ich/passwort", { alt: werte.alt, neu: werte.neu });
setWerte({ alt: "", neu: "", wiederholt: "" });
setMeldung({ art: "erledigt", text: "Passwort geändert. Andere Sitzungen sind beendet." });
} catch (fehler) {
setMeldung({
art: "fehler",
text: fehler instanceof ApiFehler ? fehler.message : "Fehler.",
felder: fehler instanceof ApiFehler ? fehler.felder : undefined,
});
}
}
return (
<Karte titel="Passwort">
<form onSubmit={(e) => void speichern(e)} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<Feld id="pw-alt" label="Bisheriges Passwort" fehler={meldung?.felder?.["alt"]}>
<input
id="pw-alt"
type="password"
autoComplete="current-password"
className={EINGABE_KLASSE}
value={werte.alt}
onChange={(e) => setWerte({ ...werte, alt: e.target.value })}
/>
</Feld>
<Feld
id="pw-neu"
label="Neues Passwort"
hilfe="Mindestens 12 Zeichen"
fehler={meldung?.felder?.["neu"]}
>
<input
id="pw-neu"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={werte.neu}
onChange={(e) => setWerte({ ...werte, neu: e.target.value })}
/>
</Feld>
<Feld
id="pw-wieder"
label="Neues Passwort wiederholen"
fehler={abweichend ? "Stimmt nicht überein." : undefined}
>
<input
id="pw-wieder"
type="password"
autoComplete="new-password"
className={EINGABE_KLASSE}
value={werte.wiederholt}
onChange={(e) => setWerte({ ...werte, wiederholt: e.target.value })}
/>
</Feld>
</div>
<div className="flex flex-wrap items-center gap-3">
<Knopf
type="submit"
disabled={!werte.alt || werte.neu.length < 12 || werte.neu !== werte.wiederholt}
>
Passwort ändern
</Knopf>
<div aria-live="polite" className="text-sm">
{meldung ? <Status art={meldung.art}>{meldung.text}</Status> : null}
</div>
</div>
</form>
</Karte>
);
}