feat(vorlagen): Liste, Formular und Live-Vorschau (#019)

Die Vorlagen-Logik aus #019 war bisher nur per Seed und Service
erreichbar. Admins koennen eigene Vorlagen jetzt anlegen, eingebaute
anpassen und eigene archivieren; jede Rolle sieht die Liste, weil
Operatoren die Vorlagen im Assistenten wiedererkennen sollen.

Die Vorschau nutzt im Browser dieselben reinen Funktionen wie der
Server (platzhalterErsetzen, optionenPruefen, sudoersInhalt). So sieht
der Admin vor dem Speichern, was in authorized_keys, ~/.ssh/config und
sudoers landen wuerde; verbindlich prueft weiterhin der Server. Voller
sudo-Zugang bekommt einen deutlichen Hinweis, weil er root gleichkommt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:16:31 +02:00
co-authored by Claude Opus 5.5
parent bdf12b05e7
commit 5c42f8dd8d
7 changed files with 686 additions and 13 deletions
@@ -0,0 +1,377 @@
"use client";
import { TriangleAlert } from "lucide-react";
import { useState } from "react";
import { useFormularAktion } from "@/components/hosts/useFormularAktion";
import { Ankreuzfeld, Button, Feld, Feldgruppe, Formularfehler, Karte, Textbereich } from "@/components/ui";
import { MonoBlock } from "@/components/ui/MonoBlock";
import { optionenPruefen } from "@/lib/ssh/optionen";
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
import { PLATZHALTER, platzhalterErsetzen, type PlatzhalterWerte } from "@/lib/vorlagen";
import { vorlageSpeichernAktion } from "@/server/actions/vorlagen";
type SudoModus = "KEINER" | "BEFEHLE" | "VOLL";
export type VorlageFormularVorgabe = {
id?: string;
name: string;
beschreibung: string;
schluesselTyp: "ED25519" | "RSA4096";
kategorie: "DIENST" | "PERSON";
standardLaufzeitTage: number | null;
zielbenutzer: string;
zielbenutzerAnlegen: boolean;
sudoModus: SudoModus;
sudoBefehle: string[];
authorizedKeysOptionen: string;
sshConfigAlias: string;
knownHostsEintragen: boolean;
privatNachAusrollenLoeschen: boolean;
};
export const LEERE_VORLAGE: VorlageFormularVorgabe = {
name: "",
beschreibung: "",
schluesselTyp: "ED25519",
kategorie: "DIENST",
standardLaufzeitTage: null,
zielbenutzer: "",
zielbenutzerAnlegen: false,
sudoModus: "KEINER",
sudoBefehle: [],
authorizedKeysOptionen: "",
sshConfigAlias: "",
knownHostsEintragen: true,
privatNachAusrollenLoeschen: false,
};
/** Beispielwerte der Vorschau – sichtbar andere als die Probewerte des Servers. */
const BEISPIEL: Required<PlatzhalterWerte> = {
quellHost: "srv-build01",
quellHostIp: "10.0.0.5",
zielHost: "srv-web01",
zielHostIp: "10.0.0.20",
};
type Vorschau = { wert: string | null; fehler?: string };
/**
* Die Vorschau ist nur Anzeige (#019): Fehler werden zum Hinweis, nicht zum Absturz – die
* verbindliche Pruefung macht der Server beim Speichern mit denselben Funktionen.
*/
function vorschau(berechnen: () => string | null): Vorschau {
try {
return { wert: berechnen() };
} catch (fehler) {
return { wert: null, fehler: fehler instanceof Error ? fehler.message : String(fehler) };
}
}
function befehlsZeilen(text: string): string[] {
return text
.split("\n")
.map((b) => b.trim())
.filter(Boolean);
}
function Vorschauzeile({ titel, ergebnis, leer }: { titel: string; ergebnis: Vorschau; leer: string }) {
return (
<div className="flex min-w-0 flex-col gap-1">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="min-w-0">
{ergebnis.fehler ? (
<span className="text-danger text-[13.5px]">{ergebnis.fehler}</span>
) : ergebnis.wert ? (
<code className="text-text font-mono text-[13px] break-all">{ergebnis.wert}</code>
) : (
<span className="text-muted text-[13.5px]">{leer}</span>
)}
</dd>
</div>
);
}
/** Formular fuer neue und bestehende Vorlagen (#019). Regeln prueft der Server. */
export function VorlagenFormular({ vorgabe }: { vorgabe: VorlageFormularVorgabe }) {
const bearbeiten = Boolean(vorgabe.id);
const { zustand, absenden, laeuft } = useFormularAktion(vorlageSpeichernAktion);
const f = zustand.feldfehler ?? {};
// Kontrolliert nur, was die Vorschau braucht; der Rest bleibt unkontrolliert.
const [zielbenutzer, setZielbenutzer] = useState(vorgabe.zielbenutzer);
const [sudoModus, setSudoModus] = useState<SudoModus>(vorgabe.sudoModus);
const [befehle, setBefehle] = useState(vorgabe.sudoBefehle.join("\n"));
const [optionen, setOptionen] = useState(vorgabe.authorizedKeysOptionen);
const [alias, setAlias] = useState(vorgabe.sshConfigAlias);
const optionenVorschau = vorschau(() =>
optionen.trim() ? optionenPruefen(platzhalterErsetzen(optionen.trim(), BEISPIEL)) || null : null,
);
const aliasVorschau = vorschau(() => (alias.trim() ? platzhalterErsetzen(alias.trim(), BEISPIEL) : null));
const sudoersVorschau = vorschau(() =>
sudoModus === "KEINER" ? null : sudoersInhalt(zielbenutzer.trim(), sudoModus, befehlsZeilen(befehle)),
);
return (
<form onSubmit={absenden} noValidate className="flex flex-col gap-6">
{vorgabe.id && <input type="hidden" name="id" value={vorgabe.id} />}
<Formularfehler text={zustand.fehler} />
<Karte titel="Allgemein">
<div className="flex flex-col gap-4">
<Feld
beschriftung="Name"
name="name"
defaultValue={vorgabe.name}
required
maxLength={80}
autoComplete="off"
placeholder="z. B. Monitoring-Zugang"
fehler={f["name"]}
/>
<Textbereich
beschriftung="Beschreibung (optional)"
name="beschreibung"
defaultValue={vorgabe.beschreibung}
maxLength={500}
fehler={f["beschreibung"]}
/>
</div>
</Karte>
<Karte titel="Schlüssel">
<div className="flex flex-col gap-5">
<Feldgruppe legende="Typ" fehler={f["schluesselTyp"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="schluesselTyp"
value="ED25519"
defaultChecked={vorgabe.schluesselTyp === "ED25519"}
beschriftung={<span className="font-mono">ed25519</span>}
erklaerung="Empfohlen – kurz, schnell, sicher."
/>
<Ankreuzfeld
type="radio"
name="schluesselTyp"
value="RSA4096"
defaultChecked={vorgabe.schluesselTyp === "RSA4096"}
beschriftung={<span className="font-mono">rsa-4096</span>}
erklaerung="Nur für alte Gegenstellen ohne ed25519."
/>
</div>
</Feldgruppe>
<Feldgruppe legende="Kategorie" fehler={f["kategorie"]}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Ankreuzfeld
type="radio"
name="kategorie"
value="DIENST"
defaultChecked={vorgabe.kategorie === "DIENST"}
beschriftung="Dienst"
erklaerung="Host-zu-Host oder Automatisierung; sshkv verwahrt den Private Key."
/>
<Ankreuzfeld
type="radio"
name="kategorie"
value="PERSON"
defaultChecked={vorgabe.kategorie === "PERSON"}
beschriftung="Person"
erklaerung="Der Private Key bleibt bei der Person; kein Benutzer anlegen, kein sudo."
/>
</div>
</Feldgruppe>
<Feld
beschriftung="Standard-Laufzeit in Tagen (optional)"
name="standardLaufzeitTage"
type="number"
inputMode="numeric"
min={1}
max={3650}
defaultValue={vorgabe.standardLaufzeitTage ?? ""}
hinweis="Leer lassen für Schlüssel ohne Ablaufdatum."
fehler={f["standardLaufzeitTage"]}
className="sm:max-w-xs"
/>
</div>
</Karte>
<Karte titel="Zielkonto" untertitel="Unter welchem Benutzer der Schlüssel auf dem Zielhost landet">
<div className="flex flex-col gap-5">
<Feld
beschriftung="Zielbenutzer"
name="zielbenutzer"
value={zielbenutzer}
onChange={(e) => setZielbenutzer(e.target.value)}
mono
required
maxLength={32}
autoComplete="off"
spellCheck={false}
placeholder="deploy"
hinweis="Kleinbuchstaben, Ziffern, _ und -, höchstens 32 Zeichen."
fehler={f["zielbenutzer"]}
className="sm:max-w-xs"
/>
<Ankreuzfeld
name="zielbenutzerAnlegen"
value="ja"
defaultChecked={vorgabe.zielbenutzerAnlegen}
beschriftung="Benutzer anlegen, falls er fehlt"
erklaerung="sshkv legt ihn beim Ausrollen an und entfernt ihn beim Widerruf wieder."
/>
<Feldgruppe
legende="sudo-Rechte"
hinweis="Nur für Benutzer, die sshkv selbst anlegt."
fehler={f["sudoModus"]}
>
<div className="grid gap-x-4 sm:grid-cols-3">
{(
[
["KEINER", "keine"],
["BEFEHLE", "bestimmte Befehle"],
["VOLL", "voll"],
] as const
).map(([wert, text]) => (
<Ankreuzfeld
key={wert}
type="radio"
name="sudoModus"
value={wert}
checked={sudoModus === wert}
onChange={() => setSudoModus(wert)}
beschriftung={text}
/>
))}
</div>
</Feldgruppe>
{/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */}
<Textbereich
beschriftung="Erlaubte Befehle"
name="sudoBefehle"
value={befehle}
onChange={(e) => setBefehle(e.target.value)}
rows={4}
spellCheck={false}
placeholder={"/usr/bin/systemctl restart nginx\n/usr/bin/systemctl reload nginx"}
hinweis="Ein Befehl je Zeile, mit absolutem Pfad. Argumente sind erlaubt, Platzhalter nicht."
fehler={f["sudoBefehle"]}
className={sudoModus === "BEFEHLE" ? "[&_textarea]:font-mono [&_textarea]:text-[13px]" : "hidden"}
/>
{sudoModus === "VOLL" && (
<p
role="note"
className="border-danger-border bg-danger-soft text-danger rounded-box flex items-start gap-2.5 border px-3 py-2.5 text-[14px]"
>
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "}
<strong className="font-semibold">root</strong>. Nur wählen, wenn bestimmte Befehle nicht
reichen.
</span>
</p>
)}
</div>
</Karte>
<Karte titel="authorized_keys & ssh">
<div className="flex flex-col gap-5">
<Feld
beschriftung="authorized_keys-Optionen (optional)"
name="authorizedKeysOptionen"
value={optionen}
onChange={(e) => setOptionen(e.target.value)}
mono
autoComplete="off"
spellCheck={false}
placeholder={'from="{{quellHostIp}}",no-port-forwarding'}
fehler={f["authorizedKeysOptionen"]}
/>
<Feld
beschriftung="Alias-Muster für ~/.ssh/config (optional)"
name="sshConfigAlias"
value={alias}
onChange={(e) => setAlias(e.target.value)}
mono
autoComplete="off"
spellCheck={false}
placeholder="deploy-{{zielHost}}"
fehler={f["sshConfigAlias"]}
/>
<div className="border-border bg-sidebar rounded-box text-muted border px-4 py-3 text-[13px]">
<p>
Platzhalter:{" "}
{PLATZHALTER.map((p, i) => (
<span key={p}>
{i > 0 && ", "}
<code className="text-text font-mono whitespace-nowrap">{`{{${p}}}`}</code>
</span>
))}
. Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein.
</p>
<p className="mt-1.5">
Beispiel: <code className="text-text font-mono break-all">{'from="{{quellHostIp}}"'}</code> wird
zu <code className="text-text font-mono break-all">{`from="${BEISPIEL.quellHostIp}"`}</code>.
</p>
</div>
<div className="flex flex-col">
<Ankreuzfeld
name="knownHostsEintragen"
value="ja"
defaultChecked={vorgabe.knownHostsEintragen}
beschriftung="Host-Key des Ziels in known_hosts der Quelle eintragen"
/>
<Ankreuzfeld
name="privatNachAusrollenLoeschen"
value="ja"
defaultChecked={vorgabe.privatNachAusrollenLoeschen}
beschriftung="Private Key nach dem Ausrollen in sshkv löschen"
erklaerung="Dann liegt er nur noch auf dem Quellhost und lässt sich nicht erneut ausrollen."
/>
</div>
</div>
</Karte>
<Karte
titel="Vorschau"
untertitel={
<>
Mit Beispielwerten: Quelle <span className="font-mono">{BEISPIEL.quellHost}</span> (
<span className="font-mono">{BEISPIEL.quellHostIp}</span>), Ziel{" "}
<span className="font-mono">{BEISPIEL.zielHost}</span> (
<span className="font-mono">{BEISPIEL.zielHostIp}</span>)
</>
}
>
<div aria-live="polite" className="flex flex-col gap-4">
<dl className="grid gap-4 sm:grid-cols-2">
<Vorschauzeile titel="authorized_keys-Optionen" ergebnis={optionenVorschau} leer="keine" />
<Vorschauzeile titel="Alias" ergebnis={aliasVorschau} leer="kein Eintrag in ~/.ssh/config" />
</dl>
{sudoModus !== "KEINER" &&
(sudoersVorschau.fehler ? (
<p className="text-danger text-[13.5px]">sudoers: {sudoersVorschau.fehler}</p>
) : (
sudoersVorschau.wert && (
<div className="flex min-w-0 flex-col gap-1.5">
<p className="text-muted text-[13px] font-medium">
<span className="font-mono">/etc/sudoers.d/sshkv-{zielbenutzer.trim()}</span>
</p>
{/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine
zweite Regel vor (#019). Der Block scrollt dann fuer sich. */}
<MonoBlock wert={sudoersVorschau.wert} beschriftung="sudoers-Inhalt" />
</div>
)
))}
</div>
</Karte>
<div className="flex flex-wrap gap-2.5">
<Button type="submit" variante="primaer" disabled={laeuft}>
{laeuft ? "Speichere …" : bearbeiten ? "Änderungen speichern" : "Vorlage anlegen"}
</Button>
<Button href="/vorlagen">Abbrechen</Button>
</div>
</form>
);
}