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
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { Archive } from "lucide-react";
import { useActionState, useRef } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Auswahlliste, Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import {
personAnlegenAktion,
personArchivierenAktion,
personBearbeitenAktion,
} from "@/server/actions/personen";
export type PersonFormularVorgabe = {
id?: string;
name: string;
email: string;
organisation: string;
notiz: string;
benutzerId: string;
};
export const LEERE_PERSON: PersonFormularVorgabe = {
name: "",
email: "",
organisation: "",
notiz: "",
benutzerId: "",
};
/** Formular fuer neue und bestehende Personen (#013). Regeln prueft der Server. */
export function PersonFormular({
vorgabe,
konten,
abbrechenZiel,
}: {
vorgabe: PersonFormularVorgabe;
konten: { id: string; name: string; email: string | null }[];
abbrechenZiel: string;
}) {
const bearbeiten = Boolean(vorgabe.id);
const { zustand, absenden, laeuft } = useFormularAktion(
bearbeiten ? personBearbeitenAktion : personAnlegenAktion,
);
const f = zustand.feldfehler ?? {};
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
<Formularfehler text={zustand.fehler} />
<Karte titel="Person">
<div className="grid gap-4 sm:grid-cols-2">
<Feld
beschriftung="Name"
name="name"
defaultValue={vorgabe.name}
required
maxLength={120}
autoComplete="off"
fehler={f["name"]}
className="sm:col-span-2"
/>
<Feld
beschriftung="E-Mail (optional)"
name="email"
type="email"
defaultValue={vorgabe.email}
maxLength={254}
autoComplete="off"
fehler={f["email"]}
/>
<Feld
beschriftung="Organisation (optional)"
name="organisation"
defaultValue={vorgabe.organisation}
maxLength={120}
placeholder="z. B. Dienstleister"
fehler={f["organisation"]}
/>
<Auswahlliste
beschriftung="Verknüpftes Konto (optional)"
name="benutzerId"
defaultValue={vorgabe.benutzerId}
className="sm:col-span-2"
>
<option value="">keines</option>
{konten.map((k) => (
<option key={k.id} value={k.id}>
{k.email ? `${k.name} (${k.email})` : k.name}
</option>
))}
</Auswahlliste>
{f["benutzerId"] && <p className="text-danger text-[13px] sm:col-span-2">{f["benutzerId"]}</p>}
<Textbereich
beschriftung="Notiz (optional)"
name="notiz"
defaultValue={vorgabe.notiz}
maxLength={1000}
fehler={f["notiz"]}
className="sm:col-span-2"
/>
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Person anlegen"}
</Button>
<Button href={abbrechenZiel}>Abbrechen</Button>
</div>
</form>
);
}
/** Archivieren mit Rueckfrage, nativer `<dialog>` wie bei Hosts (#008). */
export function PersonArchivieren({ personId, name }: { personId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(personArchivierenAktion, {});
return (
<>
<Button
variante="gefahr"
icon={<Archive className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Person archivieren
</Button>
<dialog
ref={dialog}
aria-labelledby="person-archivieren-titel"
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
>
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="id" value={personId} />
<h2 id="person-archivieren-titel" className="text-[17px] font-semibold">
{name} archivieren?
</h2>
<p className="text-muted text-[14.5px]">
Die Person verschwindet aus Listen und Auswahlfeldern, die Verknüpfung zum Konto entfällt. Ihre
Schlüssel bleiben zugeordnet und unverändert – widerrufen ist ein eigener Schritt.
</p>
<Formularfehler text={zustand.fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button type="submit" variante="gefahr" disabled={laeuft}>
{laeuft ? "Archiviere …" : "Archivieren"}
</Button>
</div>
</form>
</dialog>
</>
);
}