feat(auth): Anmeldung, Rollen und Benutzerverwaltung (#21)
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user