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