Files
sshkeyvault/src/app/(app)/schluessel/[id]/page.tsx
T
florian.wildeandClaude Opus 5.5 12603eb145 feat(schluessel): Zuweisen und Ausrollen auf der Schluesselseite (#017)
Auf der Seite eines Schluessels: Public Key einem Host oder einer
Gruppe zuweisen, Private Key (nur Admin) auf einen Quell-Host legen -
mit ~/.ssh/config-Eintrag und dem Host-Key des Ziels in known_hosts -,
Dry-Run fuer alle Ziele, anwenden und die Verbindung von A nach B
testen. Der Kern-Anwendungsfall laeuft damit im Browser durch (E2E).

Der Dry-Run nimmt Private-Key-Ziele nur fuer Admins auf: sonst koennte
ein Operator einen vorbereiteten Private Key ausrollen lassen. Die
Vorschau zeigt je Datei die Aktion; der Inhalt eines Private Keys
erreicht den Browser nie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:05:03 +02:00

374 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Download, Lock, LockOpen, Pencil } 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 { 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 { 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 }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/schluessel/${id}`);
const s = await schluesselDetail(id);
if (!s) notFound();
const aktiv = s.status === "AKTIV";
const rechte: Rechte = {
public: darf(benutzer.rolle, "publicKeyAusrollen"),
privat: darf(benutzer.rolle, "privateKeyAusrollen"),
testen: darf(benutzer.rolle, "hostsVerwalten"),
};
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");
const jetzt = new Date();
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} />
{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 && (
<Button href={`/schluessel/${s.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)
}
/>
<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 &&
(s.publicVerwendungen.length > 0 || (rechte.privat && s.privateVerwendungen.length > 0)) && (
<Karte
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>
)}
<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>
);
}