"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. */}
) ))}
); }