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:
Florian Wilde
2026-09-03 00:13:53 +02:00
co-authored by Claude Opus 5
commit 845942f36c
84 changed files with 21433 additions and 0 deletions
+42
View File
@@ -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>
);
}
+121
View File
@@ -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>
);
}
+151
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+8
View File
@@ -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;
+37
View File
@@ -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" },
},
);
}
+22
View File
@@ -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 };
+170
View File
@@ -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;
}
}
+45
View File
@@ -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>
);
}