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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]",
|
||||
},
|
||||
});
|
||||
@@ -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}/`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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?)$).*)"],
|
||||
};
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.`);
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user