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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user