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
+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>
);
}
+129 -10
View File
@@ -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 (
<Platzhalterseite
titel="Vorlagen"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup"
phase={3}
issue="#019"
/>
<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"
untertitel="Wiederkehrende Zugänge wie Deployment oder Backup – sie füllen den Assistenten vor."
aktionen={anlegen}
/>
{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>
);
}