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
+55 -7
View File
@@ -5,6 +5,7 @@ import {
FileText,
LayoutDashboard,
ListChecks,
LogOut,
Menu,
ScanLine,
Settings,
@@ -14,7 +15,10 @@ import {
import { Button } from "@/components/ui/button";
import { Bildmarke } from "@/components/bildmarke";
import { Wortmarke } from "@/components/wortmarke";
import type { Rolle } from "@/generated/prisma/enums";
import { navigationFuer, type Betriebsmodus, type NavEintrag } from "@/lib/navigation";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { darf } from "@/server/auth/rollen";
import { cn } from "@/lib/utils";
import { versionText } from "@/lib/version";
@@ -28,18 +32,25 @@ const SYMBOLE: Record<string, LucideIcon> = {
Settings,
};
function NavListe({ modus, aktiv }: { modus: Betriebsmodus; aktiv: string }) {
function NavListe({ modus, aktiv, rolle }: { modus: Betriebsmodus; aktiv: string; rolle?: Rolle }) {
const eintraege = navigationFuer(modus, (recht) => (rolle ? darf(rolle, recht) : false));
return (
<ul className="flex flex-col gap-1">
{navigationFuer(modus).map((eintrag) => (
{eintraege.map((eintrag) => (
<li key={eintrag.pfad}>
<NavZeile eintrag={eintrag} aktiv={eintrag.pfad === aktiv} />
<NavZeile eintrag={eintrag} aktiv={istAktiv(eintrag.pfad, aktiv)} />
</li>
))}
</ul>
);
}
/** Die Uebersicht nur bei genauer Uebereinstimmung, alles andere auch fuer Unterseiten. */
function istAktiv(pfad: string, aktiv: string): boolean {
if (pfad === "/") return aktiv === "/";
return aktiv === pfad || aktiv.startsWith(`${pfad}/`);
}
function NavZeile({ eintrag, aktiv }: { eintrag: NavEintrag; aktiv: boolean }) {
const Symbol = SYMBOLE[eintrag.symbol] ?? LayoutDashboard;
const inhalt = (
@@ -92,6 +103,33 @@ function ScannenKnopf({ className }: { className?: string }) {
);
}
export type AngemeldeterBenutzer = {
readonly name: string;
readonly rolle: Rolle;
};
function BenutzerBlock({ benutzer }: { benutzer: AngemeldeterBenutzer }) {
return (
<div className="flex items-center justify-between gap-2 border-t border-linie pt-3">
<div className="min-w-0">
<p className="truncate text-sm text-text">{benutzer.name}</p>
<p className="truncate text-xs text-nebentext">{ROLLEN_TEXT[benutzer.rolle]}</p>
</div>
{/* Abmelden per POST, damit ein fremder Link niemanden abmelden kann. */}
<form action="/api/auth/abmelden" method="post">
<button
type="submit"
className="flex size-11 items-center justify-center rounded-pille text-nebentext hover:bg-flaeche-hoch hover:text-text md:size-9"
title="Abmelden"
aria-label="Abmelden"
>
<LogOut className="size-[18px]" strokeWidth={1.75} aria-hidden="true" />
</button>
</form>
</div>
);
}
/**
* Huelle der Anwendung: dunkle Seitenleiste am Schreibtisch, am Handy eine
* Kopfzeile mit aufklappbarem Menue. Der Scannen-Knopf sitzt an beiden Stellen
@@ -101,10 +139,12 @@ export function Huelle({
children,
modus = "INTERN",
aktiv = "/",
benutzer,
}: {
children: React.ReactNode;
modus?: Betriebsmodus;
aktiv?: string;
benutzer?: AngemeldeterBenutzer;
}) {
return (
<div className="min-h-dvh md:flex">
@@ -121,8 +161,13 @@ export function Huelle({
>
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
</summary>
<nav className="absolute right-0 z-30 mt-2 w-56 rounded-karte border border-linie bg-flaeche-hoch p-2">
<NavListe modus={modus} aktiv={aktiv} />
<nav className="absolute right-0 z-30 mt-2 w-60 rounded-karte border border-linie bg-flaeche-hoch p-2">
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
{benutzer ? (
<div className="mt-2 px-1">
<BenutzerBlock benutzer={benutzer} />
</div>
) : null}
</nav>
</details>
</div>
@@ -136,9 +181,12 @@ export function Huelle({
</Link>
<ScannenKnopf className="mb-6" />
<nav aria-label="Hauptnavigation">
<NavListe modus={modus} aktiv={aktiv} />
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
</nav>
<span className="mono mt-auto pt-6 text-xs text-nebentext">{versionText()}</span>
<div className="mt-auto flex flex-col gap-3 pt-6">
{benutzer ? <BenutzerBlock benutzer={benutzer} /> : null}
<span className="mono text-xs text-nebentext">{versionText()}</span>
</div>
</aside>
<main className="min-w-0 flex-1 p-4 md:p-8">{children}</main>