Bisher liess sich eine angefangene Rotation nur zu Ende fuehren. Wer merkt, dass der neue Schluessel nicht passt, braucht einen Weg zurueck, ohne den alten anzufassen. "Rotation abbrechen" (Rotationsseite und Seite des Nachfolgers) merkt den neuen Schluessel ueber den bestehenden Widerruf aus #024 vor; er kann schon ausgerollt sein, deshalb folgen Dry-Run und Anwenden wie sonst. Der alte bleibt aktiv und unveraendert. Wo beide denselben Alias tragen, zeigt die config danach wieder auf die alte Datei, weil privat.ts nur die verbleibenden Zuweisungen in den Block schreibt. Zustandsmodell: kein neues Feld. Ein Nachfolger im Widerruf (oder widerrufen) ist eine abgebrochene Rotation; vorgaengerId bleibt fuer die Historie, der Abbruch steht als schluessel.rotation.abgebrochen im Protokoll (in derselben Transaktion wie das Vormerken, dafuer nimmt widerrufEinleiten optional einen Zusatzschritt an). Abbrechen geht nur, solange der alte Schluessel noch nicht entfernt wird. widerrufAbschliessen macht aus dem Widerruf des alten Schluessels kein ROTIERT mehr, wenn der Nachfolger selbst im Widerruf ist. Weil vorgaengerId eindeutig ist, gibt es nach einem Abbruch keinen zweiten Nachfolger; Seite und Fehlermeldung sagen das. Tests: DB-Tests fuer Abbruch, Idempotenz, Sperren und Status; Integrationstest gegen echte Ziele (neuer Key ausgerollt, abbrechen, neuer weg, alter in authorized_keys, ssh zu-b von A geht); E2E fuer den Ablauf im Browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
593 lines
24 KiB
TypeScript
593 lines
24 KiB
TypeScript
import { Download, Lock, LockOpen, Pencil, RefreshCw } from "lucide-react";
|
||
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import type { ReactNode } from "react";
|
||
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import { Breadcrumb } from "@/components/ui/Breadcrumb";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { InnereBox, Karte } from "@/components/ui/Karte";
|
||
import { MonoBlock } from "@/components/ui/MonoBlock";
|
||
import { MonoKopieren } from "@/components/ui/MonoKopieren";
|
||
import { Seitenkopf } from "@/components/ui/Seitenkopf";
|
||
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 { 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 { 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";
|
||
|
||
export const metadata: Metadata = { title: "Schlüssel" };
|
||
|
||
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
|
||
return (
|
||
<div className="min-w-0 px-5 py-4 sm:px-6">
|
||
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
|
||
<dd className="min-w-0">{children}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Soll-Zustand einer Verwendung als Statuspunkt – Form und Text tragen die Information mit. */
|
||
const ZUSTAND: Record<string, { status: HostStatus; text: string }> = {
|
||
GEPLANT: { status: "unbekannt", text: "geplant" },
|
||
AUSGEROLLT: { status: "synchron", text: "ausgerollt" },
|
||
ABWEICHUNG: { status: "drift", text: "Abweichung" },
|
||
ENTFERNEN: { status: "unbekannt", text: "wird entfernt" },
|
||
FEHLGESCHLAGEN: { status: "drift", text: "fehlgeschlagen" },
|
||
};
|
||
|
||
type Eintrag = {
|
||
id: string;
|
||
ziel: string;
|
||
href?: string;
|
||
pfad: string;
|
||
zusatz?: string;
|
||
zustand: string;
|
||
aktionen?: ReactNode;
|
||
};
|
||
|
||
function AusrollKarte({
|
||
titel,
|
||
untertitel,
|
||
eintraege,
|
||
leer,
|
||
aktion,
|
||
}: {
|
||
titel: string;
|
||
untertitel: string;
|
||
eintraege: Eintrag[];
|
||
leer: string;
|
||
aktion: ReactNode;
|
||
}) {
|
||
return (
|
||
<Karte titel={titel} untertitel={untertitel} aktionen={aktion}>
|
||
{eintraege.length === 0 ? (
|
||
<p className="text-muted border-border-strong rounded-box border border-dashed px-4 py-6 text-center text-[14px]">
|
||
{leer}
|
||
</p>
|
||
) : (
|
||
<ul className="flex flex-col gap-3">
|
||
{eintraege.map((e) => {
|
||
const z = ZUSTAND[e.zustand] ?? { status: "unbekannt" as const, text: e.zustand };
|
||
return (
|
||
<li key={e.id}>
|
||
<InnereBox className="flex flex-col gap-2">
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<p className="text-text font-mono text-[13.5px] font-medium break-all">
|
||
{e.href ? (
|
||
<Link href={e.href} className="hover:text-accent transition-colors">
|
||
{e.ziel}
|
||
</Link>
|
||
) : (
|
||
e.ziel
|
||
)}
|
||
</p>
|
||
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
|
||
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
|
||
</div>
|
||
<Statuspunkt status={z.status} text={z.text} className="shrink-0 sm:pt-0.5" />
|
||
</div>
|
||
{/* Aktionen unter dem Eintrag, damit lange Hostnamen nicht umbrechen (#016). */}
|
||
{e.aktionen}
|
||
</InnereBox>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
)}
|
||
</Karte>
|
||
);
|
||
}
|
||
|
||
function privateEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||
return s.privateVerwendungen.map((v) => {
|
||
const config =
|
||
v.sshConfig && typeof v.sshConfig === "object" ? (v.sshConfig as { alias?: unknown }) : null;
|
||
return {
|
||
id: v.id,
|
||
ziel: `${v.zielbenutzer}@${v.host.name}`,
|
||
href: `/hosts/${v.host.id}`,
|
||
pfad: v.pfad,
|
||
...(typeof config?.alias === "string" ? { zusatz: `config: Host ${config.alias}` } : {}),
|
||
zustand: v.zustand,
|
||
aktionen: (
|
||
<EintragAktionen
|
||
art="privat"
|
||
verwendungId={v.id}
|
||
schluesselId={s.id}
|
||
darfEntfernen={rechte.privat && v.zustand !== "ENTFERNEN"}
|
||
testbar={rechte.testen && v.zustand === "AUSGEROLLT" && typeof config?.alias === "string"}
|
||
/>
|
||
),
|
||
};
|
||
});
|
||
}
|
||
|
||
type Rechte = { readonly public: boolean; readonly privat: boolean; readonly testen: boolean };
|
||
|
||
function publicEintraege(s: SchluesselDetail, rechte: Rechte): Eintrag[] {
|
||
return s.publicVerwendungen.map((v) => ({
|
||
id: v.id,
|
||
ziel: v.host
|
||
? `${v.zielbenutzer}@${v.host.name}`
|
||
: `${v.zielbenutzer}@${v.hostgruppe?.name ?? "?"} (Gruppe)`,
|
||
...(v.host
|
||
? { href: `/hosts/${v.host.id}` }
|
||
: v.hostgruppe
|
||
? { href: `/hostgruppen/${v.hostgruppe.id}` }
|
||
: {}),
|
||
pfad: "~/.ssh/authorized_keys",
|
||
...(v.optionen ? { zusatz: v.optionen } : {}),
|
||
zustand: v.zustand,
|
||
aktionen: (
|
||
<EintragAktionen
|
||
art="public"
|
||
verwendungId={v.id}
|
||
schluesselId={s.id}
|
||
darfEntfernen={rechte.public && v.zustand !== "ENTFERNEN"}
|
||
testbar={false}
|
||
/>
|
||
),
|
||
}));
|
||
}
|
||
|
||
/** Was mit dem Private Key ist – ohne ihn je zu lesen (masterKeyVersion verraet es). */
|
||
function PrivatStatus({ s, anzeigen }: { s: SchluesselDetail; anzeigen?: ReactNode }) {
|
||
if (s.privatGespeichert) {
|
||
return (
|
||
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
|
||
<Lock className="text-ok size-3.5" aria-hidden />
|
||
verschlüsselt gespeichert
|
||
{s.passphraseGeschuetzt && <span className="text-muted">· mit Passphrase</span>}
|
||
{anzeigen}
|
||
</span>
|
||
);
|
||
}
|
||
if (s.privatGeloeschtAm) {
|
||
return (
|
||
<span className="text-muted inline-flex items-center gap-1.5 text-[14px]">
|
||
<LockOpen className="size-3.5" aria-hidden />
|
||
gelöscht am <time dateTime={s.privatGeloeschtAm.toISOString()}>{datum(s.privatGeloeschtAm)}</time>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="text-muted text-[14px]">
|
||
nicht gespeichert{s.kategorie === "PERSON" ? " (Personen-Key)" : ""}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** Schlüsseldetail (#012) nach dem Prototyp aus #002; Ausrollen folgt mit #016/#017. */
|
||
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;
|
||
|
||
// 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");
|
||
// 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 &&
|
||
(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 abgebrocheneRotation = s.status !== "ROTIERT" && rotationAbgebrochen(s.nachfolger);
|
||
const darfRotieren = verwaltenRecht && rechte.public && (aktivePrivat === 0 || rechte.privat);
|
||
// 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 ?? "")
|
||
)
|
||
.split(",")
|
||
.filter(Boolean);
|
||
const auswahl = aktiv && rechte.public ? await zielAuswahl() : undefined;
|
||
const personenKey = s.kategorie === "PERSON";
|
||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||
// Den Deployment-Key gibt sshkv nie heraus (#014) – Knoepfe dafuer erscheinen gar nicht erst.
|
||
const privatHerausgebbar = s.privatGespeichert && s.kategorie !== "DEPLOYMENT";
|
||
const darfAnzeigen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyAnzeigen");
|
||
const darfLoeschen = privatHerausgebbar && darf(benutzer.rolle, "privateKeyLoeschen");
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6 sm:gap-7">
|
||
<Seitenkopf
|
||
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.name }]} />}
|
||
titel={s.name}
|
||
untertitel={
|
||
s.person ? (
|
||
<>
|
||
Person:{" "}
|
||
<Link href={`/personen/${s.person.id}`} className="text-accent hover:underline">
|
||
{s.person.name}
|
||
</Link>
|
||
</>
|
||
) : undefined
|
||
}
|
||
badges={
|
||
<>
|
||
<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>}
|
||
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} lang />
|
||
{s.tags.map((t) => (
|
||
<Badge key={t} mono className="text-muted">
|
||
#{t}
|
||
</Badge>
|
||
))}
|
||
</>
|
||
}
|
||
aktionen={
|
||
(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 &&
|
||
(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
|
||
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 || Boolean(rotationDesVorgaengers)}
|
||
/>
|
||
</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.
|
||
{/* Ein widerrufener Nachfolger (abgebrochene Rotation, #032) ersetzt nichts. */}
|
||
{s.nachfolger && s.status === "ROTIERT" && (
|
||
<>
|
||
{" "}
|
||
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">
|
||
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
|
||
</InfoFeld>
|
||
<InfoFeld titel="Erstellt">
|
||
<time dateTime={s.angelegtAm.toISOString()} className="font-mono text-[13px]">
|
||
{datumZeit(s.angelegtAm)}
|
||
</time>
|
||
<span className="text-muted block text-[13px]">
|
||
{s.erstelltVon ? `von ${s.erstelltVon.name}` : "von sshkv"}
|
||
</span>
|
||
</InfoFeld>
|
||
<InfoFeld titel="Private Key">
|
||
<PrivatStatus
|
||
s={s}
|
||
anzeigen={
|
||
darfAnzeigen && (
|
||
<>
|
||
<span aria-hidden>·</span>
|
||
<PrivateKeyAnzeigen schluesselId={s.id} name={s.name} />
|
||
</>
|
||
)
|
||
}
|
||
/>
|
||
</InfoFeld>
|
||
</dl>
|
||
</Karte>
|
||
|
||
{/* Personen-Keys haben nie einen gespeicherten Private Key (DB-CHECK) – die Karte dafuer
|
||
waere immer leer. */}
|
||
<div className={personenKey ? "grid gap-6" : "grid gap-6 lg:grid-cols-2"}>
|
||
{!personenKey && (
|
||
<AusrollKarte
|
||
titel="Private Key ausgerollt auf"
|
||
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
|
||
eintraege={privateEintraege(s, rechte)}
|
||
leer={
|
||
s.privatGespeichert
|
||
? "Der Private Key liegt noch auf keinem Host."
|
||
: "Ohne gespeicherten Private Key kann sshkv ihn nicht ausrollen."
|
||
}
|
||
aktion={
|
||
<PrivateAusrollenAktion
|
||
schluesselId={s.id}
|
||
darf={aktiv && s.privatGespeichert && rechte.privat}
|
||
{...(auswahl ? { auswahl } : {})}
|
||
slug={s.slug}
|
||
passphraseGeschuetzt={s.passphraseGeschuetzt}
|
||
/>
|
||
}
|
||
/>
|
||
)}
|
||
<AusrollKarte
|
||
titel="Public Key ausgerollt auf"
|
||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||
eintraege={publicEintraege(s, rechte)}
|
||
leer={
|
||
personenKey
|
||
? "Noch keinem Konto zugewiesen – wirksam erst, wenn ein Operator oder Admin den Schlüssel einem Host zuweist."
|
||
: "Noch keinem Konto zugewiesen."
|
||
}
|
||
aktion={
|
||
<PublicAusrollenAktion
|
||
schluesselId={s.id}
|
||
darf={aktiv && rechte.public}
|
||
{...(auswahl ? { auswahl } : {})}
|
||
/>
|
||
}
|
||
/>
|
||
</div>
|
||
|
||
{rechte.public &&
|
||
!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"
|
||
>
|
||
<AusrollenPanel
|
||
auswahl={{ schluesselId: s.id }}
|
||
offen={
|
||
s.publicVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length +
|
||
(rechte.privat ? s.privateVerwendungen.filter((v) => v.zustand !== "AUSGEROLLT").length : 0)
|
||
}
|
||
/>
|
||
</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>
|
||
)}
|
||
{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}
|
||
</p>
|
||
)}
|
||
</Karte>
|
||
)}
|
||
|
||
<Karte
|
||
titel="Public Key"
|
||
untertitel={
|
||
<>
|
||
Kommentar: <span className="font-mono">{s.kommentar || "–"}</span>
|
||
</>
|
||
}
|
||
aktionen={
|
||
<a
|
||
href={`/api/schluessel/${s.id}/public-key`}
|
||
download={`${s.slug}.pub`}
|
||
className="border-border-strong bg-surface text-text hover:bg-raised rounded-box inline-flex min-h-11 items-center gap-2 border px-3 text-[13px] font-medium transition-colors"
|
||
>
|
||
<Download className="size-4" aria-hidden />
|
||
{s.slug}.pub
|
||
</a>
|
||
}
|
||
>
|
||
<MonoBlock wert={s.publicKey} beschriftung="Public Key" umbrechen />
|
||
</Karte>
|
||
|
||
{darfLoeschen && (
|
||
<Karte titel="Private Key löschen" untertitel="Nur noch auf den Hosts, nicht mehr in SSHKeyVault">
|
||
<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]">
|
||
Sinnvoll, sobald der Private Key ausgerollt ist und sshkv ihn nicht mehr braucht. Danach kann
|
||
ihn niemand mehr aus SSHKeyVault holen – auch kein Admin.
|
||
</p>
|
||
<PrivateKeyLoeschen schluesselId={s.id} name={s.name} />
|
||
</div>
|
||
</Karte>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|