Phase 0: Fundament der Feuerwehr-Verwaltungsplattform
Legt den kompletten Unterbau an, auf dem die Fachmodule aufsetzen. Betrieb: - docker-compose.yml mit Traefik, App, Worker, PostgreSQL 17 + PostGIS, Redis, MinIO und Gotenberg; Entwicklungs-Override ohne Traefik, dafuer mit Mailpit und Adminer - Mehrstufiges Dockerfile: eigener Benutzer, tini als PID 1, Healthcheck - .env.example mit einem Kommentar zu jeder Variable Mandantentrennung: - Row Level Security auf allen mandantenpflichtigen Tabellen - Zwei getrennte Datenbankverbindungen: florian_app mit aktiver RLS fuer die Anwendung, Eigentuemer-Zugang nur fuer Migration, Seed und Anmeldung - fuerOrganisation() als einziger Weg zu Fachdaten Nachvollziehbarkeit: - AuditLog append-only per Trigger; UPDATE ist dauerhaft gesperrt, DELETE nur im gekennzeichneten Aufbewahrungslauf - Geheimnisse werden vor dem Schreiben entfernt Anmeldung: - OIDC gegen ein extern betriebenes authentik, Rollen aus Gruppen nur als Vorbelegung beim ersten Login - Lokaler E-Mail/Passwort-Login als Rueckfallweg - Terminal per PIN und RFID mit zwei Schranken (Geraet, dann Person) Weiteres: - lib/authz.ts mit 9 Rollen und 38 Einzelrechten - lib/storage.ts (MinIO), lib/audit.ts, lib/warteschlange.ts (BullMQ) - Integrationen fuer Mail, Telegram und LLM hinter abschaltbaren Interfaces - tRPC v11, Basis-Layout mit Seitenleiste und dunkler Darstellung - Seed mit einer Beispielwehr: 16 Personen, 3 Fahrzeuge, 40 Geraete - 32 Vitest-Tests (davon 8 gegen eine echte Datenbank), 10 Playwright-Tests - README, Anleitung mit Screenshots, Roadmap, Aufwandsschaetzung, Devlog, Versionierungsregeln und Changelog Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { rechteVon } from "@/lib/authz";
|
||||
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
|
||||
/**
|
||||
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
|
||||
* landet auf der Anmeldeseite — geprüft wird serverseitig, nicht im Browser.
|
||||
*/
|
||||
export default async function AngemeldetLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
const organisation = await fuerOrganisation(sitzung.organisationId, (db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: sitzung.organisationId },
|
||||
select: { name: true, kurzname: true },
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Seitenleiste
|
||||
rechte={[...rechteVon(sitzung.rollen)]}
|
||||
rollen={sitzung.rollen}
|
||||
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
|
||||
personName={sitzung.name}
|
||||
/>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { AlertTriangle, Truck, Users, Wrench } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { ROLLEN_BEZEICHNUNG } from "@/lib/authz";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { alsDatumZeit } from "@/lib/utils";
|
||||
|
||||
export const metadata = { title: "Übersicht" };
|
||||
|
||||
/** Startseite nach der Anmeldung. */
|
||||
export default async function UebersichtSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) return null;
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const [organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt] =
|
||||
await Promise.all([
|
||||
db.organisation.findUnique({ where: { id: sitzung.organisationId } }),
|
||||
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
|
||||
]);
|
||||
return { organisation, mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt };
|
||||
});
|
||||
|
||||
const kacheln = [
|
||||
{ titel: "Aktive Mitglieder", wert: daten.mitglieder, symbol: Users },
|
||||
{ titel: "Fahrzeuge", wert: daten.fahrzeuge, symbol: Truck },
|
||||
{ titel: "Geräte", wert: daten.geraete, symbol: Wrench },
|
||||
{ titel: "Prüfung fällig", wert: daten.pruefungFaellig, symbol: AlertTriangle },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{daten.organisation?.name ?? "Feuerwehr"}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Angemeldet als {sitzung.name} —{" "}
|
||||
{sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Sicherheitshinweis nach den Projektleitplanken. Er bleibt stehen, bis
|
||||
die Wehr die Anwendung ausdrücklich freigegeben hat.
|
||||
*/}
|
||||
<div
|
||||
role="note"
|
||||
className="flex items-start gap-3 rounded-lg border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-4"
|
||||
>
|
||||
<AlertTriangle
|
||||
className="mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">Diese Installation ist noch nicht freigegeben.</p>
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Atemschutzüberwachung und Alarmmonitor sind sicherheitsrelevant. Solange die
|
||||
Wehr das System nicht ausdrücklich freigegeben hat, bleibt die analoge
|
||||
Überwachung führend. FLORIAN ersetzt sie nicht.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{kacheln.map((kachel) => {
|
||||
const Symbol = kachel.symbol;
|
||||
return (
|
||||
<Card key={kachel.titel}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
|
||||
{kachel.titel}
|
||||
</CardTitle>
|
||||
<Symbol className="size-4 text-[var(--muted-foreground)]" aria-hidden />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-semibold tabellenzahlen">{kachel.wert}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stand des Aufbaus</CardTitle>
|
||||
<CardDescription>
|
||||
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung,
|
||||
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für
|
||||
Phase dazu.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
|
||||
<Badge variant="outline">Phase 1 — Stammdaten</Badge>
|
||||
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
|
||||
<Badge variant="outline">Phase 3 — Einsätze</Badge>
|
||||
<Badge variant="outline">Phase 4 — Terminal</Badge>
|
||||
<Badge variant="outline">Phase 5 — Atemschutz</Badge>
|
||||
</div>
|
||||
<dl className="grid gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">Gesperrte Geräte</dt>
|
||||
<dd className="tabellenzahlen">{daten.gesperrt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--muted-foreground)]">Zeitpunkt</dt>
|
||||
<dd className="tabellenzahlen">{alsDatumZeit(new Date())}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound, LogIn } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export interface AnmeldeFormularProps {
|
||||
oidcVerfuegbar: boolean;
|
||||
oidcName: string;
|
||||
lokalVerfuegbar: boolean;
|
||||
fehler?: string;
|
||||
weiterZu?: string;
|
||||
}
|
||||
|
||||
/** Wandelt die Fehlerkennungen von Auth.js in verständliches Deutsch. */
|
||||
function fehlertext(kennung?: string): string | null {
|
||||
if (!kennung) return null;
|
||||
switch (kennung) {
|
||||
case "CredentialsSignin":
|
||||
return "E-Mail-Adresse oder Passwort stimmt nicht. Nach mehreren Fehlversuchen wird das Konto vorübergehend gesperrt.";
|
||||
case "AccessDenied":
|
||||
return "Für dieses Konto gibt es in FLORIAN keinen Zugang. Bitte beim Kommandanten melden.";
|
||||
case "OAuthAccountNotLinked":
|
||||
return "Dieses Konto ist bereits mit einem anderen Anmeldeweg verknüpft.";
|
||||
case "Configuration":
|
||||
return "Die Anmeldung ist nicht vollständig eingerichtet. Bitte in der Serverkonfiguration prüfen.";
|
||||
default:
|
||||
return "Die Anmeldung ist fehlgeschlagen. Bitte erneut versuchen.";
|
||||
}
|
||||
}
|
||||
|
||||
export function AnmeldeFormular({
|
||||
oidcVerfuegbar,
|
||||
oidcName,
|
||||
lokalVerfuegbar,
|
||||
fehler,
|
||||
weiterZu,
|
||||
}: AnmeldeFormularProps) {
|
||||
const router = useRouter();
|
||||
const [laeuft, setLaeuft] = React.useState(false);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(fehlertext(fehler));
|
||||
|
||||
async function lokalAnmelden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
setMeldung(null);
|
||||
setLaeuft(true);
|
||||
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
const ergebnis = await signIn("passwort", {
|
||||
email: String(daten.get("email") ?? ""),
|
||||
passwort: String(daten.get("passwort") ?? ""),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
setLaeuft(false);
|
||||
|
||||
if (ergebnis?.error) {
|
||||
setMeldung(fehlertext("CredentialsSignin"));
|
||||
return;
|
||||
}
|
||||
router.push(weiterZu ?? "/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{meldung && (
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="anmeldefehler"
|
||||
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
|
||||
>
|
||||
{meldung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{oidcVerfuegbar && (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={laeuft}
|
||||
onClick={() => {
|
||||
setLaeuft(true);
|
||||
void signIn("authentik", { callbackUrl: weiterZu ?? "/" });
|
||||
}}
|
||||
>
|
||||
<KeyRound aria-hidden />
|
||||
{oidcName}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{oidcVerfuegbar && lokalVerfuegbar && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
<span className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
|
||||
oder
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lokalVerfuegbar && (
|
||||
<form onSubmit={lokalAnmelden} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
required
|
||||
placeholder="kommandant@beispielwehr.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passwort">Passwort</Label>
|
||||
<Input
|
||||
id="passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={laeuft}>
|
||||
<LogIn aria-hidden />
|
||||
{laeuft ? "Wird geprüft ..." : "Anmelden"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!oidcVerfuegbar && !lokalVerfuegbar && (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Es ist kein Anmeldeweg eingerichtet. Bitte in der Serverkonfiguration
|
||||
AUTH_LOCAL_ENABLED oder AUTH_OIDC_ENABLED setzen.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Am Terminal im Gerätehaus wird stattdessen mit PIN oder Ausweiskarte
|
||||
angemeldet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Flame } from "lucide-react";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { env, oidcVerfuegbar } from "@/lib/env";
|
||||
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card";
|
||||
import { AnmeldeFormular } from "@/app/anmelden/formular";
|
||||
|
||||
export const metadata = { title: "Anmelden" };
|
||||
|
||||
/**
|
||||
* Anmeldeseite. Zeigt genau die Wege, die in dieser Installation eingerichtet
|
||||
* sind: OIDC gegen authentik, lokaler Login, oder beides.
|
||||
*/
|
||||
export default async function AnmeldenSeite({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ error?: string; callbackUrl?: string }>;
|
||||
}) {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (sitzung) redirect("/");
|
||||
|
||||
const { error, callbackUrl } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[var(--muted)] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Flame className="size-8 text-[var(--primary)]" aria-hidden />
|
||||
<div>
|
||||
{/* Echte h1: die Seite braucht eine Ueberschrift, die
|
||||
Hilfsmittel und Tests ansteuern koennen. */}
|
||||
<h1 className="text-xl font-semibold leading-none tracking-tight">
|
||||
FLORIAN
|
||||
</h1>
|
||||
<CardDescription className="mt-1.5">Feuerwehrverwaltung</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<AnmeldeFormular
|
||||
oidcVerfuegbar={oidcVerfuegbar}
|
||||
oidcName={env.AUTH_OIDC_NAME}
|
||||
lokalVerfuegbar={env.AUTH_LOCAL_ENABLED}
|
||||
fehler={error}
|
||||
weiterZu={callbackUrl}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* Auth.js-Endpunkte. Die Rückleitung von authentik landet hier unter
|
||||
* /api/auth/callback/authentik — genau diese Adresse muss im authentik-Provider
|
||||
* als Redirect-URI eingetragen sein.
|
||||
*/
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { datenbankErreichbar } from "@/lib/db";
|
||||
import { speicherErreichbar } from "@/lib/storage";
|
||||
import { warteschlangeErreichbar } from "@/lib/warteschlange";
|
||||
|
||||
/**
|
||||
* Erreichbarkeitsprüfung für Docker, Traefik und die Überwachung.
|
||||
* 200, wenn Datenbank, Objektspeicher und Warteschlange antworten — sonst 503.
|
||||
* Bewusst ohne personenbezogene Angaben, damit der Endpunkt offen sein kann.
|
||||
*/
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
const beginn = Date.now();
|
||||
|
||||
const [datenbank, speicher, warteschlange] = await Promise.all([
|
||||
datenbankErreichbar(),
|
||||
speicherErreichbar(),
|
||||
warteschlangeErreichbar(),
|
||||
]);
|
||||
|
||||
const gesund = datenbank && speicher && warteschlange;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: gesund ? "ok" : "gestoert",
|
||||
dienste: { datenbank, speicher, warteschlange },
|
||||
dauerMs: Date.now() - beginn,
|
||||
zeitpunkt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
status: gesund ? 200 : 503,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@/server/routers/_app";
|
||||
import { kontextErzeugen } from "@/server/context";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/** Einstiegspunkt für alle internen tRPC-Aufrufe. */
|
||||
function verarbeiten(anfrage: Request) {
|
||||
return fetchRequestHandler({
|
||||
endpoint: "/api/trpc",
|
||||
req: anfrage,
|
||||
router: appRouter,
|
||||
createContext: () => kontextErzeugen({ req: anfrage }),
|
||||
onError:
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(`[trpc] Fehler in ${path ?? "<unbekannt>"}:`, error.message);
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export { verarbeiten as GET, verarbeiten as POST };
|
||||
@@ -0,0 +1,170 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
Farbwelt FLORIAN.
|
||||
Grundton ist das Rot der Feuerwehr, bewusst entsättigt — eine Oberfläche, in
|
||||
der jemand acht Stunden Prüfungen dokumentiert, darf nicht schreien. Signalrot
|
||||
bleibt für das reserviert, was wirklich Alarm ist: gesperrte Geräte,
|
||||
überzogene Fristen, laufende Atemschutzüberwachung.
|
||||
|
||||
Keine Google Fonts, keine externen CDNs — es werden ausschließlich Schriften
|
||||
benutzt, die auf dem Gerät vorhanden sind.
|
||||
*/
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Statusfarben der Gerätezustände — überall dieselben. */
|
||||
--color-einsatzbereit: var(--einsatzbereit);
|
||||
--color-pruefung-faellig: var(--pruefung-faellig);
|
||||
--color-gesperrt: var(--gesperrt);
|
||||
--color-ausgesondert: var(--ausgesondert);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-sans: "Inter", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Cascadia Code", Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
|
||||
--background: oklch(0.99 0.002 60);
|
||||
--foreground: oklch(0.21 0.01 40);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.21 0.01 40);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.21 0.01 40);
|
||||
|
||||
/* Feuerwehrrot, für Flächen nutzbar gedämpft. */
|
||||
--primary: oklch(0.52 0.19 27);
|
||||
--primary-foreground: oklch(0.99 0.005 60);
|
||||
|
||||
--secondary: oklch(0.96 0.005 60);
|
||||
--secondary-foreground: oklch(0.28 0.015 40);
|
||||
--muted: oklch(0.96 0.005 60);
|
||||
--muted-foreground: oklch(0.53 0.012 45);
|
||||
--accent: oklch(0.95 0.012 40);
|
||||
--accent-foreground: oklch(0.28 0.015 40);
|
||||
|
||||
--destructive: oklch(0.58 0.23 27);
|
||||
--destructive-foreground: oklch(0.99 0.005 60);
|
||||
|
||||
--border: oklch(0.91 0.005 60);
|
||||
--input: oklch(0.91 0.005 60);
|
||||
--ring: oklch(0.52 0.19 27);
|
||||
|
||||
--einsatzbereit: oklch(0.62 0.15 150);
|
||||
--pruefung-faellig: oklch(0.72 0.16 75);
|
||||
--gesperrt: oklch(0.58 0.23 27);
|
||||
--ausgesondert: oklch(0.6 0.01 60);
|
||||
}
|
||||
|
||||
/*
|
||||
Dunkle Darstellung. Nicht bloß invertiert: im Gerätehaus wird abends und
|
||||
nachts gearbeitet, deshalb ein warmes, kontrastarmes Grau statt Schwarz —
|
||||
das blendet nicht, wenn man vom Fahrzeug hereinkommt.
|
||||
*/
|
||||
.dark {
|
||||
--background: oklch(0.17 0.008 40);
|
||||
--foreground: oklch(0.94 0.004 60);
|
||||
--card: oklch(0.21 0.009 40);
|
||||
--card-foreground: oklch(0.94 0.004 60);
|
||||
--popover: oklch(0.21 0.009 40);
|
||||
--popover-foreground: oklch(0.94 0.004 60);
|
||||
|
||||
--primary: oklch(0.63 0.19 27);
|
||||
--primary-foreground: oklch(0.98 0.005 60);
|
||||
|
||||
--secondary: oklch(0.26 0.01 40);
|
||||
--secondary-foreground: oklch(0.94 0.004 60);
|
||||
--muted: oklch(0.26 0.01 40);
|
||||
--muted-foreground: oklch(0.68 0.01 50);
|
||||
--accent: oklch(0.29 0.012 40);
|
||||
--accent-foreground: oklch(0.94 0.004 60);
|
||||
|
||||
--destructive: oklch(0.65 0.21 27);
|
||||
--destructive-foreground: oklch(0.98 0.005 60);
|
||||
|
||||
--border: oklch(0.29 0.01 40);
|
||||
--input: oklch(0.29 0.01 40);
|
||||
--ring: oklch(0.63 0.19 27);
|
||||
|
||||
--einsatzbereit: oklch(0.68 0.15 150);
|
||||
--pruefung-faellig: oklch(0.78 0.15 75);
|
||||
--gesperrt: oklch(0.65 0.21 27);
|
||||
--ausgesondert: oklch(0.55 0.01 60);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Tastaturbedienung muss sichtbar sein — am Terminal wird auch getabbt. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Zahlen in Tabellen sollen untereinander stehen. */
|
||||
table,
|
||||
.tabellenzahlen {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Am Kiosk-Terminal wird mit dem Finger bedient: größere Ziele, keine Auswahl. */
|
||||
.kiosk {
|
||||
--radius: 0.875rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.kiosk button,
|
||||
.kiosk [role="button"] {
|
||||
min-height: 3.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.nicht-drucken {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/layout/theme-provider";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "FLORIAN — Feuerwehrverwaltung",
|
||||
template: "%s · FLORIAN",
|
||||
},
|
||||
description:
|
||||
"Verwaltungsplattform für Freiwillige Feuerwehren: Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze.",
|
||||
applicationName: "FLORIAN",
|
||||
// Keine Suchmaschinen: die Installation steht im Gerätehaus, nicht im Netz.
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
// Am Terminal soll nicht versehentlich gezoomt werden.
|
||||
maximumScale: 5,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#1c1a19" },
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Album,
|
||||
BookOpenCheck,
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
Flame,
|
||||
Gauge,
|
||||
Map,
|
||||
Menu,
|
||||
MonitorSmartphone,
|
||||
Siren,
|
||||
Truck,
|
||||
Users,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Recht, Rolle } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Hauptnavigation.
|
||||
*
|
||||
* Welche Einträge jemand sieht, entscheidet NICHT diese Komponente. Sie bekommt
|
||||
* die Rechte der Sitzung übergeben und blendet danach aus — die Prüfung selbst
|
||||
* bleibt in lib/authz.ts, wie es die Projektregeln verlangen. Ein ausgeblendeter
|
||||
* Eintrag ist Bequemlichkeit, kein Schutz; der Schutz sitzt in der tRPC-Schicht.
|
||||
*
|
||||
* Die Phasenangaben entsprechen den Dateien Phase-0.md bis Phase-8.md.
|
||||
*/
|
||||
|
||||
interface Eintrag {
|
||||
titel: string;
|
||||
pfad: string;
|
||||
symbol: React.ComponentType<{ className?: string }>;
|
||||
/** Sichtbar, wenn eines dieser Rechte vorliegt. Leer = für alle. */
|
||||
rechte?: Recht[];
|
||||
/** Ab welcher Phase es die Ansicht wirklich gibt. */
|
||||
abPhase?: number;
|
||||
}
|
||||
|
||||
const EINTRAEGE: Eintrag[] = [
|
||||
{ titel: "Übersicht", pfad: "/", symbol: Gauge },
|
||||
{ titel: "Mitglieder", pfad: "/mitglieder", symbol: Users, rechte: ["person.lesen"], abPhase: 1 },
|
||||
{ titel: "Fahrzeuge", pfad: "/fahrzeuge", symbol: Truck, rechte: ["fahrzeug.lesen"], abPhase: 1 },
|
||||
{ titel: "Geräte", pfad: "/geraete", symbol: Wrench, rechte: ["geraet.lesen"], abPhase: 1 },
|
||||
{
|
||||
titel: "Qualifikationen",
|
||||
pfad: "/qualifikationen",
|
||||
symbol: BookOpenCheck,
|
||||
rechte: ["qualifikation.lesen"],
|
||||
abPhase: 1,
|
||||
},
|
||||
{
|
||||
titel: "Prüfungen",
|
||||
pfad: "/pruefungen",
|
||||
symbol: ClipboardCheck,
|
||||
rechte: ["pruefung.lesen"],
|
||||
abPhase: 2,
|
||||
},
|
||||
{ titel: "Einsätze", pfad: "/einsaetze", symbol: Flame, rechte: ["einsatz.lesen"], abPhase: 3 },
|
||||
{
|
||||
titel: "Terminal",
|
||||
pfad: "/kiosk",
|
||||
symbol: MonitorSmartphone,
|
||||
rechte: ["kiosk.erfassen"],
|
||||
abPhase: 4,
|
||||
},
|
||||
{
|
||||
titel: "Atemschutz",
|
||||
pfad: "/atemschutz",
|
||||
symbol: Siren,
|
||||
rechte: ["atemschutz.lesen"],
|
||||
abPhase: 5,
|
||||
},
|
||||
{ titel: "Karte", pfad: "/karte", symbol: Map, rechte: ["einsatz.lesen"], abPhase: 6 },
|
||||
{
|
||||
titel: "Termine",
|
||||
pfad: "/termine",
|
||||
symbol: CalendarDays,
|
||||
rechte: ["ausbildung.lesen"],
|
||||
abPhase: 7,
|
||||
},
|
||||
{
|
||||
titel: "Protokoll",
|
||||
pfad: "/protokoll",
|
||||
symbol: Album,
|
||||
rechte: ["system.auditlog"],
|
||||
abPhase: 1,
|
||||
},
|
||||
];
|
||||
|
||||
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */
|
||||
const AKTUELLE_PHASE = 0;
|
||||
|
||||
export interface SeitenleisteProps {
|
||||
rechte: Recht[];
|
||||
rollen: Rolle[];
|
||||
organisationName: string;
|
||||
personName: string;
|
||||
}
|
||||
|
||||
export function Seitenleiste({ rechte, organisationName, personName }: SeitenleisteProps) {
|
||||
const pfad = usePathname();
|
||||
const [offen, setOffen] = React.useState(false);
|
||||
const rechteMenge = React.useMemo(() => new Set(rechte), [rechte]);
|
||||
|
||||
const sichtbar = EINTRAEGE.filter(
|
||||
(eintrag) => !eintrag.rechte || eintrag.rechte.some((recht) => rechteMenge.has(recht)),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Auf schmalen Geräten aufklappbar. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="fixed left-3 top-3 z-50 md:hidden nicht-drucken"
|
||||
onClick={() => setOffen((zustand) => !zustand)}
|
||||
aria-label={offen ? "Navigation schließen" : "Navigation öffnen"}
|
||||
aria-expanded={offen}
|
||||
>
|
||||
{offen ? <X aria-hidden /> : <Menu aria-hidden />}
|
||||
</Button>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-[var(--border)] bg-[var(--card)] transition-transform nicht-drucken",
|
||||
"md:translate-x-0",
|
||||
offen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-16 items-center gap-3 border-b border-[var(--border)] px-6">
|
||||
<Flame className="size-6 text-[var(--primary)]" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold leading-tight">FLORIAN</p>
|
||||
<p className="truncate text-xs text-[var(--muted-foreground)]">
|
||||
{organisationName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto p-3" aria-label="Hauptnavigation">
|
||||
<ul className="space-y-1">
|
||||
{sichtbar.map((eintrag) => {
|
||||
const aktiv =
|
||||
eintrag.pfad === "/" ? pfad === "/" : pfad?.startsWith(eintrag.pfad);
|
||||
const nochNicht = (eintrag.abPhase ?? 0) > AKTUELLE_PHASE;
|
||||
const Symbol = eintrag.symbol;
|
||||
|
||||
if (nochNicht) {
|
||||
return (
|
||||
<li key={eintrag.pfad}>
|
||||
<span
|
||||
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-[var(--muted-foreground)] opacity-60"
|
||||
title={`Kommt in Phase ${eintrag.abPhase}`}
|
||||
>
|
||||
<Symbol className="size-4 shrink-0" aria-hidden />
|
||||
<span className="truncate">{eintrag.titel}</span>
|
||||
<span className="ml-auto text-[10px] uppercase tracking-wide">
|
||||
P{eintrag.abPhase}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={eintrag.pfad}>
|
||||
<Link
|
||||
href={eintrag.pfad}
|
||||
onClick={() => setOffen(false)}
|
||||
aria-current={aktiv ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
aktiv
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)] font-medium"
|
||||
: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
)}
|
||||
>
|
||||
<Symbol className="size-4 shrink-0" aria-hidden />
|
||||
<span className="truncate">{eintrag.titel}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--border)] p-4">
|
||||
<p className="truncate text-sm font-medium">{personName}</p>
|
||||
<Link
|
||||
href="/api/auth/signout"
|
||||
className="text-xs text-[var(--muted-foreground)] underline-offset-4 hover:underline"
|
||||
>
|
||||
Abmelden
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{offen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Navigation schließen"
|
||||
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
||||
onClick={() => setOffen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
/**
|
||||
* Hell/Dunkel. Standard ist die Systemeinstellung — wer im Gerätehaus abends
|
||||
* am Terminal steht, bekommt dann von selbst die dunkle Darstellung.
|
||||
*/
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/** Umschalter zwischen heller und dunkler Darstellung. */
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [bereit, setBereit] = React.useState(false);
|
||||
|
||||
// Vor der Hydratation ist das Thema unbekannt — sonst blinkt das Symbol.
|
||||
React.useEffect(() => setBereit(true), []);
|
||||
|
||||
const dunkel = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(dunkel ? "light" : "dark")}
|
||||
aria-label={dunkel ? "Zur hellen Darstellung wechseln" : "Zur dunklen Darstellung wechseln"}
|
||||
title={dunkel ? "Helle Darstellung" : "Dunkle Darstellung"}
|
||||
>
|
||||
{bereit && dunkel ? <Sun aria-hidden /> : <Moon aria-hidden />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Statusabzeichen. Die Gerätezustände haben feste Farben, die überall gleich
|
||||
* bedeuten — grün einsatzbereit, gelb Prüfung fällig, rot gesperrt, grau
|
||||
* ausgesondert. Wer die Farbe kennt, muss den Text nicht lesen.
|
||||
*/
|
||||
const badgeVarianten = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-[var(--primary)] text-[var(--primary-foreground)]",
|
||||
secondary:
|
||||
"border-transparent bg-[var(--secondary)] text-[var(--secondary-foreground)]",
|
||||
outline: "text-[var(--foreground)] border-[var(--border)]",
|
||||
einsatzbereit:
|
||||
"border-transparent bg-[var(--einsatzbereit)]/15 text-[var(--einsatzbereit)]",
|
||||
pruefungFaellig:
|
||||
"border-transparent bg-[var(--pruefung-faellig)]/20 text-[var(--pruefung-faellig)]",
|
||||
gesperrt: "border-transparent bg-[var(--gesperrt)]/15 text-[var(--gesperrt)]",
|
||||
ausgesondert:
|
||||
"border-transparent bg-[var(--ausgesondert)]/15 text-[var(--ausgesondert)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVarianten> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVarianten({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVarianten };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVarianten = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-[var(--primary)] text-[var(--primary-foreground)] hover:opacity-90 shadow-sm",
|
||||
destructive:
|
||||
"bg-[var(--destructive)] text-[var(--destructive-foreground)] hover:opacity-90 shadow-sm",
|
||||
outline:
|
||||
"border border-[var(--border)] bg-[var(--background)] hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
secondary:
|
||||
"bg-[var(--secondary)] text-[var(--secondary-foreground)] hover:opacity-80",
|
||||
ghost: "hover:bg-[var(--accent)] hover:text-[var(--accent-foreground)]",
|
||||
link: "text-[var(--primary)] underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
/** Für das Terminal im Gerätehaus — mit Handschuhen bedienbar. */
|
||||
kiosk: "h-16 rounded-xl px-8 text-lg",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVarianten> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Komponente = asChild ? Slot : "button";
|
||||
return (
|
||||
<Komponente
|
||||
className={cn(buttonVarianten({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVarianten };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--card-foreground)] shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-[var(--muted-foreground)]", className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-sm shadow-sm transition-colors",
|
||||
"placeholder:text-[var(--muted-foreground)]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--background)]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,238 @@
|
||||
import "server-only";
|
||||
import { AkteurTyp, AnmeldeArt, AuditAktion, Prisma } from "@prisma/client";
|
||||
import { fuerOrganisation, prismaSystem, type MandantenClient } from "@/lib/db";
|
||||
import type { Sitzung } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Audit-Log.
|
||||
*
|
||||
* Regel aus CLAUDE.md: Jede schreibende Operation erzeugt einen Eintrag mit
|
||||
* Akteur, Zeitpunkt, Entität und Vorher/Nachher als JSONB. Die Tabelle ist
|
||||
* append-only — auf Datenbankebene per Trigger abgesichert, nicht nur hier.
|
||||
*
|
||||
* Zwei Aufrufarten:
|
||||
* protokolliere(...) — eigene Transaktion, für Einzelaktionen
|
||||
* protokolliereIn(db, ...) — innerhalb einer bestehenden Transaktion, damit
|
||||
* Änderung und Protokoll gemeinsam gültig werden
|
||||
* oder gemeinsam zurückgerollt.
|
||||
*
|
||||
* Der zweite Weg ist der richtige, sobald es um Daten geht, die wirklich
|
||||
* zusammengehören.
|
||||
*/
|
||||
|
||||
export { AuditAktion, AkteurTyp, AnmeldeArt };
|
||||
|
||||
/** Felder, die niemals ins Protokoll wandern. */
|
||||
const GEHEIME_FELDER = new Set([
|
||||
"passwortHash",
|
||||
"passwort",
|
||||
"tokenHash",
|
||||
"pin",
|
||||
"secret",
|
||||
"clientSecret",
|
||||
"accessToken",
|
||||
"refreshToken",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Entfernt Geheimnisse und wandelt Werte, die JSONB nicht kennt (Date, BigInt),
|
||||
* in etwas Speicherbares um.
|
||||
*/
|
||||
export function fuerProtokoll(wert: unknown, tiefe = 0): Prisma.InputJsonValue | null {
|
||||
if (wert === null || wert === undefined) return null;
|
||||
if (tiefe > 6) return "[zu tief verschachtelt]";
|
||||
|
||||
if (wert instanceof Date) return wert.toISOString();
|
||||
if (typeof wert === "bigint") return wert.toString();
|
||||
if (typeof wert === "string" || typeof wert === "number" || typeof wert === "boolean") {
|
||||
return wert;
|
||||
}
|
||||
|
||||
if (Array.isArray(wert)) {
|
||||
return wert.map((eintrag) => fuerProtokoll(eintrag, tiefe + 1) ?? null);
|
||||
}
|
||||
|
||||
if (typeof wert === "object") {
|
||||
const ergebnis: Record<string, Prisma.InputJsonValue | null> = {};
|
||||
for (const [schluessel, inhalt] of Object.entries(wert as Record<string, unknown>)) {
|
||||
ergebnis[schluessel] = GEHEIME_FELDER.has(schluessel)
|
||||
? "[entfernt]"
|
||||
: fuerProtokoll(inhalt, tiefe + 1);
|
||||
}
|
||||
return ergebnis as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
return String(wert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nur die tatsächlich geänderten Felder — sonst bläht jeder Speichervorgang das
|
||||
* Protokoll mit unveränderten Werten auf.
|
||||
*/
|
||||
export function unterschied(
|
||||
vorher: Record<string, unknown> | null | undefined,
|
||||
nachher: Record<string, unknown> | null | undefined,
|
||||
): { vorher: Prisma.InputJsonValue | null; nachher: Prisma.InputJsonValue | null; felder: string[] } {
|
||||
if (!vorher) return { vorher: null, nachher: fuerProtokoll(nachher), felder: Object.keys(nachher ?? {}) };
|
||||
if (!nachher) return { vorher: fuerProtokoll(vorher), nachher: null, felder: Object.keys(vorher) };
|
||||
|
||||
const felder: string[] = [];
|
||||
const vorherGefiltert: Record<string, unknown> = {};
|
||||
const nachherGefiltert: Record<string, unknown> = {};
|
||||
|
||||
for (const schluessel of new Set([...Object.keys(vorher), ...Object.keys(nachher)])) {
|
||||
const alt = vorher[schluessel];
|
||||
const neu = nachher[schluessel];
|
||||
if (JSON.stringify(alt) === JSON.stringify(neu)) continue;
|
||||
felder.push(schluessel);
|
||||
vorherGefiltert[schluessel] = alt;
|
||||
nachherGefiltert[schluessel] = neu;
|
||||
}
|
||||
|
||||
return {
|
||||
vorher: fuerProtokoll(vorherGefiltert),
|
||||
nachher: fuerProtokoll(nachherGefiltert),
|
||||
felder,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProtokollEingabe {
|
||||
organisationId: string;
|
||||
aktion: AuditAktion;
|
||||
/** Modellname, z. B. "Geraet". */
|
||||
entitaet: string;
|
||||
entitaetId?: string | null;
|
||||
beschreibung?: string;
|
||||
|
||||
/** Wer war es. Entweder eine Sitzung übergeben oder Akteur einzeln setzen. */
|
||||
sitzung?: Sitzung | null;
|
||||
akteurTyp?: AkteurTyp;
|
||||
akteurPersonId?: string | null;
|
||||
akteurName?: string | null;
|
||||
anmeldeArt?: AnmeldeArt | null;
|
||||
|
||||
vorher?: Record<string, unknown> | null;
|
||||
nachher?: Record<string, unknown> | null;
|
||||
kontext?: Record<string, unknown> | null;
|
||||
|
||||
ipAdresse?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
function datensatzBauen(eingabe: ProtokollEingabe): Prisma.AuditLogUncheckedCreateInput {
|
||||
const abweichung = unterschied(eingabe.vorher, eingabe.nachher);
|
||||
|
||||
const kontext = {
|
||||
...(eingabe.kontext ?? {}),
|
||||
...(abweichung.felder.length > 0 ? { geaenderteFelder: abweichung.felder } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
organisationId: eingabe.organisationId,
|
||||
aktion: eingabe.aktion,
|
||||
entitaet: eingabe.entitaet,
|
||||
entitaetId: eingabe.entitaetId ?? null,
|
||||
beschreibung: eingabe.beschreibung ?? null,
|
||||
akteurTyp: eingabe.akteurTyp ?? (eingabe.sitzung ? AkteurTyp.PERSON : AkteurTyp.SYSTEM),
|
||||
akteurPersonId: eingabe.akteurPersonId ?? eingabe.sitzung?.personId ?? null,
|
||||
akteurName: eingabe.akteurName ?? eingabe.sitzung?.name ?? null,
|
||||
anmeldeArt:
|
||||
eingabe.anmeldeArt ??
|
||||
(eingabe.sitzung ? (eingabe.sitzung.anmeldeArt as AnmeldeArt) : null),
|
||||
vorher: abweichung.vorher ?? Prisma.JsonNull,
|
||||
nachher: abweichung.nachher ?? Prisma.JsonNull,
|
||||
kontext: Object.keys(kontext).length > 0 ? (fuerProtokoll(kontext) as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
ipAdresse: eingabe.ipAdresse ?? null,
|
||||
userAgent: eingabe.userAgent?.slice(0, 400) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt den Eintrag in eine BESTEHENDE Transaktion. So werden Änderung und
|
||||
* Protokoll gemeinsam wirksam.
|
||||
*
|
||||
* @example
|
||||
* await fuerOrganisation(orgId, async (db) => {
|
||||
* const vorher = await db.geraet.findUniqueOrThrow({ where: { id } });
|
||||
* const nachher = await db.geraet.update({ where: { id }, data });
|
||||
* await protokolliereIn(db, {
|
||||
* organisationId: orgId, aktion: AuditAktion.GEAENDERT,
|
||||
* entitaet: "Geraet", entitaetId: id, sitzung, vorher, nachher,
|
||||
* });
|
||||
* });
|
||||
*/
|
||||
export async function protokolliereIn(
|
||||
db: MandantenClient,
|
||||
eingabe: ProtokollEingabe,
|
||||
): Promise<void> {
|
||||
await db.auditLog.create({ data: datensatzBauen(eingabe) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt den Eintrag in einer eigenen Transaktion. Für Vorgänge ohne
|
||||
* begleitende Datenänderung — Anmeldungen, Exporte, Dateiabrufe.
|
||||
*/
|
||||
export async function protokolliere(eingabe: ProtokollEingabe): Promise<void> {
|
||||
await fuerOrganisation(eingabe.organisationId, async (db) => {
|
||||
await protokolliereIn(db, eingabe);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldevorgänge. Die laufen, bevor ein Mandantenkontext feststeht, deshalb
|
||||
* schreibt diese Funktion ausnahmsweise über den Eigentümer-Zugang.
|
||||
* Fehlschläge dürfen den Anmeldeweg nicht blockieren — deshalb nur geloggt.
|
||||
*/
|
||||
export async function protokolliereAnmeldung(eingabe: {
|
||||
organisationId: string;
|
||||
personId?: string | null;
|
||||
name?: string | null;
|
||||
anmeldeArt: AnmeldeArt;
|
||||
erfolgreich: boolean;
|
||||
grund?: string;
|
||||
ipAdresse?: string | null;
|
||||
userAgent?: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await prismaSystem.auditLog.create({
|
||||
data: datensatzBauen({
|
||||
organisationId: eingabe.organisationId,
|
||||
aktion: eingabe.erfolgreich
|
||||
? AuditAktion.ANGEMELDET
|
||||
: AuditAktion.ANMELDUNG_FEHLGESCHLAGEN,
|
||||
entitaet: "Person",
|
||||
entitaetId: eingabe.personId ?? null,
|
||||
akteurTyp: eingabe.anmeldeArt === AnmeldeArt.SYSTEM ? AkteurTyp.SYSTEM : AkteurTyp.PERSON,
|
||||
akteurPersonId: eingabe.personId ?? null,
|
||||
akteurName: eingabe.name ?? null,
|
||||
anmeldeArt: eingabe.anmeldeArt,
|
||||
beschreibung: eingabe.erfolgreich
|
||||
? `Anmeldung über ${eingabe.anmeldeArt}`
|
||||
: `Anmeldung fehlgeschlagen: ${eingabe.grund ?? "unbekannt"}`,
|
||||
ipAdresse: eingabe.ipAdresse,
|
||||
userAgent: eingabe.userAgent,
|
||||
}),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[audit] Anmeldung konnte nicht protokolliert werden:", fehler);
|
||||
}
|
||||
}
|
||||
|
||||
/** Die letzten Einträge zu einem Datensatz — für die Verlaufsansicht. */
|
||||
export async function verlauf(
|
||||
organisationId: string,
|
||||
entitaet: string,
|
||||
entitaetId: string,
|
||||
anzahl = 50,
|
||||
) {
|
||||
return fuerOrganisation(organisationId, (db) =>
|
||||
db.auditLog.findMany({
|
||||
where: { entitaet, entitaetId },
|
||||
orderBy: { zeitpunkt: "desc" },
|
||||
take: anzahl,
|
||||
include: {
|
||||
akteur: { select: { id: true, vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
+613
@@ -0,0 +1,613 @@
|
||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import { AnmeldeArt, type RolleSchluessel } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { env, oidcGruppenZuordnung, oidcVerfuegbar } from "@/lib/env";
|
||||
import { prismaSystem, fuerOrganisation } from "@/lib/db";
|
||||
import { pruefen } from "@/lib/passwort";
|
||||
import { rfidNormalisieren } from "@/lib/passwort";
|
||||
import { protokolliereAnmeldung } from "@/lib/audit";
|
||||
import { alsRolle, type Rolle, type Sitzung } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Anmeldung — drei Wege, ein Sitzungsbegriff.
|
||||
*
|
||||
* 1. OIDC gegen authentik — der Regelweg im laufenden Betrieb.
|
||||
* 2. E-Mail und Passwort — Rückfalloption für den Erststart und für den
|
||||
* Fall, dass authentik gerade nicht erreichbar ist.
|
||||
* 3. Terminal (PIN/RFID) — für das Kiosk-Gerät im Gerätehaus.
|
||||
*
|
||||
* Alle drei führen zu derselben Sitzung (siehe lib/authz.ts). Die Sitzung liegt
|
||||
* als signiertes JWT im Cookie — keine Sitzungstabelle, damit ein Neustart der
|
||||
* Anwendung niemanden abmeldet.
|
||||
*
|
||||
* Die Rollen stehen im Token. Damit eine Rollenänderung nicht erst beim
|
||||
* nächsten Login greift, werden sie bei jedem Zugriff nachgeladen, wenn das
|
||||
* Token älter als fünf Minuten ist.
|
||||
*/
|
||||
|
||||
const ROLLEN_NACHLADEN_NACH_MS = 5 * 60 * 1000;
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hilfsfunktionen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Lädt Person samt Rollen. Läuft vor der Mandantenauflösung, daher ohne RLS. */
|
||||
async function personMitRollen(personId: string) {
|
||||
return prismaSystem.person.findFirst({
|
||||
where: { id: personId, aktiv: true, geloeschtAm: null },
|
||||
include: {
|
||||
rollen: {
|
||||
where: { OR: [{ gueltigBis: null }, { gueltigBis: { gt: new Date() } }] },
|
||||
include: { rolle: true },
|
||||
},
|
||||
organisation: { select: { id: true, slug: true, name: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rollenAus(
|
||||
eintraege: Array<{ rolle: { schluessel: RolleSchluessel } }>,
|
||||
): Rolle[] {
|
||||
const rollen = eintraege
|
||||
.map((eintrag) => alsRolle(eintrag.rolle.schluessel))
|
||||
.filter((rolle): rolle is Rolle => rolle !== null);
|
||||
// Wer gar keine Rolle hat, ist trotzdem Mitglied — sonst sieht er nicht
|
||||
// einmal die eigenen Daten.
|
||||
return rollen.length > 0 ? rollen : ["MITGLIED"];
|
||||
}
|
||||
|
||||
/** Ist das Konto gerade wegen zu vieler Fehlversuche gesperrt? */
|
||||
function istGesperrt(person: { gesperrtBis: Date | null }): boolean {
|
||||
return person.gesperrtBis !== null && person.gesperrtBis > new Date();
|
||||
}
|
||||
|
||||
/** Fehlversuch zählen und ab der Grenze sperren. */
|
||||
async function fehlversuchVermerken(personId: string): Promise<void> {
|
||||
const person = await prismaSystem.person.findUnique({
|
||||
where: { id: personId },
|
||||
select: { fehlversuche: true },
|
||||
});
|
||||
const neu = (person?.fehlversuche ?? 0) + 1;
|
||||
await prismaSystem.person.update({
|
||||
where: { id: personId },
|
||||
data: {
|
||||
fehlversuche: neu,
|
||||
gesperrtBis:
|
||||
neu >= env.KIOSK_MAX_FEHLVERSUCHE
|
||||
? new Date(Date.now() + env.KIOSK_SPERRDAUER * 1000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function anmeldungVermerken(personId: string): Promise<void> {
|
||||
await prismaSystem.person.update({
|
||||
where: { id: personId },
|
||||
data: { letzteAnmeldung: new Date(), fehlversuche: 0, gesperrtBis: null },
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Anbieter
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
const lokaleAnmeldung = z.object({
|
||||
email: z.string().email("Bitte eine gültige E-Mail-Adresse eingeben."),
|
||||
passwort: z.string().min(1, "Bitte das Passwort eingeben."),
|
||||
});
|
||||
|
||||
const terminalAnmeldung = z.object({
|
||||
geraeteSchluessel: z.string().min(1),
|
||||
organisationSlug: z.string().min(1),
|
||||
art: z.enum(["PIN", "RFID"]),
|
||||
/** Bei PIN zusätzlich die Mitgliedsnummer, bei RFID nur die Kennung. */
|
||||
kennung: z.string().min(1),
|
||||
geheimnis: z.string().min(1),
|
||||
});
|
||||
|
||||
/** E-Mail und Passwort — der Rückfallweg. */
|
||||
const passwortAnbieter = Credentials({
|
||||
id: "passwort",
|
||||
name: "E-Mail und Passwort",
|
||||
credentials: {
|
||||
email: { label: "E-Mail", type: "email" },
|
||||
passwort: { label: "Passwort", type: "password" },
|
||||
},
|
||||
async authorize(eingabe) {
|
||||
const geprueft = lokaleAnmeldung.safeParse(eingabe);
|
||||
if (!geprueft.success) return null;
|
||||
|
||||
const { email, passwort } = geprueft.data;
|
||||
|
||||
// Die E-Mail-Suche geht bewusst über alle Wehren — zu diesem Zeitpunkt ist
|
||||
// der Mandant noch nicht bekannt.
|
||||
const person = await prismaSystem.person.findFirst({
|
||||
where: { email: email.toLowerCase(), aktiv: true, geloeschtAm: null },
|
||||
include: {
|
||||
rollen: { include: { rolle: true } },
|
||||
organisation: { select: { id: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!person || !person.organisation.aktiv) {
|
||||
// Trotzdem hashen, damit "Konto gibt es nicht" zeitlich nicht auffällt.
|
||||
await pruefen(passwort, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (istGesperrt(person)) {
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich: false,
|
||||
grund: "Konto vorübergehend gesperrt",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const stimmt = await pruefen(passwort, person.passwortHash);
|
||||
if (!stimmt) {
|
||||
await fehlversuchVermerken(person.id);
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich: false,
|
||||
grund: "Falsches Passwort",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
await anmeldungVermerken(person.id);
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: person.organisationId,
|
||||
personId: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
anmeldeArt: AnmeldeArt.LOKAL,
|
||||
erfolgreich: true,
|
||||
});
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
name: `${person.vorname} ${person.nachname}`,
|
||||
email: person.email ?? undefined,
|
||||
organisationId: person.organisationId,
|
||||
rollen: rollenAus(person.rollen),
|
||||
anmeldeArt: "LOKAL" as const,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Terminal im Gerätehaus. Zwei Schranken: erst weist sich das GERÄT mit
|
||||
* KIOSK_DEVICE_SECRET aus, dann die PERSON mit PIN oder RFID-Karte. Ohne die
|
||||
* erste Schranke nützt eine erratene PIN nichts.
|
||||
*/
|
||||
const terminalAnbieter = Credentials({
|
||||
id: "terminal",
|
||||
name: "Terminal im Gerätehaus",
|
||||
credentials: {
|
||||
geraeteSchluessel: { label: "Gerät", type: "text" },
|
||||
organisationSlug: { label: "Wehr", type: "text" },
|
||||
art: { label: "Art", type: "text" },
|
||||
kennung: { label: "Kennung", type: "text" },
|
||||
geheimnis: { label: "PIN oder Kartenkennung", type: "password" },
|
||||
},
|
||||
async authorize(eingabe) {
|
||||
if (!env.KIOSK_ENABLED) return null;
|
||||
|
||||
const geprueft = terminalAnmeldung.safeParse(eingabe);
|
||||
if (!geprueft.success) return null;
|
||||
|
||||
const { geraeteSchluessel, organisationSlug, art, kennung, geheimnis } = geprueft.data;
|
||||
|
||||
// Erste Schranke: Ist das ein Terminal, das wir kennen?
|
||||
if (geraeteSchluessel !== env.KIOSK_DEVICE_SECRET) return null;
|
||||
|
||||
const organisation = await prismaSystem.organisation.findFirst({
|
||||
where: { slug: organisationSlug, aktiv: true, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!organisation) return null;
|
||||
|
||||
// Zweite Schranke: die Person. Bei PIN identifiziert die Mitgliedsnummer,
|
||||
// bei RFID die Kartenkennung.
|
||||
const tokens = await fuerOrganisation(organisation.id, (db) =>
|
||||
db.ausweisToken.findMany({
|
||||
where: {
|
||||
typ: art,
|
||||
aktiv: true,
|
||||
geloeschtAm: null,
|
||||
gueltigAb: { lte: new Date() },
|
||||
OR: [{ gueltigBis: null }, { gueltigBis: { gt: new Date() } }],
|
||||
...(art === "PIN"
|
||||
? { person: { mitgliedsnummer: kennung, aktiv: true, geloeschtAm: null } }
|
||||
: { kennzeichen: rfidNormalisieren(kennung).slice(-4) }),
|
||||
},
|
||||
include: {
|
||||
person: {
|
||||
include: { rollen: { include: { rolle: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const vergleichswert = art === "RFID" ? rfidNormalisieren(geheimnis) : geheimnis;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.gesperrtBis && token.gesperrtBis > new Date()) continue;
|
||||
if (!(await pruefen(vergleichswert, token.tokenHash))) continue;
|
||||
|
||||
await fuerOrganisation(organisation.id, (db) =>
|
||||
db.ausweisToken.update({
|
||||
where: { id: token.id },
|
||||
data: { letzteNutzung: new Date(), fehlversuche: 0, gesperrtBis: null },
|
||||
}),
|
||||
);
|
||||
|
||||
const anmeldeArt = art === "PIN" ? AnmeldeArt.KIOSK_PIN : AnmeldeArt.KIOSK_RFID;
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: organisation.id,
|
||||
personId: token.person.id,
|
||||
name: `${token.person.vorname} ${token.person.nachname}`,
|
||||
anmeldeArt,
|
||||
erfolgreich: true,
|
||||
});
|
||||
|
||||
return {
|
||||
id: token.person.id,
|
||||
name: `${token.person.vorname} ${token.person.nachname}`,
|
||||
email: token.person.email ?? undefined,
|
||||
organisationId: organisation.id,
|
||||
rollen: rollenAus(token.person.rollen),
|
||||
anmeldeArt: art === "PIN" ? ("KIOSK_PIN" as const) : ("KIOSK_RFID" as const),
|
||||
};
|
||||
}
|
||||
|
||||
// Fehlversuche werden auf allen in Frage kommenden Tokens gezählt.
|
||||
await fuerOrganisation(organisation.id, async (db) => {
|
||||
for (const token of tokens) {
|
||||
const neu = token.fehlversuche + 1;
|
||||
await db.ausweisToken.update({
|
||||
where: { id: token.id },
|
||||
data: {
|
||||
fehlversuche: neu,
|
||||
gesperrtBis:
|
||||
neu >= env.KIOSK_MAX_FEHLVERSUCHE
|
||||
? new Date(Date.now() + env.KIOSK_SPERRDAUER * 1000)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: organisation.id,
|
||||
anmeldeArt: art === "PIN" ? AnmeldeArt.KIOSK_PIN : AnmeldeArt.KIOSK_RFID,
|
||||
erfolgreich: false,
|
||||
grund: `Terminal-Anmeldung fehlgeschlagen (${art})`,
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* OIDC gegen authentik. Wird nur eingehängt, wenn Issuer, Client-ID und Secret
|
||||
* gesetzt sind — sonst bliebe ein Knopf stehen, der ins Leere führt.
|
||||
*/
|
||||
function authentikAnbieter(): Provider {
|
||||
return {
|
||||
id: "authentik",
|
||||
name: env.AUTH_OIDC_NAME,
|
||||
type: "oidc",
|
||||
issuer: env.AUTH_OIDC_ISSUER,
|
||||
clientId: env.AUTH_OIDC_CLIENT_ID,
|
||||
clientSecret: env.AUTH_OIDC_CLIENT_SECRET,
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
checks: ["pkce", "state"],
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// OIDC: Person finden oder anlegen, Rollen aus Gruppen vorbelegen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
interface OidcProfil {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
given_name?: string;
|
||||
family_name?: string;
|
||||
[claim: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verknüpft ein authentik-Konto mit einer Person. Reihenfolge:
|
||||
* 1. bereits verknüpft (oidcSubject)
|
||||
* 2. E-Mail-Adresse stimmt überein -> verknüpfen
|
||||
* 3. AUTH_OIDC_AUTO_PROVISION -> neue Person anlegen
|
||||
* Sonst: Anmeldung abgelehnt.
|
||||
*/
|
||||
async function personAusOidc(profil: OidcProfil): Promise<{
|
||||
personId: string;
|
||||
organisationId: string;
|
||||
name: string;
|
||||
rollen: Rolle[];
|
||||
} | null> {
|
||||
const email = profil.email?.toLowerCase();
|
||||
|
||||
const vorhanden = await prismaSystem.person.findFirst({
|
||||
where: {
|
||||
OR: [{ oidcSubject: profil.sub }, ...(email ? [{ email }] : [])],
|
||||
geloeschtAm: null,
|
||||
},
|
||||
include: {
|
||||
rollen: { include: { rolle: true } },
|
||||
organisation: { select: { id: true, aktiv: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const gruppen = gruppenAusProfil(profil);
|
||||
|
||||
if (vorhanden) {
|
||||
if (!vorhanden.aktiv || !vorhanden.organisation.aktiv) return null;
|
||||
|
||||
await prismaSystem.person.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
oidcSubject: profil.sub,
|
||||
oidcGruppen: gruppen,
|
||||
letzteAnmeldung: new Date(),
|
||||
fehlversuche: 0,
|
||||
gesperrtBis: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Gruppen wirken nur als Vorbelegung: Rollen, die jemand in FLORIAN von
|
||||
// Hand vergeben hat, bleiben unangetastet.
|
||||
await gruppenRollenVorbelegen(vorhanden.organisationId, vorhanden.id, gruppen);
|
||||
|
||||
const aktualisiert = await personMitRollen(vorhanden.id);
|
||||
if (!aktualisiert) return null;
|
||||
|
||||
return {
|
||||
personId: aktualisiert.id,
|
||||
organisationId: aktualisiert.organisationId,
|
||||
name: `${aktualisiert.vorname} ${aktualisiert.nachname}`,
|
||||
rollen: rollenAus(aktualisiert.rollen),
|
||||
};
|
||||
}
|
||||
|
||||
if (!env.AUTH_OIDC_AUTO_PROVISION) return null;
|
||||
|
||||
const slug = env.AUTH_OIDC_STANDARD_ORGANISATION;
|
||||
if (!slug) {
|
||||
console.error(
|
||||
"[auth] AUTH_OIDC_AUTO_PROVISION=true, aber AUTH_OIDC_STANDARD_ORGANISATION fehlt.",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const organisation = await prismaSystem.organisation.findFirst({
|
||||
where: { slug, aktiv: true, geloeschtAm: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!organisation) return null;
|
||||
|
||||
const vorname = profil.given_name ?? profil.name?.split(" ")[0] ?? "Unbekannt";
|
||||
const nachname =
|
||||
profil.family_name ?? profil.name?.split(" ").slice(1).join(" ") ?? "Unbekannt";
|
||||
|
||||
const neu = await fuerOrganisation(organisation.id, (db) =>
|
||||
db.person.create({
|
||||
data: {
|
||||
organisationId: organisation.id,
|
||||
vorname,
|
||||
nachname,
|
||||
email: email ?? null,
|
||||
oidcSubject: profil.sub,
|
||||
oidcGruppen: gruppen,
|
||||
letzteAnmeldung: new Date(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await gruppenRollenVorbelegen(organisation.id, neu.id, gruppen);
|
||||
|
||||
const mitRollen = await personMitRollen(neu.id);
|
||||
return {
|
||||
personId: neu.id,
|
||||
organisationId: organisation.id,
|
||||
name: `${vorname} ${nachname}`,
|
||||
rollen: mitRollen ? rollenAus(mitRollen.rollen) : ["MITGLIED"],
|
||||
};
|
||||
}
|
||||
|
||||
function gruppenAusProfil(profil: OidcProfil): string[] {
|
||||
const claim = env.AUTH_OIDC_GROUP_CLAIM;
|
||||
if (!claim) return [];
|
||||
const wert = profil[claim];
|
||||
if (Array.isArray(wert)) return wert.map(String);
|
||||
if (typeof wert === "string") return wert.split(",").map((teil) => teil.trim());
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Belegt Rollen aus authentik-Gruppen vor. Bestehende Zuweisungen werden nicht
|
||||
* angefasst: Was in FLORIAN von Hand gesetzt wurde, ist führend.
|
||||
*/
|
||||
async function gruppenRollenVorbelegen(
|
||||
organisationId: string,
|
||||
personId: string,
|
||||
gruppen: string[],
|
||||
): Promise<void> {
|
||||
if (gruppen.length === 0) return;
|
||||
|
||||
const zuordnung = oidcGruppenZuordnung();
|
||||
if (zuordnung.size === 0) return;
|
||||
|
||||
const gewuenscht = new Set<string>();
|
||||
for (const gruppe of gruppen) {
|
||||
const rolle = zuordnung.get(gruppe.toLowerCase());
|
||||
if (rolle) gewuenscht.add(rolle);
|
||||
}
|
||||
if (gewuenscht.size === 0) return;
|
||||
|
||||
await fuerOrganisation(organisationId, async (db) => {
|
||||
const bestehend = await db.personRolle.findMany({
|
||||
where: { personId },
|
||||
select: { rolleId: true },
|
||||
});
|
||||
// Nur beim allerersten Mal vorbelegen — danach entscheidet die Wehr.
|
||||
if (bestehend.length > 0) return;
|
||||
|
||||
const rollen = await db.rolle.findMany({
|
||||
where: {
|
||||
organisationId,
|
||||
schluessel: { in: [...gewuenscht] as RolleSchluessel[] },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (rollen.length === 0) return;
|
||||
|
||||
await db.personRolle.createMany({
|
||||
data: rollen.map((rolle) => ({
|
||||
organisationId,
|
||||
personId,
|
||||
rolleId: rolle.id,
|
||||
ausOidcGruppe: true,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Auth.js-Konfiguration
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function anbieterListe(): Provider[] {
|
||||
const liste: Provider[] = [];
|
||||
if (oidcVerfuegbar) liste.push(authentikAnbieter());
|
||||
if (env.AUTH_LOCAL_ENABLED) liste.push(passwortAnbieter);
|
||||
if (env.KIOSK_ENABLED) liste.push(terminalAnbieter);
|
||||
return liste;
|
||||
}
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
trustHost: env.AUTH_TRUST_HOST,
|
||||
secret: env.AUTH_SECRET,
|
||||
session: { strategy: "jwt", maxAge: env.AUTH_SESSION_MAX_AGE },
|
||||
pages: { signIn: "/anmelden", error: "/anmelden" },
|
||||
providers: anbieterListe(),
|
||||
|
||||
callbacks: {
|
||||
async signIn({ account, profile }) {
|
||||
// Die Credentials-Anbieter haben in authorize() bereits alles geprüft.
|
||||
if (account?.provider !== "authentik") return true;
|
||||
if (!profile?.sub) return false;
|
||||
|
||||
const ergebnis = await personAusOidc(profile as unknown as OidcProfil);
|
||||
if (!ergebnis) return false;
|
||||
|
||||
await protokolliereAnmeldung({
|
||||
organisationId: ergebnis.organisationId,
|
||||
personId: ergebnis.personId,
|
||||
name: ergebnis.name,
|
||||
anmeldeArt: AnmeldeArt.OIDC,
|
||||
erfolgreich: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
async jwt({ token, user, account, trigger }) {
|
||||
// Erste Anmeldung über einen Credentials-Anbieter.
|
||||
if (user && "organisationId" in user) {
|
||||
const angemeldet = user as unknown as {
|
||||
id: string;
|
||||
name: string;
|
||||
organisationId: string;
|
||||
rollen: Rolle[];
|
||||
anmeldeArt: Sitzung["anmeldeArt"];
|
||||
};
|
||||
token.personId = angemeldet.id;
|
||||
token.organisationId = angemeldet.organisationId;
|
||||
token.rollen = angemeldet.rollen;
|
||||
token.anmeldeArt = angemeldet.anmeldeArt;
|
||||
token.geladenAm = Date.now();
|
||||
return token;
|
||||
}
|
||||
|
||||
// Erste Anmeldung über OIDC — hier steht das Profil im account/profile.
|
||||
if (account?.provider === "authentik" && token.sub) {
|
||||
const person = await prismaSystem.person.findFirst({
|
||||
where: { oidcSubject: token.sub, geloeschtAm: null },
|
||||
include: { rollen: { include: { rolle: true } } },
|
||||
});
|
||||
if (person) {
|
||||
token.personId = person.id;
|
||||
token.organisationId = person.organisationId;
|
||||
token.rollen = rollenAus(person.rollen);
|
||||
token.anmeldeArt = "OIDC";
|
||||
token.geladenAm = Date.now();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Rollen nachladen, damit eine Änderung nicht bis zum nächsten Login wartet.
|
||||
const veraltet =
|
||||
typeof token.geladenAm !== "number" ||
|
||||
Date.now() - token.geladenAm > ROLLEN_NACHLADEN_NACH_MS;
|
||||
|
||||
if (trigger === "update" || (veraltet && typeof token.personId === "string")) {
|
||||
const person = await personMitRollen(token.personId as string);
|
||||
if (!person) {
|
||||
// Person deaktiviert oder gelöscht: Sitzung entwerten.
|
||||
return { ...token, personId: undefined, organisationId: undefined, rollen: [] };
|
||||
}
|
||||
token.rollen = rollenAus(person.rollen);
|
||||
token.organisationId = person.organisationId;
|
||||
token.geladenAm = Date.now();
|
||||
}
|
||||
|
||||
return token;
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
session.user = {
|
||||
...session.user,
|
||||
id: (token.personId as string) ?? "",
|
||||
organisationId: (token.organisationId as string) ?? "",
|
||||
rollen: (token.rollen as Rolle[]) ?? [],
|
||||
anmeldeArt: (token.anmeldeArt as Sitzung["anmeldeArt"]) ?? "LOKAL",
|
||||
};
|
||||
return session;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
|
||||
|
||||
/**
|
||||
* Die Sitzung, wie der Rest der Anwendung sie erwartet. Gibt null zurück,
|
||||
* wenn niemand angemeldet ist oder die Sitzung entwertet wurde.
|
||||
*/
|
||||
export async function aktuelleSitzung(): Promise<Sitzung | null> {
|
||||
const sitzung = await auth();
|
||||
if (!sitzung?.user?.id || !sitzung.user.organisationId) return null;
|
||||
|
||||
return {
|
||||
personId: sitzung.user.id,
|
||||
organisationId: sitzung.user.organisationId,
|
||||
name: sitzung.user.name ?? "Unbekannt",
|
||||
rollen: sitzung.user.rollen ?? [],
|
||||
anmeldeArt: sitzung.user.anmeldeArt ?? "LOKAL",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Zentrale Berechtigungslogik.
|
||||
*
|
||||
* Regel aus CLAUDE.md: Keine Rollenprüfung in Komponenten. Alles, was mit
|
||||
* "darf jemand das?" zu tun hat, steht hier — und nur hier.
|
||||
*
|
||||
* Aufbau:
|
||||
* Recht = ein einzelnes Können, z. B. "geraet.bearbeiten"
|
||||
* Rolle = ein Bündel von Rechten, z. B. GERAETEWART
|
||||
* Sitzung = wer gerade angemeldet ist, mit welchen Rollen, in welcher Wehr
|
||||
*
|
||||
* Die Rechte einer Rolle sind hier als Vorgabe hinterlegt. Beim Seed werden sie
|
||||
* in die Tabellen berechtigung/rolle_berechtigung gespiegelt, damit eine Wehr
|
||||
* sie nachträglich anpassen kann, ohne dass jemand Code ändern muss.
|
||||
*/
|
||||
|
||||
/** Rollen exakt wie im Prisma-Schema (enum RolleSchluessel). */
|
||||
export const ROLLEN = [
|
||||
"SUPERADMIN",
|
||||
"KOMMANDANT",
|
||||
"GERAETEWART",
|
||||
"ATEMSCHUTZGERAETEWART",
|
||||
"KASSIER",
|
||||
"SCHRIFTFUEHRER",
|
||||
"AUSBILDER",
|
||||
"MITGLIED",
|
||||
"KIOSK",
|
||||
] as const;
|
||||
|
||||
export type Rolle = (typeof ROLLEN)[number];
|
||||
|
||||
/** Klartext für die Oberfläche. */
|
||||
export const ROLLEN_BEZEICHNUNG: Record<Rolle, string> = {
|
||||
SUPERADMIN: "Administrator",
|
||||
KOMMANDANT: "Kommandant",
|
||||
GERAETEWART: "Gerätewart",
|
||||
ATEMSCHUTZGERAETEWART: "Atemschutzgerätewart",
|
||||
KASSIER: "Kassier",
|
||||
SCHRIFTFUEHRER: "Schriftführer",
|
||||
AUSBILDER: "Ausbilder",
|
||||
MITGLIED: "Mitglied",
|
||||
KIOSK: "Terminal im Gerätehaus",
|
||||
};
|
||||
|
||||
export const ROLLEN_BESCHREIBUNG: Record<Rolle, string> = {
|
||||
SUPERADMIN:
|
||||
"Verwaltet die Installation selbst: Wehren anlegen, Rollen zuschneiden, Systemeinstellungen.",
|
||||
KOMMANDANT:
|
||||
"Führt die Wehr. Sieht und bearbeitet alles innerhalb der eigenen Organisation.",
|
||||
GERAETEWART:
|
||||
"Verantwortet Fahrzeuge, Geräte und deren Prüfungen nach DGUV.",
|
||||
ATEMSCHUTZGERAETEWART:
|
||||
"Verantwortet Atemschutzgeräte, Masken und die Überwachung nach FwDV 7.",
|
||||
KASSIER: "Führt die Kasse: Buchungen, Beiträge, Rechnungen, Auswertungen.",
|
||||
SCHRIFTFUEHRER:
|
||||
"Führt Protokolle, Berichte und die Dokumentation der Wehr.",
|
||||
AUSBILDER: "Plant Übungen und Lehrgänge, führt Teilnahmenachweise.",
|
||||
MITGLIED: "Sieht die eigenen Daten, Termine und Qualifikationen.",
|
||||
KIOSK:
|
||||
"Terminal im Gerätehaus. Darf nur anmelden, quittieren und erfassen — nie verwalten.",
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rechte
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Alle Einzelrechte. Schema: <bereich>.<taetigkeit>
|
||||
* Neue Rechte hier ergänzen, dann `npm run db:seed` — der Seed gleicht den
|
||||
* Katalog in der Datenbank ab.
|
||||
*/
|
||||
export const RECHTE = {
|
||||
// Organisation und System
|
||||
"organisation.lesen": { bereich: "Organisation", bezeichnung: "Wehrdaten ansehen" },
|
||||
"organisation.bearbeiten": { bereich: "Organisation", bezeichnung: "Wehrdaten bearbeiten" },
|
||||
"organisation.verwalten": { bereich: "Organisation", bezeichnung: "Wehren anlegen und löschen" },
|
||||
"system.einstellungen": { bereich: "System", bezeichnung: "Systemeinstellungen ändern" },
|
||||
"system.auditlog": { bereich: "System", bezeichnung: "Audit-Log einsehen" },
|
||||
|
||||
// Personen
|
||||
"person.lesen": { bereich: "Personen", bezeichnung: "Mitgliederliste ansehen" },
|
||||
"person.eigene": { bereich: "Personen", bezeichnung: "Eigene Daten ansehen und pflegen" },
|
||||
"person.bearbeiten": { bereich: "Personen", bezeichnung: "Mitglieder anlegen und bearbeiten" },
|
||||
"person.loeschen": { bereich: "Personen", bezeichnung: "Mitglieder austragen" },
|
||||
"person.rollen": { bereich: "Personen", bezeichnung: "Rollen zuweisen" },
|
||||
"person.ausweis": { bereich: "Personen", bezeichnung: "PIN und RFID-Ausweise verwalten" },
|
||||
"person.export": { bereich: "Personen", bezeichnung: "Personendaten exportieren (DSGVO)" },
|
||||
|
||||
// Fahrzeuge und Geräte
|
||||
"fahrzeug.lesen": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge ansehen" },
|
||||
"fahrzeug.bearbeiten": { bereich: "Fahrzeuge", bezeichnung: "Fahrzeuge anlegen und bearbeiten" },
|
||||
"geraet.lesen": { bereich: "Geräte", bezeichnung: "Geräte ansehen" },
|
||||
"geraet.bearbeiten": { bereich: "Geräte", bezeichnung: "Geräte anlegen und bearbeiten" },
|
||||
"geraet.aussondern": { bereich: "Geräte", bezeichnung: "Geräte aussondern oder sperren" },
|
||||
|
||||
// Prüfungen und Wartung (Phase 2)
|
||||
"pruefung.lesen": { bereich: "Prüfungen", bezeichnung: "Prüfungen und Fristen ansehen" },
|
||||
"pruefung.durchfuehren": { bereich: "Prüfungen", bezeichnung: "Prüfungen durchführen und dokumentieren" },
|
||||
"pruefung.planen": { bereich: "Prüfungen", bezeichnung: "Prüftermine und Vorlagen planen" },
|
||||
|
||||
// Atemschutz nach FwDV 7 (Phase 5)
|
||||
"atemschutz.lesen": { bereich: "Atemschutz", bezeichnung: "Atemschutzdaten ansehen" },
|
||||
"atemschutz.bearbeiten": { bereich: "Atemschutz", bezeichnung: "Atemschutzgeräte und Träger verwalten" },
|
||||
"atemschutz.ueberwachung": { bereich: "Atemschutz", bezeichnung: "Atemschutzüberwachung führen" },
|
||||
|
||||
// Einsätze und Übungen (Phase 3)
|
||||
"einsatz.lesen": { bereich: "Einsätze", bezeichnung: "Einsätze ansehen" },
|
||||
"einsatz.bearbeiten": { bereich: "Einsätze", bezeichnung: "Einsätze erfassen und bearbeiten" },
|
||||
"einsatz.bericht": { bereich: "Einsätze", bezeichnung: "Einsatzberichte erstellen" },
|
||||
|
||||
// Ausbildung und Qualifikationen (Phase 1 und 3)
|
||||
"ausbildung.lesen": { bereich: "Ausbildung", bezeichnung: "Übungen und Lehrgänge ansehen" },
|
||||
"ausbildung.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Übungen planen und Teilnahme erfassen" },
|
||||
"qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" },
|
||||
"qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" },
|
||||
|
||||
// Kasse — die Rolle KASSIER ist in Phase 0 gefordert, ein Kassenmodul ist
|
||||
// in Phase-0.md bis Phase-8.md aber nicht vorgesehen. Die Rechte stehen hier
|
||||
// bereits bereit, damit die Rolle nicht leer bleibt. Siehe docs/ROADMAP.md.
|
||||
"kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" },
|
||||
"kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" },
|
||||
"kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" },
|
||||
|
||||
// Dateien
|
||||
"datei.lesen": { bereich: "Dateien", bezeichnung: "Dateien herunterladen" },
|
||||
"datei.hochladen": { bereich: "Dateien", bezeichnung: "Dateien hochladen" },
|
||||
"datei.loeschen": { bereich: "Dateien", bezeichnung: "Dateien löschen" },
|
||||
|
||||
// Terminal im Gerätehaus (Phase 4)
|
||||
"kiosk.anmelden": { bereich: "Terminal", bezeichnung: "Am Terminal anmelden" },
|
||||
"kiosk.erfassen": { bereich: "Terminal", bezeichnung: "Am Terminal Erfassungen vornehmen" },
|
||||
} as const;
|
||||
|
||||
export type Recht = keyof typeof RECHTE;
|
||||
|
||||
export const ALLE_RECHTE = Object.keys(RECHTE) as Recht[];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Rolle zu Recht
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Rechte, die jede angemeldete Person hat. */
|
||||
const GRUNDRECHTE: Recht[] = [
|
||||
"person.eigene",
|
||||
"organisation.lesen",
|
||||
"person.lesen",
|
||||
"fahrzeug.lesen",
|
||||
"geraet.lesen",
|
||||
"pruefung.lesen",
|
||||
"einsatz.lesen",
|
||||
"ausbildung.lesen",
|
||||
"qualifikation.lesen",
|
||||
"datei.lesen",
|
||||
];
|
||||
|
||||
/**
|
||||
* Vorgabe je Rolle. SUPERADMIN steht bewusst nicht drin — der bekommt in
|
||||
* hatRecht() pauschal alles, damit ein neu ergänztes Recht nicht versehentlich
|
||||
* am Administrator vorbeigeht.
|
||||
*/
|
||||
export const ROLLEN_RECHTE: Record<Exclude<Rolle, "SUPERADMIN">, Recht[]> = {
|
||||
KOMMANDANT: [
|
||||
...GRUNDRECHTE,
|
||||
"organisation.bearbeiten",
|
||||
"system.auditlog",
|
||||
"person.bearbeiten",
|
||||
"person.loeschen",
|
||||
"person.rollen",
|
||||
"person.ausweis",
|
||||
"person.export",
|
||||
"fahrzeug.bearbeiten",
|
||||
"geraet.bearbeiten",
|
||||
"geraet.aussondern",
|
||||
"pruefung.planen",
|
||||
"pruefung.durchfuehren",
|
||||
"atemschutz.lesen",
|
||||
"atemschutz.bearbeiten",
|
||||
"atemschutz.ueberwachung",
|
||||
"einsatz.bearbeiten",
|
||||
"einsatz.bericht",
|
||||
"ausbildung.bearbeiten",
|
||||
"qualifikation.bearbeiten",
|
||||
"kasse.lesen",
|
||||
"datei.hochladen",
|
||||
"datei.loeschen",
|
||||
],
|
||||
|
||||
GERAETEWART: [
|
||||
...GRUNDRECHTE,
|
||||
"fahrzeug.bearbeiten",
|
||||
"geraet.bearbeiten",
|
||||
"geraet.aussondern",
|
||||
"pruefung.planen",
|
||||
"pruefung.durchfuehren",
|
||||
"datei.hochladen",
|
||||
],
|
||||
|
||||
ATEMSCHUTZGERAETEWART: [
|
||||
...GRUNDRECHTE,
|
||||
"geraet.bearbeiten",
|
||||
"pruefung.planen",
|
||||
"pruefung.durchfuehren",
|
||||
"atemschutz.lesen",
|
||||
"atemschutz.bearbeiten",
|
||||
"atemschutz.ueberwachung",
|
||||
"datei.hochladen",
|
||||
],
|
||||
|
||||
KASSIER: [...GRUNDRECHTE, "kasse.lesen", "kasse.bearbeiten", "kasse.abschluss", "datei.hochladen"],
|
||||
|
||||
SCHRIFTFUEHRER: [
|
||||
...GRUNDRECHTE,
|
||||
"person.bearbeiten",
|
||||
"einsatz.bearbeiten",
|
||||
"einsatz.bericht",
|
||||
"ausbildung.bearbeiten",
|
||||
"datei.hochladen",
|
||||
],
|
||||
|
||||
AUSBILDER: [
|
||||
...GRUNDRECHTE,
|
||||
"ausbildung.bearbeiten",
|
||||
"qualifikation.bearbeiten",
|
||||
"datei.hochladen",
|
||||
],
|
||||
|
||||
MITGLIED: [...GRUNDRECHTE],
|
||||
|
||||
// Das Terminal darf bewusst fast nichts. Wer daran arbeitet, meldet sich
|
||||
// zusätzlich mit PIN oder Ausweis an und handelt dann unter seiner Person.
|
||||
KIOSK: ["kiosk.anmelden", "kiosk.erfassen", "organisation.lesen"],
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Sitzung und Prüffunktionen
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Was die Anwendung über den gerade Angemeldeten weiß. */
|
||||
export interface Sitzung {
|
||||
personId: string;
|
||||
organisationId: string;
|
||||
name: string;
|
||||
rollen: Rolle[];
|
||||
/** Wie die Anmeldung zustande kam — relevant fürs Audit und für den Kiosk. */
|
||||
anmeldeArt: "LOKAL" | "OIDC" | "KIOSK_PIN" | "KIOSK_RFID" | "SYSTEM";
|
||||
}
|
||||
|
||||
/** Fehler, den die tRPC-Schicht in ein FORBIDDEN übersetzt. */
|
||||
export class KeinZugriffFehler extends Error {
|
||||
constructor(
|
||||
public readonly benoetigtesRecht: Recht | Recht[],
|
||||
nachricht?: string,
|
||||
) {
|
||||
const rechte = Array.isArray(benoetigtesRecht)
|
||||
? benoetigtesRecht.join(", ")
|
||||
: benoetigtesRecht;
|
||||
super(nachricht ?? `Fehlende Berechtigung: ${rechte}`);
|
||||
this.name = "KeinZugriffFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Rechte, die sich aus den Rollen einer Sitzung ergeben. */
|
||||
export function rechteVon(rollen: readonly Rolle[]): Set<Recht> {
|
||||
if (rollen.includes("SUPERADMIN")) return new Set(ALLE_RECHTE);
|
||||
|
||||
const gesammelt = new Set<Recht>();
|
||||
for (const rolle of rollen) {
|
||||
if (rolle === "SUPERADMIN") continue;
|
||||
for (const recht of ROLLEN_RECHTE[rolle] ?? []) gesammelt.add(recht);
|
||||
}
|
||||
return gesammelt;
|
||||
}
|
||||
|
||||
/** Hat die Sitzung das Recht? Der Administrator hat immer alles. */
|
||||
export function hatRecht(sitzung: Sitzung | null, recht: Recht): boolean {
|
||||
if (!sitzung) return false;
|
||||
if (sitzung.rollen.includes("SUPERADMIN")) return true;
|
||||
return rechteVon(sitzung.rollen).has(recht);
|
||||
}
|
||||
|
||||
/** Mindestens eines der Rechte. */
|
||||
export function hatEinesDerRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
|
||||
return rechte.some((recht) => hatRecht(sitzung, recht));
|
||||
}
|
||||
|
||||
/** Alle genannten Rechte. */
|
||||
export function hatAlleRechte(sitzung: Sitzung | null, rechte: Recht[]): boolean {
|
||||
return rechte.every((recht) => hatRecht(sitzung, recht));
|
||||
}
|
||||
|
||||
/** Wirft, wenn das Recht fehlt. Für Server-Actions und tRPC-Prozeduren. */
|
||||
export function verlangeRecht(sitzung: Sitzung | null, recht: Recht): asserts sitzung is Sitzung {
|
||||
if (!hatRecht(sitzung, recht)) throw new KeinZugriffFehler(recht);
|
||||
}
|
||||
|
||||
/** Wirft, wenn keines der Rechte vorliegt. */
|
||||
export function verlangeEinesDerRechte(
|
||||
sitzung: Sitzung | null,
|
||||
rechte: Recht[],
|
||||
): asserts sitzung is Sitzung {
|
||||
if (!hatEinesDerRechte(sitzung, rechte)) throw new KeinZugriffFehler(rechte);
|
||||
}
|
||||
|
||||
/** Hat die Sitzung eine dieser Rollen? Nur für Anzeigezwecke gedacht. */
|
||||
export function hatRolle(sitzung: Sitzung | null, ...rollen: Rolle[]): boolean {
|
||||
if (!sitzung) return false;
|
||||
return rollen.some((rolle) => sitzung.rollen.includes(rolle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Darf die Sitzung auf diesen Datensatz zugreifen? Ergänzt die Mandanten-
|
||||
* trennung der Datenbank um eine Prüfung im Code — doppelt hält besser, und
|
||||
* die Fehlermeldung ist verständlicher als ein leeres Ergebnis.
|
||||
*/
|
||||
export function gehoertZurOrganisation(
|
||||
sitzung: Sitzung | null,
|
||||
datensatz: { organisationId: string } | null | undefined,
|
||||
): boolean {
|
||||
if (!sitzung || !datensatz) return false;
|
||||
return sitzung.organisationId === datensatz.organisationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die eigenen Daten darf jeder sehen, auch ohne person.lesen. Andere nur mit
|
||||
* dem entsprechenden Recht.
|
||||
*/
|
||||
export function darfPersonSehen(sitzung: Sitzung | null, personId: string): boolean {
|
||||
if (!sitzung) return false;
|
||||
if (sitzung.personId === personId) return true;
|
||||
return hatRecht(sitzung, "person.lesen");
|
||||
}
|
||||
|
||||
/** Wandelt einen beliebigen Text in eine gültige Rolle um, sonst null. */
|
||||
export function alsRolle(wert: string | null | undefined): Rolle | null {
|
||||
if (!wert) return null;
|
||||
const gross = wert.trim().toUpperCase();
|
||||
return (ROLLEN as readonly string[]).includes(gross) ? (gross as Rolle) : null;
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Datenbankzugriff mit Mandantenkontext.
|
||||
*
|
||||
* Es gibt ZWEI Verbindungen, und der Unterschied ist der Kern der
|
||||
* Mandantentrennung:
|
||||
*
|
||||
* prismaApp — verbindet sich als "florian_app". Für diese Rolle ist Row
|
||||
* Level Security aktiv. Ohne gesetztes
|
||||
* app.current_organisation liefert jede Abfrage null Zeilen.
|
||||
* Das ist die Verbindung, über die die Anwendung arbeitet.
|
||||
*
|
||||
* prismaSystem — verbindet sich als Eigentümer (DIRECT_DATABASE_URL) und
|
||||
* umgeht RLS. Das ist nötig für genau die Vorgänge, die VOR
|
||||
* der Mandantenauflösung stattfinden: die Anmeldung muss eine
|
||||
* E-Mail-Adresse über alle Wehren hinweg suchen können, weil
|
||||
* zu dem Zeitpunkt noch niemand weiß, zu welcher Wehr sie
|
||||
* gehört. Ebenso Wartungsjobs des Workers.
|
||||
*
|
||||
* Zugriff auf Fachdaten läuft ausschließlich über fuerOrganisation(). Wer
|
||||
* prismaSystem in einem Router benutzt, umgeht die Trennung — deshalb heißt die
|
||||
* Hilfsfunktion dafür ohneMandantenfilter() und verlangt eine Begründung.
|
||||
*/
|
||||
|
||||
const globalerCache = globalThis as unknown as {
|
||||
prismaApp?: PrismaClient;
|
||||
prismaSystem?: PrismaClient;
|
||||
};
|
||||
|
||||
function protokollStufen() {
|
||||
return env.NODE_ENV === "development"
|
||||
? ([
|
||||
{ emit: "stdout", level: "warn" },
|
||||
{ emit: "stdout", level: "error" },
|
||||
] as const)
|
||||
: ([{ emit: "stdout", level: "error" }] as const);
|
||||
}
|
||||
|
||||
function clientAnlegen(url: string): PrismaClient {
|
||||
return new PrismaClient({
|
||||
datasources: { db: { url } },
|
||||
log: [...protokollStufen()],
|
||||
errorFormat: env.NODE_ENV === "development" ? "pretty" : "minimal",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Anwendungsverbindung mit aktiver Mandantentrennung.
|
||||
* Nicht direkt verwenden — immer über fuerOrganisation().
|
||||
*/
|
||||
export const prismaApp: PrismaClient =
|
||||
globalerCache.prismaApp ?? clientAnlegen(env.DATABASE_URL);
|
||||
|
||||
/**
|
||||
* Eigentümerverbindung ohne Mandantentrennung.
|
||||
* Nur für Anmeldung, Health-Checks und Wartungsjobs.
|
||||
*/
|
||||
export const prismaSystem: PrismaClient =
|
||||
globalerCache.prismaSystem ??
|
||||
clientAnlegen(env.DIRECT_DATABASE_URL ?? env.DATABASE_URL);
|
||||
|
||||
// In der Entwicklung zwischenlagern, damit Hot Reload keine neue
|
||||
// Verbindungsschar aufmacht.
|
||||
if (env.NODE_ENV !== "production") {
|
||||
globalerCache.prismaApp = prismaApp;
|
||||
globalerCache.prismaSystem = prismaSystem;
|
||||
}
|
||||
|
||||
/** Der auf eine Wehr eingeschränkte Client, den fuerOrganisation übergibt. */
|
||||
export type MandantenClient = Omit<
|
||||
PrismaClient,
|
||||
"$connect" | "$disconnect" | "$on" | "$transaction" | "$use" | "$extends"
|
||||
>;
|
||||
|
||||
/** Prüft grob, ob es überhaupt ein UUID sein kann, bevor es ins SQL geht. */
|
||||
const UUID_MUSTER =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Führt fn innerhalb einer Transaktion aus, in der app.current_organisation
|
||||
* gesetzt ist. SET LOCAL gilt nur bis zum Ende der Transaktion — danach ist der
|
||||
* Kontext wieder leer, auch wenn die Verbindung in den Pool zurückgeht.
|
||||
*
|
||||
* @example
|
||||
* const personen = await fuerOrganisation(orgId, (db) =>
|
||||
* db.person.findMany({ where: { aktiv: true } }),
|
||||
* );
|
||||
*/
|
||||
export async function fuerOrganisation<T>(
|
||||
organisationId: string,
|
||||
fn: (db: MandantenClient) => Promise<T>,
|
||||
optionen: { timeoutMs?: number; maxWaitMs?: number } = {},
|
||||
): Promise<T> {
|
||||
if (!UUID_MUSTER.test(organisationId)) {
|
||||
throw new Error(
|
||||
`Ungültige Organisations-Kennung: ${organisationId}. Erwartet wird ein UUID.`,
|
||||
);
|
||||
}
|
||||
|
||||
return prismaApp.$transaction(
|
||||
async (tx) => {
|
||||
// Parametrisiert, damit kein Wert ungeprüft ins SQL wandert.
|
||||
// set_config mit is_local=true entspricht SET LOCAL.
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return fn(tx as unknown as MandantenClient);
|
||||
},
|
||||
{
|
||||
maxWait: optionen.maxWaitMs ?? 5_000,
|
||||
timeout: optionen.timeoutMs ?? 15_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugriff ohne Mandantenfilter — ausdrücklich benannt, damit man beim Lesen
|
||||
* sofort sieht, dass hier die Trennung umgangen wird.
|
||||
*
|
||||
* Zulässig nur für: Anmeldung (E-Mail-Suche über alle Wehren), Health-Checks,
|
||||
* Wartungsjobs des Workers. Jede Verwendung gehört begründet.
|
||||
*/
|
||||
export async function ohneMandantenfilter<T>(
|
||||
grund: string,
|
||||
fn: (db: PrismaClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (env.NODE_ENV === "development") {
|
||||
console.warn(`[db] Zugriff ohne Mandantenfilter: ${grund}`);
|
||||
}
|
||||
return fn(prismaSystem);
|
||||
}
|
||||
|
||||
/** Einfache Erreichbarkeitsprüfung für /api/health. */
|
||||
export async function datenbankErreichbar(): Promise<boolean> {
|
||||
try {
|
||||
await prismaApp.$queryRaw`SELECT 1`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verbindungen schließen — für den Worker-Abbruch und für Tests. */
|
||||
export async function verbindungenTrennen(): Promise<void> {
|
||||
await Promise.allSettled([prismaApp.$disconnect(), prismaSystem.$disconnect()]);
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zentrale Umgebungsvalidierung.
|
||||
*
|
||||
* Fehlt oder taugt eine Variable nicht, bricht der Start hier ab — mit einer
|
||||
* Meldung, die sagt welche. Das ist deutlich angenehmer als ein Dienst, der
|
||||
* anläuft und beim ersten Datei-Upload merkt, dass MinIO nie konfiguriert war.
|
||||
*
|
||||
* Beim Container-Build ist keine echte Umgebung vorhanden; dort setzt das
|
||||
* Dockerfile SKIP_ENV_VALIDATION=1.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Akzeptiert "true"/"1"/"ja" als wahr — Compose liefert alles als Text.
|
||||
* Der Standardwert wird innerhalb der Umwandlung gesetzt, nicht per .default():
|
||||
* .default() erwartet den Eingabetyp (string), nicht das Ergebnis (boolean).
|
||||
*/
|
||||
const boolAusText = (standard: boolean) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((wert) => {
|
||||
if (wert === undefined || wert.trim() === "") return standard;
|
||||
return ["true", "1", "ja", "yes", "on"].includes(wert.trim().toLowerCase());
|
||||
});
|
||||
|
||||
const zahl = (standard: number) =>
|
||||
z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((wert) => (wert === undefined || wert === "" ? standard : Number(wert)))
|
||||
.pipe(z.number().int().positive());
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
TZ: z.string().default("Europe/Berlin"),
|
||||
APP_URL: z.string().url().default("http://localhost:3000"),
|
||||
|
||||
// Datenbank
|
||||
DATABASE_URL: z.string().url(),
|
||||
DIRECT_DATABASE_URL: z.string().url().optional(),
|
||||
|
||||
// Redis / Warteschlangen
|
||||
REDIS_URL: z.string().url(),
|
||||
WORKER_CONCURRENCY: zahl(5),
|
||||
|
||||
// Objektspeicher
|
||||
S3_ENDPOINT: z.string().min(1),
|
||||
S3_PORT: zahl(9000),
|
||||
S3_USE_SSL: boolAusText(false),
|
||||
S3_ACCESS_KEY: z.string().min(1),
|
||||
S3_SECRET_KEY: z.string().min(1),
|
||||
S3_REGION: z.string().default("eu-central-1"),
|
||||
S3_BUCKET: z.string().min(1).default("florian"),
|
||||
S3_PUBLIC_URL: z.string().url().optional(),
|
||||
S3_URL_TTL: zahl(900),
|
||||
|
||||
// PDF-Erzeugung
|
||||
GOTENBERG_URL: z.string().url().optional(),
|
||||
GOTENBERG_TIMEOUT: zahl(30),
|
||||
|
||||
// Sitzungen
|
||||
AUTH_SECRET: z.string().min(32, "AUTH_SECRET muss mindestens 32 Zeichen haben"),
|
||||
AUTH_TRUST_HOST: boolAusText(true),
|
||||
AUTH_SESSION_MAX_AGE: zahl(28_800),
|
||||
|
||||
// OIDC gegen authentik
|
||||
AUTH_OIDC_ENABLED: boolAusText(false),
|
||||
AUTH_OIDC_ISSUER: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT_ID: z.string().optional(),
|
||||
AUTH_OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
AUTH_OIDC_NAME: z.string().default("authentik"),
|
||||
AUTH_OIDC_GROUP_CLAIM: z.string().default("groups"),
|
||||
AUTH_OIDC_GROUP_MAPPING: z.string().default(""),
|
||||
AUTH_OIDC_AUTO_PROVISION: boolAusText(true),
|
||||
AUTH_OIDC_STANDARD_ORGANISATION: z.string().optional(),
|
||||
|
||||
// Lokaler Login
|
||||
AUTH_LOCAL_ENABLED: boolAusText(true),
|
||||
AUTH_BCRYPT_ROUNDS: zahl(12),
|
||||
|
||||
// Kiosk
|
||||
KIOSK_ENABLED: boolAusText(true),
|
||||
KIOSK_DEVICE_SECRET: z.string().min(8),
|
||||
KIOSK_PIN_LAENGE: zahl(6),
|
||||
KIOSK_MAX_FEHLVERSUCHE: zahl(5),
|
||||
KIOSK_SPERRDAUER: zahl(300),
|
||||
KIOSK_IDLE_TIMEOUT: zahl(120),
|
||||
|
||||
// Integrationen (alle optional und abschaltbar)
|
||||
MAIL_ENABLED: boolAusText(false),
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: zahl(587),
|
||||
MAIL_SECURE: boolAusText(false),
|
||||
MAIL_USER: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
|
||||
TELEGRAM_ENABLED: boolAusText(false),
|
||||
TELEGRAM_BOT_TOKEN: z.string().optional(),
|
||||
TELEGRAM_CHAT_ID: z.string().optional(),
|
||||
|
||||
LLM_ENABLED: boolAusText(false),
|
||||
LLM_BASE_URL: z.string().optional(),
|
||||
LLM_API_KEY: z.string().optional(),
|
||||
LLM_MODELL: z.string().optional(),
|
||||
|
||||
// Aufbewahrung
|
||||
AUFBEWAHRUNG_JAHRE: zahl(10),
|
||||
AUDIT_AUFBEWAHRUNG_TAGE: zahl(3650),
|
||||
PERSON_ANONYMISIERUNG_TAGE: zahl(365),
|
||||
});
|
||||
|
||||
export type Umgebung = z.infer<typeof schema>;
|
||||
|
||||
/** Ersatzwerte für Build und Tests — nie für den echten Betrieb. */
|
||||
const platzhalter: Record<string, string> = {
|
||||
DATABASE_URL: "postgresql://build:build@localhost:5432/build?schema=public",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
S3_ENDPOINT: "localhost",
|
||||
S3_ACCESS_KEY: "build",
|
||||
S3_SECRET_KEY: "build",
|
||||
AUTH_SECRET: "build_time_platzhalter_mindestens_32_zeichen",
|
||||
KIOSK_DEVICE_SECRET: "build_time_platzhalter",
|
||||
};
|
||||
|
||||
function lesen(): Umgebung {
|
||||
const roh =
|
||||
process.env.SKIP_ENV_VALIDATION === "1" || process.env.NODE_ENV === "test"
|
||||
? { ...platzhalter, ...process.env }
|
||||
: process.env;
|
||||
|
||||
const ergebnis = schema.safeParse(roh);
|
||||
|
||||
if (!ergebnis.success) {
|
||||
const zeilen = ergebnis.error.issues.map(
|
||||
(problem) => ` - ${problem.path.join(".")}: ${problem.message}`,
|
||||
);
|
||||
throw new Error(
|
||||
[
|
||||
"Die Umgebungskonfiguration ist unvollständig oder fehlerhaft:",
|
||||
...zeilen,
|
||||
"",
|
||||
"Vorlage mit Erklärungen zu jeder Variable: .env.example",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
return ergebnis.data;
|
||||
}
|
||||
|
||||
export const env: Umgebung = lesen();
|
||||
|
||||
/** true, wenn die Anmeldung über authentik vollständig konfiguriert ist. */
|
||||
export const oidcVerfuegbar =
|
||||
env.AUTH_OIDC_ENABLED &&
|
||||
Boolean(env.AUTH_OIDC_ISSUER) &&
|
||||
Boolean(env.AUTH_OIDC_CLIENT_ID) &&
|
||||
Boolean(env.AUTH_OIDC_CLIENT_SECRET);
|
||||
|
||||
/**
|
||||
* Zuordnung authentik-Gruppe zu FLORIAN-Rolle aus AUTH_OIDC_GROUP_MAPPING.
|
||||
* Format: "gruppe:ROLLE,gruppe2:ROLLE2".
|
||||
*/
|
||||
export function oidcGruppenZuordnung(): Map<string, string> {
|
||||
const karte = new Map<string, string>();
|
||||
for (const eintrag of env.AUTH_OIDC_GROUP_MAPPING.split(",")) {
|
||||
const [gruppe, rolle] = eintrag.split(":").map((teil) => teil?.trim());
|
||||
if (gruppe && rolle) karte.set(gruppe.toLowerCase(), rolle.toUpperCase());
|
||||
}
|
||||
return karte;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Sprachmodell-Anbindung hinter einer Schnittstelle.
|
||||
*
|
||||
* Vorgesehen für: Einsatzberichte aus Stichpunkten formulieren, Prüfmängel
|
||||
* zusammenfassen, Protokolle vorstrukturieren. Immer als Vorschlag, nie als
|
||||
* Entscheidung — und immer mit einem Menschen, der gegenzeichnet.
|
||||
*
|
||||
* Es wird ausschließlich gegen einen OpenAI-kompatiblen Endpunkt gesprochen,
|
||||
* der selbst betrieben werden kann (Ollama, vLLM, llama.cpp). Kein Cloud-Zwang,
|
||||
* keine Daten, die das Gerätehaus verlassen müssen.
|
||||
*/
|
||||
|
||||
export interface LlmAnfrage {
|
||||
systemAnweisung: string;
|
||||
benutzerEingabe: string;
|
||||
maxToken?: number;
|
||||
temperatur?: number;
|
||||
}
|
||||
|
||||
export interface LlmAntwort {
|
||||
erfolgreich: boolean;
|
||||
text?: string;
|
||||
fehler?: string;
|
||||
}
|
||||
|
||||
export interface LlmDienst {
|
||||
vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
/** Standard, solange LLM_ENABLED=false: liefert eine klare Absage. */
|
||||
class AbgeschaltetesLlm implements LlmDienst {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async vervollstaendigen(): Promise<LlmAntwort> {
|
||||
return {
|
||||
erfolgreich: false,
|
||||
fehler: "Die Textunterstützung ist in dieser Installation abgeschaltet (LLM_ENABLED=false).",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class OpenAiKompatiblesLlm implements LlmDienst {
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.LLM_BASE_URL && env.LLM_MODELL);
|
||||
}
|
||||
|
||||
async vervollstaendigen(anfrage: LlmAnfrage): Promise<LlmAntwort> {
|
||||
if (!this.verfuegbar()) {
|
||||
return { erfolgreich: false, fehler: "LLM ist nicht vollständig konfiguriert." };
|
||||
}
|
||||
|
||||
try {
|
||||
const antwort = await fetch(`${env.LLM_BASE_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(env.LLM_API_KEY ? { Authorization: `Bearer ${env.LLM_API_KEY}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: env.LLM_MODELL,
|
||||
messages: [
|
||||
{ role: "system", content: anfrage.systemAnweisung },
|
||||
{ role: "user", content: anfrage.benutzerEingabe },
|
||||
],
|
||||
max_tokens: anfrage.maxToken ?? 1024,
|
||||
temperature: anfrage.temperatur ?? 0.3,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!antwort.ok) {
|
||||
return { erfolgreich: false, fehler: `Endpunkt antwortete mit ${antwort.status}` };
|
||||
}
|
||||
|
||||
const daten = (await antwort.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const text = daten.choices?.[0]?.message?.content;
|
||||
|
||||
return text
|
||||
? { erfolgreich: true, text }
|
||||
: { erfolgreich: false, fehler: "Leere Antwort vom Endpunkt." };
|
||||
} catch (fehler) {
|
||||
return { erfolgreich: false, fehler: String(fehler) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache: LlmDienst | null = null;
|
||||
|
||||
export function llm(): LlmDienst {
|
||||
if (!cache) {
|
||||
cache = env.LLM_ENABLED ? new OpenAiKompatiblesLlm() : new AbgeschaltetesLlm();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function _llmErsetzen(ersatz: LlmDienst | null): void {
|
||||
cache = ersatz;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* E-Mail-Versand hinter einer Schnittstelle.
|
||||
*
|
||||
* Regel aus CLAUDE.md: externe Dienste hinter Interfaces in lib/integrations/,
|
||||
* damit sie im Test mockbar und im Betrieb abschaltbar sind. Ist MAIL_ENABLED
|
||||
* nicht gesetzt, landet die Nachricht im Log statt beim Empfänger — für
|
||||
* Entwicklung und Testbetrieb genau richtig.
|
||||
*
|
||||
* Die tatsächliche SMTP-Anbindung kommt in Phase 7 (Benachrichtigungen). Bis
|
||||
* dahin steht hier bewusst nur der Vertrag.
|
||||
*/
|
||||
|
||||
export interface Nachricht {
|
||||
an: string | string[];
|
||||
betreff: string;
|
||||
text: string;
|
||||
html?: string;
|
||||
antwortAn?: string;
|
||||
anhaenge?: Array<{ dateiname: string; inhalt: Buffer; mimeTyp: string }>;
|
||||
}
|
||||
|
||||
export interface MailVersand {
|
||||
senden(nachricht: Nachricht): Promise<{ erfolgreich: boolean; kennung?: string; fehler?: string }>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
/** Schreibt statt zu senden. Standard, solange MAIL_ENABLED=false. */
|
||||
class ProtokollVersand implements MailVersand {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async senden(nachricht: Nachricht) {
|
||||
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
|
||||
console.info(
|
||||
`[mail] (nicht versendet, MAIL_ENABLED=false) an=${empfaenger} betreff="${nachricht.betreff}"`,
|
||||
);
|
||||
return { erfolgreich: true, kennung: "protokoll" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Platzhalter für den echten SMTP-Versand aus Phase 7. */
|
||||
class SmtpVersand implements MailVersand {
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.MAIL_HOST && env.MAIL_FROM);
|
||||
}
|
||||
|
||||
async senden(nachricht: Nachricht) {
|
||||
const empfaenger = Array.isArray(nachricht.an) ? nachricht.an.join(", ") : nachricht.an;
|
||||
console.warn(
|
||||
`[mail] SMTP-Versand ist erst ab Phase 7 umgesetzt. an=${empfaenger} betreff="${nachricht.betreff}"`,
|
||||
);
|
||||
return {
|
||||
erfolgreich: false,
|
||||
fehler: "SMTP-Versand noch nicht umgesetzt (Phase 7).",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let versandCache: MailVersand | null = null;
|
||||
|
||||
export function mail(): MailVersand {
|
||||
if (!versandCache) {
|
||||
versandCache = env.MAIL_ENABLED ? new SmtpVersand() : new ProtokollVersand();
|
||||
}
|
||||
return versandCache;
|
||||
}
|
||||
|
||||
/** Nur für Tests: eigene Umsetzung unterschieben. */
|
||||
export function _mailErsetzen(ersatz: MailVersand | null): void {
|
||||
versandCache = ersatz;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Telegram-Benachrichtigungen hinter einer Schnittstelle — abschaltbar und
|
||||
* mockbar. Gedacht für Erinnerungen an fällige Prüfungen und, ab Phase 4,
|
||||
* optionale Alarmhinweise.
|
||||
*
|
||||
* ACHTUNG: Telegram ist ein externer Dienst. Es dürfen ausschließlich
|
||||
* Hinweise ohne personenbezogene Daten hinausgehen ("3 Prüfungen fällig"),
|
||||
* niemals Namen, Adressen oder Einsatzdetails.
|
||||
*/
|
||||
|
||||
export interface TelegramVersand {
|
||||
senden(text: string, chatId?: string): Promise<{ erfolgreich: boolean; fehler?: string }>;
|
||||
verfuegbar(): boolean;
|
||||
}
|
||||
|
||||
class ProtokollTelegram implements TelegramVersand {
|
||||
verfuegbar(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
async senden(text: string) {
|
||||
console.info(`[telegram] (nicht versendet, TELEGRAM_ENABLED=false) ${text}`);
|
||||
return { erfolgreich: true };
|
||||
}
|
||||
}
|
||||
|
||||
class EchtesTelegram implements TelegramVersand {
|
||||
verfuegbar(): boolean {
|
||||
return Boolean(env.TELEGRAM_BOT_TOKEN && env.TELEGRAM_CHAT_ID);
|
||||
}
|
||||
|
||||
async senden(text: string, chatId?: string) {
|
||||
if (!this.verfuegbar()) {
|
||||
return { erfolgreich: false, fehler: "Telegram ist nicht vollständig konfiguriert." };
|
||||
}
|
||||
|
||||
try {
|
||||
const antwort = await fetch(
|
||||
`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId ?? env.TELEGRAM_CHAT_ID,
|
||||
text,
|
||||
parse_mode: "HTML",
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!antwort.ok) {
|
||||
return { erfolgreich: false, fehler: `Telegram antwortete mit ${antwort.status}` };
|
||||
}
|
||||
return { erfolgreich: true };
|
||||
} catch (fehler) {
|
||||
return { erfolgreich: false, fehler: String(fehler) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cache: TelegramVersand | null = null;
|
||||
|
||||
export function telegram(): TelegramVersand {
|
||||
if (!cache) {
|
||||
cache = env.TELEGRAM_ENABLED ? new EchtesTelegram() : new ProtokollTelegram();
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/** Nur für Tests. */
|
||||
export function _telegramErsetzen(ersatz: TelegramVersand | null): void {
|
||||
cache = ersatz;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import "server-only";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Alles, was gehasht wird: Passwörter, Terminal-PINs, RFID-Kennungen.
|
||||
* Immer bcrypt, immer mit dem Kostenfaktor aus AUTH_BCRYPT_ROUNDS, und
|
||||
* Vergleiche immer über bcrypt.compare — nie per Zeichenkettenvergleich.
|
||||
*/
|
||||
|
||||
export async function hashen(klartext: string): Promise<string> {
|
||||
return bcrypt.hash(klartext, env.AUTH_BCRYPT_ROUNDS);
|
||||
}
|
||||
|
||||
export async function pruefen(klartext: string, hash: string | null | undefined): Promise<boolean> {
|
||||
if (!hash) {
|
||||
// Trotzdem einen Vergleich durchführen, damit ein fehlender Hash zeitlich
|
||||
// nicht von einem falschen Passwort zu unterscheiden ist.
|
||||
await bcrypt.compare(klartext, "$2a$12$ungueltigerhashfuerzeitgleichheit000000000000000000000");
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compare(klartext, hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mindestanforderungen an ein Passwort. Bewusst schlicht gehalten: Länge
|
||||
* schlägt Sonderzeichen, und eine Wehr soll sich nicht an Regeln abarbeiten.
|
||||
*/
|
||||
export function passwortPruefen(passwort: string): { gueltig: boolean; meldung?: string } {
|
||||
if (passwort.length < 10) {
|
||||
return { gueltig: false, meldung: "Das Passwort muss mindestens 10 Zeichen lang sein." };
|
||||
}
|
||||
if (passwort.length > 200) {
|
||||
return { gueltig: false, meldung: "Das Passwort darf höchstens 200 Zeichen lang sein." };
|
||||
}
|
||||
if (/^\d+$/.test(passwort)) {
|
||||
return { gueltig: false, meldung: "Das Passwort darf nicht nur aus Ziffern bestehen." };
|
||||
}
|
||||
return { gueltig: true };
|
||||
}
|
||||
|
||||
/** Prüft eine Terminal-PIN gegen die konfigurierte Länge. */
|
||||
export function pinPruefen(pin: string): { gueltig: boolean; meldung?: string } {
|
||||
const laenge = env.KIOSK_PIN_LAENGE;
|
||||
if (!new RegExp(`^\\d{${laenge}}$`).test(pin)) {
|
||||
return { gueltig: false, meldung: `Die PIN muss aus genau ${laenge} Ziffern bestehen.` };
|
||||
}
|
||||
if (/^(\d)\1+$/.test(pin)) {
|
||||
return { gueltig: false, meldung: "Die PIN darf nicht aus lauter gleichen Ziffern bestehen." };
|
||||
}
|
||||
const ziffern = pin.split("").map(Number);
|
||||
const aufsteigend = ziffern.every((z, i) => i === 0 || z === (ziffern[i - 1] ?? 0) + 1);
|
||||
const absteigend = ziffern.every((z, i) => i === 0 || z === (ziffern[i - 1] ?? 0) - 1);
|
||||
if (aufsteigend || absteigend) {
|
||||
return { gueltig: false, meldung: "Die PIN darf keine fortlaufende Ziffernfolge sein." };
|
||||
}
|
||||
return { gueltig: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert eine RFID-/NFC-Kennung, bevor sie gehasht wird. Lesegeräte
|
||||
* liefern die UID mal mit Doppelpunkten, mal klein-, mal großgeschrieben.
|
||||
*/
|
||||
export function rfidNormalisieren(uid: string): string {
|
||||
return uid.replace(/[\s:-]/g, "").toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import "server-only";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import { Client as MinioClient } from "minio";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Der einzige Weg zu Dateien.
|
||||
*
|
||||
* Regel aus CLAUDE.md: Dateizugriff ausschließlich über lib/storage.ts. Kein
|
||||
* fs.readFile, kein Pfad im Dateisystem, keine Blobs in der Datenbank. Alles
|
||||
* liegt in MinIO — dadurch überlebt eine Datei den Neuaufbau des Containers,
|
||||
* lässt sich versionieren und getrennt sichern.
|
||||
*
|
||||
* Objektschlüssel folgen immer demselben Muster:
|
||||
* <organisation-slug>/<entitaet>/<entitaetId>/<uuid>-<dateiname>
|
||||
* Der Slug vorn macht Sicherungen und Löschungen je Wehr einfach.
|
||||
*/
|
||||
|
||||
let clientCache: MinioClient | null = null;
|
||||
|
||||
function client(): MinioClient {
|
||||
if (!clientCache) {
|
||||
clientCache = new MinioClient({
|
||||
endPoint: env.S3_ENDPOINT,
|
||||
port: env.S3_PORT,
|
||||
useSSL: env.S3_USE_SSL,
|
||||
accessKey: env.S3_ACCESS_KEY,
|
||||
secretKey: env.S3_SECRET_KEY,
|
||||
region: env.S3_REGION,
|
||||
});
|
||||
}
|
||||
return clientCache;
|
||||
}
|
||||
|
||||
export const BUCKET = env.S3_BUCKET;
|
||||
|
||||
/** Zeichen, die in Objektschlüsseln Ärger machen, werden ersetzt. */
|
||||
function dateinameBereinigen(name: string): string {
|
||||
return name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/ä/gi, "ae")
|
||||
.replace(/ö/gi, "oe")
|
||||
.replace(/ü/gi, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
export interface SchluesselTeile {
|
||||
organisationSlug: string;
|
||||
/** Modellname, z. B. "Geraet". Wird kleingeschrieben. */
|
||||
entitaet: string;
|
||||
entitaetId?: string;
|
||||
dateiname: string;
|
||||
}
|
||||
|
||||
/** Baut einen eindeutigen Objektschlüssel nach dem oben beschriebenen Muster. */
|
||||
export function objektSchluessel({
|
||||
organisationSlug,
|
||||
entitaet,
|
||||
entitaetId,
|
||||
dateiname,
|
||||
}: SchluesselTeile): string {
|
||||
const teile = [
|
||||
organisationSlug,
|
||||
entitaet.toLowerCase(),
|
||||
entitaetId ?? "allgemein",
|
||||
`${randomUUID()}-${dateinameBereinigen(dateiname)}`,
|
||||
];
|
||||
return teile.join("/");
|
||||
}
|
||||
|
||||
export interface HochladenErgebnis {
|
||||
bucket: string;
|
||||
objektSchluessel: string;
|
||||
groesseBytes: number;
|
||||
/** SHA-256 des Inhalts — erkennt Dubletten, belegt Unverändertheit. */
|
||||
pruefsumme: string;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen Puffer in MinIO ab und gibt zurück, was in die Datei-Tabelle
|
||||
* gehört. Der Aufrufer schreibt den Datensatz und den Audit-Eintrag.
|
||||
*/
|
||||
export async function hochladen(
|
||||
schluessel: string,
|
||||
inhalt: Buffer,
|
||||
mimeTyp: string,
|
||||
metadaten: Record<string, string> = {},
|
||||
): Promise<HochladenErgebnis> {
|
||||
await bucketSicherstellen();
|
||||
|
||||
const pruefsumme = createHash("sha256").update(inhalt).digest("hex");
|
||||
|
||||
const ergebnis = await client().putObject(BUCKET, schluessel, inhalt, inhalt.length, {
|
||||
"Content-Type": mimeTyp,
|
||||
// MinIO nimmt eigene Angaben nur mit diesem Präfix an.
|
||||
"x-amz-meta-pruefsumme": pruefsumme,
|
||||
...Object.fromEntries(
|
||||
Object.entries(metadaten).map(([schlssel, wert]) => [`x-amz-meta-${schlssel}`, wert]),
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
bucket: BUCKET,
|
||||
objektSchluessel: schluessel,
|
||||
groesseBytes: inhalt.length,
|
||||
pruefsumme,
|
||||
etag: ergebnis.etag,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lädt ein Objekt vollständig in den Speicher. Für große Dateien streamen. */
|
||||
export async function herunterladen(schluessel: string): Promise<Buffer> {
|
||||
const strom = await client().getObject(BUCKET, schluessel);
|
||||
const stuecke: Buffer[] = [];
|
||||
for await (const stueck of strom) {
|
||||
stuecke.push(Buffer.isBuffer(stueck) ? stueck : Buffer.from(stueck));
|
||||
}
|
||||
return Buffer.concat(stuecke);
|
||||
}
|
||||
|
||||
/** Objekt als Strom — für Downloads, die durch die Anwendung durchgereicht werden. */
|
||||
export async function alsStrom(schluessel: string): Promise<Readable> {
|
||||
return client().getObject(BUCKET, schluessel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitlich begrenzter Download-Link. Damit muss die Datei nicht durch die
|
||||
* Anwendung laufen — der Browser holt sie direkt bei MinIO ab.
|
||||
* Der Link gilt S3_URL_TTL Sekunden und ist danach wertlos.
|
||||
*/
|
||||
export async function downloadLink(
|
||||
schluessel: string,
|
||||
dateiname?: string,
|
||||
gueltigkeitSekunden = env.S3_URL_TTL,
|
||||
): Promise<string> {
|
||||
const kopfzeilen: Record<string, string> = {};
|
||||
if (dateiname) {
|
||||
kopfzeilen["response-content-disposition"] =
|
||||
`attachment; filename="${dateinameBereinigen(dateiname)}"`;
|
||||
}
|
||||
return client().presignedGetObject(BUCKET, schluessel, gueltigkeitSekunden, kopfzeilen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitlich begrenzter Upload-Link. Der Browser lädt direkt zu MinIO hoch,
|
||||
* ohne dass große Dateien durch die Anwendung müssen.
|
||||
*/
|
||||
export async function uploadLink(
|
||||
schluessel: string,
|
||||
gueltigkeitSekunden = env.S3_URL_TTL,
|
||||
): Promise<string> {
|
||||
await bucketSicherstellen();
|
||||
return client().presignedPutObject(BUCKET, schluessel, gueltigkeitSekunden);
|
||||
}
|
||||
|
||||
/** Löscht ein Objekt endgültig. Nur für den Aufbewahrungs-Job des Workers. */
|
||||
export async function endgueltigLoeschen(schluessel: string): Promise<void> {
|
||||
await client().removeObject(BUCKET, schluessel);
|
||||
}
|
||||
|
||||
/** Gibt es das Objekt und wie groß ist es? */
|
||||
export async function objektInfo(schluessel: string) {
|
||||
try {
|
||||
const status = await client().statObject(BUCKET, schluessel);
|
||||
return {
|
||||
vorhanden: true as const,
|
||||
groesseBytes: status.size,
|
||||
geaendertAm: status.lastModified,
|
||||
mimeTyp: status.metaData?.["content-type"] ?? "application/octet-stream",
|
||||
etag: status.etag,
|
||||
};
|
||||
} catch {
|
||||
return { vorhanden: false as const };
|
||||
}
|
||||
}
|
||||
|
||||
/** Kopiert ein Objekt, z. B. beim Duplizieren einer Prüfvorlage. */
|
||||
export async function kopieren(vonSchluessel: string, nachSchluessel: string): Promise<void> {
|
||||
await client().copyObject(BUCKET, nachSchluessel, `/${BUCKET}/${vonSchluessel}`);
|
||||
}
|
||||
|
||||
/** Legt den Bucket an, falls er fehlt. Idempotent, deshalb überall aufrufbar. */
|
||||
export async function bucketSicherstellen(): Promise<void> {
|
||||
const vorhanden = await client().bucketExists(BUCKET).catch(() => false);
|
||||
if (!vorhanden) {
|
||||
await client().makeBucket(BUCKET, env.S3_REGION);
|
||||
}
|
||||
}
|
||||
|
||||
/** Erreichbarkeitsprüfung für /api/health. */
|
||||
export async function speicherErreichbar(): Promise<boolean> {
|
||||
try {
|
||||
await client().bucketExists(BUCKET);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Nur für Tests: setzt den zwischengespeicherten Client zurück. */
|
||||
export function _clientZuruecksetzen(): void {
|
||||
clientCache = null;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { formatInTimeZone } from "date-fns-tz";
|
||||
|
||||
/** Tailwind-Klassen zusammenführen, spätere gewinnen. */
|
||||
export function cn(...eingaben: ClassValue[]) {
|
||||
return twMerge(clsx(eingaben));
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Anzeigen in Europe/Berlin. Gespeichert wird in UTC — die Umrechnung
|
||||
* passiert ausschließlich hier, damit sie nirgends vergessen wird.
|
||||
*/
|
||||
export const ZEITZONE = "Europe/Berlin";
|
||||
|
||||
function alsDate(wert: Date | string | number): Date {
|
||||
return wert instanceof Date ? wert : new Date(wert);
|
||||
}
|
||||
|
||||
/** 02.09.2026 */
|
||||
export function alsDatum(wert: Date | string | number | null | undefined): string {
|
||||
if (!wert) return "—";
|
||||
return formatInTimeZone(alsDate(wert), ZEITZONE, "dd.MM.yyyy", { locale: de });
|
||||
}
|
||||
|
||||
/** 02.09.2026, 14:35 */
|
||||
export function alsDatumZeit(wert: Date | string | number | null | undefined): string {
|
||||
if (!wert) return "—";
|
||||
return formatInTimeZone(alsDate(wert), ZEITZONE, "dd.MM.yyyy, HH:mm", { locale: de });
|
||||
}
|
||||
|
||||
/** 14:35 */
|
||||
export function alsUhrzeit(wert: Date | string | number | null | undefined): string {
|
||||
if (!wert) return "—";
|
||||
return formatInTimeZone(alsDate(wert), ZEITZONE, "HH:mm", { locale: de });
|
||||
}
|
||||
|
||||
/** "vor 3 Tagen" */
|
||||
export function alsRelativeZeit(wert: Date | string | number | null | undefined): string {
|
||||
if (!wert) return "—";
|
||||
return formatDistanceToNow(alsDate(wert), { addSuffix: true, locale: de });
|
||||
}
|
||||
|
||||
/** Nachname, Vorname — so stehen Mitglieder in jeder Liste. */
|
||||
export function personName(
|
||||
person: { vorname: string; nachname: string } | null | undefined,
|
||||
): string {
|
||||
if (!person) return "—";
|
||||
return `${person.nachname}, ${person.vorname}`;
|
||||
}
|
||||
|
||||
/** Vorname Nachname — für Anreden und Überschriften. */
|
||||
export function personAnrede(
|
||||
person: { vorname: string; nachname: string } | null | undefined,
|
||||
): string {
|
||||
if (!person) return "—";
|
||||
return `${person.vorname} ${person.nachname}`;
|
||||
}
|
||||
|
||||
/** Initialen für den Platzhalter im Profilbild. */
|
||||
export function initialen(
|
||||
person: { vorname: string; nachname: string } | null | undefined,
|
||||
): string {
|
||||
if (!person) return "??";
|
||||
return `${person.vorname.charAt(0)}${person.nachname.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
/** 1,4 MB — deutsche Schreibweise mit Komma. */
|
||||
export function alsDateigroesse(bytes: number | bigint | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
const zahl = typeof bytes === "bigint" ? Number(bytes) : bytes;
|
||||
if (zahl < 1024) return `${zahl} B`;
|
||||
const einheiten = ["kB", "MB", "GB", "TB"];
|
||||
let wert = zahl / 1024;
|
||||
let index = 0;
|
||||
while (wert >= 1024 && index < einheiten.length - 1) {
|
||||
wert /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${wert.toLocaleString("de-DE", { maximumFractionDigits: 1 })} ${einheiten[index]}`;
|
||||
}
|
||||
|
||||
/** 1.234,50 € */
|
||||
export function alsEuro(betrag: number | null | undefined): string {
|
||||
if (betrag === null || betrag === undefined) return "—";
|
||||
return betrag.toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
}
|
||||
|
||||
/** Erzeugt aus einem Namen einen URL-tauglichen Slug. */
|
||||
export function alsSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import "server-only";
|
||||
import { Queue, type JobsOptions } from "bullmq";
|
||||
import IORedis from "ioredis";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Warteschlangen für Hintergrundarbeit.
|
||||
*
|
||||
* Was hier hineingehört: alles, was länger dauert als eine Anfrage dauern darf
|
||||
* oder nach Plan laufen soll — Fristenberechnung, Erinnerungen, PDF-Erzeugung,
|
||||
* Exporte, Aufräumjobs. Der Worker in src/worker/index.ts arbeitet sie ab.
|
||||
*
|
||||
* Die Anwendung schreibt nur hinein, der Worker liest. So kann man den Worker
|
||||
* neu starten, ohne dass die Oberfläche stehen bleibt.
|
||||
*/
|
||||
|
||||
export const WARTESCHLANGE = "florian";
|
||||
|
||||
/** Bekannte Auftragsarten. Ab Phase 2 kommen die fachlichen Jobs dazu. */
|
||||
export const AUFTRAG = {
|
||||
/** Prüft alle Geräte auf fällige Fristen und setzt den Status. */
|
||||
FRISTEN_PRUEFEN: "fristen.pruefen",
|
||||
/** Verschickt Erinnerungen an fällige Prüfungen. */
|
||||
ERINNERUNG_SENDEN: "erinnerung.senden",
|
||||
/** Erzeugt ein PDF über Gotenberg. */
|
||||
PDF_ERZEUGEN: "pdf.erzeugen",
|
||||
/** Räumt abgelaufene Daten nach den Aufbewahrungsfristen ab. */
|
||||
AUFBEWAHRUNG_PRUEFEN: "aufbewahrung.pruefen",
|
||||
} as const;
|
||||
|
||||
export type AuftragsArt = (typeof AUFTRAG)[keyof typeof AUFTRAG];
|
||||
|
||||
/** Jeder Auftrag trägt die Wehr mit, für die er läuft. */
|
||||
export interface AuftragsDaten {
|
||||
organisationId: string;
|
||||
[feld: string]: unknown;
|
||||
}
|
||||
|
||||
let verbindungCache: IORedis | null = null;
|
||||
let warteschlangeCache: Queue | null = null;
|
||||
|
||||
export function redis(): IORedis {
|
||||
if (!verbindungCache) {
|
||||
verbindungCache = new IORedis(env.REDIS_URL, {
|
||||
// BullMQ verlangt null, sonst bricht es Aufträge bei Netzhängern ab.
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
});
|
||||
verbindungCache.on("error", (fehler) => {
|
||||
console.error("[redis] Verbindungsfehler:", fehler.message);
|
||||
});
|
||||
}
|
||||
return verbindungCache;
|
||||
}
|
||||
|
||||
export function warteschlange(): Queue {
|
||||
if (!warteschlangeCache) {
|
||||
warteschlangeCache = new Queue(WARTESCHLANGE, {
|
||||
connection: redis(),
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: "exponential", delay: 5_000 },
|
||||
removeOnComplete: { age: 3600, count: 500 },
|
||||
removeOnFail: { age: 7 * 24 * 3600 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return warteschlangeCache;
|
||||
}
|
||||
|
||||
/** Stellt einen Auftrag ein. */
|
||||
export async function beauftrage(
|
||||
art: AuftragsArt,
|
||||
daten: AuftragsDaten,
|
||||
optionen: JobsOptions = {},
|
||||
): Promise<string | undefined> {
|
||||
const auftrag = await warteschlange().add(art, daten, optionen);
|
||||
return auftrag.id;
|
||||
}
|
||||
|
||||
/** Wiederkehrender Auftrag nach Zeitplan (cron-Syntax, Europe/Berlin). */
|
||||
export async function beauftrageWiederkehrend(
|
||||
art: AuftragsArt,
|
||||
daten: AuftragsDaten,
|
||||
zeitplan: string,
|
||||
): Promise<void> {
|
||||
await warteschlange().add(art, daten, {
|
||||
repeat: { pattern: zeitplan, tz: "Europe/Berlin" },
|
||||
jobId: `${art}:${daten.organisationId}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Erreichbarkeitsprüfung für /api/health. */
|
||||
export async function warteschlangeErreichbar(): Promise<boolean> {
|
||||
try {
|
||||
const antwort = await redis().ping();
|
||||
return antwort === "PONG";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Verbindungen sauber schließen — für Tests und den Worker-Abbruch. */
|
||||
export async function verbindungenSchliessen(): Promise<void> {
|
||||
await warteschlangeCache?.close();
|
||||
verbindungCache?.disconnect();
|
||||
warteschlangeCache = null;
|
||||
verbindungCache = null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import type { Sitzung } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Kontext jeder tRPC-Anfrage. Enthält bewusst nur, was für JEDE Prozedur
|
||||
* sinnvoll ist — die Sitzung und die Angaben zur Anfrage fürs Audit.
|
||||
* Der mandantengebundene Datenbankzugriff kommt in der angemeldet-Middleware
|
||||
* dazu, damit er ohne Sitzung gar nicht erst existiert.
|
||||
*/
|
||||
export interface Kontext {
|
||||
sitzung: Sitzung | null;
|
||||
ipAdresse: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
/** Ermittelt die IP hinter Traefik aus den Weiterleitungs-Kopfzeilen. */
|
||||
function ipErmitteln(kopfzeilen: Headers): string | null {
|
||||
const weitergeleitet = kopfzeilen.get("x-forwarded-for");
|
||||
if (weitergeleitet) {
|
||||
const erste = weitergeleitet.split(",")[0]?.trim();
|
||||
if (erste) return erste;
|
||||
}
|
||||
return kopfzeilen.get("x-real-ip");
|
||||
}
|
||||
|
||||
export async function kontextErzeugen({ req }: { req: Request }): Promise<Kontext> {
|
||||
return {
|
||||
sitzung: await aktuelleSitzung(),
|
||||
ipAdresse: ipErmitteln(req.headers),
|
||||
userAgent: req.headers.get("user-agent"),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { z } from "zod";
|
||||
import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
|
||||
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
|
||||
import { datenbankErreichbar } from "@/lib/db";
|
||||
import { speicherErreichbar } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* Wurzel-Router. Ab Phase 1 kommen hier die Fachbereiche als Unter-Router
|
||||
* dazu (stammdaten, wartung, atemschutz, einsatz, ausbildung, kasse).
|
||||
* Phase 0 liefert nur, was zum Prüfen des Fundaments nötig ist.
|
||||
*/
|
||||
|
||||
const systemRouter = router({
|
||||
/** Erreichbarkeit der Randsysteme. Ohne Anmeldung abrufbar. */
|
||||
status: oeffentlich.query(async () => {
|
||||
const [datenbank, speicher] = await Promise.all([
|
||||
datenbankErreichbar(),
|
||||
speicherErreichbar(),
|
||||
]);
|
||||
return {
|
||||
datenbank,
|
||||
speicher,
|
||||
zeitpunkt: new Date(),
|
||||
zeitzone: "Europe/Berlin",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const sitzungRouter = router({
|
||||
/** Wer bin ich, welcher Wehr gehöre ich an, was darf ich? */
|
||||
ich: angemeldet.query(async ({ ctx }) => {
|
||||
const person = await ctx.db((db) =>
|
||||
db.person.findUnique({
|
||||
where: { id: ctx.sitzung.personId },
|
||||
select: {
|
||||
id: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
email: true,
|
||||
dienstgrad: true,
|
||||
mitgliedsnummer: true,
|
||||
letzteAnmeldung: true,
|
||||
organisation: { select: { id: true, name: true, kurzname: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
person,
|
||||
rollen: ctx.sitzung.rollen,
|
||||
rollenBezeichnung: ctx.sitzung.rollen.map((rolle) => ROLLEN_BEZEICHNUNG[rolle]),
|
||||
rechte: [...rechteVon(ctx.sitzung.rollen)],
|
||||
anmeldeArt: ctx.sitzung.anmeldeArt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const organisationRouter = router({
|
||||
/** Stammdaten der eigenen Wehr. */
|
||||
eigene: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db((db) =>
|
||||
db.organisation.findUnique({
|
||||
where: { id: ctx.sitzung.organisationId },
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
/** Kennzahlen für die Startseite. */
|
||||
kennzahlen: angemeldet.query(async ({ ctx }) =>
|
||||
ctx.db(async (db) => {
|
||||
const [mitglieder, fahrzeuge, geraete, pruefungFaellig] = await Promise.all([
|
||||
db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { geloeschtAm: null } }),
|
||||
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
|
||||
]);
|
||||
return { mitglieder, fahrzeuge, geraete, pruefungFaellig };
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const auditRouter = router({
|
||||
/** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */
|
||||
letzte: mitRecht("system.auditlog")
|
||||
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional())
|
||||
.query(async ({ ctx, input }) =>
|
||||
ctx.db((db) =>
|
||||
db.auditLog.findMany({
|
||||
orderBy: { zeitpunkt: "desc" },
|
||||
take: input?.anzahl ?? 50,
|
||||
include: {
|
||||
akteur: { select: { id: true, vorname: true, nachname: true } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
sitzung: sitzungRouter,
|
||||
organisation: organisationRouter,
|
||||
audit: auditRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
import { ZodError } from "zod";
|
||||
import type { Kontext } from "@/server/context";
|
||||
import { KeinZugriffFehler, hatRecht, type Recht } from "@/lib/authz";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* tRPC-Grundgerüst.
|
||||
*
|
||||
* Drei Arten von Prozeduren:
|
||||
* oeffentlich — ohne Anmeldung erreichbar (Health, Anmeldestatus)
|
||||
* angemeldet — verlangt eine Sitzung; stellt db bereit, der bereits auf
|
||||
* die Wehr des Angemeldeten eingeschränkt ist
|
||||
* mitRecht(...) — zusätzlich ein bestimmtes Recht aus lib/authz.ts
|
||||
*
|
||||
* Wichtig: In den Prozeduren steht ctx.db für den mandantengebundenen Zugriff.
|
||||
* Wer prismaSystem direkt benutzt, umgeht die Trennung — das soll auffallen.
|
||||
*/
|
||||
|
||||
const t = initTRPC.context<Kontext>().create({
|
||||
// superjson erhaelt Date, BigInt und Decimal auf dem Weg zum Browser.
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape, error }) {
|
||||
return {
|
||||
...shape,
|
||||
data: {
|
||||
...shape.data,
|
||||
// Feldfehler aus Zod direkt an die Oberfläche durchreichen.
|
||||
zodFehler:
|
||||
error.cause instanceof ZodError ? error.cause.flatten().fieldErrors : null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const router = t.router;
|
||||
export const middleware = t.middleware;
|
||||
export const createCallerFactory = t.createCallerFactory;
|
||||
|
||||
/** Ohne Anmeldung erreichbar. */
|
||||
export const oeffentlich = t.procedure;
|
||||
|
||||
/** Übersetzt KeinZugriffFehler in ein sauberes tRPC-FORBIDDEN. */
|
||||
const fehlerUebersetzen = middleware(async ({ next }) => {
|
||||
try {
|
||||
return await next();
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof KeinZugriffFehler) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: fehler.message, cause: fehler });
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
});
|
||||
|
||||
/** Verlangt eine Sitzung und stellt den mandantengebundenen Zugriff bereit. */
|
||||
const angemeldetMiddleware = middleware(async ({ ctx, next }) => {
|
||||
if (!ctx.sitzung) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Bitte zuerst anmelden.",
|
||||
});
|
||||
}
|
||||
|
||||
const sitzung = ctx.sitzung;
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
...ctx,
|
||||
sitzung,
|
||||
/**
|
||||
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
|
||||
* @example const personen = await ctx.db((db) => db.person.findMany());
|
||||
*/
|
||||
db: <T>(fn: Parameters<typeof fuerOrganisation<T>>[1]) =>
|
||||
fuerOrganisation(sitzung.organisationId, fn),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const angemeldet = t.procedure.use(fehlerUebersetzen).use(angemeldetMiddleware);
|
||||
|
||||
/**
|
||||
* Prozedur, die ein bestimmtes Recht verlangt.
|
||||
* @example export const geraeteAendern = mitRecht("geraet.bearbeiten").mutation(...)
|
||||
*/
|
||||
export function mitRecht(recht: Recht) {
|
||||
return angemeldet.use(
|
||||
middleware(async ({ ctx, next }) => {
|
||||
if (!hatRecht(ctx.sitzung, recht)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`,
|
||||
});
|
||||
}
|
||||
return next({ ctx });
|
||||
}),
|
||||
);
|
||||
}
|
||||
Vendored
+38
@@ -0,0 +1,38 @@
|
||||
import type { Rolle, Sitzung } from "@/lib/authz";
|
||||
|
||||
/**
|
||||
* Erweitert die Typen von Auth.js um das, was FLORIAN zusätzlich in der
|
||||
* Sitzung führt: Person, Wehr, Rollen und die Art der Anmeldung.
|
||||
*/
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
organisationId: string;
|
||||
rollen: Rolle[];
|
||||
anmeldeArt: Sitzung["anmeldeArt"];
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
organisationId?: string;
|
||||
rollen?: Rolle[];
|
||||
anmeldeArt?: Sitzung["anmeldeArt"];
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
personId?: string;
|
||||
organisationId?: string;
|
||||
rollen?: Rolle[];
|
||||
anmeldeArt?: Sitzung["anmeldeArt"];
|
||||
/** Zeitpunkt, zu dem die Rollen zuletzt aus der Datenbank kamen. */
|
||||
geladenAm?: number;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Worker, type Job } from "bullmq";
|
||||
import { env } from "@/lib/env";
|
||||
import { AUFTRAG, WARTESCHLANGE, redis, type AuftragsDaten } from "@/lib/warteschlange";
|
||||
import { verbindungenTrennen } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Hintergrundprozess.
|
||||
*
|
||||
* Läuft als eigener Container neben der Anwendung und arbeitet die
|
||||
* Warteschlange ab. In Phase 0 sind die Auftragsarten angelegt, aber fachlich
|
||||
* noch leer — die Fristenberechnung kommt in Phase 2, die Erinnerungen in
|
||||
* Phase 7. Was hier schon steht, ist das Gerüst: Aufnahme, Protokollierung,
|
||||
* Fehlerbehandlung und ein sauberes Herunterfahren.
|
||||
*/
|
||||
|
||||
function protokoll(nachricht: string, ...weiteres: unknown[]): void {
|
||||
console.info(`[worker] ${nachricht}`, ...weiteres);
|
||||
}
|
||||
|
||||
async function fristenPruefen(auftrag: Job<AuftragsDaten>): Promise<{ geprueft: number }> {
|
||||
protokoll(`Fristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 2: Geräte mit überschrittener Frist auf PRUEFUNG_FAELLIG setzen.
|
||||
return { geprueft: 0 };
|
||||
}
|
||||
|
||||
async function erinnerungSenden(auftrag: Job<AuftragsDaten>): Promise<{ versendet: number }> {
|
||||
protokoll(`Erinnerungen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 7: Mail und Telegram über lib/integrations.
|
||||
return { versendet: 0 };
|
||||
}
|
||||
|
||||
async function pdfErzeugen(auftrag: Job<AuftragsDaten>): Promise<{ erzeugt: boolean }> {
|
||||
protokoll(`PDF erzeugen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 2: HTML an Gotenberg, Ergebnis nach MinIO über lib/storage.
|
||||
return { erzeugt: false };
|
||||
}
|
||||
|
||||
async function aufbewahrungPruefen(auftrag: Job<AuftragsDaten>): Promise<{ entfernt: number }> {
|
||||
protokoll(`Aufbewahrungsfristen prüfen für Organisation ${auftrag.data.organisationId}`);
|
||||
// Ab Phase 8: Audit-Log exportieren und danach abräumen,
|
||||
// ausgetretene Mitglieder nach PERSON_ANONYMISIERUNG_TAGE anonymisieren.
|
||||
return { entfernt: 0 };
|
||||
}
|
||||
|
||||
const bearbeiter: Record<string, (auftrag: Job<AuftragsDaten>) => Promise<unknown>> = {
|
||||
[AUFTRAG.FRISTEN_PRUEFEN]: fristenPruefen,
|
||||
[AUFTRAG.ERINNERUNG_SENDEN]: erinnerungSenden,
|
||||
[AUFTRAG.PDF_ERZEUGEN]: pdfErzeugen,
|
||||
[AUFTRAG.AUFBEWAHRUNG_PRUEFEN]: aufbewahrungPruefen,
|
||||
};
|
||||
|
||||
const worker = new Worker<AuftragsDaten>(
|
||||
WARTESCHLANGE,
|
||||
async (auftrag) => {
|
||||
const bearbeiten = bearbeiter[auftrag.name];
|
||||
if (!bearbeiten) {
|
||||
throw new Error(`Unbekannte Auftragsart: ${auftrag.name}`);
|
||||
}
|
||||
const beginn = Date.now();
|
||||
const ergebnis = await bearbeiten(auftrag);
|
||||
protokoll(`${auftrag.name} erledigt in ${Date.now() - beginn} ms`);
|
||||
return ergebnis;
|
||||
},
|
||||
{
|
||||
connection: redis(),
|
||||
concurrency: env.WORKER_CONCURRENCY,
|
||||
},
|
||||
);
|
||||
|
||||
worker.on("failed", (auftrag, fehler) => {
|
||||
console.error(
|
||||
`[worker] Auftrag ${auftrag?.name ?? "?"} (${auftrag?.id ?? "?"}) fehlgeschlagen:`,
|
||||
fehler.message,
|
||||
);
|
||||
});
|
||||
|
||||
worker.on("ready", () => {
|
||||
protokoll(
|
||||
`bereit — Warteschlange "${WARTESCHLANGE}", ${env.WORKER_CONCURRENCY} parallele Aufträge`,
|
||||
);
|
||||
});
|
||||
|
||||
/** Laufende Aufträge zu Ende bringen, bevor der Prozess endet. */
|
||||
async function herunterfahren(signal: string): Promise<void> {
|
||||
protokoll(`${signal} empfangen — fahre herunter ...`);
|
||||
await worker.close();
|
||||
await verbindungenTrennen();
|
||||
redis().disconnect();
|
||||
protokoll("beendet.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => void herunterfahren("SIGTERM"));
|
||||
process.on("SIGINT", () => void herunterfahren("SIGINT"));
|
||||
Reference in New Issue
Block a user