feat(personen): Personen und Self-Service Meine Schluessel (#013)

Personen lassen sich anlegen, bearbeiten und archivieren; ihr Detail
zeigt ihre Schluessel. Unter "Meine Schluessel" hinterlegt jede Rolle
eigene Public Keys (Freigabe Frage 4), wirksam erst nach Zuweisung
durch einen Operator oder Admin.

Warum so:
- Die eigene Person entsteht beim ersten Aufruf oder wird verknuepft:
  per benutzerId, sonst per E-Mail - aber nur, wenn der OIDC-Anbieter
  die Adresse bestaetigt hat. Dafuer merkt sich Benutzer jetzt
  emailBestaetigt (Migration); sonst koennte jemand mit selbst
  gewaehlter Adresse die Keys einer fremden Person sehen.
- Die Self-Service-Services nehmen nur den angemeldeten Benutzer und
  leiten die Person selbst ab; fremde Schluessel-IDs gelten als nicht
  vorhanden.
- Entfernen nur fuer eigene, selbst hinterlegte Keys ohne Verwendung,
  bedingt geloescht, damit eine gleichzeitige Zuweisung nicht per
  Cascade verschwindet.
- Archivieren statt Loeschen: wer bis wann welchen Key hatte, bleibt
  nachvollziehbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:38:55 +02:00
co-authored by Claude Opus 5.5
parent 263143134f
commit b5b43f41f6
22 changed files with 1569 additions and 10 deletions
+128 -4
View File
@@ -1,13 +1,137 @@
import { Plus, Search, Users } from "lucide-react";
import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import Link from "next/link";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personenListe } from "@/server/queries/personen";
export const metadata: Metadata = { title: "Personen" };
export default async function Personen() {
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string {
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
}
function Anzahl({ n }: { n: number }) {
return n === 0 ? <span className="text-muted">keine</span> : <>{n}</>;
}
/** Personenliste (#013): wem welche Schlüssel gehören. */
export default async function Personen({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const { benutzer } = await sitzungErforderlich("lesen", "/personen");
const q = einzeln((await searchParams)["q"]);
const personen = await personenListe({ q });
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
const anlegen = verwalten && (
<Button href="/personen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Person anlegen
</Button>
);
return (
<Platzhalterseite titel="Personen" untertitel="Wem welche Schlüssel gehören" phase={2} issue="#013" />
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Personen"
untertitel={q ? `${personen.length} Treffer` : "Wem welche Schlüssel gehören"}
aktionen={anlegen}
/>
<form method="get" role="search" className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={q}
placeholder="Name, E-Mail oder Organisation"
autoComplete="off"
/>
<div className="flex gap-2.5">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Suchen
</Button>
{q && (
<Button href="/personen" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{personen.length === 0 ? (
<Leerzustand
icon={<Users className="size-5" />}
titel={q ? "Keine Treffer" : "Noch keine Personen"}
text={
q
? "Keine Person passt zur Suche."
: "Personen entstehen hier oder von selbst, wenn jemand unter „Meine Schlüssel“ einen Key hinterlegt."
}
aktion={q ? <Button href="/personen">Suche zurücksetzen</Button> : anlegen}
/>
) : (
<Karte>
<ul className="flex flex-col md:hidden">
{personen.map((p) => (
<li key={p.id} className="border-border border-b last:border-b-0">
<Link
href={`/personen/${p.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-1 px-2 py-3 transition-colors"
>
<span className="text-text text-[14.5px] font-medium">{p.name}</span>
{p.email && <span className="text-muted truncate text-[13px]">{p.email}</span>}
<span className="flex flex-wrap items-center gap-1.5 pt-1">
{p.organisation && <Badge>{p.organisation}</Badge>}
{p.benutzer && <Badge variante="ok">Konto verknüpft</Badge>}
</span>
<span className="text-muted text-[13px]">
Aktive Schlüssel: <Anzahl n={p.aktiveSchluessel} />
</span>
</Link>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Personen"
spalten={[
{ titel: "Name" },
{ titel: "E-Mail" },
{ titel: "Organisation" },
{ titel: "Konto" },
{ titel: "Aktive Schlüssel" },
]}
>
{personen.map((p) => (
<TabellenZeile key={p.id}>
<TabellenZelle>
<Link
href={`/personen/${p.id}`}
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
>
{p.name}
</Link>
</TabellenZelle>
<TabellenZelle className="text-muted">{p.email ?? "–"}</TabellenZelle>
<TabellenZelle>{p.organisation ?? <span className="text-muted">–</span>}</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
{p.benutzer ? p.benutzer.name : <span className="text-muted">–</span>}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Anzahl n={p.aktiveSchluessel} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
)}
</div>
);
}