Merge branch 'phase/023-024' (Rotation, Widerruf)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Download, Lock, LockOpen, Pencil } from "lucide-react";
|
||||
import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -15,10 +15,15 @@ import { Statuspunkt, type HostStatus } from "@/components/ui/Statuspunkt";
|
||||
import { datum, datumZeit } from "@/lib/format";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
import { rotationsKette, schluesselDetail, type SchluesselDetail } from "@/server/queries/schluessel";
|
||||
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 { rotationGesperrt } from "@/server/services/rotation";
|
||||
import { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
import { PrivateKeyAnzeigen, PrivateKeyLoeschen } from "./PrivateKeyAktionen";
|
||||
import { PublicAusrollenAktion } from "./PublicAusrollenAktion";
|
||||
@@ -187,18 +192,61 @@ 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 verwaltenRecht = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
const widerrufbar =
|
||||
!imWiderruf &&
|
||||
!(s.nachfolger?.status === "AKTIV") &&
|
||||
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;
|
||||
// Rotation (#023): Kette, Knopf bzw. Hinweis fuer Schluessel ohne Private Key in sshkv.
|
||||
const kette = await rotationsKette(s.id);
|
||||
const aktivePrivat = s.privateVerwendungen.filter((v) => v.zustand !== "ENTFERNEN").length;
|
||||
const rotationsSperre = rotationGesperrt({
|
||||
kategorie: s.kategorie,
|
||||
status: s.status,
|
||||
masterKeyVersion: s.privatGespeichert ? 1 : null,
|
||||
widerrufEingeleitetAm: s.widerrufEingeleitetAm,
|
||||
privatZuweisungen: aktivePrivat,
|
||||
});
|
||||
const rotationLaeuft = s.nachfolger?.status === "AKTIV" && s.status !== "ROTIERT";
|
||||
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;
|
||||
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 +254,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 +275,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 +288,99 @@ 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 || rotierbar || (widerrufbar && widerruf)) && (
|
||||
<>
|
||||
{verwalten && (
|
||||
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
{rotierbar && (
|
||||
<Button href={`/schluessel/${s.id}/rotieren`} icon={<RefreshCw className="size-4" />}>
|
||||
{rotationLaeuft ? "Rotation fortsetzen" : "Rotieren"}
|
||||
</Button>
|
||||
)}
|
||||
{widerrufbar && widerruf && (
|
||||
<WiderrufKnopf uebersicht={widerruf} ruecksprung={`/schluessel/${s.id}`} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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}
|
||||
ohneZielkonten={rotationLaeuft}
|
||||
/>
|
||||
</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 +454,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"
|
||||
@@ -336,6 +470,42 @@ export default async function SchluesselSeite({ params }: { params: Promise<{ id
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{(kette.length > 1 || (rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf)) && (
|
||||
<Karte
|
||||
titel="Rotation"
|
||||
untertitel={kette.length > 1 ? "Vorgänger und Nachfolger dieses Schlüssels" : undefined}
|
||||
>
|
||||
{kette.length > 1 && (
|
||||
<ol className="flex flex-wrap items-center gap-2 text-[14px]" aria-label="Rotationskette">
|
||||
{kette.map((glied, index) => (
|
||||
<li key={glied.id} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<span className="text-muted" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
{glied.id === s.id ? (
|
||||
<span className="font-medium" aria-current="true">
|
||||
{glied.name}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={`/schluessel/${glied.id}`} className="text-accent hover:underline">
|
||||
{glied.name}
|
||||
</Link>
|
||||
)}
|
||||
<StatusBadge status={glied.status} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{rotationsSperre?.neuerPublicKeyNoetig && !imWiderruf && (
|
||||
<p className={`text-muted text-[14px] ${kette.length > 1 ? "mt-3" : ""}`}>
|
||||
{rotationsSperre.grund}
|
||||
</p>
|
||||
)}
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
<Karte
|
||||
titel="Public Key"
|
||||
untertitel={
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { KeyRound } from "lucide-react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Rotation } from "@/components/rotation/Rotation";
|
||||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Leerzustand } from "@/components/ui/Leerzustand";
|
||||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||||
import { darf } from "@/server/auth/rechte";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { RotationsFehler, rotationsStand } from "@/server/services/rotation";
|
||||
import { offeneEntfernungen } from "@/server/services/widerruf";
|
||||
|
||||
export const metadata: Metadata = { title: "Schlüssel rotieren" };
|
||||
|
||||
/** Gefuehrte Rotation (#023): Schrittfolge, wiederaufnehmbar. */
|
||||
export default async function RotierenSeite({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { benutzer } = await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/rotieren`);
|
||||
if (!darf(benutzer.rolle, "publicKeyAusrollen")) notFound();
|
||||
let stand;
|
||||
try {
|
||||
stand = await rotationsStand(id);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RotationsFehler) notFound();
|
||||
throw fehler;
|
||||
}
|
||||
const { alt } = stand;
|
||||
|
||||
const kopf = (
|
||||
<Seitenkopf
|
||||
oben={
|
||||
<Breadcrumb
|
||||
pfad={[
|
||||
{ titel: "Schlüssel", href: "/schluessel" },
|
||||
{ titel: alt.name, href: `/schluessel/${alt.id}` },
|
||||
{ titel: "Rotieren" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
titel={`„${alt.name}“ rotieren`}
|
||||
untertitel="Neuer Schlüssel an allen Stellen, testen, erst dann den alten entfernen."
|
||||
/>
|
||||
);
|
||||
|
||||
// Ohne Nachfolger und nicht rotierbar: nur der Hinweis, keine halbe Rotation.
|
||||
if (!stand.neu && stand.gesperrt) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
{kopf}
|
||||
<Leerzustand
|
||||
icon={<KeyRound className="size-5" />}
|
||||
titel={stand.gesperrt.neuerPublicKeyNoetig ? "Neuer Public Key nötig" : "Nicht rotierbar"}
|
||||
text={stand.gesperrt.grund}
|
||||
aktion={<Button href={`/schluessel/${alt.id}`}>Zurück zum Schlüssel</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const widerruf =
|
||||
stand.neu && alt.widerrufEingeleitetAm && alt.status !== "ROTIERT"
|
||||
? await offeneEntfernungen([alt.id])
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 sm:gap-7">
|
||||
{kopf}
|
||||
<Rotation
|
||||
daten={{
|
||||
alt: { id: alt.id, name: alt.name },
|
||||
neu: stand.neu ? { id: stand.neu.id, name: stand.neu.name } : null,
|
||||
schritt: stand.schritt,
|
||||
neuOffen: stand.neu ? stand.neuOffen : 0,
|
||||
tests: stand.neu ? stand.tests : [],
|
||||
widerruf,
|
||||
darfPrivat: darf(benutzer.rolle, "privateKeyAusrollen"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 disabled:opacity-30"
|
||||
/>
|
||||
</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