Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery, PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig, und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen — eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht mit gültigem Cookie noch zwölf Stunden weiter. Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose, wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben. Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei Bedingungen. Browser entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim Verarbeiten einer URL, übrig bleibt "//fremd.example" und damit ein fremder Host — genau das Phishing, das die Prüfung abwehren sollte. Sie löst das Ziel jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft. Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive Administrator konnte sich selbst herabstufen. Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass scrollWidth es zeigen konnte. 274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
import Link from "next/link";
|
||
import {
|
||
Archive,
|
||
Building2,
|
||
FileText,
|
||
LayoutDashboard,
|
||
ListChecks,
|
||
LogOut,
|
||
Menu,
|
||
ScanLine,
|
||
Settings,
|
||
Users,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
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";
|
||
|
||
const SYMBOLE: Record<string, LucideIcon> = {
|
||
LayoutDashboard,
|
||
ListChecks,
|
||
FileText,
|
||
Users,
|
||
Building2,
|
||
Archive,
|
||
Settings,
|
||
};
|
||
|
||
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">
|
||
{eintraege.map((eintrag) => (
|
||
<li key={eintrag.pfad}>
|
||
<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 = (
|
||
<>
|
||
<Symbol className="size-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||
<span className="truncate">{eintrag.beschriftung}</span>
|
||
</>
|
||
);
|
||
const gemeinsam =
|
||
"flex min-h-11 items-center gap-3 rounded-pille px-3 text-sm transition-colors md:min-h-10";
|
||
|
||
// Noch nicht vorhandene Seiten werden nicht verlinkt, damit kein Klick ins Leere geht.
|
||
if (!eintrag.verfuegbar) {
|
||
return (
|
||
<span
|
||
className={`${gemeinsam} cursor-not-allowed text-nebentext/50`}
|
||
title={`Kommt mit #${String(eintrag.ausIssue).padStart(3, "0")}`}
|
||
aria-disabled="true"
|
||
>
|
||
{inhalt}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Link
|
||
href={eintrag.pfad}
|
||
aria-current={aktiv ? "page" : undefined}
|
||
className={`${gemeinsam} ${
|
||
aktiv
|
||
? "bg-akzent-flaeche font-semibold text-akzent"
|
||
: "text-nebentext hover:bg-flaeche-hoch hover:text-text"
|
||
}`}
|
||
>
|
||
{inhalt}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
function ScannenKnopf({ className }: { className?: string }) {
|
||
return (
|
||
// Pille statt der shadcn-Vorgabe rounded-lg, und 44 px am Handy / 40 px am
|
||
// Schreibtisch statt 32 px – so verlangt es die Gestaltungsvorgabe.
|
||
<Button asChild className={cn("h-11 gap-2 rounded-pille px-4 text-sm font-semibold md:h-10", className)}>
|
||
<Link href="/scannen">
|
||
<ScanLine className="size-[18px]" strokeWidth={2} aria-hidden="true" />
|
||
Scannen
|
||
</Link>
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
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
|
||
* ganz oben, weil der Techniker damit anfaengt.
|
||
*/
|
||
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">
|
||
{/* Handy: Kopfzeile mit Menue */}
|
||
<header className="sticky top-0 z-20 border-b border-linie bg-flaeche md:hidden">
|
||
<div className="flex items-center gap-3 px-4 py-3">
|
||
<Bildmarke className="size-6 shrink-0 text-akzent" />
|
||
<Wortmarke className="text-lg" />
|
||
<ScannenKnopf className="ml-auto" />
|
||
<details className="relative">
|
||
<summary
|
||
className="flex size-11 cursor-pointer list-none items-center justify-center rounded-pille text-nebentext"
|
||
aria-label="Menü"
|
||
>
|
||
<Menu className="size-5" strokeWidth={1.75} aria-hidden="true" />
|
||
</summary>
|
||
<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>
|
||
</header>
|
||
|
||
{/* Schreibtisch: Seitenleiste */}
|
||
<aside className="hidden w-60 shrink-0 flex-col border-r border-linie bg-flaeche p-4 md:flex">
|
||
<Link href="/" className="mb-4 flex items-center gap-2">
|
||
<Bildmarke className="size-7 shrink-0 text-akzent" />
|
||
<Wortmarke className="text-xl" />
|
||
</Link>
|
||
<ScannenKnopf className="mb-6" />
|
||
<nav aria-label="Hauptnavigation">
|
||
<NavListe modus={modus} aktiv={aktiv} rolle={benutzer?.rolle} />
|
||
</nav>
|
||
<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>
|
||
|
||
{/* Am Handy steht die Version unten im Inhalt, nicht fest ueber allem. */}
|
||
<span className="mono block px-4 pb-4 text-xs text-nebentext md:hidden">{versionText()}</span>
|
||
</div>
|
||
);
|
||
}
|