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