Merge branch 'phase/023-024' (Rotation, Widerruf)

This commit is contained in:
2026-10-12 13:31:47 +02:00
39 changed files with 3701 additions and 101 deletions
+179 -9
View File
@@ -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>
);
}
+54 -3
View File
@@ -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>
+118
View File
@@ -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>
);
}