feat: Grundgerüst mit Anmeldung, Layout und Design-Tokens

Next.js 16, Prisma 7, Tailwind 4 und Vitest/Playwright, Konfiguration aus
Laufzettel übernommen, weil dort derselbe Stack erprobt ist.

Die Farben der Branding-Vorgabe stehen als Tokens in globals.css, das
helle Theme setzt dieselben Namen neu. Amber und Rot sind dort
abgedunkelt, sonst reicht der Kontrast nicht – ein Test prüft beide
Themes gegen 4,5:1.

Anmeldung mit lokalem Benutzer, Rolle je Anfrage aus der Datenbank,
Audit-Log per Trigger nur anhängbar. Geprüft im gebauten Stand auf
1440 px und 390 px.

Closes #001

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:23:41 +02:00
co-authored by Claude Opus 5.5
parent c3211d0fe2
commit 871bb3a77c
75 changed files with 10155 additions and 15 deletions
+25
View File
@@ -0,0 +1,25 @@
import type { Rolle } from "@/generated/prisma/enums";
/**
* Wer was darf – die einzige Stelle. Seiten, Route Handler und Navigation fragen `darf()`;
* niemand vergleicht selbst mit einer Rolle.
*
* Admin: Server, Katalog, Benutzer, Einstellungen. Operator: ansehen und ausrollen.
*/
export const RECHTE = {
lesen: ["ADMIN", "OPERATOR"],
ausrollen: ["ADMIN", "OPERATOR"],
vorlagenPflegen: ["ADMIN", "OPERATOR"],
eigeneZugaenge: ["ADMIN", "OPERATOR"],
serverVerwalten: ["ADMIN"],
katalogVerwalten: ["ADMIN"],
benutzerVerwalten: ["ADMIN"],
einstellungenVerwalten: ["ADMIN"],
protokollLesen: ["ADMIN"],
} as const satisfies Record<string, readonly Rolle[]>;
export type Recht = keyof typeof RECHTE;
export function darf(rolle: Rolle, recht: Recht): boolean {
return (RECHTE[recht] as readonly Rolle[]).includes(rolle);
}
+61
View File
@@ -0,0 +1,61 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { Benutzer } from "@/generated/prisma/client";
import type { Quelle } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rechte";
import { keinRecht, nichtAngemeldet } from "@/server/fehler";
/**
* Wer fragt an? Rolle und Aktiv-Status kommen **je Anfrage aus der Datenbank**, nie aus dem
* Token. Jede Seite und jeder Route Handler prüft selbst – das Layout ist nur das zweite Netz,
* weil Next es bei Navigation innerhalb desselben Layouts nicht neu rendert.
*/
export type Aufrufer = { benutzer: Benutzer; quelle: Quelle };
async function benutzerAusSitzung(jwt: string | undefined): Promise<Benutzer | null> {
const sitzung = await sitzungLesen(jwt);
if (!sitzung) return null;
const benutzer = await prisma.benutzer.findUnique({ where: { id: sitzung.benutzerId } });
return benutzer?.aktiv ? benutzer : null;
}
/** Für Server Components. */
export async function aktuellerBenutzer(): Promise<Benutzer | null> {
const speicher = await cookies();
return await benutzerAusSitzung(speicher.get(SITZUNG_COOKIE)?.value);
}
/** Für Seiten: leitet zur Anmeldung um oder zeigt „kein Recht“. */
export async function seiteSchuetzen(recht: Recht, pfad: string): Promise<Benutzer> {
const benutzer = await aktuellerBenutzer();
if (!benutzer) redirect(`/anmelden?ziel=${encodeURIComponent(pfad)}`);
if (!darf(benutzer.rolle, recht)) redirect("/kein-recht");
return benutzer;
}
function cookieAusKopf(kopf: string | null, name: string): string | undefined {
if (!kopf) return undefined;
for (const teil of kopf.split(";")) {
const [schluessel, ...wert] = teil.trim().split("=");
if (schluessel === name) return decodeURIComponent(wert.join("="));
}
return undefined;
}
/**
* Für Route Handler: Sitzungscookie (Web). Bearer-Tokens der CLI kommen mit #013 dazu und
* landen hier, damit kein Handler zwei Wege kennen muss.
*/
export async function anfrageAufrufer(anfrage: Request): Promise<Aufrufer | null> {
const benutzer = await benutzerAusSitzung(cookieAusKopf(anfrage.headers.get("cookie"), SITZUNG_COOKIE));
return benutzer ? { benutzer, quelle: "WEB" } : null;
}
export async function rechtErforderlich(anfrage: Request, recht: Recht): Promise<Aufrufer> {
const aufrufer = await anfrageAufrufer(anfrage);
if (!aufrufer) throw nichtAngemeldet();
if (!darf(aufrufer.benutzer.rolle, recht)) throw keinRecht();
return aufrufer;
}