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
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { aufruferAdresse } from "@/lib/adressen";
import { SITZUNG_COOKIE } from "@/lib/sitzung";
import { api } from "@/server/api";
import { anfrageAufrufer } from "@/server/auth/zugang";
import { protokollieren } from "@/server/services/protokoll";
/** Öffentlich: Abmelden muss auch mit abgelaufener Sitzung gehen. */
export const POST = api(async (anfrage: Request) => {
const aufrufer = await anfrageAufrufer(anfrage);
if (aufrufer) {
await protokollieren({
benutzerId: aufrufer.benutzer.id,
quelle: aufrufer.quelle,
aktion: "Abgemeldet",
ip: aufruferAdresse(anfrage.headers),
});
}
const antwort = NextResponse.json({ ok: true });
antwort.cookies.delete(SITZUNG_COOKIE);
return antwort;
});
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { aufruferAdresse, istHttps } from "@/lib/adressen";
import { SITZUNG_COOKIE, SITZUNG_LAUFZEIT_STUNDEN, sitzungErzeugen } from "@/lib/sitzung";
import { z } from "@/lib/validierung";
import { api, jsonLesen } from "@/server/api";
import { mitPasswortAnmelden } from "@/server/services/anmeldung";
const Anmeldung = z.object({
benutzername: z.string().trim().min(1, "Benutzername fehlt.").max(64),
passwort: z.string().min(1, "Passwort fehlt.").max(1024),
});
/** Öffentlich (Proxy-Ausnahme) – sonst könnte sich niemand anmelden. */
export const POST = api(async (anfrage: Request) => {
const eingabe = Anmeldung.parse(await jsonLesen(anfrage));
const benutzer = await mitPasswortAnmelden(
eingabe.benutzername,
eingabe.passwort,
aufruferAdresse(anfrage.headers),
);
const antwort = NextResponse.json({ benutzername: benutzer.benutzername, rolle: benutzer.rolle });
antwort.cookies.set(
SITZUNG_COOKIE,
await sitzungErzeugen({ benutzerId: benutzer.id, herkunft: "passwort" }),
{
httpOnly: true,
sameSite: "lax",
secure: istHttps(anfrage.headers, anfrage.url),
path: "/",
maxAge: SITZUNG_LAUFZEIT_STUNDEN * 3600,
},
);
return antwort;
});
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { VERSION } from "@/lib/version";
/** Healthcheck für Docker und Reverse-Proxy. Öffentlich, verrät nur die Version. */
export async function GET(): Promise<Response> {
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: "ok", version: VERSION });
} catch {
return NextResponse.json({ status: "datenbank_nicht_erreichbar", version: VERSION }, { status: 503 });
}
}
export const dynamic = "force-dynamic";
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { VERSION } from "@/lib/version";
import { api } from "@/server/api";
import { RECHTE, darf, type Recht } from "@/server/auth/rechte";
import { rechtErforderlich } from "@/server/auth/zugang";
/** Wer bin ich, was darf ich – für die CLI (`proxdeploy login`) und die GUI. */
export const GET = api(async (anfrage: Request) => {
const { benutzer } = await rechtErforderlich(anfrage, "lesen");
const rechte = (Object.keys(RECHTE) as Recht[]).filter((recht) => darf(benutzer.rolle, recht));
return NextResponse.json({
benutzername: benutzer.benutzername,
anzeigename: benutzer.anzeigename,
rolle: benutzer.rolle,
rechte,
version: VERSION,
});
});