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
+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>
);
}