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
+35
View File
@@ -0,0 +1,35 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Seitenleiste } from "@/components/seitenleiste";
import { NAVIGATION } from "@/lib/navigation";
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
import { versionText } from "@/lib/version";
import { darf } from "@/server/auth/rechte";
import { aktuellerBenutzer } from "@/server/auth/zugang";
/**
* Hülle aller angemeldeten Seiten. Sie ist das **zweite** Netz – jede Seite prüft ihr Recht
* selbst, weil Next dieses Layout bei Navigation innerhalb der Gruppe nicht neu rendert.
*/
export default async function AppHuelle({ children }: { children: React.ReactNode }) {
const benutzer = await aktuellerBenutzer();
if (!benutzer) redirect("/anmelden");
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
const eintraege = NAVIGATION.filter((eintrag) => !eintrag.ab && darf(benutzer.rolle, eintrag.recht));
return (
<div className="lg:flex">
<Seitenleiste
eintraege={eintraege}
benutzer={{ anzeigename: benutzer.anzeigename, rolle: benutzer.rolle }}
version={versionText()}
theme={theme}
serverUmschalter={null}
/>
<main id="inhalt" className="min-w-0 flex-1 px-4 py-6 sm:px-8 lg:py-10">
<div className="mx-auto max-w-6xl">{children}</div>
</main>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { Karte } from "@/components/karte";
import { Status } from "@/components/status";
import { seiteSchuetzen } from "@/server/auth/zugang";
export const metadata: Metadata = { title: "Übersicht" };
export default async function Uebersicht() {
const benutzer = await seiteSchuetzen("lesen", "/");
return (
<div className="flex flex-col gap-6">
<header>
<p className="text-muted text-sm">Willkommen, {benutzer.anzeigename}</p>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Übersicht</h1>
</header>
<div className="grid gap-6 lg:grid-cols-2">
<Karte titel="Server">
<Status art="offen">Noch kein Proxmox-Server angelegt.</Status>
<p className="text-muted mt-2 text-sm">
Server lassen sich anlegen, sobald die Server-Verwaltung (#002) fertig ist.
</p>
</Karte>
<Karte titel="Letzte Deployments">
<Status art="offen">Noch keine Deployments.</Status>
</Karte>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Knopf } from "@/components/knopf";
import { Status } from "@/components/status";
export function Anmeldeformular({ ziel }: { ziel: string }) {
const router = useRouter();
const [fehler, setFehler] = useState<string | null>(null);
const [sendet, setSendet] = useState(false);
async function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
ereignis.preventDefault();
const daten = new FormData(ereignis.currentTarget);
setSendet(true);
setFehler(null);
try {
const antwort = await fetch("/api/v1/auth/anmelden", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ benutzername: daten.get("benutzername"), passwort: daten.get("passwort") }),
});
if (!antwort.ok) {
const inhalt = (await antwort.json().catch(() => null)) as {
fehler?: { nachricht?: string; hinweis?: string };
} | null;
setFehler(
[inhalt?.fehler?.nachricht ?? "Anmeldung fehlgeschlagen.", inhalt?.fehler?.hinweis]
.filter(Boolean)
.join(" "),
);
return;
}
router.replace(ziel);
router.refresh();
} catch {
setFehler("ProxDeploy ist nicht erreichbar.");
} finally {
setSendet(false);
}
}
const feld =
"bg-panel-hoch border-linie text-text h-11 w-full rounded-lg border px-3 placeholder:text-muted focus:border-akzent";
return (
<form
onSubmit={absenden}
className="bg-panel border-linie rounded-karte flex flex-col gap-4 border p-6"
noValidate
>
<div className="flex flex-col gap-1.5">
<label htmlFor="benutzername" className="text-sm font-medium">
Benutzername
</label>
<input
id="benutzername"
name="benutzername"
autoComplete="username"
required
autoFocus
className={feld}
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="passwort" className="text-sm font-medium">
Passwort
</label>
<input
id="passwort"
name="passwort"
type="password"
autoComplete="current-password"
required
className={feld}
/>
</div>
<div aria-live="polite" className="min-h-5 text-sm">
{fehler ? <Status art="fehler">{fehler}</Status> : null}
</div>
<Knopf type="submit" disabled={sendet}>
{sendet ? "Anmelden …" : "Anmelden"}
</Knopf>
</form>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { aktuellerBenutzer } from "@/server/auth/zugang";
import { Anmeldeformular } from "./anmeldeformular";
export const metadata: Metadata = { title: "Anmelden" };
/** Nur relative Ziele innerhalb der Anwendung – sonst wäre `?ziel=` eine offene Weiterleitung. */
function sicheresZiel(ziel: string | string[] | undefined): string {
return typeof ziel === "string" && ziel.startsWith("/") && !ziel.startsWith("//") ? ziel : "/";
}
export default async function Anmelden({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[]>>;
}) {
const ziel = sicheresZiel((await searchParams)["ziel"]);
if (await aktuellerBenutzer()) redirect(ziel);
return (
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
<Symbol className="h-14 w-14" />
<h1 className="text-2xl">
<Wortmarke blinkt />
</h1>
<p className="text-muted text-center text-sm">VMs und LXC-Container auf Proxmox VE ausrollen</p>
</div>
<Anmeldeformular ziel={ziel} />
</div>
</main>
);
}
+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,
});
});
+92
View File
@@ -0,0 +1,92 @@
@import "tailwindcss";
/*
* Farben von ProxDeploy – die einzige Stelle mit Hexwerten. Komponenten greifen nur über die
* Tailwind-Klassen (bg-panel, text-muted, border-linie …) oder var(--…) darauf zu.
*
* Dunkel ist der Standard. Das helle Theme setzt dieselben Namen neu, damit keine Komponente
* zwei Varianten kennen muss. Amber und Rot sind auf Hell abgedunkelt: die Originaltöne
* erreichen auf #F4F6F8 keine 4,5:1 (tests/kontrast.test.ts prüft beide Themes).
*/
:root,
[data-theme="dunkel"] {
--farbe-grund: #11161c; /* Ink */
--farbe-panel: #1a2129;
--farbe-panel-hoch: #212a34; /* Hover, Eingabefelder */
--farbe-linie: #2a333d;
--farbe-muted: #8b98a5;
--farbe-text: #e6edf3; /* Paper */
--farbe-akzent: #2dd4bf; /* Teal */
--farbe-akzent-text: #11161c; /* Text auf Akzentfläche */
--farbe-laeuft: #f5b841; /* Amber */
--farbe-fehler: #f87171;
--farbe-fokus: #2dd4bf;
color-scheme: dark;
}
[data-theme="hell"] {
--farbe-grund: #f4f6f8;
--farbe-panel: #ffffff;
--farbe-panel-hoch: #eef1f4;
--farbe-linie: #d5dbe1;
--farbe-muted: #4f5b66;
--farbe-text: #11161c;
--farbe-akzent: #0f766e; /* Teal dunkel */
--farbe-akzent-text: #ffffff;
--farbe-laeuft: #8a5a00;
--farbe-fehler: #b91c1c;
--farbe-fokus: #0f766e;
color-scheme: light;
}
@theme inline {
--color-grund: var(--farbe-grund);
--color-panel: var(--farbe-panel);
--color-panel-hoch: var(--farbe-panel-hoch);
--color-linie: var(--farbe-linie);
--color-muted: var(--farbe-muted);
--color-text: var(--farbe-text);
--color-akzent: var(--farbe-akzent);
--color-akzent-text: var(--farbe-akzent-text);
--color-laeuft: var(--farbe-laeuft);
--color-fehler: var(--farbe-fehler);
--font-sans: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, "SFMono-Regular", monospace;
--radius-karte: 14px;
}
html {
background: var(--farbe-grund);
color: var(--farbe-text);
}
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Fokus immer sichtbar, aber nur bei Tastaturbedienung. */
:focus-visible {
outline: 2px solid var(--farbe-fokus);
outline-offset: 2px;
border-radius: 6px;
}
@keyframes pd-cursor {
0%,
49% {
opacity: 1;
}
50%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: no-preference) {
.pd-cursor-blinkt {
animation: pd-cursor 1.1s steps(1) infinite;
}
}
+17
View File
@@ -0,0 +1,17 @@
import Link from "next/link";
import { Status } from "@/components/status";
/** Ziel von `seiteSchuetzen`, wenn die Rolle nicht reicht. Zeigt nichts außer dem Hinweis. */
export default function KeinRecht() {
return (
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center gap-4 px-4">
<Status art="fehler" className="text-lg font-semibold">
Dafür fehlt die Berechtigung.
</Status>
<p className="text-muted">Ein Administrator kann die Rolle ändern.</p>
<Link href="/" className="text-akzent underline underline-offset-4">
Zur Übersicht
</Link>
</main>
);
}
+25
View File
@@ -0,0 +1,25 @@
import "@fontsource-variable/space-grotesk";
import "@fontsource-variable/jetbrains-mono";
import "./globals.css";
import type { Metadata, Viewport } from "next";
import { cookies } from "next/headers";
import { THEME_COOKIE, themeAusCookie } from "@/lib/theme";
export const metadata: Metadata = {
title: { default: "ProxDeploy", template: "%s · ProxDeploy" },
description: "VMs und LXC-Container auf Proxmox VE ausrollen, per Web oder CLI.",
icons: { icon: "/brand/symbol.svg" },
};
// themeColor verträgt keine CSS-Variablen; Ink ist der Hintergrund des dunklen Themes.
export const viewport: Viewport = { themeColor: "#11161C", width: "device-width", initialScale: 1 };
export default async function Wurzel({ children }: { children: React.ReactNode }) {
// Das Theme steht im Cookie, damit der Server gleich richtig rendert und nichts aufblitzt.
const theme = themeAusCookie((await cookies()).get(THEME_COOKIE)?.value);
return (
<html lang="de" data-theme={theme}>
<body className="bg-grund text-text min-h-dvh">{children}</body>
</html>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { clsx } from "clsx";
/** Panel-Karte: 1-px-Rahmen in Linienfarbe, Radius 14 px, keine Schatten. */
export function Karte({
titel,
aktion,
children,
className,
}: {
titel?: React.ReactNode;
aktion?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<section className={clsx("bg-panel border-linie rounded-karte border", className)}>
{titel ? (
<header className="border-linie flex items-center justify-between gap-3 border-b px-5 py-3.5">
<h2 className="text-base font-semibold">{titel}</h2>
{aktion}
</header>
) : null}
<div className="p-5">{children}</div>
</section>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { clsx } from "clsx";
type Variante = "primaer" | "sekundaer" | "gefahr";
const VARIANTEN: Record<Variante, string> = {
primaer: "bg-akzent text-akzent-text hover:opacity-90",
sekundaer: "border border-linie bg-panel-hoch text-text hover:border-muted",
gefahr: "border border-fehler text-fehler hover:bg-fehler/10",
};
/** Ein echter `<button>`; 40 px hoch, am Handy 44 px (Tippfläche). */
export function Knopf({
variante = "primaer",
className,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variante?: Variante }) {
return (
<button
type="button"
{...rest}
className={clsx(
"inline-flex h-11 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition sm:h-10",
"disabled:cursor-not-allowed disabled:opacity-50",
VARIANTEN[variante],
className,
)}
/>
);
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import { clsx } from "clsx";
import {
BookOpen,
FileStack,
History,
LayoutDashboard,
LogOut,
Menu,
Rocket,
Server,
Settings,
Layers,
X,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { istAktiv, type NavigationsEintrag } from "@/lib/navigation";
import type { Theme } from "@/lib/theme";
import { Symbol, Wortmarke } from "@/components/wortmarke";
import { ThemeUmschalter } from "@/components/theme-umschalter";
const SYMBOLE: Record<
NavigationsEintrag["symbol"],
React.ComponentType<{ size?: number; "aria-hidden"?: boolean }>
> = {
uebersicht: LayoutDashboard,
ausrollen: Rocket,
verlauf: History,
templates: Layers,
vorlagen: FileStack,
katalog: BookOpen,
server: Server,
einstellungen: Settings,
};
type Eigenschaften = {
eintraege: readonly NavigationsEintrag[];
benutzer: { anzeigename: string; rolle: string };
version: string;
theme: Theme;
serverUmschalter: React.ReactNode;
};
export function Seitenleiste({ eintraege, benutzer, version, theme, serverUmschalter }: Eigenschaften) {
const pfad = usePathname();
const router = useRouter();
const [offen, setOffen] = useState(false);
async function abmelden() {
await fetch("/api/v1/auth/abmelden", { method: "POST" });
router.replace("/anmelden");
router.refresh();
}
const inhalt = (
<div className="flex h-full flex-col gap-6 p-4">
<Link href="/" className="flex items-center gap-2.5 px-2 pt-1" onClick={() => setOffen(false)}>
<Symbol className="h-8 w-8 shrink-0" />
<Wortmarke className="text-lg" />
</Link>
{serverUmschalter}
<nav aria-label="Hauptnavigation" className="flex-1">
<ul className="flex flex-col gap-0.5">
{eintraege.map((eintrag) => {
const Icon = SYMBOLE[eintrag.symbol];
const aktiv = istAktiv(eintrag.pfad, pfad);
return (
<li key={eintrag.pfad}>
<Link
href={eintrag.pfad}
onClick={() => setOffen(false)}
aria-current={aktiv ? "page" : undefined}
className={clsx(
"flex h-11 items-center gap-3 rounded-lg px-3 text-sm font-medium transition sm:h-10",
aktiv ? "bg-panel-hoch text-text" : "text-muted hover:text-text hover:bg-panel-hoch/60",
)}
>
<span className={aktiv ? "text-akzent" : undefined}>
<Icon size={18} aria-hidden={true} />
</span>
{eintrag.titel}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-linie flex flex-col gap-3 border-t pt-4">
<div className="flex items-center justify-between gap-2 px-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{benutzer.anzeigename}</p>
<p className="text-muted text-xs">{benutzer.rolle === "ADMIN" ? "Administrator" : "Operator"}</p>
</div>
<div className="flex items-center">
<ThemeUmschalter anfang={theme} />
<button
type="button"
onClick={abmelden}
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
aria-label="Abmelden"
title="Abmelden"
>
<LogOut size={18} aria-hidden="true" />
</button>
</div>
</div>
<p className="text-muted px-2 font-mono text-xs">{version}</p>
</div>
</div>
);
return (
<>
{/* Handy: Kopfleiste mit Menüknopf, Navigation als Schublade. */}
<div className="border-linie bg-panel sticky top-0 z-30 flex h-14 items-center justify-between border-b px-4 lg:hidden">
<Link href="/" className="flex items-center gap-2">
<Symbol className="h-7 w-7" />
<Wortmarke />
</Link>
<button
type="button"
onClick={() => setOffen(true)}
className="inline-flex h-11 w-11 items-center justify-center rounded-lg"
aria-label="Navigation öffnen"
aria-expanded={offen}
aria-controls="seitenleiste"
>
<Menu size={22} aria-hidden="true" />
</button>
</div>
{offen ? (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
aria-hidden="true"
onClick={() => setOffen(false)}
/>
) : null}
<aside
id="seitenleiste"
className={clsx(
"border-linie bg-panel fixed inset-y-0 left-0 z-50 w-72 border-r transition-transform lg:sticky lg:top-0 lg:h-dvh lg:translate-x-0",
offen ? "translate-x-0" : "-translate-x-full",
)}
>
<button
type="button"
onClick={() => setOffen(false)}
className="absolute top-3 right-3 inline-flex h-11 w-11 items-center justify-center rounded-lg lg:hidden"
aria-label="Navigation schließen"
>
<X size={20} aria-hidden="true" />
</button>
{inhalt}
</aside>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { clsx } from "clsx";
/**
* Status immer als Symbol + Farbe + Text, nie nur Farbe (Vorgabe Barrierearmut).
* Dieselben Symbole wie in der CLI-Ausgabe.
*/
export type StatusArt = "erledigt" | "laeuft" | "fehler" | "offen";
export const STATUS_SYMBOL: Record<StatusArt, string> = {
erledigt: "✓",
laeuft: "◐",
fehler: "✗",
offen: "○",
};
const STATUS_FARBE: Record<StatusArt, string> = {
erledigt: "text-akzent",
laeuft: "text-laeuft",
fehler: "text-fehler",
offen: "text-muted",
};
export function Status({
art,
children,
className,
}: {
art: StatusArt;
children: React.ReactNode;
className?: string;
}) {
return (
<span className={clsx("inline-flex items-center gap-1.5", STATUS_FARBE[art], className)}>
<span aria-hidden="true" className="font-mono">
{STATUS_SYMBOL[art]}
</span>
<span>{children}</span>
</span>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useState } from "react";
import { THEME_COOKIE, type Theme } from "@/lib/theme";
export function ThemeUmschalter({ anfang }: { anfang: Theme }) {
const [theme, setTheme] = useState<Theme>(anfang);
const naechstes: Theme = theme === "dunkel" ? "hell" : "dunkel";
function umschalten() {
document.documentElement.dataset["theme"] = naechstes;
document.cookie = `${THEME_COOKIE}=${naechstes}; path=/; max-age=31536000; samesite=lax`;
setTheme(naechstes);
}
return (
<button
type="button"
onClick={umschalten}
className="text-muted hover:text-text inline-flex h-10 w-10 items-center justify-center rounded-lg"
aria-label={naechstes === "hell" ? "Helles Theme einschalten" : "Dunkles Theme einschalten"}
title={naechstes === "hell" ? "Helles Theme" : "Dunkles Theme"}
>
{theme === "dunkel" ? <Sun size={18} aria-hidden="true" /> : <Moon size={18} aria-hidden="true" />}
</button>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { clsx } from "clsx";
/**
* Wortmarke nach Branding-Vorgabe: „Prox“ normal, „Deploy“ fett in Akzentfarbe, dahinter ein
* Block-Cursor. Die Schrift ist immer JetBrains Mono.
*/
export function Wortmarke({ className, blinkt = false }: { className?: string; blinkt?: boolean }) {
return (
<span className={clsx("font-mono tracking-tight whitespace-nowrap", className)}>
<span className="text-text">Prox</span>
<span className="text-akzent font-bold">Deploy</span>
<span
aria-hidden="true"
className={clsx("bg-akzent ml-[0.12em] inline-block h-[0.95em] w-[0.5em] align-[-0.12em]", {
"pd-cursor-blinkt": blinkt,
})}
/>
</span>
);
}
/** Symbol „Launch-Node“ als Inline-SVG, damit es die Theme-Farben übernimmt. */
export function Symbol({ className, titel }: { className?: string; titel?: string }) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
className={className}
role={titel ? "img" : undefined}
aria-hidden={titel ? undefined : true}
>
{titel ? <title>{titel}</title> : null}
<path
d="M32 6 L54.5 19 V45 L32 58 L9.5 45 V19 Z"
stroke="var(--farbe-text)"
strokeWidth="3.5"
strokeLinejoin="round"
/>
<path
d="M32 41 V20 M23.5 28.5 L32 20 L40.5 28.5"
stroke="var(--farbe-akzent)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M24 47 H40" stroke="var(--farbe-akzent)" strokeWidth="4" strokeLinecap="round" />
</svg>
);
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Läuft einmal beim Start des Next-Servers. Prüft die Konfiguration früh und legt den ersten
* Administrator an – statt beim ersten Seitenaufruf mit einer unklaren Meldung zu scheitern.
*/
export async function register(): Promise<void> {
if (process.env["NEXT_RUNTIME"] !== "nodejs") return;
const { schluesselPruefen } = await import("@/lib/geheimnisse");
const { ersterAdminFallsLeer } = await import("@/server/services/anmeldung");
const { logger } = await import("@/lib/logger");
try {
schluesselPruefen();
await ersterAdminFallsLeer();
} catch (fehler) {
logger.error({ err: fehler }, "Start-Prüfung fehlgeschlagen");
}
}
+41
View File
@@ -0,0 +1,41 @@
/**
* Adressen der Anwendung nach außen.
*
* Maßgeblich ist APP_URL, nicht die eingehende Anfrage: hinter dem Reverse-Proxy trägt die
* Anfrage den internen Host und oft das falsche Schema. Die Herkunft der Anfrage dient nur als
* Notnagel, damit die Anwendung ohne APP_URL nicht stehen bleibt.
*/
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
const basis = process.env["APP_URL"]?.trim();
if (basis) {
try {
return new URL(pfad, basis);
} catch {
// Eine unbrauchbare APP_URL soll die Anmeldung nicht blockieren.
}
}
return new URL(pfad, ersatzHerkunft);
}
/**
* Ob die Verbindung des Browsers HTTPS ist. Hinter einem Reverse-Proxy (TRUST_PROXY=true) zählt
* X-Forwarded-Proto, sonst das Schema der Anfrage. Davon hängt das `Secure`-Attribut des
* Sitzungscookies ab: fest gesetzt, scheitert die Anmeldung über reines HTTP stillschweigend
* (in ProxLedger 0.11.1 passiert).
*/
export function istHttps(kopfzeilen: Headers, anfrageUrl: string): boolean {
if (process.env["TRUST_PROXY"] === "true") {
const weitergeleitet = kopfzeilen.get("x-forwarded-proto")?.split(",")[0]?.trim();
if (weitergeleitet) return weitergeleitet === "https";
}
return new URL(anfrageUrl).protocol === "https:";
}
/**
* Adresse des Aufrufers für Protokoll und Anmeldesperre. Ohne vertrauten Proxy sind alle
* Kopfzeilen vom Aufrufer frei wählbar, dann bleibt die Adresse lieber leer als erfunden.
*/
export function aufruferAdresse(kopfzeilen: Headers): string | null {
if (process.env["TRUST_PROXY"] !== "true") return null;
return kopfzeilen.get("x-forwarded-for")?.split(",")[0]?.trim() || kopfzeilen.get("x-real-ip");
}
+39
View File
@@ -0,0 +1,39 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
/**
* Zugriff auf die Datenbank.
*
* Der Client wird erst beim ersten Zugriff gebaut. Dadurch laeuft `next build`
* ohne gesetzte DATABASE_URL durch, obwohl Module den Import auf oberster Ebene
* haben. In der Entwicklung liegt die Instanz an globalThis, damit Hot Reload
* nicht bei jedem Speichern eine neue Verbindung aufbaut.
*/
function clientBauen(): PrismaClient {
const url = process.env["DATABASE_URL"];
if (!url) {
throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
}
return new PrismaClient({ adapter: new PrismaPg({ connectionString: url }) });
}
const globalerZwischenspeicher = globalThis as typeof globalThis & {
proxdeployPrisma?: PrismaClient;
};
let instanz: PrismaClient | undefined;
function instanzHolen(): PrismaClient {
if (process.env["NODE_ENV"] !== "production") {
globalerZwischenspeicher.proxdeployPrisma ??= clientBauen();
return globalerZwischenspeicher.proxdeployPrisma;
}
instanz ??= clientBauen();
return instanz;
}
export const prisma = new Proxy({} as PrismaClient, {
get(_ziel, eigenschaft, empfaenger) {
return Reflect.get(instanzHolen(), eigenschaft, empfaenger);
},
});
+99
View File
@@ -0,0 +1,99 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
/**
* Verschlüsselt Geheimnisse, die ProxDeploy im Klartext wieder braucht: Proxmox-Token-Secrets,
* SSH-Privatschlüssel, das OIDC-Client-Secret. Anmeldepasswörter und CLI-Tokens werden dagegen
* gehasht und nie entschlüsselt.
*
* AES-256-GCM, weil es neben der Vertraulichkeit auch die Unverfälschtheit bezeugt: ein
* veränderter Geheimtext wirft beim Entschlüsseln, statt still Unsinn zu liefern.
*
* Der Schlüssel kommt **direkt** aus `PROXDEPLOY_SCHLUESSEL` (32 Byte, base64) und wird nicht aus
* einem Passwort abgeleitet: Er ist ohnehin zufällig erzeugt, und eine Ableitung hätte nur ein
* festes Salz im Code gebraucht, ohne etwas zu schützen.
*/
const ALGORITHMUS = "aes-256-gcm";
const IV_LAENGE = 12;
const AUTH_TAG_LAENGE = 16;
const SCHLUESSEL_LAENGE = 32;
/** Versionspräfix, damit ein späterer Schlüsseltausch alte Werte noch erkennt. */
const FORMAT_VERSION = "v1";
export class GeheimnisFehler extends Error {
constructor(nachricht: string, optionen?: ErrorOptions) {
super(nachricht, optionen);
this.name = "GeheimnisFehler";
}
}
let zwischenspeicher: { quelle: string; schluessel: Buffer } | undefined;
function schluesselHolen(): Buffer {
const quelle = process.env["PROXDEPLOY_SCHLUESSEL"]?.trim() ?? "";
if (zwischenspeicher?.quelle === quelle) return zwischenspeicher.schluessel;
const schluessel = Buffer.from(quelle, "base64");
// Die tatsächliche Länge steht bewusst nicht in der Meldung – sie könnte in einem Log landen.
if (schluessel.length !== SCHLUESSEL_LAENGE) {
throw new GeheimnisFehler(
"PROXDEPLOY_SCHLUESSEL fehlt oder ist kein base64-kodierter 32-Byte-Schlüssel. " +
"Erzeugen mit: openssl rand -base64 32",
);
}
zwischenspeicher = { quelle, schluessel };
return schluessel;
}
/** Prüft beim Start, ob der Schlüssel brauchbar ist, statt erst beim ersten Server-Zugriff. */
export function schluesselPruefen(): void {
schluesselHolen();
}
export function verschluesseln(klartext: string): string {
const schluessel = schluesselHolen();
// Je Aufruf ein neuer IV. Derselbe IV zweimal mit demselben Schlüssel bricht GCM vollständig.
const iv = randomBytes(IV_LAENGE);
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
// base64 enthält keinen Punkt, er taugt also als Trenner.
return [
FORMAT_VERSION,
iv.toString("base64"),
chiffre.getAuthTag().toString("base64"),
geheim.toString("base64"),
].join(".");
}
export function entschluesseln(geheimtext: string): string {
const teile = geheimtext.split(".");
const [version, ivTeil, tagTeil, datenTeil] = teile;
if (teile.length !== 4 || !version || !ivTeil || !tagTeil || datenTeil === undefined) {
throw new GeheimnisFehler("Geheimtext hat ein unbekanntes Format.");
}
if (version !== FORMAT_VERSION) {
throw new GeheimnisFehler(`Geheimtext trägt die unbekannte Version „${version}“.`);
}
const iv = Buffer.from(ivTeil, "base64");
const authTag = Buffer.from(tagTeil, "base64");
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
throw new GeheimnisFehler("Geheimtext ist unvollständig: IV oder Auth-Tag haben die falsche Länge.");
}
const schluessel = schluesselHolen();
try {
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
entschluessler.setAuthTag(authTag);
// `final` prüft das Auth-Tag und wirft bei verändertem Text oder falschem Schlüssel.
return Buffer.concat([
entschluessler.update(Buffer.from(datenTeil, "base64")),
entschluessler.final(),
]).toString("utf8");
} catch (ursache) {
throw new GeheimnisFehler(
"Geheimtext ließ sich nicht entschlüsseln. Entweder wurde der Wert verändert, oder " +
"PROXDEPLOY_SCHLUESSEL ist nicht mehr derselbe wie beim Verschlüsseln.",
{ cause: ursache },
);
}
}
+26
View File
@@ -0,0 +1,26 @@
import pino from "pino";
/**
* Strukturierte Logs (JSON auf stdout) für den Betrieb. Geheimnisse werden an den bekannten
* Stellen geschwärzt – zusätzlich zur Regel, sie gar nicht erst in Log-Objekte zu geben.
*/
export const logger = pino({
level: process.env["LOG_LEVEL"] ?? "info",
base: { dienst: process.env["PROXDEPLOY_DIENST"] ?? "app" },
messageKey: "nachricht",
timestamp: pino.stdTimeFunctions.isoTime,
redact: {
paths: [
"passwort",
"*.passwort",
"tokenGeheimnis",
"*.tokenGeheimnis",
"sshPrivat",
"*.sshPrivat",
"authorization",
"*.authorization",
"headers.cookie",
],
censor: "[geschwärzt]",
},
});
+44
View File
@@ -0,0 +1,44 @@
/**
* Navigation der Seitenleiste. Jeder Eintrag nennt sein Recht als Schlüssel aus RECHTE
* (`src/server/auth/rechte.ts`); die Hülle blendet aus, was die Rolle nicht darf.
* Hier nur Text und Pfade, damit Client-Komponenten es importieren dürfen.
*/
export type NavigationsEintrag = {
pfad: string;
titel: string;
symbol:
| "uebersicht"
| "ausrollen"
| "verlauf"
| "templates"
| "vorlagen"
| "katalog"
| "server"
| "einstellungen";
recht: "lesen" | "ausrollen" | "serverVerwalten" | "katalogVerwalten" | "eigeneZugaenge";
/** Erscheint erst, wenn das Issue umgesetzt ist – bis dahin nicht anzeigen statt ins Leere führen. */
ab?: string;
};
export const NAVIGATION: readonly NavigationsEintrag[] = [
{ pfad: "/", titel: "Übersicht", symbol: "uebersicht", recht: "lesen" },
{ pfad: "/ausrollen", titel: "Ausrollen", symbol: "ausrollen", recht: "ausrollen", ab: "#008" },
{ pfad: "/auftraege", titel: "Verlauf", symbol: "verlauf", recht: "lesen", ab: "#006" },
{ pfad: "/templates", titel: "Templates", symbol: "templates", recht: "lesen", ab: "#007" },
{ pfad: "/vorlagen", titel: "Vorlagen", symbol: "vorlagen", recht: "lesen", ab: "#012" },
{ pfad: "/katalog", titel: "Katalog", symbol: "katalog", recht: "lesen", ab: "#004" },
{ pfad: "/server", titel: "Server", symbol: "server", recht: "lesen", ab: "#002" },
{
pfad: "/einstellungen",
titel: "Einstellungen",
symbol: "einstellungen",
recht: "eigeneZugaenge",
ab: "#015",
},
];
/** Aktiv ist ein Eintrag auch für Unterseiten – außer der Übersicht, sonst wäre sie immer aktiv. */
export function istAktiv(eintrag: string, pfad: string): boolean {
if (eintrag === "/") return pfad === "/";
return pfad === eintrag || pfad.startsWith(`${eintrag}/`);
}
+32
View File
@@ -0,0 +1,32 @@
import { hash, verify } from "@node-rs/argon2";
/**
* Anmeldepasswörter: argon2id mit den Vorgaben der Bibliothek (OWASP-konform).
* Wird nie entschlüsselt – das unterscheidet es von `geheimnisse.ts`.
*/
export const PASSWORT_MINDESTLAENGE = 12;
export async function passwortHashen(passwort: string): Promise<string> {
return await hash(passwort);
}
export async function passwortPruefen(passwortHash: string, passwort: string): Promise<boolean> {
try {
return await verify(passwortHash, passwort);
} catch {
// Ein kaputter Hash in der Datenbank ist kein Grund, die Anmeldung abstürzen zu lassen.
return false;
}
}
/**
* Ein fester Hash für den Fall „Benutzer gibt es nicht“: so dauert eine Anmeldung mit
* unbekanntem Namen genauso lange wie eine mit falschem Passwort, und die Antwortzeit verrät
* nicht, welche Benutzernamen existieren.
*/
let attrappe: Promise<string> | undefined;
export async function attrappenPruefung(passwort: string): Promise<false> {
attrappe ??= hash("proxdeploy-attrappe-ohne-bedeutung");
await passwortPruefen(await attrappe, passwort);
return false;
}
+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;
}
}
+7
View File
@@ -0,0 +1,7 @@
export const THEME_COOKIE = "pd_theme";
export type Theme = "dunkel" | "hell";
/** Dunkel ist der Standard; alles Unbekannte im Cookie wird zu dunkel. */
export function themeAusCookie(wert: string | undefined): Theme {
return wert === "hell" ? "hell" : "dunkel";
}
+23
View File
@@ -0,0 +1,23 @@
import { z } from "zod";
/**
* Zod mit deutschen Standardmeldungen.
*
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
* englische Saetze heraus („Invalid input: expected string“), und die landen
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
* ab, woran niemand gedacht hat.
*
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
*/
z.config(z.locales.de());
export { z };
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
export { ZodError } from "zod";
export type { ZodIssue, ZodType } from "zod";
+10
View File
@@ -0,0 +1,10 @@
/**
* Version und Git-Stand für Seitenleiste, `/api/v1/ich` und die Kopfzeile der CLI.
* Beide Werte werden beim Bauen gesetzt (Docker-Build-Arg bzw. Installer).
*/
export const VERSION = process.env["NEXT_PUBLIC_APP_VERSION"] ?? "0.1.0-dev";
export const GIT_SHA = (process.env["NEXT_PUBLIC_GIT_SHA"] ?? "").slice(0, 7);
export function versionText(): string {
return GIT_SHA ? `v${VERSION} · ${GIT_SHA}` : `v${VERSION}`;
}
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse, type NextRequest } from "next/server";
import { oeffentlicheAdresse } from "@/lib/adressen";
import { SITZUNG_COOKIE } from "@/lib/sitzung";
/**
* Next 16: die frühere middleware.ts. Sie setzt die Content-Security-Policy mit Nonce und ist
* ein billiges Tor: ohne Sitzungscookie geht es zur Anmeldung. **Geprüft wird das Cookie hier
* nicht** – das tun Seiten und Route Handler selbst gegen die Datenbank.
*/
export const OEFFENTLICH: readonly string[] = [
"/anmelden",
"/api/v1/auth",
"/api/v1/gesundheit",
"/brand",
"/manifest.webmanifest",
];
export function istOeffentlich(pfad: string): boolean {
return OEFFENTLICH.some((eintrag) => pfad === eintrag || pfad.startsWith(`${eintrag}/`));
}
/**
* Im Entwicklungsmodus braucht React `unsafe-eval` und Fast Refresh `ws:` – ohne beides hydriert
* die Seite nicht. Der gebaute Stand bekommt die strenge Fassung (tests/sicherheitsrichtlinie).
*/
export function sicherheitsrichtlinie(nonce: string, entwicklung: boolean): string {
return [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data:`,
`font-src 'self'`,
`connect-src 'self'${entwicklung ? " ws: wss:" : ""}`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`object-src 'none'`,
].join("; ");
}
export function proxy(anfrage: NextRequest): NextResponse {
const pfad = anfrage.nextUrl.pathname;
// Die CLI meldet sich per Bearer-Token an; das prüft der Route Handler.
const hatToken = anfrage.headers.get("authorization")?.startsWith("Bearer ") ?? false;
if (!istOeffentlich(pfad) && !anfrage.cookies.has(SITZUNG_COOKIE) && !hatToken) {
// API bekommt 401 statt Umleitung: fetch folgte ihr sonst und hielte die Anmeldeseite für Daten.
if (pfad.startsWith("/api/")) {
return NextResponse.json(
{ fehler: { code: "nicht_angemeldet", nachricht: "Nicht angemeldet." } },
{ status: 401 },
);
}
const anmelden = oeffentlicheAdresse("/anmelden", anfrage.nextUrl.origin);
const ziel = pfad + anfrage.nextUrl.search;
if (ziel !== "/") anmelden.searchParams.set("ziel", ziel);
return NextResponse.redirect(anmelden);
}
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const kopfzeilen = new Headers(anfrage.headers);
kopfzeilen.set("x-nonce", nonce);
kopfzeilen.set("x-pfad", pfad);
const antwort = NextResponse.next({ request: { headers: kopfzeilen } });
antwort.headers.set(
"Content-Security-Policy",
sicherheitsrichtlinie(nonce, process.env.NODE_ENV !== "production"),
);
return antwort;
}
export const config = {
matcher: ["/((?!_next/|favicon.ico|.*\\.(?:svg|png|webp|ico|woff2?)$).*)"],
};
+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,
},
});
}