feat(widerruf): Widerruf und Massenwiderruf mit Dry-Run (#024)

Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts
verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch
liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen
werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre
zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr
offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023).
Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite
nennt die offenen Ziele.

Zielkonten lassen sich optional mit entfernen, aber erst nach den
Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt.
Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen
Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen
im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie
automatisch.

Migration 20261012130000_widerruf_eingeleitet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:14:01 +02:00
co-authored by Claude Opus 5.5
parent 2dad8e5631
commit c6361ff3c6
26 changed files with 2334 additions and 91 deletions
+4 -36
View File
@@ -1,12 +1,13 @@
"use client";
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
import { useState } from "react";
import { clsx } from "clsx";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
import { warten } from "@/components/vorgang/warten";
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import {
@@ -91,19 +92,6 @@ function kurz(text: string): string {
);
}
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
async function warten(
vorgangId: string,
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
for (;;) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
const [schritt, setSchritt] = useState(0);
const [w, setW] = useState<Werte>(LEER);
@@ -249,27 +237,7 @@ export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestae
return (
<div className="flex flex-col gap-6">
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
{SCHRITTE.map((titel, index) => (
<li
key={titel}
aria-current={index === schritt ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === schritt
? "border-accent text-accent bg-raised font-medium"
: index < schritt
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
{schritt === 0 && (
<Karte
+29 -6
View File
@@ -2,7 +2,7 @@
import { Play, ScanSearch } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useRef, useState, useTransition } from "react";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { laeuftNoch, useVorgang } from "@/components/vorgang/useVorgang";
@@ -34,7 +34,16 @@ type PlanZiel = {
fehler?: string;
};
export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; offen: number }) {
export function AusrollenPanel({
auswahl,
offen,
nachAusrollen,
}: {
auswahl: AusrollAuswahl;
offen: number;
/** Nach erfolgreichem Ausrollen – etwa um einen Widerruf abzuschliessen (#024). */
nachAusrollen?: () => void;
}) {
const router = useRouter();
const [trockenlaufId, setTrockenlaufId] = useState<string>();
const [ausrollenId, setAusrollenId] = useState<string>();
@@ -43,12 +52,22 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
const trockenlauf = useVorgang(trockenlaufId);
const ausrollen = useVorgang(ausrollenId);
// Ueber eine Ref, damit der Rueckruf genau einmal je Lauf feuert und nicht bei jedem Render.
const rueckruf = useRef(nachAusrollen);
useEffect(() => {
if (ausrollen.stand && !laeuftNoch(ausrollen.stand)) router.refresh();
rueckruf.current = nachAusrollen;
});
useEffect(() => {
if (!ausrollen.stand || laeuftNoch(ausrollen.stand)) return;
router.refresh();
if (ausrollen.stand.status === "ERLEDIGT") rueckruf.current?.();
}, [ausrollen.stand, router]);
const plan = (trockenlauf.stand?.plan as { ziele?: PlanZiel[] } | null)?.ziele;
const aenderungen = plan?.filter((z) => z.geaendert && !z.fehler).length ?? 0;
// Steht schon alles so auf dem Host (etwa ein Key, den jemand von Hand entfernt hat), gibt es
// nichts zu schreiben – der Zustand der Zuweisungen muss trotzdem nachgezogen werden (#024).
const nurZustand = aenderungen === 0 && offen > 0 && (plan?.some((z) => !z.fehler) ?? false);
const laeuft = startet || laeuftNoch(trockenlauf.stand) || laeuftNoch(ausrollen.stand);
const fertig = ausrollen.stand?.status === "ERLEDIGT";
@@ -103,7 +122,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div>
))}
{plan && aenderungen > 0 && !fertig && (
{plan && (aenderungen > 0 || nurZustand) && !fertig && (
<div className="border-border flex flex-col gap-3 border-t pt-4">
<label className="text-muted flex min-h-11 items-center gap-3 text-[14px]">
<input type="checkbox" checked disabled className="accent-accent size-5" />
@@ -124,7 +143,11 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
})
}
>
{laeuftNoch(ausrollen.stand) ? "Wird ausgerollt …" : `Änderung anwenden (${aenderungen})`}
{laeuftNoch(ausrollen.stand)
? "Wird ausgerollt …"
: nurZustand
? "Zustand übernehmen"
: `Änderung anwenden (${aenderungen})`}
</Button>
<Button disabled={laeuft} onClick={() => setTrockenlaufId(undefined)}>
Verwerfen
@@ -132,7 +155,7 @@ export function AusrollenPanel({ auswahl, offen }: { auswahl: AusrollAuswahl; of
</div>
</div>
)}
{plan && aenderungen === 0 && (
{plan && aenderungen === 0 && !fertig && (
<p className="text-ok text-[14px]">Alles ist bereits auf dem Soll-Stand.</p>
)}
{ausrollen.stand?.status === "FEHLGESCHLAGEN" && (
+41
View File
@@ -0,0 +1,41 @@
import { Check } from "lucide-react";
import { clsx } from "clsx";
/**
* Schrittanzeige fuer gefuehrte Ablaeufe (Assistent #020, Widerruf #024, Rotation #023):
* erledigte Schritte mit Haken, der aktuelle in Akzentfarbe und als `aria-current="step"`.
*/
export function Schrittleiste({
schritte,
aktuell,
beschriftung = "Schritte",
}: {
schritte: readonly string[];
/** Index des aktuellen Schritts; `schritte.length` heisst: alles erledigt. */
aktuell: number;
beschriftung?: string;
}) {
return (
<ol className="flex flex-wrap gap-2" aria-label={beschriftung}>
{schritte.map((titel, index) => (
<li
key={titel}
aria-current={index === aktuell ? "step" : undefined}
className={clsx(
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
index === aktuell
? "border-accent text-accent bg-raised font-medium"
: index < aktuell
? "border-border text-text"
: "border-border text-muted",
)}
>
<span className="font-mono">
{index < aktuell ? <Check className="size-4" aria-hidden /> : index + 1}
</span>
{titel}
</li>
))}
</ol>
);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Wartet auf das Ende eines Vorgangs – fuer Ketten im Klick (Assistent #020, Widerruf #024,
* Rotation #023), nicht als Hook.
*/
export type VorgangsEnde = { status: string; fehler: string | null; ergebnis: unknown };
export async function warten(vorgangId: string): Promise<VorgangsEnde> {
for (;;) {
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
const stand = (await antwort.json()) as VorgangsEnde;
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
await new Promise((weiter) => setTimeout(weiter, 800));
}
}
@@ -0,0 +1,24 @@
"use client";
/**
* „Alle auswählen“ fuer den Massenwiderruf (#024): setzt alle aktivierbaren Kaestchen, die zum
* Formular gehoeren. Die Auswahl selbst bleibt ein schlichtes GET-Formular – ohne JavaScript
* waehlt man eben einzeln.
*/
export function AlleAuswaehlen({ formular }: { formular: string }) {
return (
<label className="hover:bg-raised/60 rounded-box flex min-h-11 cursor-pointer items-center gap-3 px-2 text-[14px]">
<input
type="checkbox"
className="accent-accent size-[18px] cursor-pointer"
onChange={(e) => {
const kaestchen = document.querySelectorAll<HTMLInputElement>(
`input[type="checkbox"][form="${formular}"]:not(:disabled)`,
);
for (const k of kaestchen) k.checked = e.target.checked;
}}
/>
Alle auswählen
</label>
);
}
+224
View File
@@ -0,0 +1,224 @@
"use client";
import { CheckCheck, ExternalLink } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten";
import { widerrufAbschliessenAktion, widerrufZielkontenAktion } from "@/server/actions/widerruf";
import type { OffeneEntfernung, ZielkontoKandidat } from "@/server/services/widerruf";
/**
* Ein eingeleiteter Widerruf (#024), wiederaufnehmbar wie der Assistent (#020): Dry-Run und
* Anwenden fuer alle offenen Entfernungen, dann optional Zielkonten, dann Abschluss.
*
* Was noch fehlt, kommt immer frisch vom Server (`offen`) – nach einem Neuladen, einem
* Fehlschlag auf einem Host oder einer erneuten Bestaetigung geht es an derselben Stelle weiter.
*/
const SCHRITTE = ["Vorgemerkt", "Dry-Run & Anwenden", "Zielkonten", "Abgeschlossen"] as const;
const ZUSTAND_TEXT: Record<string, string> = {
ENTFERNEN: "steht aus",
FEHLGESCHLAGEN: "letzter Versuch fehlgeschlagen",
};
export function WiderrufAblauf({
schluesselIds,
offen,
zielkonten,
vorauswahl,
ruecksprung,
darfPrivat,
ohneZielkonten = false,
nachAbschluss,
}: {
schluesselIds: string[];
offen: OffeneEntfernung[];
zielkonten: ZielkontoKandidat[];
vorauswahl: string[];
/** Diese Seite – Ziel der erneuten Bestaetigung. */
ruecksprung: string;
darfPrivat: boolean;
/** Rotation (#023): das Zielkonto nutzt der Nachfolger weiter. */
ohneZielkonten?: boolean;
nachAbschluss?: () => void;
}) {
const router = useRouter();
const waehlbar = ohneZielkonten ? [] : zielkonten.filter((k) => k.nochGenutztVon.length === 0);
// Ohne waehlbares Zielkonto entfaellt der Schritt ganz – ein leerer Schritt verwirrt nur.
const ohneSchritt = waehlbar.length === 0;
const [auswahl, setAuswahl] = useState<string[]>(() =>
vorauswahl.filter((id) => waehlbar.some((k) => k.id === id)),
);
const [zielkontenFertig, setZielkontenFertig] = useState(false);
const [laeuft, setLaeuft] = useState<string>();
const [fehler, setFehler] = useState<string>();
const [bestaetigen, setBestaetigen] = useState(false);
const [noch, setNoch] = useState<OffeneEntfernung[]>();
const aktuell = offen.length > 0 ? 1 : auswahl.length > 0 && !zielkontenFertig ? 2 : 3;
const privatOhneRecht = !darfPrivat && offen.some((o) => o.art === "privat");
async function abschliessen() {
setFehler(undefined);
setBestaetigen(false);
setNoch(undefined);
try {
if (auswahl.length > 0 && !zielkontenFertig) {
setLaeuft("Zielkonten werden entfernt …");
const antwort = await widerrufZielkontenAktion(schluesselIds, auswahl);
if (antwort.bestaetigen) setBestaetigen(true);
if (antwort.fehler || !antwort.vorgaenge) {
setFehler(antwort.fehler);
return;
}
const fehlschlaege: string[] = [];
for (const vorgang of antwort.vorgaenge) {
const ende = await warten(vorgang.id);
if (ende.status !== "ERLEDIGT")
fehlschlaege.push(`${vorgang.beschriftung}: ${ende.fehler ?? "fehlgeschlagen"}`);
}
if (fehlschlaege.length > 0) {
setFehler(`Zielkonto nicht entfernt – ${fehlschlaege.join(" · ")}`);
return;
}
setZielkontenFertig(true);
}
setLaeuft("Widerruf wird abgeschlossen …");
const antwort = await widerrufAbschliessenAktion(schluesselIds);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
const offeneSchluessel = (antwort.ergebnisse ?? []).filter((e) => e.status === "OFFEN");
if (offeneSchluessel.length > 0) {
setNoch(offeneSchluessel.flatMap((e) => e.offen));
} else {
nachAbschluss?.();
}
router.refresh();
} catch (fehler) {
setFehler((fehler as Error).message);
} finally {
setLaeuft(undefined);
}
}
const bestaetigenZiel = (() => {
const url = new URL(ruecksprung, "http://x");
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
else url.searchParams.delete("zielkonten");
return `${url.pathname}${url.search}`;
})();
return (
<div className="flex flex-col gap-5">
<Schrittleiste
schritte={ohneSchritt ? SCHRITTE.filter((s) => s !== "Zielkonten") : SCHRITTE}
aktuell={ohneSchritt && aktuell >= 2 ? aktuell - 1 : aktuell}
beschriftung="Schritte des Widerrufs"
/>
{offen.length > 0 && (
<div className="flex flex-col gap-2">
<p className="text-[14px] font-medium">Noch nicht entfernt ({offen.length}):</p>
<ul className="flex flex-col gap-1 font-mono text-[13px]">
{offen.map((o) => (
<li key={o.id} className="break-all">
<span className={o.zustand === "FEHLGESCHLAGEN" ? "text-danger" : "text-accent"}>●</span>{" "}
{o.ziel}
<span className="text-muted font-sans">
{" "}
· {ZUSTAND_TEXT[o.zustand] ?? o.zustand.toLowerCase()}
</span>
</li>
))}
</ul>
{privatOhneRecht ? (
<p className="text-danger text-[14px]">Private Keys entfernt nur ein Admin.</p>
) : (
<>
<p className="text-muted text-[13.5px]">
Der Dry-Run zeigt je Host, was entfernt wird – und warum ein Host gerade nicht geht (etwa
nicht erreichbar). Fehlgeschlagene Ziele bleiben offen; ein neuer Dry-Run versucht es erneut.
</p>
<AusrollenPanel
auswahl={schluesselIds.length === 1 ? { schluesselId: schluesselIds[0]! } : { schluesselIds }}
offen={offen.length}
nachAusrollen={() => void abschliessen()}
/>
</>
)}
</div>
)}
{waehlbar.length > 0 && (
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-[14px] font-medium">Zielkonten entfernen (optional)</legend>
{zielkonten.map((k) => {
const genutzt = k.nochGenutztVon.length > 0;
return (
<Ankreuzfeld
key={k.id}
disabled={genutzt || zielkontenFertig || Boolean(laeuft)}
checked={auswahl.includes(k.id)}
onChange={(e) =>
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
}
beschriftung={
<>
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
<span className="font-mono">{k.hostName}</span> entfernen
</>
}
erklaerung={
genutzt
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
: "Nach dem Entfernen der Schlüssel; verlangt eine frische Bestätigung."
}
/>
);
})}
</fieldset>
)}
{noch && noch.length > 0 && (
<p
role="status"
className="border-danger-border bg-danger-soft text-danger rounded-box border px-4 py-3 text-[14px]"
>
Noch nicht widerrufen – offen: {noch.map((o) => o.ziel).join(", ")}. Bitte einen neuen Dry-Run
starten.
</p>
)}
<Formularfehler text={fehler} />
{bestaetigen && (
<a
href={`/bestaetigen?ziel=${encodeURIComponent(bestaetigenZiel)}`}
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
>
<ExternalLink className="size-4" aria-hidden /> Anmeldung bestätigen, danach hier fortsetzen
</a>
)}
{laeuft && (
<p role="status" className="text-muted text-[14px]">
{laeuft}
</p>
)}
{offen.length === 0 && (
<div>
<Button
variante="gefahr"
icon={<CheckCheck className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void abschliessen()}
>
Widerruf abschließen
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,209 @@
"use client";
import { Ban } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Ankreuzfeld, Button, Formularfehler } from "@/components/ui";
import { widerrufEinleitenAktion } from "@/server/actions/widerruf";
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
/**
* Bestaetigung eines Widerrufs (#024): listet genau auf, was auf welchem Host passiert, und
* laesst Zielkonten (Standard: aus) zum Entfernen auswaehlen. Der Klick merkt nur vor – auf den
* Hosts aendert sich erst nach Dry-Run und „Anwenden“ etwas.
*
* Die Auswahl der Zielkonten reist als `?zielkonten=` in der Adresse weiter: so uebersteht sie
* ein Neuladen und die erneute Bestaetigung der Anmeldung.
*/
export function WiderrufFormular({
uebersicht,
ruecksprung,
abbrechen,
}: {
uebersicht: WiderrufUebersicht;
/** Wohin nach dem Vormerken (Schluesselseite bzw. Massenwiderruf-Seite). */
ruecksprung: string;
abbrechen?: () => void;
}) {
const router = useRouter();
const [auswahl, setAuswahl] = useState<string[]>([]);
const [fehler, setFehler] = useState<string>();
const [bestaetigen, setBestaetigen] = useState(false);
const [laeuft, starten] = useTransition();
const gesperrt = uebersicht.schluessel.filter((s) => s.gesperrt);
const mehrere = uebersicht.schluessel.length > 1;
const ziel = (() => {
const url = new URL(ruecksprung, "http://x");
if (auswahl.length > 0) url.searchParams.set("zielkonten", auswahl.join(","));
return `${url.pathname}${url.search}`;
})();
return (
<div className="flex flex-col gap-5">
<WiderrufInhalt uebersicht={uebersicht} mehrere={mehrere} />
{uebersicht.zielkonten.length > 0 && (
<fieldset className="flex flex-col gap-1">
<legend className="mb-1 text-[14px] font-medium">Zielkonten zusätzlich entfernen (optional)</legend>
{uebersicht.zielkonten.map((k) => {
const genutzt = k.nochGenutztVon.length > 0;
return (
<Ankreuzfeld
key={k.id}
name="zielkonten"
value={k.id}
disabled={genutzt}
checked={auswahl.includes(k.id)}
onChange={(e) =>
setAuswahl((alt) => (e.target.checked ? [...alt, k.id] : alt.filter((id) => id !== k.id)))
}
beschriftung={
<>
Benutzer <span className="font-mono">{k.benutzername}</span> auf{" "}
<span className="font-mono">{k.hostName}</span> entfernen
</>
}
erklaerung={
genutzt
? `Bleibt: wird noch von „${k.nochGenutztVon.join("“, „")}“ genutzt.`
: `Von sshkv angelegt. Benutzer samt Home${k.sudoModus !== "KEINER" ? " und sudoers-Datei" : ""} werden gelöscht – verlangt eine frische Bestätigung.`
}
/>
);
})}
</fieldset>
)}
<div className="border-border bg-bg rounded-box border px-4 py-3 text-[13.5px]">
<p className="mb-1.5 font-medium">So geht es weiter</p>
<ol className="text-muted list-decimal space-y-1 pl-5">
<li>
Die Zuweisungen werden zum Entfernen vorgemerkt; der Schlüssel lässt sich nicht mehr zuweisen.
</li>
<li>Der Dry-Run zeigt jede Änderung. Erst „Anwenden“ ändert etwas auf den Hosts.</li>
<li>
Erst wenn alle Entfernungen erfolgreich waren, gilt der Schlüssel als widerrufen. Scheitert ein
Host, bleibt er im Zwischenzustand und die Seite zeigt, was fehlt.
</li>
</ol>
</div>
{gesperrt.map((s) => (
<p key={s.id} className="text-danger text-[14px]">
{s.gesperrt}
</p>
))}
<Formularfehler text={fehler} />
{bestaetigen && (
<a
href={`/bestaetigen?ziel=${encodeURIComponent(ruecksprung)}`}
className="text-accent inline-flex min-h-11 items-center text-[14px] font-medium"
>
Anmeldung bestätigen und danach erneut „Widerruf vorbereiten“ wählen
</a>
)}
<div className="flex flex-wrap justify-end gap-2.5">
{abbrechen && <Button onClick={abbrechen}>Abbrechen</Button>}
<Button
variante="gefahr"
icon={<Ban className="size-4" />}
disabled={laeuft || gesperrt.length > 0}
onClick={() =>
starten(async () => {
setFehler(undefined);
const antwort = await widerrufEinleitenAktion(uebersicht.schluessel.map((s) => s.id));
if (antwort.bestaetigen) setBestaetigen(true);
if (antwort.fehler) {
setFehler(antwort.fehler);
return;
}
abbrechen?.();
router.replace(ziel);
router.refresh();
})
}
>
{laeuft
? "Wird vorgemerkt …"
: mehrere
? `${uebersicht.schluessel.length} Schlüssel widerrufen`
: "Widerruf vorbereiten"}
</Button>
</div>
</div>
);
}
/** Die Liste „was passiert“ – im Dialog und auf der Massenwiderruf-Seite. */
export function WiderrufInhalt({
uebersicht,
mehrere,
}: {
uebersicht: WiderrufUebersicht;
mehrere: boolean;
}) {
return (
<div className="flex flex-col gap-4">
{uebersicht.schluessel.map((s) => {
const oeffentlich = uebersicht.public.filter((v) => v.schluesselId === s.id);
const privat = uebersicht.privat.filter((v) => v.schluesselId === s.id);
return (
<section key={s.id} className="flex flex-col gap-2" aria-label={`Widerruf von ${s.name}`}>
{mehrere && (
<h3 className="text-[14.5px] font-semibold break-words">
{s.name}
{s.person && <span className="text-muted font-normal"> · {s.person}</span>}
</h3>
)}
{oeffentlich.length === 0 && privat.length === 0 && (
<p className="text-muted text-[13.5px]">
Keine Zuweisungen – der Schlüssel wird ohne Änderung auf einem Host widerrufen.
</p>
)}
{oeffentlich.length > 0 && (
<div>
<p className="text-muted mb-1 text-[13px]">Public Key wird aus authorized_keys entfernt:</p>
<ul className="flex flex-col gap-1 font-mono text-[13px]">
{oeffentlich.map((v) => (
<li key={v.id} className="break-all">
<span className="text-danger">−</span> {v.ziel}
{v.archiviert && (
<span className="text-muted font-sans">
{" "}
· Host archiviert – sshkv ändert dort nichts
</span>
)}
</li>
))}
</ul>
</div>
)}
{privat.length > 0 && (
<div>
<p className="text-muted mb-1 text-[13px]">Private Key wird vom Quell-Host entfernt:</p>
<ul className="flex flex-col gap-1.5 font-mono text-[13px]">
{privat.map((v) => (
<li key={v.id}>
<span className="break-all">
<span className="text-danger">−</span> {v.ziel}:{v.pfad}
</span>
<span className="text-muted block pl-3.5 font-sans text-[12.5px]">
{v.beimWiderruf === "UMBENENNEN"
? `Wird umbenannt in …${v.pfad.split("/").pop()}.sshkv-widerrufen.<Zeit>`
: "Wird gelöscht"}
, dazu die .pub-Datei
{v.alias ? `, der config-Eintrag „Host ${v.alias}“ und sein known_hosts-Block` : ""}
{v.archiviert ? " · Host archiviert – sshkv ändert dort nichts" : ""}
</span>
</li>
))}
</ul>
</div>
)}
</section>
);
})}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { Ban, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/Button";
import type { WiderrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufFormular } from "./WiderrufFormular";
/**
* „Widerrufen“ auf der Schluesselseite (#024): Gefahr-Knopf mit Bestaetigungsdialog. Offen/zu
* als Zustand wie im AusrollDialog – kein Render-Pfad liest die Ref.
*/
export function WiderrufKnopf({
uebersicht,
ruecksprung,
beschriftung = "Widerrufen",
}: {
uebersicht: WiderrufUebersicht;
ruecksprung: string;
beschriftung?: string;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [offen, setOffen] = useState(false);
const name = uebersicht.schluessel[0]?.name ?? "";
useEffect(() => {
const element = dialog.current;
if (!element) return;
if (offen && !element.open) element.showModal();
if (!offen && element.open) element.close();
}, [offen]);
const schliessen = () => setOffen(false);
return (
<>
<Button variante="gefahr" icon={<Ban className="size-4" />} onClick={() => setOffen(true)}>
{beschriftung}
</Button>
<dialog
ref={dialog}
aria-labelledby="widerruf-titel"
onClose={schliessen}
className="border-border-strong bg-surface text-text rounded-karte m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] max-w-2xl overflow-y-auto border p-5 backdrop:bg-black/70 sm:p-6"
>
<div className="mb-4 flex items-start justify-between gap-3">
<h2 id="widerruf-titel" className="font-display text-[18px] font-semibold break-words">
„{name}“ widerrufen?
</h2>
<button
type="button"
onClick={schliessen}
aria-label="Schließen"
className="text-muted hover:text-text hover:bg-raised rounded-feld -mt-2 -mr-2 flex size-11 shrink-0 items-center justify-center"
>
<X className="size-5" aria-hidden />
</button>
</div>
{offen && (
<WiderrufFormular uebersicht={uebersicht} ruecksprung={ruecksprung} abbrechen={schliessen} />
)}
</dialog>
</>
);
}