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:
@@ -19,6 +19,10 @@ import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schlue
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { EintragAktionen } from "@/components/ausrollen/EintragAktionen";
|
||||
import { zielAuswahl } from "@/server/queries/ziele";
|
||||
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
|
||||
import { ablaufZustand } from "@/lib/schluessel";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||
@@ -187,18 +191,41 @@ function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNo
|
||||
}
|
||||
|
||||
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||||
export default async function SchluesselSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
export default async function SchluesselSeite({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ zielkonten?: string | string[] }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
|
||||
const s = await schluesselDetail(id);
|
||||
if (!s) notFound();
|
||||
const zielkontenParameter = (await searchParams).zielkonten;
|
||||
|
||||
const aktiv = s.status === "AKTIV";
|
||||
// Im Widerruf (#024) bleibt der Status AKTIV, bis alles entfernt ist – zuweisen geht aber nicht mehr.
|
||||
const imWiderruf = Boolean(s.widerrufEingeleitetAm) && (s.status === "AKTIV" || s.status === "ABGELAUFEN");
|
||||
const aktiv = s.status === "AKTIV" && !imWiderruf;
|
||||
const rechte: Rechte = {
|
||||
public: darf(benutzer.rolle, "publicKeyAusrollen"),
|
||||
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
testen: darf(benutzer.rolle, "hostsVerwalten"),
|
||||
};
|
||||
const jetzt = new Date();
|
||||
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
|
||||
const widerrufbar =
|
||||
!imWiderruf &&
|
||||
s.kategorie !== "DEPLOYMENT" &&
|
||||
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
|
||||
rechte.public &&
|
||||
(s.privateVerwendungen.length === 0 || rechte.privat);
|
||||
const widerruf = widerrufbar || imWiderruf ? await widerrufUebersicht([s.id]) : undefined;
|
||||
const vorauswahl = (
|
||||
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
|
||||
)
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||||
const personenKey = s.kategorie === "PERSON";
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
@@ -206,7 +233,6 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
||||
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||||
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||||
const jetzt = new Date();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
@@ -228,6 +254,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
<StatusBadge status={s.status} />
|
||||
{imWiderruf && <Badge variante="gefahr">Widerruf unvollständig</Badge>}
|
||||
{s.instanzStandard && <Badge variante="messing">Instanz-Standard</Badge>}
|
||||
{s.selbstHinterlegt && <Badge>selbst hinterlegt</Badge>}
|
||||
{s.vorlage && <Badge>Vorlage: {s.vorlage.name}</Badge>}
|
||||
@@ -240,14 +267,78 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
</>
|
||||
}
|
||||
aktionen={
|
||||
verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
(verwalten || (widerrufbar && widerruf)) && (
|
||||
<>
|
||||
{verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
{widerrufbar && widerruf && (
|
||||
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{imWiderruf && widerruf && (
|
||||
<Karte
|
||||
titel="Widerruf läuft"
|
||||
untertitel="Der Schlüssel gilt erst als widerrufen, wenn er von allen Hosts entfernt ist."
|
||||
className="border-danger-border"
|
||||
>
|
||||
<WiderrufAblauf
|
||||
schluesselIds={[s.id]}
|
||||
offen={widerruf.offen}
|
||||
zielkonten={widerruf.zielkonten}
|
||||
vorauswahl={vorauswahl}
|
||||
ruecksprung={`/schluessel/${s.id}`}
|
||||
darfPrivat={rechte.privat}
|
||||
/>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{abgelaufen && widerrufbar && widerruf && (
|
||||
<Karte titel="Abgelaufen" className="border-danger-border">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted max-w-xl text-[14px]">
|
||||
{s.ablaufAm ? (
|
||||
<>
|
||||
Das Ablaufdatum (<time dateTime={s.ablaufAm.toISOString()}>{datum(s.ablaufAm)}</time>) ist
|
||||
überschritten.{" "}
|
||||
</>
|
||||
) : null}
|
||||
Nach Projektende bzw. Ablaufdatum: Widerruf mit einem Klick. sshkv entfernt abgelaufene
|
||||
Schlüssel nie von selbst – erst nach Ihrer Bestätigung und dem Dry-Run.
|
||||
</p>
|
||||
<WiderrufKnopf
|
||||
uebersicht={widerruf}
|
||||
ruecksprung={`/schluessel/${s.id}`}
|
||||
beschriftung="Jetzt widerrufen"
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{(s.status === "WIDERRUFEN" || s.status === "ROTIERT") && (
|
||||
<Karte titel={s.status === "WIDERRUFEN" ? "Widerrufen" : "Rotiert"}>
|
||||
<p className="text-muted text-[14px]">
|
||||
Von allen Hosts entfernt; der Schlüssel lässt sich nicht mehr zuweisen.
|
||||
{s.nachfolger && (
|
||||
<>
|
||||
{" "}
|
||||
Ersetzt durch{" "}
|
||||
<Link href={`/schluessel/${s.nachfolger.id}`} className="text-accent hover:underline">
|
||||
{s.nachfolger.name}
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
<Karte as="div" innen="ohne">
|
||||
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
|
||||
<InfoFeld titel="Fingerprint">
|
||||
@@ -321,6 +412,7 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
</div>
|
||||
|
||||
{rechte.public &&
|
||||
!imWiderruf &&
|
||||
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
|
||||
<Karte
|
||||
titel="Dry-Run-Vorschau"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||
import { Ban, KeyRound, Lock, Plus, Search } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { AlleAuswaehlen } from "@/components/widerruf/AlleAuswaehlen";
|
||||
import { Auswahlliste, Feld } from "@/components/ui/Feld";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
@@ -50,6 +52,31 @@ function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
|
||||
);
|
||||
}
|
||||
|
||||
const MASSEN_FORMULAR = "massenwiderruf";
|
||||
|
||||
/** Nur Schluessel, die sich widerrufen lassen, sind waehlbar (#024). */
|
||||
function Auswahl({ s }: { s: SchluesselListenEintrag }) {
|
||||
const gesperrt = s.kategorie === "DEPLOYMENT" || s.status === "WIDERRUFEN" || s.status === "ROTIERT";
|
||||
return (
|
||||
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="id"
|
||||
value={s.id}
|
||||
form={MASSEN_FORMULAR}
|
||||
disabled={gesperrt}
|
||||
aria-label={`${s.name} für den Massenwiderruf auswählen`}
|
||||
className="accent-accent size-[18px] cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function WiderrufBadge({ s }: { s: SchluesselListenEintrag }) {
|
||||
if (!s.widerrufEingeleitetAm || (s.status !== "AKTIV" && s.status !== "ABGELAUFEN")) return null;
|
||||
return <Badge variante="gefahr">Widerruf unvollständig</Badge>;
|
||||
}
|
||||
|
||||
/** Schlüsselliste mit Filter (#012). Filter per GET, damit Links teilbar sind. */
|
||||
export default async function Schluessel({ searchParams }: { searchParams: Suchparameter }) {
|
||||
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
|
||||
@@ -60,6 +87,8 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
const status = einzeln(parameter["status"]);
|
||||
const liste = await schluesselListe({ q, kategorie, status });
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
// Massenwiderruf (#024): Auswahl nur fuer Admins; die frische Bestaetigung prueft die Aktion.
|
||||
const massen = darf(benutzer.rolle, "massenwiderruf");
|
||||
const gefiltert = q !== "" || kategorie !== "" || status !== "";
|
||||
const jetzt = new Date();
|
||||
|
||||
@@ -142,13 +171,27 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
)
|
||||
) : (
|
||||
<Karte>
|
||||
{massen && (
|
||||
<form
|
||||
id={MASSEN_FORMULAR}
|
||||
method="get"
|
||||
action="/schluessel/widerruf"
|
||||
className="border-border mb-3 flex flex-wrap items-center justify-between gap-2 border-b pb-3"
|
||||
>
|
||||
<AlleAuswaehlen formular={MASSEN_FORMULAR} />
|
||||
<Button type="submit" variante="gefahr" klein icon={<Ban className="size-4" />}>
|
||||
Ausgewählte widerrufen
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px. */}
|
||||
<ul className="flex flex-col md:hidden">
|
||||
{liste.map((s) => (
|
||||
<li key={s.id} className="border-border border-b last:border-b-0">
|
||||
<li key={s.id} className="border-border flex items-start gap-1 border-b last:border-b-0">
|
||||
{massen && <Auswahl s={s} />}
|
||||
<Link
|
||||
href={`/schluessel/${s.id}`}
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
|
||||
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 min-w-0 flex-1 flex-col gap-2 px-2 py-3 transition-colors"
|
||||
>
|
||||
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
|
||||
<span className="truncate">{s.name}</span>
|
||||
@@ -161,6 +204,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
<TypBadge typ={s.typ} publicKey={s.publicKey} />
|
||||
<KategorieBadge kategorie={s.kategorie} />
|
||||
<StatusBadge status={s.status} />
|
||||
<WiderrufBadge s={s} />
|
||||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
|
||||
</span>
|
||||
<span className="text-muted text-[13px]">
|
||||
@@ -175,6 +219,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
className="hidden md:block"
|
||||
beschriftung="Schlüssel"
|
||||
spalten={[
|
||||
...(massen ? [{ titel: "Auswahl", versteckt: true, className: "w-10" }] : []),
|
||||
{ titel: "Name" },
|
||||
{ titel: "Typ" },
|
||||
{ titel: "Kategorie" },
|
||||
@@ -185,6 +230,11 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
>
|
||||
{liste.map((s) => (
|
||||
<TabellenZeile key={s.id}>
|
||||
{massen && (
|
||||
<TabellenZelle className="w-10">
|
||||
<Auswahl s={s} />
|
||||
</TabellenZelle>
|
||||
)}
|
||||
<TabellenZelle>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Link
|
||||
@@ -195,6 +245,7 @@ export default async function Schluessel({ searchParams }: { searchParams: Suchp
|
||||
</Link>
|
||||
<PrivatHinweis s={s} />
|
||||
<StatusBadge status={s.status} />
|
||||
<WiderrufBadge s={s} />
|
||||
</span>
|
||||
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
|
||||
</TabellenZelle>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Ban } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { StatusBadge } from "@/components/schluessel/Anzeige";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Karte } from "@/components/ui/Karte";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
|
||||
import { WiderrufFormular } from "@/components/widerruf/WiderrufFormular";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { widerrufUebersicht } from "@/server/queries/widerruf";
|
||||
|
||||
export const metadata: Metadata = { title: "Massenwiderruf" };
|
||||
|
||||
type Suchparameter = Promise<{ id?: string | string[]; zielkonten?: string | string[] }>;
|
||||
|
||||
function liste(wert: string | string[] | undefined): string[] {
|
||||
const roh = Array.isArray(wert) ? wert : wert ? [wert] : [];
|
||||
return [...new Set(roh.flatMap((w) => w.split(",")).filter((w) => /^[a-z0-9]{1,64}$/i.test(w)))].slice(
|
||||
0,
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Massenwiderruf (#024): mehrere Schluessel – etwa alle einer Person nach Projektende – in einem
|
||||
* Durchgang mit gemeinsamem Dry-Run. Nur Admins; das Vormerken verlangt eine frische
|
||||
* Bestaetigung (Recht `massenwiderruf`). Protokolliert wird je Schluessel.
|
||||
*/
|
||||
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
|
||||
const parameter = await searchParams;
|
||||
const ids = liste(parameter.id);
|
||||
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
|
||||
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
|
||||
|
||||
const kopf = (
|
||||
<Seitenkopf
|
||||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Massenwiderruf" }]} />}
|
||||
titel="Massenwiderruf"
|
||||
untertitel="Mehrere Schlüssel gemeinsam von allen Hosts entfernen – ein Dry-Run für alle."
|
||||
/>
|
||||
);
|
||||
if (ids.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
{kopf}
|
||||
<Leerzustand
|
||||
icon={<Ban className="size-5" />}
|
||||
titel="Keine Schlüssel gewählt"
|
||||
text="Wählen Sie in der Schlüsselliste die Schlüssel aus, die widerrufen werden sollen."
|
||||
aktion={<Button href="/schluessel">Zur Schlüsselliste</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const uebersicht = await widerrufUebersicht(ids);
|
||||
const offeneStati = (s: { status: string }) => s.status === "AKTIV" || s.status === "ABGELAUFEN";
|
||||
const nochNichtEingeleitet = uebersicht.schluessel.filter((s) => offeneStati(s) && !s.eingeleitet);
|
||||
const laufend = uebersicht.schluessel.filter((s) => offeneStati(s) && s.eingeleitet);
|
||||
const vorauswahl = liste(parameter.zielkonten);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
{kopf}
|
||||
|
||||
{nochNichtEingeleitet.length > 0 || uebersicht.schluessel.some((s) => s.gesperrt && !s.eingeleitet) ? (
|
||||
<Karte
|
||||
titel={`${uebersicht.schluessel.length} Schlüssel widerrufen?`}
|
||||
untertitel="Genau das passiert – erst nach Dry-Run und „Anwenden“ auf den Hosts."
|
||||
>
|
||||
{!bestaetigungFrisch(benutzer.bestaetigtAm) && (
|
||||
<p className="text-accent mb-4 text-[13.5px]">
|
||||
Der Massenwiderruf verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||
Vormerken danach.
|
||||
</p>
|
||||
)}
|
||||
<WiderrufFormular uebersicht={uebersicht} ruecksprung={selbst} />
|
||||
</Karte>
|
||||
) : laufend.length > 0 ? (
|
||||
<Karte
|
||||
titel="Widerruf läuft"
|
||||
untertitel="Ein gemeinsamer Dry-Run für alle Schlüssel. Widerrufen ist ein Schlüssel erst, wenn er überall entfernt ist."
|
||||
className="border-danger-border"
|
||||
>
|
||||
<WiderrufAblauf
|
||||
schluesselIds={laufend.map((s) => s.id)}
|
||||
offen={uebersicht.offen}
|
||||
zielkonten={uebersicht.zielkonten}
|
||||
vorauswahl={vorauswahl}
|
||||
ruecksprung={selbst}
|
||||
darfPrivat={darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||
/>
|
||||
</Karte>
|
||||
) : null}
|
||||
|
||||
<Karte titel="Gewählte Schlüssel">
|
||||
<ul className="flex flex-col gap-2">
|
||||
{uebersicht.schluessel.map((s) => (
|
||||
<li key={s.id} className="flex flex-wrap items-center gap-2 text-[14px]">
|
||||
<Link href={`/schluessel/${s.id}`} className="text-accent hover:underline">
|
||||
{s.name}
|
||||
</Link>
|
||||
{s.person && <span className="text-muted">· {s.person}</span>}
|
||||
<StatusBadge status={s.status} />
|
||||
{s.eingeleitet && offeneStati(s) && (
|
||||
<span className="text-danger text-[13px]">Widerruf unvollständig</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Karte>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +74,11 @@ export async function publicEntfernenAktion(
|
||||
}
|
||||
}
|
||||
|
||||
export type AusrollAuswahl = { readonly schluesselId: string } | { readonly hostId: string };
|
||||
export type AusrollAuswahl =
|
||||
| { readonly schluesselId: string }
|
||||
| { readonly hostId: string }
|
||||
/** Ein gemeinsamer Dry-Run fuer mehrere Schluessel (Massenwiderruf, #024). */
|
||||
| { readonly schluesselIds: readonly string[] };
|
||||
|
||||
export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<VorgangsAntwort> {
|
||||
try {
|
||||
@@ -84,7 +88,9 @@ export async function trockenlaufAktion(auswahl: AusrollAuswahl): Promise<Vorgan
|
||||
vorgangId: await trockenlaufStarten(
|
||||
"schluesselId" in auswahl
|
||||
? { schluesselId: id.parse(auswahl.schluesselId) }
|
||||
: { hostId: id.parse(auswahl.hostId) },
|
||||
: "schluesselIds" in auswahl
|
||||
? { schluesselIds: z.array(id).min(1).max(200).parse(auswahl.schluesselIds) }
|
||||
: { hostId: id.parse(auswahl.hostId) },
|
||||
akteurVon(benutzer),
|
||||
// Private-Key-Ziele nur fuer Admins – ein Operator rollt so nie einen Private Key aus.
|
||||
darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { akteurVon } from "@/server/actions/formular";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { BestaetigungFehler, RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
import {
|
||||
widerrufAbschliessen,
|
||||
widerrufEinleiten,
|
||||
widerrufZielkontenEntfernen,
|
||||
type AbschlussErgebnis,
|
||||
} from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Widerruf und Massenwiderruf (#024). Duenn: Recht, Eingabe, Service.
|
||||
*
|
||||
* Rechte: einen Public Key entfernen darf, wer ausrollen darf; sobald Private Keys betroffen
|
||||
* sind, nur ein Admin (prueft der Service mit `privatErlaubt`). Mehrere Schluessel auf einmal
|
||||
* (Massenwiderruf) und das Entfernen von Zielkonten verlangen Admin mit frischer Bestaetigung.
|
||||
*/
|
||||
export type WiderrufAntwort = {
|
||||
readonly fehler?: string;
|
||||
/** Frische Bestaetigung noetig: die Oberflaeche verlinkt /bestaetigen. */
|
||||
readonly bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function alsAntwort(fehler: unknown): WiderrufAntwort {
|
||||
if (fehler instanceof BestaetigungFehler) return { bestaetigen: true, fehler: fehler.message };
|
||||
if (fehler instanceof RechteFehler || fehler instanceof VerwendungsFehler)
|
||||
return { fehler: fehler.message };
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const idListe = z.array(z.string().min(1).max(64)).min(1).max(200);
|
||||
|
||||
function pfadeNeuLaden(ids: readonly string[]) {
|
||||
revalidatePath("/schluessel");
|
||||
for (const id of ids) revalidatePath(`/schluessel/${id}`);
|
||||
}
|
||||
|
||||
export async function widerrufEinleitenAktion(schluesselIds: string[]): Promise<WiderrufAntwort> {
|
||||
try {
|
||||
const ids = idListe.parse(schluesselIds);
|
||||
const { benutzer } = await rechtErforderlich(ids.length > 1 ? "massenwiderruf" : "publicKeyAusrollen");
|
||||
await widerrufEinleiten(ids, akteurVon(benutzer), {
|
||||
privatErlaubt: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
});
|
||||
pfadeNeuLaden(ids);
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function widerrufZielkontenAktion(
|
||||
schluesselIds: string[],
|
||||
zielkontoIds: string[],
|
||||
): Promise<WiderrufAntwort & { vorgaenge?: { id: string; beschriftung: string }[] }> {
|
||||
try {
|
||||
const ids = idListe.parse(schluesselIds);
|
||||
const konten = idListe.parse(zielkontoIds);
|
||||
// Ein Benutzer samt Home verschwindet: Admin mit frischer Bestaetigung (#018).
|
||||
const { benutzer } = await rechtErforderlich("zielkontenVerwalten");
|
||||
return { vorgaenge: await widerrufZielkontenEntfernen(ids, konten, akteurVon(benutzer)) };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
|
||||
export async function widerrufAbschliessenAktion(
|
||||
schluesselIds: string[],
|
||||
): Promise<WiderrufAntwort & { ergebnisse?: AbschlussErgebnis[] }> {
|
||||
try {
|
||||
const ids = idListe.parse(schluesselIds);
|
||||
// Abschliessen setzt nur den Status nach dem, was die Datenbank belegt – kein Host-Zugriff.
|
||||
const { benutzer } = await rechtErforderlich("publicKeyAusrollen");
|
||||
const ergebnisse = await widerrufAbschliessen(ids, akteurVon(benutzer));
|
||||
pfadeNeuLaden(ids);
|
||||
return { ergebnisse };
|
||||
} catch (fehler) {
|
||||
return alsAntwort(fehler);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export const SCHLUESSEL_LISTE_SELECT = {
|
||||
ablaufAm: true,
|
||||
masterKeyVersion: true,
|
||||
passphraseGeschuetzt: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
person: { select: { id: true, name: true } },
|
||||
_count: {
|
||||
select: {
|
||||
@@ -50,12 +51,15 @@ export const SCHLUESSEL_DETAIL_SELECT = {
|
||||
tags: true,
|
||||
ablaufAm: true,
|
||||
zuletztVerwendetAm: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
angelegtAm: true,
|
||||
personId: true,
|
||||
vorlageId: true,
|
||||
person: { select: { id: true, name: true } },
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
erstelltVon: { select: { name: true } },
|
||||
vorgaenger: { select: { id: true, name: true, status: true } },
|
||||
nachfolger: { select: { id: true, name: true, status: true } },
|
||||
publicVerwendungen: {
|
||||
where: { zustand: { not: "ENTFERNT" } },
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
@@ -101,13 +105,34 @@ function alsStatus(wert: string | undefined): SchluesselStatus | undefined {
|
||||
return STATI.find((s) => s === wert);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heute 00:00 UTC nach dem Kalender in Berlin – Ablaufdaten liegen als UTC-Mitternacht ihres
|
||||
* Tages in der Datenbank, und `ablaufZustand()` rechnet ebenfalls mit dem Berliner Datum.
|
||||
*/
|
||||
function heute(jetzt = new Date()): Date {
|
||||
const tag = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Berlin" }).format(jetzt);
|
||||
return new Date(`${tag}T00:00:00Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Status als Filter (#024): ein aktiver Schluessel mit Ablaufdatum in der Vergangenheit gilt als
|
||||
* abgelaufen, auch wenn niemand den Status von Hand gesetzt hat. Automatisch entfernt wird nichts.
|
||||
*/
|
||||
function statusFilter(status: SchluesselStatus | undefined): Prisma.SchluesselWhereInput {
|
||||
if (status === "AKTIV") return { status, OR: [{ ablaufAm: null }, { ablaufAm: { gte: heute() } }] };
|
||||
if (status === "ABGELAUFEN") {
|
||||
return { OR: [{ status }, { status: "AKTIV", ablaufAm: { lt: heute() } }] };
|
||||
}
|
||||
return status ? { status } : {};
|
||||
}
|
||||
|
||||
export async function schluesselListe(filter: SchluesselFilter = {}) {
|
||||
const q = filter.q?.trim().slice(0, 100);
|
||||
const kategorie = alsKategorie(filter.kategorie);
|
||||
const status = alsStatus(filter.status);
|
||||
const where: Prisma.SchluesselWhereInput = {
|
||||
...(kategorie ? { kategorie } : {}),
|
||||
...(status ? { status } : {}),
|
||||
AND: [statusFilter(status)],
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
offeneEntfernungen,
|
||||
widerrufGesperrt,
|
||||
zielkontoKandidaten,
|
||||
type OffeneEntfernung,
|
||||
type ZielkontoKandidat,
|
||||
} from "@/server/services/widerruf";
|
||||
|
||||
/**
|
||||
* Was ein Widerruf bewirkt (#024) – fuer den Bestaetigungsdialog, die Massenwiderruf-Seite und
|
||||
* die Fortsetzen-Ansicht. Nur Namen, Ziele und Pfade; nie Schluesselinhalte.
|
||||
*/
|
||||
export type WiderrufSchluessel = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly kategorie: "PERSON" | "DIENST" | "DEPLOYMENT";
|
||||
readonly status: "AKTIV" | "ROTIERT" | "WIDERRUFEN" | "ABGELAUFEN";
|
||||
readonly eingeleitet: boolean;
|
||||
readonly person: string | null;
|
||||
readonly gesperrt: string | null;
|
||||
};
|
||||
|
||||
export type WiderrufZiel = {
|
||||
readonly id: string;
|
||||
readonly schluesselId: string;
|
||||
readonly ziel: string;
|
||||
readonly archiviert: boolean;
|
||||
};
|
||||
|
||||
export type WiderrufPrivatZiel = WiderrufZiel & {
|
||||
readonly pfad: string;
|
||||
readonly alias: string | null;
|
||||
readonly beimWiderruf: "LOESCHEN" | "UMBENENNEN";
|
||||
};
|
||||
|
||||
export type WiderrufUebersicht = {
|
||||
readonly schluessel: WiderrufSchluessel[];
|
||||
readonly public: WiderrufZiel[];
|
||||
readonly privat: WiderrufPrivatZiel[];
|
||||
readonly zielkonten: ZielkontoKandidat[];
|
||||
readonly offen: OffeneEntfernung[];
|
||||
};
|
||||
|
||||
export async function widerrufUebersicht(schluesselIds: readonly string[]): Promise<WiderrufUebersicht> {
|
||||
const ids = [...new Set(schluesselIds)];
|
||||
const schluessel = await prisma.schluessel.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
person: { select: { name: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const [oeffentlich, privat, zielkonten, offen] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
hostgruppe: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
sshConfig: true,
|
||||
beimWiderruf: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
zielkontoKandidaten(ids),
|
||||
offeneEntfernungen(ids),
|
||||
]);
|
||||
return {
|
||||
schluessel: schluessel.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
kategorie: s.kategorie,
|
||||
status: s.status,
|
||||
eingeleitet: Boolean(s.widerrufEingeleitetAm),
|
||||
person: s.person?.name ?? null,
|
||||
gesperrt: widerrufGesperrt(s),
|
||||
})),
|
||||
public: oeffentlich.map((v) => ({
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
ziel: v.host
|
||||
? `${v.zielbenutzer}@${v.host.name}`
|
||||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||||
archiviert: Boolean(v.host?.archiviertAm),
|
||||
})),
|
||||
privat: privat.map((v) => {
|
||||
const config =
|
||||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||||
return {
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||||
pfad: v.pfad,
|
||||
alias: typeof config?.alias === "string" ? config.alias : null,
|
||||
beimWiderruf: v.beimWiderruf,
|
||||
archiviert: Boolean(v.host.archiviertAm),
|
||||
};
|
||||
}),
|
||||
zielkonten,
|
||||
offen,
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export class VerwendungsFehler extends Error {
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
/** Ein Schluessel im Widerruf (#024) bekommt keine neuen Ziele – sonst wuerde er nie fertig. */
|
||||
const WIDERRUF_LAEUFT = "Dieser Schlüssel wird gerade widerrufen und lässt sich nicht mehr zuweisen.";
|
||||
|
||||
export type PublicZuweisung = {
|
||||
readonly schluesselId: string;
|
||||
readonly hostId?: string | null;
|
||||
@@ -46,11 +49,12 @@ export async function publicVerwendungAnlegen(eingabe: PublicZuweisung, akteur:
|
||||
}
|
||||
const schluessel = await prisma.schluessel.findUnique({
|
||||
where: { id: eingabe.schluesselId },
|
||||
select: { status: true, fingerprint: true, kategorie: true },
|
||||
select: { status: true, fingerprint: true, kategorie: true, widerrufEingeleitetAm: true },
|
||||
});
|
||||
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (schluessel.status !== "AKTIV")
|
||||
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich zuweisen.");
|
||||
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
|
||||
if (schluessel.kategorie === "DEPLOYMENT") {
|
||||
throw new VerwendungsFehler("Der Deployment-Key wird von sshkv selbst verwaltet.");
|
||||
}
|
||||
@@ -116,13 +120,47 @@ export async function publicVerwendungEntfernen(id: string, akteur: Akteur): Pro
|
||||
});
|
||||
}
|
||||
|
||||
/** Ziele mehrerer Schluessel ohne Doppelte – ein gemeinsamer Dry-Run beim Massenwiderruf (#024). */
|
||||
async function zieleMehrererSchluessel(
|
||||
schluesselIds: readonly string[],
|
||||
mitPrivat: boolean,
|
||||
): Promise<Ziel[]> {
|
||||
const ziele = new Map<string, Ziel>();
|
||||
for (const schluesselId of schluesselIds) {
|
||||
const liste = [
|
||||
...(await zieleVonVerwendungen({ schluesselId })),
|
||||
...(mitPrivat ? await zieleVonPrivatVerwendungen({ schluesselId }) : []),
|
||||
];
|
||||
for (const ziel of liste)
|
||||
ziele.set(`${ziel.hostId}\u0000${ziel.zielbenutzer}\u0000${ziel.art ?? "public"}`, ziel);
|
||||
}
|
||||
return [...ziele.values()];
|
||||
}
|
||||
|
||||
/** Trockenlauf fuer die Ziele eines Schluessels oder fuer ausgewaehlte Verwendungen. */
|
||||
export async function trockenlaufStarten(
|
||||
auswahl: { schluesselId?: string; hostId?: string; verwendungIds?: string[]; ziele?: Ziel[] },
|
||||
auswahl: {
|
||||
schluesselId?: string;
|
||||
/** Mehrere Schluessel in einem Dry-Run (Massenwiderruf, #024). */
|
||||
schluesselIds?: readonly string[];
|
||||
hostId?: string;
|
||||
verwendungIds?: string[];
|
||||
ziele?: Ziel[];
|
||||
},
|
||||
akteur: Akteur,
|
||||
/** Private-Key-Ziele nur fuer Admins (Freigabe Frage 3) – der Aufrufer entscheidet per darf(). */
|
||||
mitPrivat = false,
|
||||
): Promise<string> {
|
||||
if (auswahl.schluesselIds && !auswahl.ziele) {
|
||||
return trockenlaufStarten(
|
||||
{
|
||||
ziele: await zieleMehrererSchluessel(auswahl.schluesselIds, mitPrivat),
|
||||
...(auswahl.schluesselIds.length === 1 ? { schluesselId: auswahl.schluesselIds[0] } : {}),
|
||||
},
|
||||
akteur,
|
||||
mitPrivat,
|
||||
);
|
||||
}
|
||||
const ziele = auswahl.ziele ?? [
|
||||
...(await zieleVonVerwendungen({
|
||||
...(auswahl.schluesselId ? { schluesselId: auswahl.schluesselId } : {}),
|
||||
@@ -197,11 +235,13 @@ export async function privateVerwendungAnlegen(eingabe: PrivateZuweisung, akteur
|
||||
kategorie: true,
|
||||
privatGeloeschtAm: true,
|
||||
masterKeyVersion: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
},
|
||||
});
|
||||
if (!schluessel) throw new VerwendungsFehler("Diesen Schlüssel gibt es nicht.");
|
||||
if (schluessel.status !== "AKTIV")
|
||||
throw new VerwendungsFehler("Nur aktive Schlüssel lassen sich ausrollen.");
|
||||
if (schluessel.widerrufEingeleitetAm) throw new VerwendungsFehler(WIDERRUF_LAEUFT);
|
||||
if (schluessel.kategorie !== "DIENST")
|
||||
throw new VerwendungsFehler("Private Keys werden nur für Service-Schlüssel ausgerollt.");
|
||||
// masterKeyVersion ist genau dann gesetzt, wenn ein Private Key gespeichert ist (DB-CHECK).
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { protokollieren } from "@/server/services/protokoll";
|
||||
import { VerwendungsFehler } from "@/server/services/verwendungen";
|
||||
import { vorgangEinreihen } from "@/server/vorgaenge/vorgaenge";
|
||||
|
||||
/**
|
||||
* Widerruf und Massenwiderruf (#024).
|
||||
*
|
||||
* Ablauf in drei Schritten, damit der Status nie mehr verspricht, als auf den Hosts passiert ist:
|
||||
*
|
||||
* 1. `widerrufEinleiten`: alle Verwendungen auf ENTFERNEN, `widerrufEingeleitetAm` setzen.
|
||||
* Der Schluessel bleibt AKTIV, laesst sich aber nicht mehr zuweisen.
|
||||
* 2. Dry-Run und Ausrollen wie sonst auch (#016/#017). Was gelingt, wird ENTFERNT; was auf einem
|
||||
* Host scheitert (nicht erreichbar, Datei geaendert), bleibt ENTFERNEN.
|
||||
* 3. `widerrufAbschliessen`: erst wenn keine Verwendung mehr offen ist, wird der Status
|
||||
* WIDERRUFEN (bzw. ROTIERT, wenn ein Nachfolger aus einer Rotation existiert, #023).
|
||||
*
|
||||
* Optional dazwischen: Zielkonten entfernen, die fuer diese Schluessel angelegt wurden.
|
||||
*/
|
||||
export class WiderrufFehler extends VerwendungsFehler {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "WiderrufFehler";
|
||||
}
|
||||
}
|
||||
|
||||
type Akteur = { readonly id: string; readonly name: string };
|
||||
|
||||
/** Verwendungen in diesen Zustaenden zaehlen als „noch in Gebrauch“ (fuer Zielkonten). */
|
||||
const IN_GEBRAUCH = ["GEPLANT", "AUSGEROLLT", "ABWEICHUNG", "FEHLGESCHLAGEN"] as const;
|
||||
|
||||
const SCHLUESSEL_KOPF = {
|
||||
id: true,
|
||||
name: true,
|
||||
kategorie: true,
|
||||
status: true,
|
||||
fingerprint: true,
|
||||
widerrufEingeleitetAm: true,
|
||||
nachfolger: { select: { id: true, status: true } },
|
||||
} satisfies Prisma.SchluesselSelect;
|
||||
|
||||
type SchluesselKopf = Prisma.SchluesselGetPayload<{ select: typeof SCHLUESSEL_KOPF }>;
|
||||
|
||||
/** Warum ein Schluessel nicht widerrufen werden kann – oder null. */
|
||||
export function widerrufGesperrt(s: Pick<SchluesselKopf, "kategorie" | "status" | "name">): string | null {
|
||||
// Der Deployment-Key ist sshkvs eigener Zugang; ohne ihn ist kein Host mehr erreichbar.
|
||||
if (s.kategorie === "DEPLOYMENT")
|
||||
return "Der Deployment-Key lässt sich nicht widerrufen – er wird über eine eigene Rotation ersetzt.";
|
||||
if (s.status === "WIDERRUFEN") return `„${s.name}“ ist bereits widerrufen.`;
|
||||
if (s.status === "ROTIERT") return `„${s.name}“ ist bereits rotiert und von allen Hosts entfernt.`;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function schluesselLaden(ids: readonly string[]): Promise<SchluesselKopf[]> {
|
||||
const eindeutig = [...new Set(ids)];
|
||||
if (eindeutig.length === 0) throw new WiderrufFehler("Bitte mindestens einen Schlüssel wählen.");
|
||||
const liste = await prisma.schluessel.findMany({
|
||||
where: { id: { in: eindeutig } },
|
||||
select: SCHLUESSEL_KOPF,
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
if (liste.length !== eindeutig.length) throw new WiderrufFehler("Einen der Schlüssel gibt es nicht.");
|
||||
return liste;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zielkonten
|
||||
|
||||
export type ZielkontoKandidat = {
|
||||
readonly id: string;
|
||||
readonly hostId: string;
|
||||
readonly hostName: string;
|
||||
readonly benutzername: string;
|
||||
readonly sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
/** Namen anderer Schluessel, die das Konto noch nutzen – dann bleibt es. */
|
||||
readonly nochGenutztVon: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Zielkonten, die sshkv fuer diese Schluessel angelegt hat: ein von sshkv angelegtes, noch
|
||||
* vorhandenes Konto auf einem Host, auf dem einer der Schluessel (in irgendeinem Zustand) fuer
|
||||
* genau diesen Benutzer zugewiesen war. Ein Zielkonto kennt seinen Schluessel nicht; die
|
||||
* Zuweisung ist die einzige belastbare Verbindung.
|
||||
*/
|
||||
export async function zielkontoKandidaten(schluesselIds: readonly string[]): Promise<ZielkontoKandidat[]> {
|
||||
const ids = [...new Set(schluesselIds)];
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids } },
|
||||
select: {
|
||||
zielbenutzer: true,
|
||||
hostId: true,
|
||||
hostgruppe: { select: { mitglieder: { select: { hostId: true } } } },
|
||||
},
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids } },
|
||||
select: { zielbenutzer: true, hostId: true },
|
||||
}),
|
||||
]);
|
||||
const paare = new Set<string>();
|
||||
for (const v of oeffentlich) {
|
||||
const hosts = v.hostId ? [v.hostId] : (v.hostgruppe?.mitglieder.map((m) => m.hostId) ?? []);
|
||||
for (const hostId of hosts) paare.add(`${hostId}\u0000${v.zielbenutzer}`);
|
||||
}
|
||||
for (const v of privat) paare.add(`${v.hostId}\u0000${v.zielbenutzer}`);
|
||||
if (paare.size === 0) return [];
|
||||
|
||||
const konten = await prisma.zielkonto.findMany({
|
||||
where: {
|
||||
entferntAm: null,
|
||||
vonSshkvAngelegt: true,
|
||||
host: { archiviertAm: null },
|
||||
OR: [...paare].map((p) => {
|
||||
const [hostId, benutzername] = p.split("\u0000") as [string, string];
|
||||
return { hostId, benutzername };
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hostId: true,
|
||||
benutzername: true,
|
||||
sudoModus: true,
|
||||
host: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ host: { name: "asc" } }, { benutzername: "asc" }],
|
||||
});
|
||||
|
||||
const ergebnis: ZielkontoKandidat[] = [];
|
||||
for (const konto of konten) {
|
||||
const [andere, anderePrivat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: {
|
||||
schluesselId: { notIn: ids },
|
||||
zielbenutzer: konto.benutzername,
|
||||
zustand: { in: [...IN_GEBRAUCH] },
|
||||
schluessel: { widerrufEingeleitetAm: null },
|
||||
OR: [{ hostId: konto.hostId }, { hostgruppe: { mitglieder: { some: { hostId: konto.hostId } } } }],
|
||||
},
|
||||
select: { schluessel: { select: { name: true } } },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: {
|
||||
schluesselId: { notIn: ids },
|
||||
hostId: konto.hostId,
|
||||
zielbenutzer: konto.benutzername,
|
||||
zustand: { in: [...IN_GEBRAUCH] },
|
||||
schluessel: { widerrufEingeleitetAm: null },
|
||||
},
|
||||
select: { schluessel: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
ergebnis.push({
|
||||
id: konto.id,
|
||||
hostId: konto.hostId,
|
||||
hostName: konto.host.name,
|
||||
benutzername: konto.benutzername,
|
||||
sudoModus: konto.sudoModus,
|
||||
nochGenutztVon: [...new Set([...andere, ...anderePrivat].map((v) => v.schluessel.name))].sort(),
|
||||
});
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Einleiten
|
||||
|
||||
export type EinleitenOptionen = {
|
||||
/** Darf der Akteur Private Keys entfernen (privateKeyAusrollen)? Prueft die Aktion per darf(). */
|
||||
readonly privatErlaubt: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Schritt 1: Verwendungen zum Entfernen vormerken. Idempotent – ein zweiter Aufruf (etwa beim
|
||||
* Fortsetzen) aendert an bereits vorgemerkten Verwendungen nichts.
|
||||
*/
|
||||
export async function widerrufEinleiten(
|
||||
schluesselIds: readonly string[],
|
||||
akteur: Akteur,
|
||||
optionen: EinleitenOptionen,
|
||||
): Promise<void> {
|
||||
const liste = await schluesselLaden(schluesselIds);
|
||||
for (const s of liste) {
|
||||
const grund = widerrufGesperrt(s);
|
||||
if (grund) throw new WiderrufFehler(grund);
|
||||
}
|
||||
const ids = liste.map((s) => s.id);
|
||||
const privatOffen = await prisma.privateVerwendung.count({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
});
|
||||
if (privatOffen > 0 && !optionen.privatErlaubt) {
|
||||
throw new WiderrufFehler(
|
||||
"Private Keys auf Hosts entfernt nur ein Admin. Bitte einen Admin widerrufen lassen.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const jetzt = new Date();
|
||||
for (const s of liste) {
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
tx.publicVerwendung.findMany({
|
||||
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
},
|
||||
}),
|
||||
tx.privateVerwendung.findMany({
|
||||
where: { schluesselId: s.id, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
zustand: true,
|
||||
zuletztAngewendetAm: true,
|
||||
host: { select: { name: true, archiviertAm: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// Nie ausgerollt: auf dem Host steht nichts. Archivierter Host: sshkv verbindet sich dort
|
||||
// nicht mehr – das Protokoll haelt fest, dass dort nichts entfernt wurde.
|
||||
const sofort = (v: (typeof oeffentlich)[number]) =>
|
||||
(v.zustand === "GEPLANT" && !v.zuletztAngewendetAm) || Boolean(v.host?.archiviertAm);
|
||||
const archiviert = [...oeffentlich, ...privat]
|
||||
.filter((v) => v.host?.archiviertAm)
|
||||
.map((v) => v.host!.name);
|
||||
await tx.publicVerwendung.updateMany({
|
||||
where: { id: { in: oeffentlich.filter(sofort).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await tx.publicVerwendung.updateMany({
|
||||
where: { id: { in: oeffentlich.filter((v) => !sofort(v)).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNEN" },
|
||||
});
|
||||
await tx.privateVerwendung.updateMany({
|
||||
where: { id: { in: privat.filter(sofort).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNT" },
|
||||
});
|
||||
await tx.privateVerwendung.updateMany({
|
||||
where: { id: { in: privat.filter((v) => !sofort(v)).map((v) => v.id) } },
|
||||
data: { zustand: "ENTFERNEN" },
|
||||
});
|
||||
if (!s.widerrufEingeleitetAm) {
|
||||
await tx.schluessel.update({
|
||||
where: { id: s.id },
|
||||
data: { widerrufEingeleitetAm: jetzt },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: "schluessel.widerruf.eingeleitet",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: s.fingerprint,
|
||||
details: {
|
||||
name: s.name,
|
||||
public: oeffentlich.length,
|
||||
privat: privat.length,
|
||||
ohneHostZugriff: oeffentlich.filter(sofort).length + privat.filter(sofort).length,
|
||||
archivierteHosts: [...new Set(archiviert)].sort(),
|
||||
massenwiderruf: liste.length > 1,
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Offene Entfernungen
|
||||
|
||||
export type OffeneEntfernung = {
|
||||
readonly id: string;
|
||||
readonly schluesselId: string;
|
||||
readonly art: "public" | "privat";
|
||||
readonly ziel: string;
|
||||
readonly zustand: string;
|
||||
};
|
||||
|
||||
/** Was fuer diese Schluessel noch nicht entfernt ist – fuer Seite und Abschluss. */
|
||||
export async function offeneEntfernungen(schluesselIds: readonly string[]): Promise<OffeneEntfernung[]> {
|
||||
const ids = [...new Set(schluesselIds)];
|
||||
const [oeffentlich, privat] = await Promise.all([
|
||||
prisma.publicVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
zustand: true,
|
||||
host: { select: { name: true } },
|
||||
hostgruppe: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
prisma.privateVerwendung.findMany({
|
||||
where: { schluesselId: { in: ids }, zustand: { not: "ENTFERNT" } },
|
||||
select: {
|
||||
id: true,
|
||||
schluesselId: true,
|
||||
zielbenutzer: true,
|
||||
pfad: true,
|
||||
zustand: true,
|
||||
host: { select: { name: true } },
|
||||
},
|
||||
orderBy: { angelegtAm: "asc" },
|
||||
}),
|
||||
]);
|
||||
return [
|
||||
...oeffentlich.map((v) => ({
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
art: "public" as const,
|
||||
ziel: v.host
|
||||
? `${v.zielbenutzer}@${v.host.name}:~/.ssh/authorized_keys`
|
||||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe):~/.ssh/authorized_keys`,
|
||||
zustand: v.zustand,
|
||||
})),
|
||||
...privat.map((v) => ({
|
||||
id: v.id,
|
||||
schluesselId: v.schluesselId,
|
||||
art: "privat" as const,
|
||||
ziel: `${v.zielbenutzer}@${v.host.name}:${v.pfad}`,
|
||||
zustand: v.zustand,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Zielkonten entfernen
|
||||
|
||||
/**
|
||||
* Optionaler Schritt nach dem Ausrollen: ausgewaehlte Zielkonten entfernen. Erst wenn die
|
||||
* Schluessel von den Hosts verschwunden sind – sonst haette ein Fehlschlag beim Ausrollen einen
|
||||
* halb geloeschten Benutzer mit noch gueltigem Schluessel zur Folge.
|
||||
*/
|
||||
export async function widerrufZielkontenEntfernen(
|
||||
schluesselIds: readonly string[],
|
||||
zielkontoIds: readonly string[],
|
||||
akteur: Akteur,
|
||||
): Promise<{ id: string; beschriftung: string }[]> {
|
||||
const liste = await schluesselLaden(schluesselIds);
|
||||
if (liste.some((s) => !s.widerrufEingeleitetAm))
|
||||
throw new WiderrufFehler("Bitte zuerst den Widerruf einleiten.");
|
||||
const offen = await offeneEntfernungen(liste.map((s) => s.id));
|
||||
if (offen.length > 0) {
|
||||
throw new WiderrufFehler(
|
||||
"Zielkonten werden erst entfernt, wenn die Schlüssel von allen Hosts entfernt sind.",
|
||||
);
|
||||
}
|
||||
const kandidaten = new Map((await zielkontoKandidaten(liste.map((s) => s.id))).map((k) => [k.id, k]));
|
||||
const vorgaenge: { id: string; beschriftung: string }[] = [];
|
||||
const gewaehlt = [...new Set(zielkontoIds)];
|
||||
for (const id of gewaehlt) {
|
||||
const konto = kandidaten.get(id);
|
||||
if (!konto) throw new WiderrufFehler("Dieses Zielkonto gehört nicht zu den gewählten Schlüsseln.");
|
||||
if (konto.nochGenutztVon.length > 0) {
|
||||
throw new WiderrufFehler(
|
||||
`${konto.benutzername}@${konto.hostName} wird noch von „${konto.nochGenutztVon.join("“, „")}“ genutzt und bleibt.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const id of gewaehlt) {
|
||||
const konto = kandidaten.get(id)!;
|
||||
vorgaenge.push({
|
||||
id: await vorgangEinreihen({
|
||||
art: "ZIELKONTO_ENTFERNEN",
|
||||
angefordertVonId: akteur.id,
|
||||
hostId: konto.hostId,
|
||||
eingabe: { benutzer: konto.benutzername },
|
||||
}),
|
||||
beschriftung: `${konto.benutzername}@${konto.hostName}`,
|
||||
});
|
||||
}
|
||||
return vorgaenge;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Abschliessen
|
||||
|
||||
export type AbschlussErgebnis = {
|
||||
readonly schluesselId: string;
|
||||
readonly name: string;
|
||||
readonly status: "WIDERRUFEN" | "ROTIERT" | "OFFEN";
|
||||
readonly offen: OffeneEntfernung[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Schritt 3: Status setzen – aber nur fuer Schluessel ohne offene Entfernung. Die anderen
|
||||
* bleiben im Zwischenzustand; die Oberflaeche zeigt, was fehlt.
|
||||
*/
|
||||
export async function widerrufAbschliessen(
|
||||
schluesselIds: readonly string[],
|
||||
akteur: Akteur,
|
||||
): Promise<AbschlussErgebnis[]> {
|
||||
const liste = await schluesselLaden(schluesselIds);
|
||||
const offen = await offeneEntfernungen(liste.map((s) => s.id));
|
||||
const ergebnisse: AbschlussErgebnis[] = [];
|
||||
for (const s of liste) {
|
||||
if (s.status === "WIDERRUFEN" || s.status === "ROTIERT") {
|
||||
ergebnisse.push({ schluesselId: s.id, name: s.name, status: s.status, offen: [] });
|
||||
continue;
|
||||
}
|
||||
if (!s.widerrufEingeleitetAm) throw new WiderrufFehler(`Für „${s.name}“ ist kein Widerruf eingeleitet.`);
|
||||
const eigene = offen.filter((o) => o.schluesselId === s.id);
|
||||
if (eigene.length > 0) {
|
||||
ergebnisse.push({ schluesselId: s.id, name: s.name, status: "OFFEN", offen: eigene });
|
||||
continue;
|
||||
}
|
||||
// Ein aktiver Nachfolger heisst: der Widerruf ist der letzte Schritt einer Rotation (#023).
|
||||
const status = s.nachfolger?.status === "AKTIV" ? "ROTIERT" : "WIDERRUFEN";
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const geaendert = await tx.schluessel.updateMany({
|
||||
where: { id: s.id, status: { in: ["AKTIV", "ABGELAUFEN"] } },
|
||||
data: { status },
|
||||
});
|
||||
if (geaendert.count === 0) return;
|
||||
await protokollieren(
|
||||
{
|
||||
akteur,
|
||||
aktion: status === "ROTIERT" ? "schluessel.rotiert" : "schluessel.widerrufen",
|
||||
ergebnis: "ERFOLG",
|
||||
fingerprint: s.fingerprint,
|
||||
details: {
|
||||
name: s.name,
|
||||
...(s.nachfolger && status === "ROTIERT" ? { nachfolgerId: s.nachfolger.id } : {}),
|
||||
},
|
||||
},
|
||||
tx,
|
||||
);
|
||||
});
|
||||
ergebnisse.push({ schluesselId: s.id, name: s.name, status, offen: [] });
|
||||
}
|
||||
return ergebnisse;
|
||||
}
|
||||
Reference in New Issue
Block a user