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:
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user