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
+3
View File
@@ -1,6 +1,7 @@
import {
Boxes,
KeyRound,
KeySquare,
LayoutDashboard,
LayoutTemplate,
Network,
@@ -20,6 +21,8 @@ export const HAUPTNAVIGATION: NavPunkt[] = [
{ href: "/hostgruppen", titel: "Hostgruppen", icon: Boxes },
{ href: "/schluessel", titel: "Schlüssel", icon: KeyRound },
{ href: "/personen", titel: "Personen", icon: Users },
// Self-Service fuer jede Rolle (#013): eigene Public Keys hinterlegen.
{ href: "/meine-schluessel", titel: "Meine Schlüssel", icon: KeySquare },
{ href: "/zuweisungen", titel: "Zuweisungen", icon: Network },
{ href: "/vorlagen", titel: "Vorlagen", icon: LayoutTemplate },
];
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { useActionState, useEffect, useRef } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Button, Feld, Formularfehler, Karte, Textbereich } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import {
eigenenKeyEntfernenAktion,
eigenenKeyHinterlegenAktion,
type HinterlegenZustand,
} from "@/server/actions/meine-schluessel";
/** Eigenen Public Key hinterlegen (#013). Nach Erfolg leert sich das Formular. */
export function KeyHinterlegen() {
const formular = useRef<HTMLFormElement>(null);
const { zustand, absenden, laeuft } = useFormularAktion(
eigenenKeyHinterlegenAktion as (v: FormularZustand, f: FormData) => Promise<HinterlegenZustand>,
);
const erfolg = (zustand as HinterlegenZustand).erfolg;
useEffect(() => {
if (erfolg) formular.current?.reset();
}, [erfolg]);
const f = zustand.feldfehler ?? {};
return (
<Karte
titel="Public Key hinterlegen"
untertitel="Eine Zeile im OpenSSH-Format, z. B. aus ~/.ssh/id_ed25519.pub"
>
<form ref={formular} onSubmit={absenden} noValidate className="flex flex-col gap-4">
<Formularfehler text={zustand.fehler} />
{erfolg && (
<p role="status" className="text-ok text-[14px] break-all">
{erfolg}
</p>
)}
<Feld
beschriftung="Bezeichnung"
name="name"
required
maxLength={120}
placeholder="Laptop, Workstation …"
autoComplete="off"
fehler={f["name"]}
/>
<Textbereich
beschriftung="Public Key"
name="publicKey"
required
rows={3}
spellCheck={false}
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
fehler={f["publicKey"]}
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
/>
<p className="text-muted text-[13.5px]">
Wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist. Den Private Key
behalten Sie bei sich – sshkv fragt nie danach.
</p>
<div>
<Button
type="submit"
variante="primaer"
disabled={laeuft}
icon={<Plus className="size-4" strokeWidth={2.4} />}
>
{laeuft ? "Speichere …" : "Hinterlegen"}
</Button>
</div>
</form>
</Karte>
);
}
/** Eigenen, noch nirgends zugewiesenen Key entfernen – mit Rueckfrage. */
export function KeyEntfernen({ schluesselId, name }: { schluesselId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(eigenenKeyEntfernenAktion, {});
return (
<>
<Button
variante="gefahr"
klein
icon={<Trash2 className="size-4" />}
onClick={() => dialog.current?.showModal()}
aria-label={`${name} entfernen`}
>
Entfernen
</Button>
<dialog
ref={dialog}
aria-labelledby={`entfernen-${schluesselId}`}
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={schluesselId} />
<h2 id={`entfernen-${schluesselId}`} className="text-[17px] font-semibold">
„{name}“ entfernen?
</h2>
<p className="text-muted text-[14.5px]">
Der Public Key verschwindet aus sshkv. Er ist noch keinem Host zugewiesen; auf keinem Server
ändert sich etwas.
</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 ? "Entferne …" : "Entfernen"}
</Button>
</div>
</form>
</dialog>
</>
);
}
+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>
</>
);
}