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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user