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
+115
View File
@@ -0,0 +1,115 @@
import { Rolle } from "@/generated/prisma/enums";
import { BenutzerAnlegen } from "@/components/benutzer-anlegen";
import { aktivSetzenAktion, rolleSetzenAktion } from "@/server/actions/benutzer";
import { lokalerLoginAktiv } from "@/server/auth/benutzer";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { benutzerListe, type BenutzerZeile } from "@/server/queries/benutzer";
const DATUM = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
/**
* Bewusst keine Tabelle: bei 390 px waere die letzte Spalte abgeschnitten und der
* Knopf nicht mehr erreichbar. Karten stapeln am Handy und legen sich am
* Schreibtisch nebeneinander.
*/
function BenutzerKarte({ zeile, selbst }: { zeile: BenutzerZeile; selbst: boolean }) {
const anmeldewege =
[zeile.ueberOidc ? "Anbieter" : null, zeile.mitPasswort ? "Passwort" : null]
.filter(Boolean)
.join(" · ") || "kein Anmeldeweg";
return (
<li className="rounded-karte border border-linie bg-flaeche p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0 md:flex-1">
<p className={`font-medium ${zeile.aktiv ? "text-text" : "text-nebentext line-through"}`}>
{zeile.name}
</p>
<p className="mono truncate text-xs text-nebentext">{zeile.email}</p>
<p className="mt-1 text-xs text-nebentext">
{anmeldewege}
{" · "}
{zeile.letzteAnmeldungAm
? `zuletzt ${DATUM.format(zeile.letzteAnmeldungAm)}`
: "noch nie angemeldet"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<form action={rolleSetzenAktion} className="flex items-center gap-2">
<input type="hidden" name="benutzerId" value={zeile.id} />
<select
name="rolle"
defaultValue={zeile.rolle}
aria-label={`Rolle von ${zeile.name}`}
className="min-h-11 rounded-pille border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent md:min-h-9"
>
{Object.values(Rolle).map((rolle) => (
<option key={rolle} value={rolle}>
{ROLLEN_TEXT[rolle]}
</option>
))}
</select>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9"
>
Setzen
</button>
</form>
{selbst ? (
<span className="rounded-pille bg-akzent-flaeche px-3 py-2 text-xs text-akzent">das bist du</span>
) : (
<form action={aktivSetzenAktion}>
<input type="hidden" name="benutzerId" value={zeile.id} />
<input type="hidden" name="aktiv" value={zeile.aktiv ? "false" : "true"} />
<button
type="submit"
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
zeile.aktiv
? "bg-erledigt/10 text-erledigt hover:bg-erledigt/20"
: "bg-flaeche-hoch text-nebentext hover:text-text"
}`}
>
{zeile.aktiv ? "aktiv" : "deaktiviert"}
</button>
</form>
)}
</div>
</div>
</li>
);
}
export default async function BenutzerSeite() {
const { benutzer: angemeldeter } = await sitzungErforderlich("verwaltung", "/admin/benutzer");
const zeilen = await benutzerListe();
return (
<>
<h1 className="text-2xl font-bold">Benutzer</h1>
<p className="mt-2 max-w-prose text-nebentext">
Die Menschen, die Laufzettel bedienen. Nicht zu verwechseln mit den Mitarbeitern aus dem Verzeichnis.
Bei Anmeldung über den Anbieter wird die Rolle aus den Gruppen gesetzt und überschreibt eine hier
gewählte.
</p>
<ul className="mt-8 flex flex-col gap-3">
{zeilen.map((zeile) => (
<BenutzerKarte key={zeile.id} zeile={zeile} selbst={zeile.id === angemeldeter.id} />
))}
</ul>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Lokales Konto anlegen</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Gedacht als Notfallzugang, falls der Anbieter ausfällt. Das Passwort wird nur als argon2-Hash
gespeichert.
</p>
<BenutzerAnlegen aktiv={lokalerLoginAktiv()} />
</section>
</>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { headers } from "next/headers";
import { Huelle } from "@/components/huelle";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
/**
* Alles unter dieser Huelle verlangt eine Anmeldung.
*
* Der Proxy leitet zwar schon um, wenn gar kein Cookie da ist – geprueft wird
* aber hier, gegen die Datenbank. Der Proxy ist nur das billige Tor, damit nicht
* jede Anfrage ohne Cookie erst eine Datenbankabfrage kostet.
*/
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const kopfzeilen = await headers();
const pfad = kopfzeilen.get("x-pfad") ?? "/";
// Der Abfrageteil gehoert zum Ziel: wer mit abgelaufenem Cookie auf
// /laeufe?filter=offen kommt, soll auch dorthin zurueck und nicht auf /laeufe.
const ziel = pfad + (kopfzeilen.get("x-abfrage") ?? "");
const { benutzer } = await sitzungErforderlich(undefined, ziel);
return (
<Huelle aktiv={pfad} benutzer={{ name: benutzer.name, rolle: benutzer.rolle }}>
{children}
</Huelle>
);
}
+27
View File
@@ -0,0 +1,27 @@
export default function Startseite() {
return (
<>
<h1 className="text-2xl font-bold">Übersicht</h1>
<p className="mt-2 max-w-prose text-nebentext">
Datenmodell und Anmeldung stehen. Als Nächstes kommen die Betriebsmodi und darauf die Vorlagen —
danach lässt sich der erste Lauf wirklich abarbeiten.
</p>
<section className="mt-8 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{[
{ nummer: "01", titel: "Betriebsmodus und Kunden", issue: "#008" },
{ nummer: "02", titel: "Vorlagen-Versionierung", issue: "#009" },
{ nummer: "03", titel: "Vorlagen-Editor", issue: "#010" },
].map((karte) => (
<article key={karte.nummer} className="rounded-karte border border-linie bg-flaeche p-5">
<p className="mono text-xs text-nebentext">
<span className="text-akzent">{karte.nummer}</span> · als Nächstes
</p>
<h2 className="mt-2 text-lg font-semibold">{karte.titel}</h2>
<p className="mono mt-1 text-xs text-nebentext">{karte.issue}</p>
</article>
))}
</section>
</>
);
}