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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user