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
+53
View File
@@ -0,0 +1,53 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
import { istOeffentlich, OEFFENTLICH } from "@/proxy";
/**
* Strukturelle Prüfung: jede Seite und jeder Route Handler prüft sein Recht selbst.
* Ausnahmen stehen hier namentlich mit Begründung – ein neuer Handler ohne Prüfung soll
* jemanden zwingen, sie aufzuschreiben.
*/
const AUSNAHMEN: Record<string, string> = {
"src/app/api/v1/auth/anmelden/route.ts": "Anmeldung selbst",
"src/app/api/v1/auth/abmelden/route.ts": "Abmelden muss ohne gültige Sitzung gehen",
"src/app/api/v1/gesundheit/route.ts": "Healthcheck, verrät nur die Version",
"src/app/anmelden/page.tsx": "Anmeldeseite",
"src/app/kein-recht/page.tsx": "zeigt nur einen Hinweis",
};
function dateien(verzeichnis: string): string[] {
return readdirSync(verzeichnis).flatMap((name) => {
const pfad = join(verzeichnis, name);
return statSync(pfad).isDirectory() ? dateien(pfad) : [pfad];
});
}
const wurzel = process.cwd();
const kandidaten = dateien(join(wurzel, "src/app"))
.map((pfad) => relative(wurzel, pfad))
.filter((pfad) => pfad.endsWith("/page.tsx") || pfad.endsWith("/route.ts"));
describe("Zugangsprüfung", () => {
it.each(kandidaten.filter((pfad) => !(pfad in AUSNAHMEN)))("%s prüft sein Recht", (pfad) => {
const quelltext = readFileSync(join(wurzel, pfad), "utf8");
expect(quelltext).toMatch(/seiteSchuetzen\(|rechtErforderlich\(/);
});
it("jede Ausnahme gibt es noch", () => {
for (const pfad of Object.keys(AUSNAHMEN)) expect(kandidaten).toContain(pfad);
});
it("öffentliche Pfade sind genau die bekannten", () => {
expect(OEFFENTLICH).toEqual([
"/anmelden",
"/api/v1/auth",
"/api/v1/gesundheit",
"/brand",
"/manifest.webmanifest",
]);
expect(istOeffentlich("/api/v1/auth/anmelden")).toBe(true);
expect(istOeffentlich("/api/v1/authentifizieren")).toBe(false);
expect(istOeffentlich("/server")).toBe(false);
});
});