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:
2026-10-12 10:29:52 +02:00
co-authored by Claude Opus 5.5
parent f0833aa1db
commit 263143134f
33 changed files with 3322 additions and 287 deletions
+1 -1
View File
@@ -136,7 +136,7 @@ export default async function Uebersicht() {
return (
<li key={s.zweck} className="border-border border-b last:border-b-0">
<Link
href="/schluessel/beispiel"
href="/schluessel"
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
>
<span className="min-w-0">
@@ -0,0 +1,10 @@
"use client";
/**
* Platzhalter fuer „+ Ausrollen“ in der Karte „Private Key ausgerollt auf“ (#012).
* Inhalt folgt mit #017 (Private Key auf einen Host legen); bis dahin bewusst leer.
*/
export function PrivateAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
void props;
return null;
}
@@ -0,0 +1,10 @@
"use client";
/**
* Platzhalter fuer „+ Ausrollen“ in der Karte „Public Key ausgerollt auf“ (#012).
* Inhalt folgt mit #016 (Zuweisung, Dry-Run, Ausrollen); bis dahin bewusst leer.
*/
export function PublicAusrollenAktion(props: { schluesselId: string; darf: boolean }): null {
void props;
return null;
}
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { SchluesselBearbeiten } from "@/components/schluessel/SchluesselBearbeiten";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personenAuswahl, schluesselDetail, vorlagenAuswahl } from "@/server/queries/schluessel";
export const metadata: Metadata = { title: "Schlüssel bearbeiten" };
export default async function SchluesselBearbeitenSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("schluesselVerwalten", `/schluessel/${id}/bearbeiten`);
const [s, personen, vorlagen] = await Promise.all([
schluesselDetail(id),
personenAuswahl(),
vorlagenAuswahl(),
]);
if (!s) notFound();
// Eine inzwischen archivierte Person oder Vorlage bleibt waehlbar, solange sie zugeordnet
// ist – sonst loeschte schon das blosse Speichern die Zuordnung.
const personListe = personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }));
if (s.person && !personListe.some((p) => p.id === s.person?.id))
personListe.push({ ...s.person, zusatz: null });
const vorlagenListe = vorlagen.map((v) => ({ id: v.id, name: v.name }));
if (s.vorlage && !vorlagenListe.some((v) => v.id === s.vorlage?.id)) vorlagenListe.push(s.vorlage);
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb
pfad={[
{ titel: "Schlüssel", href: "/schluessel" },
{ titel: s.name, href: `/schluessel/${s.id}` },
{ titel: "Bearbeiten" },
]}
/>
}
titel="Schlüssel bearbeiten"
untertitel={
<>
Kurzname <span className="text-text font-mono">{s.slug}</span> und Kommentar bleiben unverändert.
</>
}
/>
<SchluesselBearbeiten
werte={{
id: s.id,
name: s.name,
tags: s.tags,
ablaufAm: s.ablaufAm ? s.ablaufAm.toISOString().slice(0, 10) : "",
personId: s.personId ?? "",
vorlageId: s.vorlageId ?? "",
status: s.status,
statusAenderbar:
s.kategorie !== "DEPLOYMENT" && (s.status === "AKTIV" || s.status === "ABGELAUFEN"),
}}
personen={personListe}
vorlagen={vorlagenListe}
/>
</div>
);
}
+282
View File
@@ -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>
);
}
-184
View File
@@ -1,184 +0,0 @@
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import {
beispielSchluessel as s,
dryRunDatei,
dryRunDiff,
privatAusgerollt,
publicAusgerollt,
zusatzaktionen,
type Ausrollung,
} from "@/beispiel/schluessel";
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { InnereBox, Karte } from "@/components/ui/Karte";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { datum, datumZeit, tageBis } from "@/lib/format";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
export default async function SchluesselBeispiel() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
titel={s.zweck}
badges={
<>
<Badge mono>{s.typ}</Badge>
<Badge>{s.kategorie}</Badge>
<Badge>Vorlage: {s.vorlage}</Badge>
<Badge variante="messing">
<span>
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
</span>
</Badge>
<Badge variante="neutral" className="text-muted">
Beispieldaten
</Badge>
</>
}
aktionen={
<>
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
<Button variante="gefahr" icon={<Ban className="size-4" />}>
Widerrufen
</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.erstellt} className="font-mono text-[13px]">
{datumZeit(s.erstellt)}
</time>
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
</InfoFeld>
<InfoFeld titel="Private Key">
<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 ·
<button
type="button"
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
>
anzeigen (Re-Auth)
</button>
</span>
</InfoFeld>
</dl>
</Karte>
<div className="grid gap-6 lg:grid-cols-2">
<AusrollKarte
titel="Private Key ausgerollt auf"
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
eintraege={privatAusgerollt}
/>
<AusrollKarte
titel="Public Key ausgerollt auf"
untertitel="Konten, die diesen Schlüssel annehmen"
eintraege={publicAusgerollt}
/>
</div>
<Karte
titel="Dry-Run-Vorschau"
untertitel={
<>
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
</>
}
>
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
<fieldset className="mt-5">
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
<ul className="flex flex-col">
{zusatzaktionen.map((z) => (
<li key={z.id}>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
<input
type="checkbox"
name={z.id}
defaultChecked={z.an}
className="accent-accent size-[18px] shrink-0"
/>
<span>{z.text}</span>
</label>
</li>
))}
</ul>
</fieldset>
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
Änderung anwenden
</Button>
<Button>Verwerfen</Button>
</div>
</Karte>
</div>
);
}
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>
);
}
function AusrollKarte({
titel,
untertitel,
eintraege,
}: {
titel: string;
untertitel: string;
eintraege: Ausrollung[];
}) {
return (
<Karte
titel={titel}
untertitel={untertitel}
aktionen={
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
Ausrollen
</Button>
}
>
<ul className="flex flex-col gap-3">
{eintraege.map((e) => (
<li key={`${e.ziel}${e.pfad}`}>
<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.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={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
</InnereBox>
</li>
))}
</ul>
</Karte>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from "next";
import { SchluesselNeu } from "@/components/schluessel/SchluesselNeu";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { personenAuswahl, vorlagenAuswahl } from "@/server/queries/schluessel";
export const metadata: Metadata = { title: "Schlüssel anlegen" };
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
export default async function SchluesselNeuSeite({ searchParams }: { searchParams: Suchparameter }) {
await sitzungErforderlich("schluesselVerwalten", "/schluessel/neu");
const person = (await searchParams)["person"];
const [personen, vorlagen] = await Promise.all([personenAuswahl(), vorlagenAuswahl()]);
const vorgabePersonId =
typeof person === "string" && personen.some((p) => p.id === person) ? person : undefined;
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={
<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: "Neuer Schlüssel" }]} />
}
titel="Schlüssel anlegen"
untertitel="Neu erzeugen oder einen vorhandenen Schlüssel übernehmen."
/>
<SchluesselNeu
personen={personen.map((p) => ({ id: p.id, name: p.name, zusatz: p.organisation }))}
vorlagen={vorlagen.map((v) => ({
id: v.id,
name: v.name,
zusatz: v.standardLaufzeitTage ? `${v.standardLaufzeitTage} Tage` : null,
}))}
{...(vorgabePersonId ? { vorgabePersonId } : {})}
/>
</div>
);
}
+211 -15
View File
@@ -1,27 +1,223 @@
import { KeyRound, Lock, Plus, Search } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite";
import { AblaufBadge, KategorieBadge, StatusBadge, TypBadge } from "@/components/schluessel/Anzeige";
import { Button } from "@/components/ui/Button";
import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datum } from "@/lib/format";
import { ablaufZustand, fingerprintKurz, KATEGORIE_TEXT, STATUS_TEXT } from "@/lib/schluessel";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { schluesselListe, type SchluesselListenEintrag } from "@/server/queries/schluessel";
export const metadata: Metadata = { title: "Schlüssel" };
export default async function Schluessel() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich();
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string {
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
}
function Verwendungen({ anzahl }: { anzahl: number }) {
if (anzahl === 0) return <span className="text-muted">keine</span>;
return <>{anzahl}</>;
}
/** In der Tabelle: Badge, wenn es drängt; sonst nur das Datum. */
function Ablauf({ ablaufAm, jetzt }: { ablaufAm: Date | null; jetzt: Date }) {
if (!ablaufAm) return <span className="text-muted">–</span>;
if (ablaufZustand(ablaufAm, jetzt).art === "spaeter") {
return (
<time dateTime={ablaufAm.toISOString()} className="text-muted">
{datum(ablaufAm)}
</time>
);
}
return <AblaufBadge ablaufAm={ablaufAm} jetzt={jetzt} />;
}
function PrivatHinweis({ s }: { s: SchluesselListenEintrag }) {
if (!s.privatGespeichert) return null;
return (
<div className="flex flex-col gap-5">
<Platzhalterseite
<span title="Private Key verschlüsselt gespeichert" className="text-muted inline-flex">
<Lock className="size-3.5" aria-hidden />
<span className="sr-only">Private Key gespeichert</span>
</span>
);
}
/** 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).
const { benutzer } = await sitzungErforderlich("lesen", "/schluessel");
const parameter = await searchParams;
const q = einzeln(parameter["q"]);
const kategorie = einzeln(parameter["kategorie"]);
const status = einzeln(parameter["status"]);
const liste = await schluesselListe({ q, kategorie, status });
const verwalten = darf(benutzer.rolle, "schluesselVerwalten");
const gefiltert = q !== "" || kategorie !== "" || status !== "";
const jetzt = new Date();
const anlegen = verwalten && (
<Button href="/schluessel/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Schlüssel anlegen
</Button>
);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Schlüssel"
untertitel="SSH-Schlüssel erzeugen, importieren und ausrollen"
phase={2}
issue="#012"
untertitel={
gefiltert
? `${liste.length} Treffer`
: `${liste.length} ${liste.length === 1 ? "verwalteter Schlüssel" : "verwaltete Schlüssel"}`
}
aktionen={anlegen}
/>
<p className="text-muted text-[14px]">
Prototyp ansehen:{" "}
<Link href="/schluessel/beispiel" className="text-accent font-medium hover:underline">
Schlüsseldetail mit Beispieldaten
</Link>
</p>
<form
method="get"
role="search"
className="grid items-end gap-3 sm:grid-cols-2 lg:grid-cols-[minmax(0,1fr)_11rem_11rem_auto]"
>
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={q}
placeholder="Zweck, Fingerprint, Tag, Person"
autoComplete="off"
spellCheck={false}
className="sm:col-span-2 lg:col-span-1"
/>
<Auswahlliste beschriftung="Kategorie" name="kategorie" defaultValue={kategorie}>
<option value="">Alle Kategorien</option>
{Object.entries(KATEGORIE_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<Auswahlliste beschriftung="Status" name="status" defaultValue={status}>
<option value="">Alle</option>
{Object.entries(STATUS_TEXT).map(([wert, text]) => (
<option key={wert} value={wert}>
{text}
</option>
))}
</Auswahlliste>
<div className="flex gap-2.5 sm:col-span-2 lg:col-span-1">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 lg:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/schluessel" className="flex-1 lg:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{liste.length === 0 ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Schlüssel passt zu Suche und Filter."
aktion={<Button href="/schluessel">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<KeyRound className="size-5" />}
titel="Noch keine Schlüssel"
text="Erzeugen Sie einen Schlüssel oder importieren Sie einen vorhandenen Public Key."
aktion={anlegen}
/>
)
) : (
<Karte>
{/* 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">
<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"
>
<span className="text-text flex min-w-0 items-center gap-2 text-[14.5px] font-medium">
<span className="truncate">{s.name}</span>
<PrivatHinweis s={s} />
</span>
<span className="text-muted block truncate font-mono text-[12.5px]">
{fingerprintKurz(s.fingerprint)}
</span>
<span className="flex flex-wrap items-center gap-1.5">
<TypBadge typ={s.typ} publicKey={s.publicKey} />
<KategorieBadge kategorie={s.kategorie} />
<StatusBadge status={s.status} />
<AblaufBadge ablaufAm={s.ablaufAm} jetzt={jetzt} />
</span>
<span className="text-muted text-[13px]">
Verwendungen: <Verwendungen anzahl={s.verwendungen} />
{s.person && <> · {s.person.name}</>}
</span>
</Link>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Schlüssel"
spalten={[
{ titel: "Name" },
{ titel: "Typ" },
{ titel: "Kategorie" },
{ titel: "Fingerprint" },
{ titel: "Ablauf" },
{ titel: "Verwendungen" },
]}
>
{liste.map((s) => (
<TabellenZeile key={s.id}>
<TabellenZelle>
<span className="flex min-w-0 items-center gap-2">
<Link
href={`/schluessel/${s.id}`}
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center text-[14px] font-medium transition-colors"
>
{s.name}
</Link>
<PrivatHinweis s={s} />
<StatusBadge status={s.status} />
</span>
{s.person && <span className="text-muted block text-[12.5px]">{s.person.name}</span>}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<TypBadge typ={s.typ} publicKey={s.publicKey} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<KategorieBadge kategorie={s.kategorie} />
</TabellenZelle>
<TabellenZelle mono className="text-muted whitespace-nowrap">
<span title={s.fingerprint}>{fingerprintKurz(s.fingerprint)}</span>
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Ablauf ablaufAm={s.ablaufAm} jetzt={jetzt} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Verwendungen anzahl={s.verwendungen} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
)}
</div>
);
}
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { publicKeyExport } from "@/server/queries/schluessel";
/**
* Public Key als Datei `<slug>.pub` (#012). Ein Public Key ist kein Geheimnis – jede
* angemeldete Rolle darf ihn holen. Der Slug passt per DB-CHECK in einen Dateinamen.
*/
export async function GET(_anfrage: Request, { params }: { params: Promise<{ id: string }> }) {
try {
await rechtErforderlich("lesen");
} catch (fehler) {
if (fehler instanceof RechteFehler) return NextResponse.json({ fehler: fehler.message }, { status: 403 });
throw fehler;
}
const schluessel = await publicKeyExport((await params).id);
if (!schluessel) return NextResponse.json({ fehler: "Unbekannter Schlüssel." }, { status: 404 });
return new NextResponse(`${schluessel.publicKey}\n`, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="${schluessel.slug}.pub"`,
"Cache-Control": "no-store",
},
});
}