From 5c42f8dd8d6c03450d28f6fb2facf25ee2126b82 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Mon, 12 Oct 2026 11:16:31 +0200 Subject: [PATCH] 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 --- docs/issues/019-vorlagen.md | 19 +- src/app/(app)/vorlagen/[id]/page.tsx | 54 +++ src/app/(app)/vorlagen/neu/page.tsx | 22 + src/app/(app)/vorlagen/page.tsx | 139 ++++++- .../vorlagen/VorlageArchivierenDialog.tsx | 48 +++ src/components/vorlagen/VorlagenFormular.tsx | 377 ++++++++++++++++++ src/server/queries/vorlagen.ts | 40 ++ 7 files changed, 686 insertions(+), 13 deletions(-) create mode 100644 src/app/(app)/vorlagen/[id]/page.tsx create mode 100644 src/app/(app)/vorlagen/neu/page.tsx create mode 100644 src/components/vorlagen/VorlageArchivierenDialog.tsx create mode 100644 src/components/vorlagen/VorlagenFormular.tsx create mode 100644 src/server/queries/vorlagen.ts diff --git a/docs/issues/019-vorlagen.md b/docs/issues/019-vorlagen.md index 1b3f521..82874d8 100644 --- a/docs/issues/019-vorlagen.md +++ b/docs/issues/019-vorlagen.md @@ -1,5 +1,5 @@ # #019 Vorlagen -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.3.0 **Labels:** backend, ui **Geschätzt:** 2 h **Tatsächlich:** – @@ -8,12 +8,25 @@ Eingebaute Vorlagen „Deployment-Zugang“, „Backup-Zugang“, „Admin-Zugang Person“; eigene in den Einstellungen. ## 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 -- [ ] Vorlage füllt Assistent vor +- [ ] Vorlage füllt Assistent vor (folgt mit dem Assistenten, #020) ## Abhängigkeiten #017, #018 ## 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. diff --git a/src/app/(app)/vorlagen/[id]/page.tsx b/src/app/(app)/vorlagen/[id]/page.tsx new file mode 100644 index 0000000..e83f3e4 --- /dev/null +++ b/src/app/(app)/vorlagen/[id]/page.tsx @@ -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 ( +
+ } + titel={`${v.name} bearbeiten`} + badges={v.eingebaut ? eingebaut : undefined} + untertitel={ + v.eingebaut + ? "Eingebaute Vorlagen lassen sich anpassen, aber nicht archivieren. Ein späterer Seed überschreibt Ihre Änderungen nicht." + : undefined + } + aktionen={!v.eingebaut && } + /> + +
+ ); +} diff --git a/src/app/(app)/vorlagen/neu/page.tsx b/src/app/(app)/vorlagen/neu/page.tsx new file mode 100644 index 0000000..de32f85 --- /dev/null +++ b/src/app/(app)/vorlagen/neu/page.tsx @@ -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 ( +
+ } + titel="Vorlage anlegen" + untertitel="Eine Vorlage füllt den Assistenten vor; im Einzelfall lässt sich alles noch ändern." + /> + +
+ ); +} diff --git a/src/app/(app)/vorlagen/page.tsx b/src/app/(app)/vorlagen/page.tsx index e7b9e27..e7be395 100644 --- a/src/app/(app)/vorlagen/page.tsx +++ b/src/app/(app)/vorlagen/page.tsx @@ -1,18 +1,137 @@ +import { FileStack, Pencil, Plus } from "lucide-react"; 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 { vorlagenListe, type VorlagenListenEintrag } from "@/server/queries/vorlagen"; export const metadata: Metadata = { title: "Vorlagen" }; -export default async function Vorlagen() { - // Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md). - await sitzungErforderlich(); +const SUDO_TEXT = { KEINER: null, BEFEHLE: "sudo: bestimmte Befehle", VOLL: "sudo: voll" } as const; + +function Angabe({ titel, children }: { titel: string; children: ReactNode }) { return ( - +
+
{titel}
+
{children}
+
+ ); +} + +function Mono({ wert }: { wert: string | null }) { + if (!wert) return –; + // Lange Optionslisten sollen auf 390 px nach einem Komma umbrechen, nicht mitten im Wort (#019). + const teile = wert.split(","); + return ( + + {teile.map((teil, i) => ( + + {teil} + {i < teile.length - 1 && ( + <> + , + + )} + + ))} + + ); +} + +function VorlagenKarte({ v, verwalten }: { v: VorlagenListenEintrag; verwalten: boolean }) { + const sudo = SUDO_TEXT[v.sudoModus]; + return ( + } + aria-label={`${v.name} bearbeiten`} + > + Bearbeiten + + ) + } + className="flex flex-col" + > +
+ + {v.schluesselTyp === "RSA4096" ? "rsa-4096" : "ed25519"} + {v.eingebaut && eingebaut} + {v.standardLaufzeitTage && ( + + {v.standardLaufzeitTage === 1 ? "1 Tag" : `${v.standardLaufzeitTage} Tage`} + + )} +
+ {v.beschreibung &&

{v.beschreibung}

} +
+ + + + {v.zielbenutzerAnlegen && wird angelegt} + {sudo && ( + + {sudo} + {v.sudoModus === "BEFEHLE" && ` (${v.sudoBefehle.length})`} + + )} + + + + + + + + +
+
+ ); +} + +/** 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 && ( + + ); + + return ( +
+ + {vorlagen.length === 0 ? ( + } + titel="Keine Vorlagen" + text="Die eingebauten Vorlagen legt der Seed an (pnpm db:seed)." + aktion={anlegen} + /> + ) : ( +
+ {vorlagen.map((v) => ( + + ))} +
+ )} +
); } diff --git a/src/components/vorlagen/VorlageArchivierenDialog.tsx b/src/components/vorlagen/VorlageArchivierenDialog.tsx new file mode 100644 index 0000000..d1a4649 --- /dev/null +++ b/src/components/vorlagen/VorlageArchivierenDialog.tsx @@ -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(null); + const [zustand, aktion, laeuft] = useActionState(vorlageArchivierenAktion, {}); + + return ( + <> + + +
+ +

+ „{name}“ archivieren? +

+

+ Die Vorlage steht im Assistenten nicht mehr zur Auswahl. Schlüssel, die mit ihr angelegt wurden, + bleiben unverändert; das Protokoll bleibt erhalten. +

+ +
+ + +
+ +
+ + ); +} diff --git a/src/components/vorlagen/VorlagenFormular.tsx b/src/components/vorlagen/VorlagenFormular.tsx new file mode 100644 index 0000000..d278346 --- /dev/null +++ b/src/components/vorlagen/VorlagenFormular.tsx @@ -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 = { + 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 ( +
+
{titel}
+
+ {ergebnis.fehler ? ( + {ergebnis.fehler} + ) : ergebnis.wert ? ( + {ergebnis.wert} + ) : ( + {leer} + )} +
+
+ ); +} + +/** 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(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 ( +
+ {vorgabe.id && } + + + +
+ + +
+
+ + +
+ +
+ ed25519} + erklaerung="Empfohlen – kurz, schnell, sicher." + /> + rsa-4096} + erklaerung="Nur für alte Gegenstellen ohne ed25519." + /> +
+
+ +
+ + +
+
+ +
+
+ + +
+ 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" + /> + + +
+ {( + [ + ["KEINER", "keine"], + ["BEFEHLE", "bestimmte Befehle"], + ["VOLL", "voll"], + ] as const + ).map(([wert, text]) => ( + setSudoModus(wert)} + beschriftung={text} + /> + ))} +
+
+ {/* Ausgeblendet statt entfernt, damit die Befehle beim Hin- und Herschalten bleiben. */} + 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" && ( +

+ + + Voller sudo-Zugang ohne Passwort: Wer diesen Schlüssel hat, ist auf dem Zielhost faktisch{" "} + root. Nur wählen, wenn bestimmte Befehle nicht + reichen. + +

+ )} +
+
+ + +
+ setOptionen(e.target.value)} + mono + autoComplete="off" + spellCheck={false} + placeholder={'from="{{quellHostIp}}",no-port-forwarding'} + fehler={f["authorizedKeysOptionen"]} + /> + setAlias(e.target.value)} + mono + autoComplete="off" + spellCheck={false} + placeholder="deploy-{{zielHost}}" + fehler={f["sshConfigAlias"]} + /> +
+

+ Platzhalter:{" "} + {PLATZHALTER.map((p, i) => ( + + {i > 0 && ", "} + {`{{${p}}}`} + + ))} + . Beim Ausrollen setzt sshkv Name und IP von Quell- und Zielhost ein. +

+

+ Beispiel: {'from="{{quellHostIp}}"'} wird + zu {`from="${BEISPIEL.quellHostIp}"`}. +

+
+
+ + +
+
+
+ + + Mit Beispielwerten: Quelle {BEISPIEL.quellHost} ( + {BEISPIEL.quellHostIp}), Ziel{" "} + {BEISPIEL.zielHost} ( + {BEISPIEL.zielHostIp}) + + } + > +
+
+ + +
+ {sudoModus !== "KEINER" && + (sudoersVorschau.fehler ? ( +

sudoers: {sudoersVorschau.fehler}

+ ) : ( + sudoersVorschau.wert && ( +
+

+ /etc/sudoers.d/sshkv-{zielbenutzer.trim()} +

+ {/* Ohne Umbruch: sudoers ist zeilenbasiert, ein optischer Umbruch taeuschte eine + zweite Regel vor (#019). Der Block scrollt dann fuer sich. */} + +
+ ) + ))} +
+
+ +
+ + +
+ + ); +} diff --git a/src/server/queries/vorlagen.ts b/src/server/queries/vorlagen.ts new file mode 100644 index 0000000..bcc7603 --- /dev/null +++ b/src/server/queries/vorlagen.ts @@ -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>[number]; + +export async function vorlageDetail(id: string) { + return prisma.vorlage.findUnique({ where: { id }, select: AUSWAHL }); +}