Merge branch 'phase/032-nacharbeiten'

This commit is contained in:
2026-10-12 17:56:33 +02:00
27 changed files with 789 additions and 113 deletions
+72 -23
View File
@@ -23,7 +23,8 @@ import { widerrufUebersicht } from "@/server/queries/widerruf";
import { WiderrufAblauf } from "@/components/widerruf/WiderrufAblauf";
import { WiderrufKnopf } from "@/components/widerruf/WiderrufKnopf";
import { ablaufZustand } from "@/lib/schluessel";
import { rotationGesperrt } from "@/server/services/rotation";
import { RotationAbbrechen } from "@/components/rotation/RotationAbbrechen";
import { rotationAbgebrochen, rotationGesperrt } from "@/server/services/rotation";
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
@@ -216,9 +217,17 @@ export default async function SchluesselSeite({
const jetzt = new Date();
const abgelaufen = s.status === "ABGELAUFEN" || ablaufZustand(s.ablaufAm, jetzt).art === "abgelaufen";
const verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
const widerrufbar =
!imWiderruf &&
!(s.nachfolger?.status === "AKTIV") &&
// Ein Nachfolger mitten in der Rotation wird ueber „Rotation abbrechen“ widerrufen (#032) –
// so steht der Abbruch im Protokoll und die Rotationsseite kennt den Stand.
!rotationDesVorgaengers &&
s.kategorie !== "DEPLOYMENT" &&
(s.status === "AKTIV" || s.status === "ABGELAUFEN") &&
rechte.public &&
@@ -235,13 +244,19 @@ export default async function SchluesselSeite({
privatZuweisungen: aktivePrivat,
});
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
const abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf));
// Auf dem Nachfolger: die laufende Rotation gehoert zum Vorgaenger.
const rotationDesVorgaengers =
s.vorgaenger && (s.vorgaenger.status === "AKTIV" || s.vorgaenger.status === "ABGELAUFEN")
? s.vorgaenger
: null;
// Nach einem abgeschlossenen Abbruch (#032) bleibt der Nachfolger in der Kette; ein zweiter
// passt nicht hinein (vorgaengerId ist eindeutig) – deshalb kein „Rotieren“ mehr.
const rotierbar = darfRotieren && (rotationLaeuft || (!rotationsSperre && !imWiderruf && !s.nachfolger));
// Abbrechen auf dem Nachfolger: gleiche Rechte wie die Rotation, Private Keys nur mit Recht.
const darfAbbrechen =
Boolean(rotationDesVorgaengers && !rotationDesVorgaengers.widerrufEingeleitetAm) &&
!imWiderruf &&
s.status === "AKTIV" &&
verwaltenRecht &&
rechte.public &&
(s.privateVerwendungen.length === 0 || rechte.privat);
const vorauswahl = (
Array.isArray(zielkontenParameter) ? zielkontenParameter.join(",") : (zielkontenParameter ?? "")
)
@@ -308,20 +323,41 @@ export default async function SchluesselSeite({
}
/>
{rotationDesVorgaengers && (
<Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
</Karte>
)}
{rotationDesVorgaengers &&
(imWiderruf || s.status === "WIDERRUFEN" ? (
<Karte titel="Rotation abgebrochen">
<p className="text-muted text-[14px]">
Dieser Schlüssel sollte „{rotationDesVorgaengers.name}“ ablösen; die Rotation wurde abgebrochen.
{imWiderruf ? " Er wird gerade entfernt; " : " Er ist von allen Hosts entfernt; "}
<Link href={`/schluessel/${rotationDesVorgaengers.id}`} className="text-accent hover:underline">
{rotationDesVorgaengers.name}
</Link>{" "}
bleibt aktiv.
</p>
</Karte>
) : (
<Karte titel="Rotation läuft" className="border-accent">
<p className="text-muted text-[14px]">
Dieser Schlüssel löst „{rotationDesVorgaengers.name}“ ab. Die Rotation ist erst fertig, wenn der
neue Schlüssel ausgerollt und getestet und der alte entfernt ist.{" "}
<Link
href={`/schluessel/${rotationDesVorgaengers.id}/rotieren`}
className="text-accent hover:underline"
>
Rotation fortsetzen
</Link>
</p>
{darfAbbrechen && (
<div className="mt-4">
<RotationAbbrechen
altId={rotationDesVorgaengers.id}
altName={rotationDesVorgaengers.name}
neuName={s.name}
/>
</div>
)}
</Karte>
))}
{imWiderruf && widerruf && (
<Karte
@@ -336,7 +372,7 @@ export default async function SchluesselSeite({
vorauswahl={vorauswahl}
ruecksprung={`/schluessel/${s.id}`}
darfPrivat={rechte.privat}
ohneZielkonten={rotationLaeuft}
ohneZielkonten={rotationLaeuft || Boolean(rotationDesVorgaengers)}
/>
</Karte>
)}
@@ -367,7 +403,8 @@ export default async function SchluesselSeite({
<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 && (
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
{s.nachfolger && s.status === "ROTIERT" && (
<>
{" "}
Ersetzt durch{" "}
@@ -457,6 +494,10 @@ export default async function SchluesselSeite({
!imWiderruf &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte
// Sprungziel fuer „Zum Dry-Run“ aus der Zuweisungsmatrix; scroll-mt haelt die Karte
// unter der festen Kopfleiste der schmalen Ansicht (#032).
id="dry-run"
className="scroll-mt-20"
titel="Dry-Run-Vorschau"
untertitel="Erst prüfen, dann anwenden – auf den Hosts ändert sich vorher nichts"
>
@@ -498,6 +539,14 @@ export default async function SchluesselSeite({
))}
</ol>
)}
{abgebrocheneRotation && s.nachfolger && (
<p className="text-muted mt-3 text-[14px]">
Die Rotation zu „{s.nachfolger.name}“ wurde abgebrochen
{s.nachfolger.status === "WIDERRUFEN" ? "" : " – der neue Schlüssel wird gerade entfernt"};
dieser Schlüssel bleibt aktiv. Ein zweiter Nachfolger ist nicht vorgesehen: für eine neue
Rotation einen neuen Schlüssel anlegen, genauso zuweisen und diesen danach widerrufen.
</p>
)}
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
{rotationsSperre.grund}
@@ -59,9 +59,14 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
}
const widerruf =
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
stand.neu && !stand.abgebrochen && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
? await offeneEntfernungen([alt.id])
: null;
// Abbruch (#032): solange der neue Schluessel nicht widerrufen ist, laeuft sein Widerruf hier weiter.
const abbruch =
stand.neu && stand.abgebrochen
? { offen: stand.neu.status === "WIDERRUFEN" ? null : await offeneEntfernungen([stand.neu.id]) }
: null;
return (
<div className="flex flex-col gap-6 sm:gap-7">
@@ -74,6 +79,7 @@ export default async function RotierenSeite({ params }: { params: Promise<{ id:
neuOffen: stand.neu ? stand.neuOffen : 0,
tests: stand.neu ? stand.tests : [],
widerruf,
abbruch,
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
}}
/>
+3 -10
View File
@@ -9,6 +9,7 @@ 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 { idsAusSuchparameter } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { bestaetigungFrisch, sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { widerrufUebersicht } from "@/server/queries/widerruf";
@@ -17,14 +18,6 @@ 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
@@ -32,7 +25,7 @@ function liste(wert: string | string[] | undefined): string[] {
*/
export default async function MassenwiderrufSeite({ searchParams }: { searchParams: Suchparameter }) {
const parameter = await searchParams;
const ids = liste(parameter.id);
const ids = idsAusSuchparameter(parameter.id);
const selbst = `/schluessel/widerruf?${ids.map((id) => `id=${id}`).join("&")}`;
const { benutzer } = await sitzungErforderlich("massenwiderruf", selbst);
@@ -61,7 +54,7 @@ export default async function MassenwiderrufSeite({ searchParams }: { searchPara
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);
const vorauswahl = idsAusSuchparameter(parameter.zielkonten);
return (
<div className="flex flex-col gap-6 sm:gap-7">