Merge branch 'phase/012-014' (Schluessel, Personen, Private Key)
# Conflicts: # src/components/vorgang/useVorgang.ts # src/worker/handler.ts
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { datum } from "@/lib/format";
|
||||
import {
|
||||
ablaufZustand,
|
||||
KATEGORIE_TEXT,
|
||||
STATUS_TEXT,
|
||||
typAnzeige,
|
||||
type Kategorie,
|
||||
type Status,
|
||||
} from "@/lib/schluessel";
|
||||
|
||||
/** Kleine Anzeigebausteine fuer Schluessel (#012) – reine Darstellung. */
|
||||
|
||||
export function TypBadge({ typ, publicKey }: { typ: "ED25519" | "RSA4096" | "ANDERER"; publicKey: string }) {
|
||||
return <Badge mono>{typAnzeige(typ, publicKey)}</Badge>;
|
||||
}
|
||||
|
||||
export function KategorieBadge({ kategorie }: { kategorie: Kategorie }) {
|
||||
return <Badge>{KATEGORIE_TEXT[kategorie]}</Badge>;
|
||||
}
|
||||
|
||||
/** AKTIV zeigen wir nicht eigens – nur Abweichungen vom Normalfall. */
|
||||
export function StatusBadge({ status }: { status: Status }) {
|
||||
if (status === "AKTIV") return null;
|
||||
return <Badge variante={status === "ROTIERT" ? "neutral" : "gefahr"}>{STATUS_TEXT[status]}</Badge>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ablauf: in Messing, sobald er in hoechstens 30 Tagen ansteht (Design #002), in Koralle,
|
||||
* wenn er vorbei ist. `lang` zeigt zusaetzlich das Datum (Detailseite).
|
||||
*/
|
||||
export function AblaufBadge({
|
||||
ablaufAm,
|
||||
jetzt,
|
||||
lang = false,
|
||||
}: {
|
||||
ablaufAm: Date | null;
|
||||
jetzt: Date;
|
||||
lang?: boolean;
|
||||
}) {
|
||||
const zustand = ablaufZustand(ablaufAm, jetzt);
|
||||
if (zustand.art === "keiner" || !ablaufAm) return null;
|
||||
const zeit = <time dateTime={ablaufAm.toISOString()}>{datum(ablaufAm)}</time>;
|
||||
if (zustand.art === "abgelaufen") {
|
||||
return <Badge variante="gefahr">{lang ? <span>abgelaufen am {zeit}</span> : "abgelaufen"}</Badge>;
|
||||
}
|
||||
const in_ = zustand.tage === 0 ? "heute" : zustand.tage === 1 ? "morgen" : `in ${zustand.tage} Tagen`;
|
||||
if (zustand.art === "bald") {
|
||||
return (
|
||||
<Badge variante="messing">
|
||||
{lang ? (
|
||||
<span>
|
||||
läuft ab am {zeit} · {in_}
|
||||
</span>
|
||||
) : (
|
||||
<span>läuft ab {in_}</span>
|
||||
)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return lang ? (
|
||||
<Badge variante="neutral" className="text-muted">
|
||||
<span>läuft ab am {zeit}</span>
|
||||
</Badge>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Auswahlliste, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
|
||||
import { schluesselBearbeitenAktion } from "@/server/actions/schluessel";
|
||||
import type { Auswahl } from "./SchluesselNeu";
|
||||
|
||||
export type SchluesselFormularWerte = {
|
||||
id: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
/** JJJJ-MM-TT oder leer */
|
||||
ablaufAm: string;
|
||||
personId: string;
|
||||
vorlageId: string;
|
||||
status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||
statusAenderbar: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadaten bearbeiten (#012). Slug, Typ und Kommentar bleiben – sie stehen schon in
|
||||
* Dateinamen und authorized_keys-Zeilen auf den Hosts.
|
||||
*/
|
||||
export function SchluesselBearbeiten({
|
||||
werte,
|
||||
personen,
|
||||
vorlagen,
|
||||
}: {
|
||||
werte: SchluesselFormularWerte;
|
||||
personen: Auswahl[];
|
||||
vorlagen: Auswahl[];
|
||||
}) {
|
||||
const { zustand, absenden, laeuft } = useFormularAktion(schluesselBearbeitenAktion);
|
||||
const f = zustand.feldfehler ?? {};
|
||||
return (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
|
||||
<input type="hidden" name="id" value={werte.id} />
|
||||
<Formularfehler text={zustand.fehler} />
|
||||
<Karte titel="Metadaten">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Zweck"
|
||||
name="name"
|
||||
defaultValue={werte.name}
|
||||
required
|
||||
maxLength={120}
|
||||
fehler={f["name"]}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<Auswahlliste beschriftung="Person" name="personId" defaultValue={werte.personId}>
|
||||
<option value="">keine</option>
|
||||
{personen.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Vorlage" name="vorlageId" defaultValue={werte.vorlageId}>
|
||||
<option value="">keine</option>
|
||||
{vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld
|
||||
beschriftung="Ablaufdatum"
|
||||
name="ablaufAm"
|
||||
type="date"
|
||||
defaultValue={werte.ablaufAm}
|
||||
fehler={f["ablaufAm"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Tags"
|
||||
name="tags"
|
||||
mono
|
||||
defaultValue={werte.tags.join(", ")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||
fehler={f["tags"]}
|
||||
/>
|
||||
{werte.statusAenderbar && (
|
||||
<Auswahlliste beschriftung="Status" name="status" defaultValue={werte.status}>
|
||||
<option value="AKTIV">aktiv</option>
|
||||
<option value="ABGELAUFEN">abgelaufen</option>
|
||||
</Auswahlliste>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button type="submit" variante="primaer" disabled={laeuft}>
|
||||
{laeuft ? "Speichere …" : "Änderungen speichern"}
|
||||
</Button>
|
||||
<Button href={`/schluessel/${werte.id}`}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
"use client";
|
||||
|
||||
import { Download, KeyRound, Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition, type FormEvent, type ReactNode } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
Ankreuzfeld,
|
||||
Auswahlliste,
|
||||
Button,
|
||||
Feld,
|
||||
Feldgruppe,
|
||||
Formularfehler,
|
||||
Karte,
|
||||
Textbereich,
|
||||
} from "@/components/ui";
|
||||
import { istLaufend, useVorgang } from "@/components/vorgang/useVorgang";
|
||||
import {
|
||||
paarImportierenAktion,
|
||||
publicKeyImportierenAktion,
|
||||
schluesselErzeugenAktion,
|
||||
type SchluesselAntwort,
|
||||
} from "@/server/actions/schluessel";
|
||||
|
||||
/**
|
||||
* Neuer Schluessel (#012): erzeugen oder importieren (nur Public Key oder Paar).
|
||||
*
|
||||
* Erzeugen und Paar-Import laufen als Vorgang im Worker; das Formular verfolgt ihn und
|
||||
* springt nach Erfolg zum Schluessel. Passphrase und Private Key stehen nur so lange im
|
||||
* Formular, bis sie abgeschickt sind.
|
||||
*/
|
||||
type Modus = "erzeugen" | "public" | "paar";
|
||||
|
||||
export type Auswahl = { id: string; name: string; zusatz?: string | null };
|
||||
|
||||
const HINWEIS_PASSPHRASE = "Für automatische Anmeldungen (Host-zu-Host) ungeeignet.";
|
||||
|
||||
function Umschalter({
|
||||
wert,
|
||||
setzen,
|
||||
optionen,
|
||||
beschriftung,
|
||||
}: {
|
||||
wert: string;
|
||||
setzen: (wert: string) => void;
|
||||
optionen: { wert: string; text: string; icon?: ReactNode }[];
|
||||
beschriftung: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={beschriftung}
|
||||
className="border-border-strong bg-sidebar rounded-box inline-flex w-full max-w-md gap-1 border p-1 sm:w-auto sm:self-start"
|
||||
>
|
||||
{optionen.map((o) => (
|
||||
<button
|
||||
key={o.wert}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={wert === o.wert}
|
||||
onClick={() => setzen(o.wert)}
|
||||
className={clsx(
|
||||
"rounded-feld inline-flex min-h-10 flex-1 items-center justify-center gap-2 px-4 text-[14px] font-medium transition-colors sm:flex-none",
|
||||
wert === o.wert ? "bg-raised text-text" : "text-muted hover:text-text",
|
||||
)}
|
||||
>
|
||||
{o.icon}
|
||||
{o.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchluesselNeu({
|
||||
personen,
|
||||
vorlagen,
|
||||
vorgabePersonId,
|
||||
}: {
|
||||
personen: Auswahl[];
|
||||
vorlagen: Auswahl[];
|
||||
vorgabePersonId?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [art, setArt] = useState<"erzeugen" | "importieren">("erzeugen");
|
||||
const [importArt, setImportArt] = useState<"public" | "paar">("public");
|
||||
const modus: Modus = art === "erzeugen" ? "erzeugen" : importArt;
|
||||
|
||||
const [antwort, setAntwort] = useState<SchluesselAntwort>({});
|
||||
const [sendet, senden] = useTransition();
|
||||
const { stand, abfrageFehler } = useVorgang(antwort.vorgangId);
|
||||
|
||||
// Geheimnisse kontrolliert, damit sie nach dem Absenden sofort aus dem Formular verschwinden.
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [wiederholung, setWiederholung] = useState("");
|
||||
|
||||
const ergebnisId =
|
||||
stand?.status === "ERLEDIGT" && stand.ergebnis && typeof stand.ergebnis === "object"
|
||||
? (stand.ergebnis as { schluesselId?: unknown }).schluesselId
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof ergebnisId === "string") router.push(`/schluessel/${ergebnisId}`);
|
||||
}, [ergebnisId, router]);
|
||||
|
||||
const vorgangLaeuft = antwort.vorgangId !== undefined && istLaufend(stand) && !abfrageFehler;
|
||||
const beschaeftigt = sendet || vorgangLaeuft || typeof ergebnisId === "string";
|
||||
const f = antwort.feldfehler ?? {};
|
||||
const fehler =
|
||||
antwort.fehler ??
|
||||
abfrageFehler ??
|
||||
(stand?.status === "FEHLGESCHLAGEN" ? (stand.fehler ?? undefined) : undefined);
|
||||
|
||||
function absenden(ereignis: FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault();
|
||||
const daten = new FormData(ereignis.currentTarget);
|
||||
const aktion =
|
||||
modus === "erzeugen"
|
||||
? schluesselErzeugenAktion
|
||||
: modus === "paar"
|
||||
? paarImportierenAktion
|
||||
: publicKeyImportierenAktion;
|
||||
senden(async () => {
|
||||
setAntwort({});
|
||||
const neu = await aktion({}, daten);
|
||||
if (neu.vorgangId || neu.schluesselId) {
|
||||
setPrivateKey("");
|
||||
setPassphrase("");
|
||||
setWiederholung("");
|
||||
}
|
||||
if (neu.schluesselId) router.push(`/schluessel/${neu.schluesselId}`);
|
||||
setAntwort(neu);
|
||||
});
|
||||
}
|
||||
|
||||
const knopfText = beschaeftigt
|
||||
? modus === "erzeugen"
|
||||
? "Erzeuge …"
|
||||
: "Importiere …"
|
||||
: modus === "erzeugen"
|
||||
? "Schlüssel erzeugen"
|
||||
: "Schlüssel importieren";
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} noValidate className="flex flex-col gap-6" autoComplete="off">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Umschalter
|
||||
beschriftung="Art"
|
||||
wert={art}
|
||||
setzen={(w) => {
|
||||
setArt(w as typeof art);
|
||||
setAntwort({});
|
||||
}}
|
||||
optionen={[
|
||||
{ wert: "erzeugen", text: "Erzeugen", icon: <Sparkles className="size-4" aria-hidden /> },
|
||||
{ wert: "importieren", text: "Importieren", icon: <Download className="size-4" aria-hidden /> },
|
||||
]}
|
||||
/>
|
||||
{art === "importieren" && (
|
||||
<Umschalter
|
||||
beschriftung="Was importieren?"
|
||||
wert={importArt}
|
||||
setzen={(w) => {
|
||||
setImportArt(w as typeof importArt);
|
||||
setAntwort({});
|
||||
}}
|
||||
optionen={[
|
||||
{ wert: "public", text: "Nur Public Key" },
|
||||
{ wert: "paar", text: "Schlüsselpaar" },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Formularfehler text={fehler} />
|
||||
|
||||
<Karte titel="Zweck">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Zweck"
|
||||
name="name"
|
||||
required
|
||||
maxLength={120}
|
||||
placeholder="Backup srv-backup01 → srv-pve01"
|
||||
fehler={f["name"]}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Kurzname (optional)"
|
||||
name="slug"
|
||||
mono
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
placeholder="wird aus dem Zweck gebildet"
|
||||
hinweis="Für Dateinamen und Kommentare: a–z, 0–9, Bindestrich."
|
||||
fehler={f["slug"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Kommentar (optional)"
|
||||
name="kommentar"
|
||||
mono
|
||||
maxLength={200}
|
||||
spellCheck={false}
|
||||
placeholder={modus === "erzeugen" ? "sshkv:<kurzname>" : "aus dem Schlüssel"}
|
||||
hinweis="Steht hinter dem Public Key in authorized_keys."
|
||||
fehler={f["kommentar"]}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
{modus === "erzeugen" && (
|
||||
<Karte
|
||||
titel="Schlüssel"
|
||||
untertitel="sshkv erzeugt das Paar im Worker und speichert den Private Key verschlüsselt"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Feldgruppe legende="Typ" fehler={f["typ"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="typ"
|
||||
value="ED25519"
|
||||
defaultChecked
|
||||
beschriftung={<span className="font-mono">ed25519</span>}
|
||||
erklaerung="Empfohlen – kurz, schnell, sicher."
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="typ"
|
||||
value="RSA4096"
|
||||
beschriftung={<span className="font-mono">rsa-4096</span>}
|
||||
erklaerung="Nur für alte Gegenstellen ohne ed25519."
|
||||
/>
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
<p className="text-muted text-[13.5px]">
|
||||
Kategorie: <span className="text-text">Dienst</span> – Personen hinterlegen ihre Public Keys
|
||||
selbst unter „Meine Schlüssel“, deren Private Keys speichert sshkv nie.
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Passphrase (optional)"
|
||||
name="passphrase"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
hinweis={`Wird nicht gespeichert. ${HINWEIS_PASSPHRASE}`}
|
||||
fehler={f["passphrase"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Passphrase wiederholen"
|
||||
name="passphraseWiederholung"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={wiederholung}
|
||||
onChange={(e) => setWiederholung(e.target.value)}
|
||||
disabled={!passphrase}
|
||||
fehler={f["passphraseWiederholung"]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{modus === "public" && (
|
||||
<Karte titel="Public Key" untertitel="Eine Zeile im OpenSSH-Format, z. B. aus id_ed25519.pub">
|
||||
<div className="flex flex-col gap-5">
|
||||
<Textbereich
|
||||
beschriftung="Public Key"
|
||||
name="publicKey"
|
||||
required
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
placeholder="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… name@rechner"
|
||||
fehler={f["publicKey"]}
|
||||
className="[&_textarea]:font-mono [&_textarea]:text-[13px]"
|
||||
/>
|
||||
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
|
||||
<div className="grid gap-x-4 sm:grid-cols-2">
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="kategorie"
|
||||
value="DIENST"
|
||||
defaultChecked={!vorgabePersonId}
|
||||
beschriftung="Dienst"
|
||||
erklaerung="Ein Programm oder Host meldet sich damit an."
|
||||
/>
|
||||
<Ankreuzfeld
|
||||
type="radio"
|
||||
name="kategorie"
|
||||
value="PERSON"
|
||||
defaultChecked={Boolean(vorgabePersonId)}
|
||||
beschriftung="Person"
|
||||
erklaerung="Ein Mensch meldet sich damit an."
|
||||
/>
|
||||
</div>
|
||||
</Feldgruppe>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{modus === "paar" && (
|
||||
<Karte
|
||||
titel="Schlüsselpaar"
|
||||
untertitel="Private Key im OpenSSH-Format – sshkv prüft ihn im Worker und speichert ihn verschlüsselt"
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
<Textbereich
|
||||
beschriftung="Private Key"
|
||||
name="privateKey"
|
||||
required
|
||||
rows={7}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
placeholder={"-----BEGIN OPENSSH PRIVATE KEY-----\n…\n-----END OPENSSH PRIVATE KEY-----"}
|
||||
fehler={f["privateKey"]}
|
||||
className="[&_textarea]:font-mono [&_textarea]:text-[12.5px]"
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Passphrase (nur falls der Key eine hat)"
|
||||
name="passphrase"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
hinweis={`Nur zur Prüfung, wird verworfen. Der Key bleibt passphrasengeschützt gespeichert – ${HINWEIS_PASSPHRASE.toLowerCase()}`}
|
||||
fehler={f["passphrase"]}
|
||||
className="sm:max-w-md"
|
||||
/>
|
||||
<p className="text-muted text-[13.5px]">
|
||||
Kategorie: <span className="text-text">Dienst</span> – Private Keys von Personen speichert sshkv
|
||||
nie.
|
||||
</p>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
<Karte titel="Einordnung">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Auswahlliste
|
||||
beschriftung={modus === "public" ? "Person (optional)" : "Verantwortliche Person (optional)"}
|
||||
name="personId"
|
||||
defaultValue={vorgabePersonId ?? ""}
|
||||
>
|
||||
<option value="">keine</option>
|
||||
{personen.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.zusatz ? `${p.name} (${p.zusatz})` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Auswahlliste beschriftung="Vorlage (optional)" name="vorlageId" defaultValue="">
|
||||
<option value="">keine</option>
|
||||
{vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.zusatz ? `${v.name} (${v.zusatz})` : v.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld
|
||||
beschriftung="Ablaufdatum (optional)"
|
||||
name="ablaufAm"
|
||||
type="date"
|
||||
hinweis="Ohne Angabe gilt die Laufzeit der Vorlage, sonst keine."
|
||||
fehler={f["ablaufAm"]}
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Tags"
|
||||
name="tags"
|
||||
mono
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="backup, pve"
|
||||
hinweis="Durch Komma oder Leerzeichen getrennt, max. 20."
|
||||
fehler={f["tags"]}
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<Button
|
||||
type="submit"
|
||||
variante="primaer"
|
||||
disabled={beschaeftigt}
|
||||
icon={<KeyRound className="size-4" />}
|
||||
>
|
||||
{knopfText}
|
||||
</Button>
|
||||
<Button href="/schluessel">Abbrechen</Button>
|
||||
<p role="status" className="text-muted text-[13px]">
|
||||
{vorgangLaeuft && "Der Worker arbeitet …"}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { clsx } from "clsx";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -8,10 +9,13 @@ export function MonoBlock({
|
||||
wert,
|
||||
beschriftung,
|
||||
hoehe,
|
||||
umbrechen = false,
|
||||
}: {
|
||||
wert: string;
|
||||
beschriftung: string;
|
||||
hoehe?: string;
|
||||
/** Lange Einzeiler (Public Keys) umbrechen statt seitlich scrollen (#012). */
|
||||
umbrechen?: boolean;
|
||||
}) {
|
||||
const [kopiert, setKopiert] = useState(false);
|
||||
return (
|
||||
@@ -33,7 +37,10 @@ export function MonoBlock({
|
||||
{kopiert ? <Check className="text-ok size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
|
||||
</button>
|
||||
<pre
|
||||
className="text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]"
|
||||
className={clsx(
|
||||
"text-text overflow-auto p-4 pr-16 font-mono text-[12.5px] leading-[1.55]",
|
||||
umbrechen && "break-all whitespace-pre-wrap",
|
||||
)}
|
||||
style={hoehe ? { maxHeight: hoehe } : undefined}
|
||||
tabIndex={0}
|
||||
aria-label={beschriftung}
|
||||
|
||||
@@ -2,52 +2,67 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Was /api/vorgaenge/[id] liefert (#009, #016). */
|
||||
/**
|
||||
* Verfolgt einen Vorgang bis zum Ende (#012, #016): fragt /api/vorgaenge/[id] ab, bis der
|
||||
* Status nicht mehr WARTEND oder LAEUFT ist. Der Stand ist nach Vorgangs-ID gemerkt – ein
|
||||
* neuer Vorgang zeigt so nie den Stand des vorigen, ohne dass der Effekt synchron
|
||||
* zuruecksetzen muss.
|
||||
*/
|
||||
export type VorgangsStand = {
|
||||
readonly id: string;
|
||||
readonly id?: string;
|
||||
readonly status: string;
|
||||
readonly fehler: string | null;
|
||||
readonly plan: unknown;
|
||||
readonly plan?: unknown;
|
||||
readonly ergebnis: unknown;
|
||||
};
|
||||
|
||||
const LAUFEND = new Set(["WARTEND", "LAEUFT"]);
|
||||
|
||||
/** Fuer Formulare: ohne Stand gilt der Vorgang als laufend (er wurde gerade angelegt). */
|
||||
export function istLaufend(stand: VorgangsStand | undefined): boolean {
|
||||
return stand === undefined || LAUFEND.has(stand.status);
|
||||
}
|
||||
|
||||
/** Fuer Anzeigen: ohne Stand laeuft (noch) nichts. */
|
||||
export function laeuftNoch(stand: VorgangsStand | undefined): boolean {
|
||||
return stand !== undefined && LAUFEND.has(stand.status);
|
||||
}
|
||||
|
||||
/** Fragt einen Vorgang jede Sekunde ab, bis er fertig ist. */
|
||||
export function useVorgang(vorgangId: string | undefined): { stand?: VorgangsStand; fehler?: string } {
|
||||
// Der Stand merkt sich seine Vorgangs-ID: so zeigt ein neuer Vorgang nie den alten Stand,
|
||||
// ohne dass der Effekt synchron zuruecksetzen muss.
|
||||
const [stand, setStand] = useState<VorgangsStand>();
|
||||
const [fehler, setFehler] = useState<{ id: string; text: string }>();
|
||||
type Gemerkt = { readonly id: string; readonly stand?: VorgangsStand; readonly fehler?: string };
|
||||
|
||||
export function useVorgang(vorgangId: string | undefined): {
|
||||
stand: VorgangsStand | undefined;
|
||||
/** Die Statusabfrage selbst scheiterte (nicht der Vorgang). */
|
||||
abfrageFehler: string | undefined;
|
||||
/** Gleichbedeutend mit abfrageFehler (aeltere Aufrufer, #016). */
|
||||
fehler: string | undefined;
|
||||
} {
|
||||
const [gemerkt, setGemerkt] = useState<Gemerkt>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!vorgangId) return;
|
||||
let abgebrochen = false;
|
||||
void (async () => {
|
||||
while (!abgebrochen) {
|
||||
let abbrechen = false;
|
||||
const abfragen = async () => {
|
||||
while (!abbrechen) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (abbrechen) return;
|
||||
if (!antwort.ok) {
|
||||
setFehler({ id: vorgangId, text: "Status des Vorgangs nicht abrufbar." });
|
||||
setGemerkt({ id: vorgangId, fehler: "Status des Vorgangs nicht abrufbar." });
|
||||
return;
|
||||
}
|
||||
const neu = (await antwort.json()) as VorgangsStand;
|
||||
if (abgebrochen) return;
|
||||
setStand(neu);
|
||||
if (abbrechen) return;
|
||||
setGemerkt({ id: vorgangId, stand: neu });
|
||||
if (!LAUFEND.has(neu.status)) return;
|
||||
await new Promise((weiter) => setTimeout(weiter, 1000));
|
||||
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||
}
|
||||
})();
|
||||
};
|
||||
void abfragen();
|
||||
return () => {
|
||||
abgebrochen = true;
|
||||
abbrechen = true;
|
||||
};
|
||||
}, [vorgangId]);
|
||||
|
||||
return {
|
||||
...(stand && stand.id === vorgangId ? { stand } : {}),
|
||||
...(fehler && fehler.id === vorgangId ? { fehler: fehler.text } : {}),
|
||||
};
|
||||
const aktuell = gemerkt && gemerkt.id === vorgangId ? gemerkt : undefined;
|
||||
return { stand: aktuell?.stand, abfrageFehler: aktuell?.fehler, fehler: aktuell?.fehler };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user