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
+86
View File
@@ -0,0 +1,86 @@
import { SignJWT, jwtVerify } from "jose";
/**
* Das signierte Sitzungscookie der Web-GUI.
*
* Framework-frei, damit es ohne Next und ohne Datenbank testbar ist. Im Token steht nur, wer
* angemeldet ist und wie. Rolle und Aktiv-Status stehen bewusst **nicht** darin – sie werden je
* Anfrage aus der Datenbank gelesen, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
*/
export const SITZUNG_COOKIE = "pd_sitzung";
/** Ein Arbeitstag. */
export const SITZUNG_LAUFZEIT_STUNDEN = 12;
export type SitzungsHerkunft = "passwort" | "oidc";
export type Sitzung = {
benutzerId: string;
herkunft: SitzungsHerkunft;
ausgestelltAm: number;
};
const AUSSTELLER = "proxdeploy";
const ZIELGRUPPE = "proxdeploy-web";
const ALGORITHMUS = "HS256";
const MINDESTLAENGE = 32;
export class SitzungsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "SitzungsFehler";
}
}
let zwischenspeicher: { quelle: string; schluessel: Uint8Array } | undefined;
function schluesselHolen(): Uint8Array {
const quelle = process.env["SITZUNG_GEHEIMNIS"] ?? "";
if (quelle.length < MINDESTLAENGE) {
throw new SitzungsFehler(
`SITZUNG_GEHEIMNIS fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
"Erzeugen mit: openssl rand -base64 48",
);
}
if (zwischenspeicher?.quelle !== quelle) {
zwischenspeicher = { quelle, schluessel: new TextEncoder().encode(quelle) };
}
return zwischenspeicher.schluessel;
}
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
return await new SignJWT({ herkunft: daten.herkunft })
.setProtectedHeader({ alg: ALGORITHMUS })
.setSubject(daten.benutzerId)
.setIssuer(AUSSTELLER)
.setAudience(ZIELGRUPPE)
.setIssuedAt()
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
.sign(schluesselHolen());
}
/** Gibt undefined zurück, wenn das Token fehlt, abgelaufen, verfälscht oder fremd ist. */
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
// Vor dem `try`: ein fehlendes Geheimnis ist ein Konfigurationsfehler, kein „nicht angemeldet“.
const schluessel = schluesselHolen();
if (!jwt) return undefined;
try {
const { payload } = await jwtVerify(jwt, schluessel, {
algorithms: [ALGORITHMUS],
issuer: AUSSTELLER,
audience: ZIELGRUPPE,
});
const { sub, iat, herkunft } = payload;
if (
typeof sub !== "string" ||
sub.length === 0 ||
typeof iat !== "number" ||
(herkunft !== "passwort" && herkunft !== "oidc")
) {
return undefined;
}
return { benutzerId: sub, herkunft, ausgestelltAm: iat };
} catch {
return undefined;
}
}