Merge branch 'phase/023-024' (Rotation, Widerruf)

This commit is contained in:
2026-10-12 13:31:47 +02:00
39 changed files with 3701 additions and 101 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" && (
+241
View File
@@ -0,0 +1,241 @@
"use client";
import { Ban, CircleCheck, CircleX, KeyRound, PlugZap } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
import { Button, Formularfehler, Karte } from "@/components/ui";
import { Schrittleiste } from "@/components/ui/Schrittleiste";
import { warten } from "@/components/vorgang/warten";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import {
rotationAltWiderrufenAktion,
rotationStartenAktion,
rotationUebernehmenAktion,
} from "@/server/actions/rotation";
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
import type { OffeneEntfernung } from "@/server/services/widerruf";
/**
* Gefuehrte Rotation (#023) als Schrittfolge wie der Assistent (#020). Der Schritt kommt vom
* Server (aus der Datenbank errechnet) – nach Neuladen, Abbruch oder Fehlschlag geht es an
* derselben Stelle weiter.
*/
const SCHRITTE = [
"Neuer Schlüssel",
"Zuweisungen übernehmen",
"Dry-Run & Ausrollen",
"Verbindungstests",
"Alten widerrufen",
] as const;
export type RotationsTest = {
id: string;
ziel: string;
ausgerollt: boolean;
status: string | null;
fehler: string | null;
};
export type RotationsDaten = {
alt: { id: string; name: string };
neu: { id: string; name: string } | null;
schritt: 0 | 1 | 2 | 3 | 4 | 5;
neuOffen: number;
tests: RotationsTest[];
/** Offene Entfernungen des alten Schluessels, sobald sein Widerruf laeuft. */
widerruf: OffeneEntfernung[] | null;
darfPrivat: boolean;
};
export function Rotation({ daten }: { daten: RotationsDaten }) {
const router = useRouter();
const [laeuft, setLaeuft] = useState<string>();
const [fehler, setFehler] = useState<string>();
const { alt, neu, schritt } = daten;
async function kette(arbeit: () => Promise<string | undefined>) {
setFehler(undefined);
try {
const meldung = await arbeit();
if (meldung) setFehler(meldung);
} catch (f) {
setFehler((f as Error).message);
} finally {
setLaeuft(undefined);
router.refresh();
}
}
const vorbereiten = () =>
kette(async () => {
if (!neu) {
setLaeuft("Neues Schlüsselpaar wird erzeugt …");
const antwort = await rotationStartenAktion(alt.id);
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
const ende = await warten(antwort.vorgangId);
if (ende.status !== "ERLEDIGT") return ende.fehler ?? "Erzeugen fehlgeschlagen.";
}
setLaeuft("Zuweisungen werden übernommen …");
return (await rotationUebernehmenAktion(alt.id)).fehler;
});
const testen = () =>
kette(async () => {
for (const test of daten.tests) {
setLaeuft(`Teste ${test.ziel} …`);
const antwort = await verbindungTestenAktion(test.id);
if (antwort.fehler || !antwort.vorgangId) return antwort.fehler;
await warten(antwort.vorgangId);
}
return undefined;
});
const altWiderrufen = () =>
kette(async () => {
setLaeuft("Alter Schlüssel wird zum Entfernen vorgemerkt …");
return (await rotationAltWiderrufenAktion(alt.id)).fehler;
});
const status = (
<>
<Formularfehler text={fehler} />
{laeuft && (
<p role="status" className="text-muted text-[14px]">
{laeuft}
</p>
)}
</>
);
return (
<div className="flex flex-col gap-6">
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} beschriftung="Schritte der Rotation" />
{schritt <= 1 && (
<Karte
titel={schritt === 0 ? "Neuen Schlüssel erzeugen" : "Zuweisungen übernehmen"}
untertitel="Noch ändert sich auf keinem Host etwas."
>
<ul className="text-muted mb-4 list-disc space-y-1 pl-5 text-[14px]">
<li>Neues Schlüsselpaar gleichen Typs, Name mit „(rotiert …)“, verknüpft mit dem alten.</li>
<li>Public Key: dieselben Ziele und Einschränkungen wie bisher.</li>
<li>
Private Key: derselbe Host und Benutzer, neue Datei mit Suffix <code>-rJJJJMMTT</code>; der
config-Eintrag behält seinen Alias und zeigt danach auf die neue Datei.
</li>
<li>Der alte Schlüssel bleibt gültig, bis der neue getestet ist.</li>
</ul>
{status}
<Button
variante="primaer"
icon={<KeyRound className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void vorbereiten()}
>
{neu ? "Zuweisungen übernehmen" : "Neuen Schlüssel erzeugen und Zuweisungen übernehmen"}
</Button>
</Karte>
)}
{schritt === 2 && neu && (
<Karte
titel="Neuen Schlüssel ausrollen"
untertitel="Dry-Run prüfen, dann anwenden – der alte Schlüssel bleibt vorerst daneben bestehen."
>
<AusrollenPanel auswahl={{ schluesselId: neu.id }} offen={daten.neuOffen} />
</Karte>
)}
{schritt === 3 && (
<Karte
titel="Verbindungen mit dem neuen Schlüssel testen"
untertitel="Erst wenn alle Tests grün sind, wird der alte Schlüssel entfernt."
>
<ul className="mb-4 flex flex-col gap-2">
{daten.tests.map((t) => (
<li key={t.id} className="flex flex-col gap-0.5 text-[14px]">
<span className="flex items-center gap-2 font-mono text-[13px] break-all">
{t.status === "ERLEDIGT" ? (
<CircleCheck className="text-ok size-4 shrink-0" aria-label="grün" />
) : t.status === "FEHLGESCHLAGEN" ? (
<CircleX className="text-danger size-4 shrink-0" aria-label="fehlgeschlagen" />
) : (
<PlugZap className="text-muted size-4 shrink-0" aria-label="noch nicht getestet" />
)}
{t.ziel}
</span>
{t.fehler && <span className="text-danger pl-6 text-[13px]">{t.fehler}</span>}
</li>
))}
</ul>
{status}
<Button
variante="primaer"
icon={<PlugZap className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void testen()}
>
Verbindungen testen
</Button>
</Karte>
)}
{schritt === 4 && !daten.widerruf && (
<Karte
titel="Alten Schlüssel entfernen"
untertitel={
daten.tests.length > 0
? "Alle Verbindungstests sind grün."
: "Keine Private-Key-Zuweisung mit Alias – es gibt nichts zu testen."
}
>
<p className="text-muted mb-4 text-[14px]">
Der alte Schlüssel „{alt.name}“ wird wie bei einem Widerruf von allen Hosts entfernt (Dry-Run
folgt). Danach gilt er als rotiert.
</p>
{status}
<Button
variante="gefahr"
icon={<Ban className="size-4" />}
disabled={Boolean(laeuft)}
onClick={() => void altWiderrufen()}
>
Alten Schlüssel widerrufen
</Button>
</Karte>
)}
{schritt === 4 && daten.widerruf && (
<Karte
titel="Alten Schlüssel entfernen"
untertitel="Der Dry-Run zeigt, was vom alten Schlüssel verschwindet."
className="border-danger-border"
>
<WiderrufAblauf
schluesselIds={[alt.id]}
offen={daten.widerruf}
zielkonten={[]}
vorauswahl={[]}
ruecksprung={`/schluessel/${alt.id}/rotieren`}
darfPrivat={daten.darfPrivat}
ohneZielkonten
/>
</Karte>
)}
{schritt === 5 && neu && (
<Karte titel="Rotation abgeschlossen">
<p className="text-[14px]">
Der alte Schlüssel ist von allen Hosts entfernt. Weiter mit{" "}
<Link href={`/schluessel/${neu.id}`} className="text-accent hover:underline">
{neu.name}
</Link>
.
</p>
</Karte>
)}
</div>
);
}
+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>
</>
);
}