Merge branch 'phase/023-024' (Rotation, Widerruf)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user