Phase B1: Anmeldung, Benutzer, Gruppen und Rollen

Umschaltbar ueber AUTH_ENABLED in der .env. Steht sie auf false, verhaelt
sich alles wie bisher - geprueft mit den 200 bestehenden Pruefungen.

Kern ist eine Actor- und Policy-Schicht: getActor() liefert bei
ausgeschalteter Anmeldung einen system-Actor mit Vollzugriff, sonst den
angemeldeten Benutzer. Dadurch braucht keine einzige Route ein
'if (anmeldungAktiv)' - sie fragen alle nur can() bzw. requirePermission().

- Datenmodell: User, Session, Group, Membership; Codes bekommen Gruppe und
  Ersteller. Die Migration legt eine Standardgruppe an und ordnet ihr
  bestehende Codes zu, bevor die Spalte verpflichtend wird - sonst
  scheiterte sie auf jeder bereits benutzten Installation.
- Passwoerter mit scrypt aus der Node-Standardbibliothek. Bewusst kein
  Argon2: das braeuchte ein natives Modul passend zur Container-Plattform.
- Sitzungen in der Datenbank, im Cookie nur ein Zufallstoken; gespeichert
  wird dessen SHA-256. Deaktivieren oder ein neues Passwort beendet alle
  offenen Sitzungen sofort.
- Aussperrschutz: der letzte aktive Administrator kann sich nicht selbst
  entmachten, deaktivieren oder loeschen. Der Schalter liegt in der .env,
  nicht in der Oberflaeche - so bleibt der Weg zurueck immer offen.
- Ersteinrichtung legt den ersten Administrator an und schliesst sich danach.
- Die Weiterleitung /r/{slug} bleibt oeffentlich, sonst liesse sich kein
  gedruckter Code mehr scannen.

Zwei Fehler dabei gefunden und behoben:

- 'return handleLogin(...)' innerhalb eines try-Blocks gibt das Promise
  zurueck, bevor es sich aufloest. Die Ablehnung kam nie im catch an und
  wurde zu einem nackten 500er statt einer 401. Jetzt mit await.
- Die Fehlerklassen wurden ueber instanceof erkannt. type-module.ts landet
  aber sowohl im Server- als auch im Client-Buendel, es gibt also zwei
  Klassenidentitaeten - saubere 400er wurden dadurch still zu 500ern. Die
  Erkennung laeuft jetzt ueber eine Eigenschaft.

Geprueft: 51 neue Pruefungen fuer Anmeldung und die vollstaendige
Rollenmatrix, dazu die 200 bestehenden bei ausgeschalteter Anmeldung.
This commit is contained in:
Florian Wilde
2026-08-14 12:31:16 +02:00
parent 94a373ff48
commit f1cdb3da13
39 changed files with 3076 additions and 64 deletions
+50 -20
View File
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { LogoutButton } from "@/components/auth-forms";
import { Icon } from "@/components/icons";
import { actorLabel, getActor, isAuthEnabled } from "@/lib/auth/actor";
import { can } from "@/lib/auth/policy";
import "./globals.css";
export const metadata: Metadata = {
@@ -13,7 +16,18 @@ export const metadata: Metadata = {
"QR-Codes erstellen: statisch oder dynamisch, mit eigenen Farben, frei wählbarer Größe und Download als PNG oder SVG.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
// Der Kopfbereich hängt von der Anmeldung ab und darf nicht vorgerendert
// werden.
export const dynamic = "force-dynamic";
const NAV_LINK =
"rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900";
export default async function RootLayout({ children }: LayoutProps<"/">) {
const actor = await getActor();
const signedIn = actor.kind !== "anonymous";
const showAdmin = can(actor, "instance:manage");
return (
<html lang="de" className="h-full">
<body className="flex min-h-full flex-col">
@@ -30,25 +44,41 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
Navigation nicht mehr in eine Zeile. */}
<span className="hidden sm:inline">QR-Code-Generator</span>
</Link>
<nav className="flex items-center gap-0.5 text-sm font-medium sm:gap-1">
<Link
href="/"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Erstellen
</Link>
<Link
href="/meine-codes"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Meine Codes
</Link>
<Link
href="/verwaltung/typen"
className="rounded-lg px-3 py-2 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Verwaltung
</Link>
<nav className="flex flex-wrap items-center gap-0.5 text-sm font-medium sm:gap-1">
{signedIn ? (
<>
<Link href="/" className={NAV_LINK}>
Erstellen
</Link>
<Link href="/meine-codes" className={NAV_LINK}>
Meine Codes
</Link>
{showAdmin ? (
<Link href="/verwaltung" className={NAV_LINK}>
Verwaltung
</Link>
) : null}
</>
) : null}
{isAuthEnabled() && actor.kind === "user" ? (
<>
<span
className="ml-1 hidden max-w-[14rem] truncate text-slate-500 sm:inline"
title={actor.user.email}
>
{actorLabel(actor)}
</span>
<LogoutButton />
</>
) : null}
{isAuthEnabled() && actor.kind === "anonymous" ? (
<Link href="/anmelden" className={NAV_LINK}>
Anmelden
</Link>
) : null}
</nav>
</div>
</header>