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
@@ -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>
);
}