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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user