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
+16 -3
View File
@@ -1,5 +1,5 @@
# #019 Vorlagen # #019 Vorlagen
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.3.0 **Meilenstein:** v0.3.0
**Labels:** backend, ui **Labels:** backend, ui
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** –
@@ -8,12 +8,25 @@
Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen. Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen.
## Aufgaben ## Aufgaben
- [ ] Platzhalter `{{quellHostIp}}` usw. - [x] Platzhalter `{{quellHostIp}}` usw.
- [x] Oberfläche: Liste `/vorlagen` (alle lesen), Anlegen/Bearbeiten/Archivieren nur Admin
- [x] Live-Vorschau der aufgelösten Optionen, des Alias und der sudoers-Datei
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Vorlage füllt Assistent vor - [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020)
## Abhängigkeiten ## Abhängigkeiten
#017, #018 #017, #018
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Vorlagen pflegt nur, wer `einstellungenVerwalten` darf (Admin); lesen darf jede Rolle, weil
Vorlagen keine Geheimnisse enthalten und Operatoren sie im Assistenten sehen.
- Die Vorschau im Formular ruft dieselben reinen Funktionen wie der Server
(`platzhalterErsetzen`, `optionenPruefen`, `sudoersInhalt`) mit eigenen Beispielwerten auf;
Fehler werden dort nur angezeigt, verbindlich prüft der Server beim Speichern.
- Das Befehlsfeld wird bei anderem sudo-Modus nur ausgeblendet, nicht entfernt – so gehen
eingegebene Befehle beim Umschalten nicht verloren; der Service verwirft sie ohnehin.
- Die sudoers-Vorschau bricht nicht um: sudoers ist zeilenbasiert, ein optischer Umbruch sähe
wie eine zweite Regel aus.
- Eingebaute Vorlagen lassen sich bearbeiten, aber nicht archivieren; ihre Bearbeiten-Seite
zeigt deshalb keinen Archivieren-Knopf.
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { VorlageArchivierenDialog } from "@/components/vorlagen/VorlageArchivierenDialog";
import { VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlageDetail } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlage bearbeiten" };
export default async function VorlageBearbeiten({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
await sitzungErforderlich("einstellungenVerwalten", `/vorlagen/${id}`);
const v = await vorlageDetail(id);
// Archivierte Vorlagen sind fuer die Oberflaeche weg; das Protokoll kennt sie noch (#019).
if (!v || v.archiviertAm) notFound();
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: v.name }]} />}
titel={`${v.name} bearbeiten`}
badges={v.eingebaut ? <Badge>eingebaut</Badge> : undefined}
untertitel={
v.eingebaut
? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht."
: undefined
}
aktionen={!v.eingebaut && <VorlageArchivierenDialog vorlageId={v.id} name={v.name} />}
/>
<VorlagenFormular
vorgabe={{
id: v.id,
name: v.name,
beschreibung: v.beschreibung ?? "",
// Das Formular kennt nur, was Vorlagen erzeugen; ANDERER/DEPLOYMENT kommen hier nicht vor.
schluesselTyp: v.schluesselTyp === "RSA4096" ? "RSA4096" : "ED25519",
kategorie: v.kategorie === "PERSON" ? "PERSON" : "DIENST",
standardLaufzeitTage: v.standardLaufzeitTage,
zielbenutzer: v.zielbenutzer,
zielbenutzerAnlegen: v.zielbenutzerAnlegen,
sudoModus: v.sudoModus,
sudoBefehle: v.sudoBefehle,
authorizedKeysOptionen: v.authorizedKeysOptionen ?? "",
sshConfigAlias: v.sshConfigAlias ?? "",
knownHostsEintragen: v.knownHostsEintragen,
privatNachAusrollenLoeschen: v.privatNachAusrollenLoeschen,
}}
/>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { LEERE_VORLAGE, VorlagenFormular } from "@/components/vorlagen/VorlagenFormular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
export const metadata: Metadata = { title: "Vorlage anlegen" };
export default async function VorlageNeu() {
// Vorlagen pflegen nur Admins (#019) – wie die uebrigen Einstellungen.
await sitzungErforderlich("einstellungenVerwalten", "/vorlagen/neu");
return (
<div className="flex max-w-3xl flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Vorlagen", href: "/vorlagen" }, { titel: "Neue Vorlage" }]} />}
titel="Vorlage anlegen"
untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern."
/>
<VorlagenFormular vorgabe={LEERE_VORLAGE} />
</div>
);
}
+127 -8
View File
@@ -1,18 +1,137 @@
import { FileStack, Pencil, Plus } from "lucide-react";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Platzhalterseite } from "@/components/huelle/Platzhalterseite"; import { Fragment, type ReactNode } from "react";
import { KategorieBadge } from "@/components/schluessel/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen";
export const metadata: Metadata = { title: "Vorlagen" }; export const metadata: Metadata = { title: "Vorlagen" };
export default async function Vorlagen() { const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const;
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
await sitzungErforderlich(); function Angabe({ titel, children }: { titel: string; children: ReactNode }) {
return ( return (
<Platzhalterseite <div className="flex min-w-0 flex-col gap-0.5">
<dt className="text-muted text-[12.5px] font-medium">{titel}</dt>
<dd className="min-w-0 text-[14px]">{children}</dd>
</div>
);
}
function Mono({ wert }: { wert: string | null }) {
if (!wert) return <span className="text-muted">–</span>;
// Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019).
const teile = wert.split(",");
return (
<code className="text-text font-mono text-[13px] [overflow-wrap:anywhere]">
{teile.map((teil, i) => (
<Fragment key={i}>
{teil}
{i < teile.length - 1 && (
<>
,<wbr />
</>
)}
</Fragment>
))}
</code>
);
}
function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) {
const sudo = SUDO_TEXT[v.sudoModus];
return (
<Karte
as="article"
titel={v.name}
aktionen={
verwalten && (
<Button
href={`/vorlagen/${v.id}`}
klein
icon={<Pencil className="size-3.5" />}
aria-label={`${v.name} bearbeiten`}
>
Bearbeiten
</Button>
)
}
className="flex flex-col"
>
<div className="-mt-2 mb-4 flex flex-wrap items-center gap-1.5">
<KategorieBadge kategorie={v.kategorie} />
<Badge mono>{v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"}</Badge>
{v.eingebaut && <Badge>eingebaut</Badge>}
{v.standardLaufzeitTage && (
<Badge variante="messing">
{v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`}
</Badge>
)}
</div>
{v.beschreibung && <p className="text-muted mb-4 text-[14px]">{v.beschreibung}</p>}
<dl className="border-border mt-auto grid gap-3 border-t pt-4">
<Angabe titel="Zielbenutzer">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<Mono wert={v.zielbenutzer} />
{v.zielbenutzerAnlegen && <span className="text-muted text-[13px]">wird angelegt</span>}
{sudo && (
<Badge variante={v.sudoModus === "VOLL" ? "gefahr" : "neutral"}>
{sudo}
{v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`}
</Badge>
)}
</span>
</Angabe>
<Angabe titel="authorized_keys-Optionen">
<Mono wert={v.authorizedKeysOptionen} />
</Angabe>
<Angabe titel="Alias-Muster">
<Mono wert={v.sshConfigAlias} />
</Angabe>
</dl>
</Karte>
);
}
/** Vorlagen (#019): lesen darf jeder, anlegen und bearbeiten nur, wer Einstellungen verwaltet. */
export default async function Vorlagen() {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/vorlagen");
const vorlagen = await vorlagenListe();
const verwalten = darf(benutzer.rolle, "einstellungenVerwalten");
const anlegen = verwalten && (
<Button href="/vorlagen/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Vorlage anlegen
</Button>
);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Vorlagen" titel="Vorlagen"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup" untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
phase={3} aktionen={anlegen}
issue="#019"
/> />
{vorlagen.length === 0 ? (
<Leerzustand
icon={<FileStack className="size-5" />}
titel="Keine Vorlagen"
text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)."
aktion={anlegen}
/>
) : (
<div className="grid gap-4 lg:grid-cols-2 2xl:grid-cols-3">
{vorlagen.map((v) => (
<VorlagenKarte key={v.id} v={v} verwalten={verwalten} />
))}
</div>
)}
</div>
); );
} }
@@ -0,0 +1,48 @@
"use client";
import { Archive } from "lucide-react";
import { useActionState, useRef } from "react";
import { Button, Formularfehler } from "@/components/ui";
import type { FormularZustand } from "@/server/actions/formular";
import { vorlageArchivierenAktion } from "@/server/actions/vorlagen";
/** Archivieren mit Rueckfrage (#019), gleiches Muster wie bei Hosts (#008). */
export function VorlageArchivierenDialog({ vorlageId, name }: { vorlageId: string; name: string }) {
const dialog = useRef<HTMLDialogElement>(null);
const [zustand, aktion, laeuft] = useActionState<FormularZustand, FormData>(vorlageArchivierenAktion, {});
return (
<>
<Button
variante="gefahr"
icon={<Archive className="size-4" />}
onClick={() => dialog.current?.showModal()}
>
Vorlage archivieren
</Button>
<dialog
ref={dialog}
aria-labelledby="archivieren-titel"
className="border-border-strong bg-surface text-text rounded-karte m-auto w-[calc(100%-32px)] max-w-md border p-5 backdrop:bg-black/70 sm:p-6"
>
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="id" value={vorlageId} />
<h2 id="archivieren-titel" className="text-[17px] font-semibold">
„{name}“ archivieren?
</h2>
<p className="text-muted text-[14.5px]">
Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden,
bleiben unverändert; das Protokoll bleibt erhalten.
</p>
<Formularfehler text={zustand.fehler} />
<div className="flex flex-wrap justify-end gap-2.5">
<Button onClick={() => dialog.current?.close()}>Abbrechen</Button>
<Button type="submit" variante="gefahr" disabled={laeuft}>
{laeuft ? "Archiviere …" : "Archivieren"}
</Button>
</div>
</form>
</dialog>
</>
);
}
@@ -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>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { prisma } from "@/lib/db";
/**
* Lesemodelle fuer Vorlagen (#019). Vorlagen enthalten keine Geheimnisse – jede angemeldete
* Rolle darf sie sehen; die Seite blendet nur die Bearbeiten-Knoepfe aus.
*/
const AUSWAHL = {
id: true,
name: true,
beschreibung: true,
eingebaut: true,
schluesselTyp: true,
kategorie: true,
standardLaufzeitTage: true,
zielbenutzer: true,
zielbenutzerAnlegen: true,
sudoModus: true,
sudoBefehle: true,
authorizedKeysOptionen: true,
sshConfigAlias: true,
knownHostsEintragen: true,
privatNachAusrollenLoeschen: true,
archiviertAm: true,
} as const;
/** Eingebaute zuerst in Seed-Reihenfolge – sie sind der Normalfall, eigene die Ausnahme. */
export async function vorlagenListe() {
return prisma.vorlage.findMany({
where: { archiviertAm: null },
orderBy: [{ eingebaut: "desc" }, { angelegtAm: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
}
export type VorlagenListenEintrag = Awaited<ReturnType<typeof vorlagenListe>>[number];
export async function vorlageDetail(id: string) {
return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL });
}