feat(schluessel): erzeugen, importieren, exportieren (#012)
Schluessel werden jetzt verwaltet statt nur im Prototyp gezeigt: Liste mit Filter, Anlegen (erzeugen oder importieren), Detail nach dem Prototyp aus #002, Bearbeiten der Metadaten und Download <slug>.pub. Warum so: - Erzeugen und Import eines Paars laufen als Vorgang im Worker, weil nur er den Master-Key hat. Passphrase und importierter Private Key reisen wie das Bootstrap-Passwort mit SSHKV_APP_SECRET verschluesselt im Job, nie in Vorgang.eingabe; der Worker loescht den Job danach. - Ein reiner Public Key ist kein Geheimnis und wird direkt im Web angelegt, mit verstaendlicher Meldung bei einem Duplikat. - Das Web liest nie das Chiffrat: ob ein Private Key gespeichert ist, verraet masterKeyVersion (per CHECK an das Chiffrat gekoppelt). Ein struktureller Test verlangt fuer jede zeilenliefernde Abfrage auf Schluessel ausserhalb des Workers ein festes select. - Migration: Vorgangsarten fuer Erzeugen, Import und (fuer #014) das Loeschen des Private Keys; CHECKs fuer den Slug (landet in Dateinamen) und "geloescht nur ohne Chiffrat". - Platzhalter PublicAusrollenAktion/PrivateAusrollenAktion fuer #016/#017. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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 { PrivateAusrollenAktion } from "./PrivateAusrollenAktion";
|
||||
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 };
|
||||
|
||||
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 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" />
|
||||
</InnereBox>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Karte>
|
||||
);
|
||||
}
|
||||
|
||||
function privateEintraege(s: SchluesselDetail): 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function publicEintraege(s: SchluesselDetail): 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,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 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 personenKey = s.kategorie === "PERSON";
|
||||
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
|
||||
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: <span className="text-text">{s.person.name}</span>
|
||||
</>
|
||||
) : 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} />
|
||||
</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)}
|
||||
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 && darf(benutzer.rolle, "privateKeyAusrollen")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AusrollKarte
|
||||
titel="Public Key ausgerollt auf"
|
||||
untertitel="Konten, die diesen Schlüssel annehmen"
|
||||
eintraege={publicEintraege(s)}
|
||||
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 && darf(benutzer.rolle, "publicKeyAusrollen")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user