Files
FireAdministration/src/app/(app)/mitglieder/[id]/page.tsx
T
Florian WildeandClaude Opus 5 ff40437a1a Phase 10: Mandantenfaehigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehoerigkeiten. Eine
Kreisebene, die zusieht.

Datenmodell und RLS trugen seit Phase 0 mehrere Wehren. Was fehlte, war
alles darueber: bis hierher war die Personenakte zugleich das Anmeldekonto,
und die gehoert genau einer Wehr. Wer in zweien gefuehrt wurde, brauchte
zwei Passwoerter, und die Anmeldung per E-Mail war mehrdeutig.

Neu sind `Benutzer` (das Konto) und `Mitgliedschaft` (die Zugehoerigkeit).
Das Konto traegt bewusst keine Mandantenkennung und kann keine tragen - es
ist der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehoert. Geschuetzt
wird es deshalb ueber Rechte statt ueber RLS, wie `Berechtigung` seit Phase
0. Was es in einer Wehr darf, entscheidet seine Mitgliedschaft.

Die Sitzung behaelt ihre Form und entsteht nur anders - aus der aktiven
Mitgliedschaft statt aus der Person. Von 414 Fundstellen in 61 Dateien
musste dadurch keine angefasst werden. Die eine Ausnahme ist Absicht:
`personId` ist nullbar geworden, weil eine Aufsichtssitzung keine Akte hat,
und der Typechecker hat daraus genau die fuenf Stellen gemacht, an denen
die Kreisaufsicht nichts verloren hat.

Ihre Rechte werden gefiltert statt aufgezaehlt: alles, was `.lesen` heisst,
minus Kasse, Audit-Log, KI-Protokoll und Personenexport. So bekommt sie
neue Leserechte automatisch und neue Schreibrechte nie.

Die Migration ist datenerhaltend und bricht ab, statt einen halben Zustand
zu hinterlassen.

Nebenbei behoben: `db:test-reset` liess die Nachbarwehr stehen, der
Fehlversuch-Test sperrte das gemeinsame Testkonto, ein Testaufbau war
nicht wiederholbar, und die Wartelogik der Bildschirmfotos konnte
ausloesen, bevor eine Seite ueberhaupt geladen war.

581 Unit-Tests und 266 E2E-Tests gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:43:24 +02:00

240 lines
8.7 KiB
TypeScript

import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { darfPersonSehen } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personAnrede } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
export const metadata = { title: "Mitglied" };
/**
* Mitgliedsdetail.
*
* Die Zugriffsprüfung läuft über darfPersonSehen(): die eigenen Daten darf
* jeder sehen, fremde nur mit dem Recht person.lesen. Adressen und
* Geburtsdatum sind personenbezogene Daten — sie stehen deshalb hinter
* derselben Schranke wie alles andere.
*/
export default async function MitgliedSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
if (!darfPersonSehen(sitzung, id)) redirect("/mitglieder");
const person = await fuerOrganisation(sitzung.organisationId, (db) =>
db.person.findUnique({
where: { id },
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
telefon: true,
mobil: true,
strasse: true,
plz: true,
ort: true,
geburtsdatum: true,
eintrittsdatum: true,
austrittsdatum: true,
aktiv: true,
// Seit Phase 10 hängt die Anmeldung am Konto, nicht an der Akte. Ob
// ein Mitglied überhaupt eines hat, ist die nützlichere Auskunft als
// ein Zeitstempel: ohne Konto kommt es nicht in FLORIAN hinein.
mitgliedschaft: {
select: {
art: true,
benutzer: {
select: { email: true, letzteAnmeldung: true, aktiv: true },
},
},
},
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
include: { typ: true },
orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }],
},
ausweisTokens: {
where: { geloeschtAm: null, aktiv: true },
select: { id: true, typ: true, bezeichnung: true, letzteNutzung: true },
},
},
}),
);
if (!person) notFound();
/*
* eslint-disable-next-line react/jsx-key -- Fehlalarm: Diese JSX-Elemente
* sind nicht die Kinder einer Liste, sondern die zweiten Glieder von
* Wertepaaren. Gerendert werden sie unten in einer map, deren Wrapper den
* key traegt (`<div key={bezeichnung}>`). Ein key an dieser Stelle waere
* wirkungslos und wuerde nur verschleiern, wo er wirklich sitzt.
*/
/* eslint-disable react/jsx-key */
const angaben: Array<[string, React.ReactNode]> = [
["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>],
["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad],
["Abteilung", person.abteilung ?? "—"],
["Eintritt", alsDatum(person.eintrittsdatum)],
["Geburtsdatum", alsDatum(person.geburtsdatum)],
["E-Mail", person.email ?? "—"],
["Telefon", person.telefon ?? "—"],
["Mobil", person.mobil ?? "—"],
[
"Anschrift",
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
],
[
"Zugang",
person.mitgliedschaft?.benutzer ? (
<>
{person.mitgliedschaft.benutzer.email}
<span className="block text-xs text-[var(--muted-foreground)]">
{person.mitgliedschaft.benutzer.aktiv
? person.mitgliedschaft.benutzer.letzteAnmeldung
? `zuletzt angemeldet am ${alsDatum(person.mitgliedschaft.benutzer.letzteAnmeldung)}`
: "noch nie angemeldet"
: "Konto abgeschaltet"}
</span>
</>
) : (
<>
kein Konto
<span className="block text-xs text-[var(--muted-foreground)]">
Diese Person ist geführt, meldet sich aber nicht an.
</span>
</>
),
],
];
/* eslint-enable react/jsx-key */
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/mitglieder">
<ArrowLeft aria-hidden />
Zurück zur Mitgliederliste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2">
{person.rollen.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
{zuweisung.rolle.name}
</Badge>
))}
{!person.aktiv && <Badge variant="ausgesondert">ausgetreten</Badge>}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Terminal-Zugang</CardTitle>
<CardDescription>
Anmeldemittel für das Gerätehaus. Gespeichert wird nur ein Hash.
</CardDescription>
</CardHeader>
<CardContent>
{person.ausweisTokens.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Kein Terminal-Zugang hinterlegt.
</p>
) : (
<ul className="space-y-2 text-sm">
{person.ausweisTokens.map((token) => (
<li key={token.id} className="flex items-center justify-between gap-2">
<span>
<Badge variant="outline">{token.typ}</Badge>{" "}
{token.bezeichnung ?? ""}
</span>
<span className="text-xs text-[var(--muted-foreground)]">
{token.letzteNutzung ? alsDatum(token.letzteNutzung) : "noch nicht benutzt"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Qualifikationen</CardTitle>
<CardDescription>
Lehrgänge, Befähigungen und Untersuchungen samt Gültigkeit.
</CardDescription>
</CardHeader>
<CardContent>
{person.qualifikationen.length === 0 ? (
<NichtsGefunden
titel="Keine Qualifikationen erfasst"
hinweis="Sobald Lehrgänge oder Untersuchungen eingetragen sind, erscheinen sie hier mit ihrer Gültigkeit."
/>
) : (
<ul className="divide-y divide-[var(--border)]">
{person.qualifikationen.map((qualifikation) => (
<li
key={qualifikation.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 text-sm"
>
<span className="font-medium">{qualifikation.typ.name}</span>
{qualifikation.typ.kuerzel && (
<Badge variant="outline">{qualifikation.typ.kuerzel}</Badge>
)}
<span className="text-[var(--muted-foreground)]">
erworben {alsDatum(qualifikation.erworbenAm)}
</span>
<span className="ml-auto">
{qualifikation.gueltigBis ? (
<FristAnzeige
datum={qualifikation.gueltigBis}
vorwarnTage={qualifikation.typ.vorwarnzeitTage}
/>
) : (
<span className="text-[var(--muted-foreground)]">unbefristet</span>
)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}