Kein Modul, sondern die drei Software-Voraussetzungen fuer 1.0.0: Auskunft nach Art. 15 DSGVO, ein geprueftes Sicherungskonzept und eine Pipeline. Das Auskunftsrecht war ein Versprechen ohne Umsetzung: `person.export` stand seit Phase 0 im Katalog und war dem Kommandanten zugewiesen, implementiert war nichts. Ohne Auskunftsseite ist ein Loeschkonzept nicht abnehmbar. Jetzt sammelt FLORIAN dreizehn Abschnitte ueber eine Person, mit dem Zweck ueber jedem - Art. 15 verlangt nicht nur die Daten, sondern auch Zwecke und Rechte, und eine blosse Liste erfuellt die Pflicht nicht. Ein Durchgang liefert Anzeige, PDF und JSON nach Art. 20. Die Trennlinie ist "ueber mich" gegen "von mir": was ueber die Person gespeichert ist, steht vollstaendig drin, auch das Unangenehme. Was sie getan hat, steht als Zahl drin - die Pruefprotokolle gehoeren zum Nachweisbestand der Wehr, und vollstaendig ausgeliefert kaeme jeder ueber eine Selbstauskunft an die Geraeteakten. Die Rueckspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief sauber durch und hatte dabei saemtliche Unveraenderlichkeitsgarantien der Datenbank entfernt. Die Vorgabeprivilegierung aus dem Init-Skript gibt jeder von pg_restore neu angelegten Tabelle wieder alle Rechte, und GRANT kann nur hinzufuegen. Die Anwendungsrolle durfte danach das Audit-Log aendern, Wiki-Fassungen loeschen, Kiosk-Vorgaenge umschreiben - und nichts haette darauf hingewiesen. Gefunden haben es vier Datenbanktests unmittelbar nach der Probe, also genau die Probe, wegen der 1.0.0 sie verlangt. Die Pipeline ist geschrieben, aber NICHT erprobt: in Gitea ist kein act_runner registriert. Das steht so im README und im Devlog, damit sich niemand auf eine Pruefung verlaesst, die nicht stattfindet. 601 Unit-Tests gruen; Sicherung und Wiederherstellung einmal vollstaendig durchgefuehrt und die Tests danach erneut gruen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
import Link from "next/link";
|
|
import { notFound, redirect } from "next/navigation";
|
|
import { ArrowLeft } from "lucide-react";
|
|
import { aktuelleSitzung } from "@/lib/auth";
|
|
import { hatRecht } from "@/lib/authz";
|
|
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";
|
|
import { Datenauskunft } from "@/components/stammdaten/auskunft";
|
|
|
|
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>
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
|
|
|
|
{/* Die Auskunft nach Art. 15 DSGVO. Sie steht hier und nicht in
|
|
einem Datenschutzmenü: gefragt wird sie zu einer bestimmten
|
|
Person, und genau deren Seite ist man gerade. */}
|
|
{hatRecht(sitzung, "person.export") && (
|
|
<Datenauskunft
|
|
personId={person.id}
|
|
personName={`${person.vorname} ${person.nachname}`}
|
|
/>
|
|
)}
|
|
</div>
|
|
<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>
|
|
);
|
|
}
|