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
+68
View File
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { ZodError } from "@/lib/validierung";
import { logger } from "@/lib/logger";
import { AnwendungsFehler } from "@/server/fehler";
/**
* Einheitliche Fehlerantwort der REST-API – Web-GUI und CLI lesen dasselbe Format:
* `{ fehler: { code, nachricht, hinweis?, felder? } }`.
*/
export type FehlerAntwort = {
fehler: { code: string; nachricht: string; hinweis?: string; felder?: Record<string, string> };
};
export function fehlerAntwort(fehler: unknown): NextResponse<FehlerAntwort> {
if (fehler instanceof AnwendungsFehler) {
return NextResponse.json(
{
fehler: {
code: fehler.code,
nachricht: fehler.message,
...(fehler.hinweis ? { hinweis: fehler.hinweis } : {}),
},
},
{ status: fehler.status },
);
}
if (fehler instanceof ZodError) {
const felder: Record<string, string> = {};
for (const problem of fehler.issues) {
const pfad = problem.path.join(".") || "_";
felder[pfad] ??= problem.message;
}
return NextResponse.json(
{
fehler: {
code: "ungueltig",
nachricht: "Die Eingaben sind nicht vollständig oder ungültig.",
felder,
},
},
{ status: 400 },
);
}
logger.error({ err: fehler }, "Unerwarteter Fehler in der API");
return NextResponse.json({ fehler: { code: "intern", nachricht: "Interner Fehler." } }, { status: 500 });
}
/** Umhüllt einen Route Handler: wandelt geworfene Fehler in das einheitliche Format. */
export function api<A extends unknown[]>(
handler: (...argumente: A) => Promise<Response>,
): (...argumente: A) => Promise<Response> {
return async (...argumente: A) => {
try {
return await handler(...argumente);
} catch (fehler) {
return fehlerAntwort(fehler);
}
};
}
/** Liest einen JSON-Rumpf; ein kaputter Rumpf ist ein 400, kein 500. */
export async function jsonLesen(anfrage: Request): Promise<unknown> {
try {
return await anfrage.json();
} catch {
throw new AnwendungsFehler(400, "kein_json", "Der Anfragerumpf ist kein gültiges JSON.");
}
}
+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;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Fehler, die bis zum Aufrufer durchgereicht werden dürfen. Alles andere wird als
* „Interner Fehler“ gemeldet und nur ins Log geschrieben.
*
* `hinweis` sagt, was zu tun ist – Web und CLI zeigen ihn unter der Meldung an.
*/
export class AnwendungsFehler extends Error {
constructor(
readonly status: number,
readonly code: string,
nachricht: string,
readonly hinweis?: string,
) {
super(nachricht);
this.name = "AnwendungsFehler";
}
}
export const nichtAngemeldet = () => new AnwendungsFehler(401, "nicht_angemeldet", "Nicht angemeldet.");
export const keinRecht = () =>
new AnwendungsFehler(
403,
"kein_recht",
"Dafür fehlt die Berechtigung.",
"Ein Administrator kann die Rolle ändern.",
);
export const nichtGefunden = (was: string) =>
new AnwendungsFehler(404, "nicht_gefunden", `${was} gibt es nicht.`);
+105
View File
@@ -0,0 +1,105 @@
import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { attrappenPruefung, passwortHashen, passwortPruefen, PASSWORT_MINDESTLAENGE } from "@/lib/passwort";
import { AnwendungsFehler } from "@/server/fehler";
import { protokollieren } from "@/server/services/protokoll";
/**
* Sperre gegen Durchprobieren: höchstens 10 Fehlversuche je Benutzername und Adresse in 15 min.
* Bewusst im Speicher: ProxDeploy läuft als eine Instanz, und ein Neustart, der die Zähler
* leert, ist für einen Angreifer kein Gewinn – er müsste den Dienst dafür neu starten können.
*/
const FENSTER_MS = 15 * 60 * 1000;
const HOECHSTENS = 10;
const fehlversuche = new Map<string, number[]>();
function versucheZaehlen(schluessel: string, jetzt: number): number {
const liste = (fehlversuche.get(schluessel) ?? []).filter((t) => jetzt - t < FENSTER_MS);
fehlversuche.set(schluessel, liste);
return liste.length;
}
/** Nur für Tests. */
export function sperrenZuruecksetzen(): void {
fehlversuche.clear();
}
export async function mitPasswortAnmelden(
benutzername: string,
passwort: string,
ip: string | null,
): Promise<Benutzer> {
const name = benutzername.trim().toLowerCase();
const schluessel = `${name}|${ip ?? "-"}`;
const jetzt = Date.now();
if (versucheZaehlen(schluessel, jetzt) >= HOECHSTENS) {
throw new AnwendungsFehler(
429,
"zu_viele_versuche",
"Zu viele Fehlversuche.",
"In 15 Minuten erneut versuchen.",
);
}
const benutzer = await prisma.benutzer.findUnique({ where: { benutzername: name } });
const passt =
benutzer?.passwortHash && benutzer.aktiv
? await passwortPruefen(benutzer.passwortHash, passwort)
: await attrappenPruefung(passwort);
if (!benutzer || !passt) {
fehlversuche.get(schluessel)?.push(jetzt);
await protokollieren({
benutzerId: benutzer?.id ?? null,
quelle: "WEB",
aktion: "Anmeldung fehlgeschlagen",
ziel: `benutzer:${name}`,
ip,
});
// Eine Meldung für alle Fälle – sie verrät nicht, ob es den Benutzer gibt.
throw new AnwendungsFehler(401, "anmeldung_fehlgeschlagen", "Benutzername oder Passwort ist falsch.");
}
fehlversuche.delete(schluessel);
await protokollieren({ benutzerId: benutzer.id, quelle: "WEB", aktion: "Angemeldet", ip });
return benutzer;
}
/**
* Legt beim ersten Start den Administrator aus ADMIN_BENUTZER/ADMIN_PASSWORT an – nur solange die
* Tabelle leer ist. Danach sind die Variablen wirkungslos, damit ein vergessener Eintrag in der
* .env kein zweites Hintertürchen ist.
*/
export async function ersterAdminFallsLeer(): Promise<void> {
if ((await prisma.benutzer.count()) > 0) return;
const benutzername = process.env["ADMIN_BENUTZER"]?.trim().toLowerCase() || "admin";
const passwort = process.env["ADMIN_PASSWORT"] ?? "";
if (passwort.length < PASSWORT_MINDESTLAENGE) {
logger.warn(
`Es gibt noch keinen Benutzer. ADMIN_PASSWORT setzen (mindestens ${PASSWORT_MINDESTLAENGE} Zeichen) und neu starten.`,
);
return;
}
let benutzer: Benutzer;
try {
benutzer = await prisma.benutzer.create({
data: {
benutzername,
anzeigename: "Administrator",
rolle: "ADMIN",
passwortHash: await passwortHashen(passwort),
},
});
} catch (fehler) {
// Ein zweiter Prozess war schneller (app und worker starten gleichzeitig) – das ist in Ordnung.
if ((fehler as { code?: string }).code === "P2002") return;
throw fehler;
}
await protokollieren({
benutzerId: benutzer.id,
quelle: "SYSTEM",
aktion: "Erster Administrator angelegt",
});
logger.info({ benutzername }, "Erster Administrator angelegt");
}
+30
View File
@@ -0,0 +1,30 @@
import type { Quelle } from "@/generated/prisma/enums";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export type ProtokollEintrag = {
benutzerId: string | null;
quelle: Quelle;
/** Lesbar und deutsch, z. B. „Server angelegt“. */
aktion: string;
/** Maschinenlesbar, z. B. „server:rz1“. */
ziel?: string;
details?: Prisma.InputJsonValue;
ip?: string | null;
};
type Client = Pick<typeof prisma, "protokoll">;
/** Schreibt einen Audit-Eintrag; in einer Transaktion mit `tx` als Client aufrufen. */
export async function protokollieren(eintrag: ProtokollEintrag, client: Client = prisma): Promise<void> {
await client.protokoll.create({
data: {
benutzerId: eintrag.benutzerId,
quelle: eintrag.quelle,
aktion: eintrag.aktion,
ziel: eintrag.ziel ?? null,
details: eintrag.details,
ip: eintrag.ip ?? null,
},
});
}