Merge branch 'phase/018-020-assistent'
This commit is contained in:
@@ -0,0 +1,637 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Check, ExternalLink, Play } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||||
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||||
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||||
import { istIpAdresse, platzhalterErsetzen } from "@/lib/vorlagen";
|
||||
import { sudoersInhalt } from "@/lib/ssh/zielkonto";
|
||||
import {
|
||||
assistentErzeugenAktion,
|
||||
assistentZielkontoAktion,
|
||||
assistentZuweisenAktion,
|
||||
type SchrittAntwort,
|
||||
} from "@/server/actions/assistent";
|
||||
import { verbindungTestenAktion } from "@/server/actions/verwendungen";
|
||||
import type { AssistentDaten } from "@/server/queries/assistent";
|
||||
|
||||
/**
|
||||
* Host-zu-Host-Assistent (#020). Vier Schritte Eingabe, dann eine Zusammenfassung, dann die
|
||||
* Ausfuehrung: Schluessel erzeugen → (Zielbenutzer anlegen) → zuweisen → Dry-Run und Ausrollen →
|
||||
* Verbindungstest. Die Ausfuehrung ist wiederaufnehmbar: was schon erledigt ist, wird nicht
|
||||
* wiederholt – wichtig, wenn mittendrin eine erneute Bestaetigung noetig wird.
|
||||
*/
|
||||
type Werte = {
|
||||
vorlageId: string;
|
||||
zweck: string;
|
||||
ablaufAm: string;
|
||||
quelleHostId: string;
|
||||
quelleBenutzer: string;
|
||||
pfad: string;
|
||||
zielHostId: string;
|
||||
zielBenutzer: string;
|
||||
anlegen: boolean;
|
||||
sudoModus: "KEINER" | "BEFEHLE" | "VOLL";
|
||||
sudoBefehle: string;
|
||||
optionen: string;
|
||||
quellIp: string;
|
||||
alias: string;
|
||||
hostName: string;
|
||||
port: string;
|
||||
knownHosts: boolean;
|
||||
fingerprintGeprueft: boolean;
|
||||
};
|
||||
|
||||
const LEER: Werte = {
|
||||
vorlageId: "",
|
||||
zweck: "",
|
||||
ablaufAm: "",
|
||||
quelleHostId: "",
|
||||
quelleBenutzer: "root",
|
||||
pfad: "",
|
||||
zielHostId: "",
|
||||
zielBenutzer: "root",
|
||||
anlegen: false,
|
||||
sudoModus: "KEINER",
|
||||
sudoBefehle: "",
|
||||
optionen: "",
|
||||
quellIp: "",
|
||||
alias: "",
|
||||
hostName: "",
|
||||
port: "22",
|
||||
knownHosts: true,
|
||||
fingerprintGeprueft: false,
|
||||
};
|
||||
|
||||
const SCHRITTE = ["Vorlage & Zweck", "Quelle (Host A)", "Ziel (Host B)", "Prüfen", "Ausrollen"] as const;
|
||||
|
||||
type Ablauf = {
|
||||
schluesselId?: string;
|
||||
zielkontoFertig?: boolean;
|
||||
privatVerwendungId?: string;
|
||||
laeuft?: string;
|
||||
fehler?: string;
|
||||
bestaetigen?: boolean;
|
||||
};
|
||||
|
||||
function datumIn(tage: number): string {
|
||||
return new Date(Date.now() + tage * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function kurz(text: string): string {
|
||||
return (
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 40) || "zugang"
|
||||
);
|
||||
}
|
||||
|
||||
/** Wartet auf das Ende eines Vorgangs (fuer die Kette im Klick, nicht als Hook). */
|
||||
async function warten(
|
||||
vorgangId: string,
|
||||
): Promise<{ status: string; fehler: string | null; ergebnis: unknown }> {
|
||||
for (;;) {
|
||||
const antwort = await fetch(`/api/vorgaenge/${vorgangId}`, { cache: "no-store" });
|
||||
if (!antwort.ok) throw new Error("Status des Vorgangs nicht abrufbar.");
|
||||
const stand = (await antwort.json()) as { status: string; fehler: string | null; ergebnis: unknown };
|
||||
if (stand.status !== "WARTEND" && stand.status !== "LAEUFT") return stand;
|
||||
await new Promise((weiter) => setTimeout(weiter, 800));
|
||||
}
|
||||
}
|
||||
|
||||
export function Assistent({ daten, bestaetigt }: { daten: AssistentDaten; bestaetigt: boolean }) {
|
||||
const [schritt, setSchritt] = useState(0);
|
||||
const [w, setW] = useState<Werte>(LEER);
|
||||
const [ablauf, setAblauf] = useState<Ablauf>({});
|
||||
const setze = <K extends keyof Werte>(feld: K, wert: Werte[K]) => setW((alt) => ({ ...alt, [feld]: wert }));
|
||||
|
||||
const vorlage = daten.vorlagen.find((v) => v.id === w.vorlageId);
|
||||
const quelle = daten.hosts.find((h) => h.id === w.quelleHostId);
|
||||
const ziel = daten.hosts.find((h) => h.id === w.zielHostId);
|
||||
|
||||
function vorlageWaehlen(id: string) {
|
||||
const v = daten.vorlagen.find((x) => x.id === id);
|
||||
setW((alt) => ({
|
||||
...alt,
|
||||
vorlageId: id,
|
||||
...(v
|
||||
? {
|
||||
zielBenutzer: v.zielbenutzer,
|
||||
anlegen: v.zielbenutzerAnlegen,
|
||||
sudoModus: v.sudoModus,
|
||||
sudoBefehle: v.sudoBefehle.join("\n"),
|
||||
knownHosts: v.knownHostsEintragen,
|
||||
ablaufAm: v.standardLaufzeitTage ? datumIn(v.standardLaufzeitTage) : "",
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Beim Wechsel zu „Ziel“: Muster der Vorlage mit A und B aufloesen (nur leere Felder). */
|
||||
function zielVorbelegen(zielId: string) {
|
||||
const b = daten.hosts.find((h) => h.id === zielId);
|
||||
const quellIp = w.quellIp || (quelle && istIpAdresse(quelle.adresse) ? quelle.adresse : "");
|
||||
const werte = {
|
||||
quellHost: quelle?.name,
|
||||
quellHostIp: quellIp,
|
||||
zielHost: b?.name,
|
||||
zielHostIp: b?.adresse,
|
||||
};
|
||||
const aufloesen = (muster: string | null | undefined) => {
|
||||
if (!muster) return "";
|
||||
try {
|
||||
return platzhalterErsetzen(muster, werte);
|
||||
} catch {
|
||||
return muster;
|
||||
}
|
||||
};
|
||||
setW((alt) => ({
|
||||
...alt,
|
||||
zielHostId: zielId,
|
||||
quellIp,
|
||||
optionen: alt.optionen || aufloesen(vorlage?.authorizedKeysOptionen),
|
||||
alias: alt.alias || aufloesen(vorlage?.sshConfigAlias) || (b ? b.name : ""),
|
||||
hostName: b ? b.adresse : "",
|
||||
port: b ? String(b.port) : "22",
|
||||
pfad: alt.pfad || `~/.ssh/id_ed25519_${kurz(alt.zweck)}`,
|
||||
fingerprintGeprueft: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const offenePlatzhalter = /\{\{/.test(w.optionen) || /\{\{/.test(w.alias);
|
||||
let sudoVorschau: string | null = null;
|
||||
let sudoFehler: string | undefined;
|
||||
if (w.anlegen) {
|
||||
try {
|
||||
sudoVorschau = sudoersInhalt(
|
||||
w.zielBenutzer,
|
||||
w.sudoModus,
|
||||
w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||
);
|
||||
} catch (fehler) {
|
||||
sudoFehler = (fehler as Error).message;
|
||||
}
|
||||
}
|
||||
|
||||
const schrittOk = [
|
||||
w.zweck.trim().length > 0,
|
||||
Boolean(w.quelleHostId && w.quelleBenutzer),
|
||||
Boolean(w.zielHostId && w.zielHostId !== w.quelleHostId && w.zielBenutzer && w.alias && w.hostName) &&
|
||||
!offenePlatzhalter &&
|
||||
!sudoFehler &&
|
||||
(!w.knownHosts || w.fingerprintGeprueft),
|
||||
true,
|
||||
];
|
||||
|
||||
async function ausfuehren() {
|
||||
let stand = { ...ablauf, fehler: undefined, bestaetigen: false } as Ablauf;
|
||||
const melden = (neu: Partial<Ablauf>) => {
|
||||
stand = { ...stand, ...neu };
|
||||
setAblauf(stand);
|
||||
};
|
||||
const pruefen = (antwort: SchrittAntwort) => {
|
||||
if (antwort.fehler || antwort.bestaetigen) {
|
||||
melden({ laeuft: undefined, fehler: antwort.fehler, bestaetigen: Boolean(antwort.bestaetigen) });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
if (!stand.schluesselId) {
|
||||
melden({ laeuft: "Schlüsselpaar wird erzeugt …" });
|
||||
const antwort = await assistentErzeugenAktion({
|
||||
name: w.zweck.trim(),
|
||||
vorlageId: w.vorlageId || null,
|
||||
ablaufAm: w.ablaufAm || null,
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
const ergebnis = await warten(antwort.vorgangId!);
|
||||
if (ergebnis.status !== "ERLEDIGT")
|
||||
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Erzeugen fehlgeschlagen." });
|
||||
melden({ schluesselId: (ergebnis.ergebnis as { schluesselId: string }).schluesselId });
|
||||
}
|
||||
if (w.anlegen && !stand.zielkontoFertig) {
|
||||
melden({ laeuft: `Benutzer ${w.zielBenutzer} wird auf ${ziel?.name} eingerichtet …` });
|
||||
const antwort = await assistentZielkontoAktion({
|
||||
hostId: w.zielHostId,
|
||||
benutzer: w.zielBenutzer,
|
||||
modus: w.sudoModus,
|
||||
befehle: w.sudoBefehle.split("\n").filter((z) => z.trim()),
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
const ergebnis = await warten(antwort.vorgangId!);
|
||||
if (ergebnis.status !== "ERLEDIGT")
|
||||
return melden({ laeuft: undefined, fehler: ergebnis.fehler ?? "Zielkonto fehlgeschlagen." });
|
||||
melden({ zielkontoFertig: true });
|
||||
}
|
||||
if (!stand.privatVerwendungId) {
|
||||
melden({ laeuft: "Zuweisungen werden angelegt …" });
|
||||
const antwort = await assistentZuweisenAktion({
|
||||
schluesselId: stand.schluesselId!,
|
||||
quelle: { hostId: w.quelleHostId, benutzer: w.quelleBenutzer, pfad: w.pfad },
|
||||
ziel: { hostId: w.zielHostId, benutzer: w.zielBenutzer, optionen: w.optionen.trim() || null },
|
||||
ssh: { alias: w.alias, hostName: w.hostName, port: Number(w.port), knownHosts: w.knownHosts },
|
||||
});
|
||||
if (!pruefen(antwort)) return;
|
||||
melden({ privatVerwendungId: antwort.privatVerwendungId });
|
||||
}
|
||||
melden({ laeuft: undefined });
|
||||
setSchritt(4);
|
||||
} catch (fehler) {
|
||||
melden({ laeuft: undefined, fehler: (fehler as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ol className="flex flex-wrap gap-2" aria-label="Schritte">
|
||||
{SCHRITTE.map((titel, index) => (
|
||||
<li
|
||||
key={titel}
|
||||
aria-current={index === schritt ? "step" : undefined}
|
||||
className={clsx(
|
||||
"rounded-box flex min-h-11 items-center gap-2 border px-3 text-[13.5px]",
|
||||
index === schritt
|
||||
? "border-accent text-accent bg-raised font-medium"
|
||||
: index < schritt
|
||||
? "border-border text-text"
|
||||
: "border-border text-muted",
|
||||
)}
|
||||
>
|
||||
<span className="font-mono">
|
||||
{index < schritt ? <Check className="size-4" aria-hidden /> : index + 1}
|
||||
</span>
|
||||
{titel}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{schritt === 0 && (
|
||||
<Karte
|
||||
titel="Vorlage & Zweck"
|
||||
untertitel="Eine Vorlage füllt Zielbenutzer, Einschränkungen und Ablauf vor."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Vorlage"
|
||||
name="vorlageId"
|
||||
value={w.vorlageId}
|
||||
onChange={(e) => vorlageWaehlen(e.target.value)}
|
||||
>
|
||||
<option value="">– ohne Vorlage –</option>
|
||||
{daten.vorlagen.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
{vorlage?.beschreibung && <p className="text-muted text-[14px]">{vorlage.beschreibung}</p>}
|
||||
<Feld
|
||||
beschriftung="Zweck"
|
||||
name="zweck"
|
||||
value={w.zweck}
|
||||
onChange={(e) => setze("zweck", e.target.value)}
|
||||
placeholder="Backup srv-backup01 → srv-pve01"
|
||||
required
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Ablaufdatum (optional)"
|
||||
name="ablaufAm"
|
||||
type="date"
|
||||
value={w.ablaufAm}
|
||||
onChange={(e) => setze("ablaufAm", e.target.value)}
|
||||
hinweis="Projektbezogene Zugänge sollten ablaufen."
|
||||
/>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 1 && (
|
||||
<Karte
|
||||
titel="Quelle – Host A"
|
||||
untertitel="Dieser Host bekommt den Private Key und meldet sich später an."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Host A"
|
||||
name="quelleHostId"
|
||||
value={w.quelleHostId}
|
||||
onChange={(e) => setze("quelleHostId", e.target.value)}
|
||||
>
|
||||
<option value="">Host wählen …</option>
|
||||
{daten.hosts.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Feld
|
||||
beschriftung="Benutzer auf A"
|
||||
name="quelleBenutzer"
|
||||
value={w.quelleBenutzer}
|
||||
onChange={(e) => setze("quelleBenutzer", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Pfad des Private Keys"
|
||||
name="pfad"
|
||||
value={w.pfad || `~/.ssh/id_ed25519_${kurz(w.zweck)}`}
|
||||
onChange={(e) => setze("pfad", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 2 && (
|
||||
<Karte titel="Ziel – Host B" untertitel="Dieser Host nimmt den Public Key an.">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Auswahlliste
|
||||
beschriftung="Host B"
|
||||
name="zielHostId"
|
||||
value={w.zielHostId}
|
||||
onChange={(e) => zielVorbelegen(e.target.value)}
|
||||
>
|
||||
<option value="">Host wählen …</option>
|
||||
{daten.hosts
|
||||
.filter((h) => h.id !== w.quelleHostId)
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name}
|
||||
</option>
|
||||
))}
|
||||
</Auswahlliste>
|
||||
<Feld
|
||||
beschriftung="Benutzer auf B"
|
||||
name="zielBenutzer"
|
||||
value={w.zielBenutzer}
|
||||
onChange={(e) => setze("zielBenutzer", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
{w.zielBenutzer === "root" && (
|
||||
<p className="border-danger-border bg-danger-soft text-danger rounded-box border px-3 py-2.5 text-[13.5px]">
|
||||
▲ Zugang als root: Wer den Private Key auf A nutzt – etwa ein KI-Agent wie Claude Code –, hat
|
||||
auf B uneingeschränkten Zugriff. Für Deployments ist ein eigener Benutzer wie „deploy“ besser.
|
||||
</p>
|
||||
)}
|
||||
<Ankreuzfeld
|
||||
beschriftung={`Benutzer „${w.zielBenutzer}“ auf B anlegen bzw. pflegen`}
|
||||
erklaerung="Mit Home und ~/.ssh; optional mit sudoers-Datei. Verlangt eine frische Bestätigung."
|
||||
checked={w.anlegen}
|
||||
onChange={(e) => setze("anlegen", e.target.checked)}
|
||||
/>
|
||||
{w.anlegen && (
|
||||
<div className="border-border flex flex-col gap-3 border-l-2 pl-4">
|
||||
<Auswahlliste
|
||||
beschriftung="sudo"
|
||||
name="sudoModus"
|
||||
value={w.sudoModus}
|
||||
onChange={(e) => setze("sudoModus", e.target.value as Werte["sudoModus"])}
|
||||
>
|
||||
<option value="KEINER">keine sudo-Rechte</option>
|
||||
<option value="BEFEHLE">bestimmte Befehle</option>
|
||||
<option value="VOLL">voller sudo-Zugriff</option>
|
||||
</Auswahlliste>
|
||||
{w.sudoModus === "BEFEHLE" && (
|
||||
<Textbereich
|
||||
beschriftung="Erlaubte Befehle (einer je Zeile, absoluter Pfad)"
|
||||
name="sudoBefehle"
|
||||
value={w.sudoBefehle}
|
||||
onChange={(e) => setze("sudoBefehle", e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
{sudoFehler && <p className="text-danger text-[13px]">{sudoFehler}</p>}
|
||||
{sudoVorschau && (
|
||||
<pre className="border-border bg-bg rounded-box overflow-x-auto border p-3 font-mono text-[12.5px]">
|
||||
{sudoVorschau}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{quelle && !istIpAdresse(quelle.adresse) && (
|
||||
<Feld
|
||||
beschriftung={`IP von ${quelle.name} (für from="…")`}
|
||||
name="quellIp"
|
||||
value={w.quellIp}
|
||||
onChange={(e) => setze("quellIp", e.target.value)}
|
||||
mono
|
||||
hinweis="Die Adresse, unter der B den Host A sieht."
|
||||
/>
|
||||
)}
|
||||
<Feld
|
||||
beschriftung="Einschränkungen in authorized_keys"
|
||||
name="optionen"
|
||||
value={w.optionen}
|
||||
onChange={(e) => setze("optionen", e.target.value)}
|
||||
mono
|
||||
placeholder={
|
||||
quelle && istIpAdresse(quelle.adresse) ? `from="${quelle.adresse}"` : 'from="10.0.0.5"'
|
||||
}
|
||||
hinweis={
|
||||
offenePlatzhalter
|
||||
? "Es sind noch Platzhalter offen."
|
||||
: 'Empfohlen: from="<IP von A>", damit der Schlüssel nur von dort funktioniert.'
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Feld
|
||||
beschriftung="Alias auf A"
|
||||
name="alias"
|
||||
value={w.alias}
|
||||
onChange={(e) => setze("alias", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="HostName (aus Sicht von A)"
|
||||
name="hostName"
|
||||
value={w.hostName}
|
||||
onChange={(e) => setze("hostName", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
<Feld
|
||||
beschriftung="Port"
|
||||
name="port"
|
||||
type="number"
|
||||
value={w.port}
|
||||
onChange={(e) => setze("port", e.target.value)}
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
<Ankreuzfeld
|
||||
beschriftung="Host-Key von B in known_hosts von A eintragen"
|
||||
checked={w.knownHosts}
|
||||
onChange={(e) => setze("knownHosts", e.target.checked)}
|
||||
/>
|
||||
{w.knownHosts && ziel && (
|
||||
<div className="border-border rounded-box flex flex-col gap-2 border p-3">
|
||||
{ziel.hostKeys.map((k) => (
|
||||
<p key={k.fingerprint} className="font-mono text-[12.5px] break-all">
|
||||
<span className="text-muted">{k.algorithmus}</span> {k.fingerprint}
|
||||
</p>
|
||||
))}
|
||||
<Ankreuzfeld
|
||||
beschriftung="Fingerprint von B ist bestätigt"
|
||||
erklaerung="Derselbe Host-Key, dem sshkv beim Bootstrap von B vertraut hat."
|
||||
checked={w.fingerprintGeprueft}
|
||||
onChange={(e) => setze("fingerprintGeprueft", e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 3 && (
|
||||
<Karte
|
||||
titel="Zusammenfassung"
|
||||
untertitel="Noch ist nichts passiert. Ausgerollt wird erst nach dem Dry-Run."
|
||||
>
|
||||
<dl className="grid gap-x-6 gap-y-3 text-[14px] sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<dt className="text-muted">Schlüssel</dt>
|
||||
<dd>
|
||||
ed25519 · {w.zweck}
|
||||
{w.ablaufAm && (
|
||||
<span className="text-accent">
|
||||
{" "}
|
||||
· läuft ab am {w.ablaufAm.split("-").reverse().join(".")}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted">Private Key</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
{w.quelleBenutzer}@{quelle?.name}:{w.pfad}
|
||||
</dd>
|
||||
<dt className="text-muted">~/.ssh/config auf A</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
Host {w.alias} → {w.zielBenutzer}@{w.hostName}:{w.port}
|
||||
</dd>
|
||||
<dt className="text-muted">Public Key</dt>
|
||||
<dd className="font-mono text-[13px] break-all">
|
||||
{w.optionen && `${w.optionen} · `}
|
||||
{w.zielBenutzer}@{ziel?.name}
|
||||
</dd>
|
||||
{w.anlegen && (
|
||||
<>
|
||||
<dt className="text-muted">Zielbenutzer</dt>
|
||||
<dd>
|
||||
„{w.zielBenutzer}“ auf {ziel?.name} anlegen,{" "}
|
||||
{w.sudoModus === "KEINER" ? (
|
||||
"ohne sudo"
|
||||
) : w.sudoModus === "VOLL" ? (
|
||||
<span className="text-danger">voller sudo-Zugriff</span>
|
||||
) : (
|
||||
"sudo für bestimmte Befehle"
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{w.anlegen && !bestaetigt && !ablauf.bestaetigen && (
|
||||
<p className="text-accent mt-4 text-[13.5px]">
|
||||
Das Anlegen des Benutzers verlangt eine frische Bestätigung Ihrer Anmeldung – sshkv fragt beim
|
||||
Ausführen danach.
|
||||
</p>
|
||||
)}
|
||||
<div className="border-border mt-5 flex flex-col gap-3 border-t pt-4">
|
||||
<Formularfehler text={ablauf.fehler} />
|
||||
{ablauf.bestaetigen && (
|
||||
<a
|
||||
href="/bestaetigen?ziel=%2Fassistent%2Fbestaetigt"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-accent inline-flex min-h-11 items-center gap-2 text-[14px] font-medium"
|
||||
>
|
||||
<ExternalLink className="size-4" aria-hidden /> Anmeldung in neuem Tab bestätigen, dann hier
|
||||
erneut ausführen
|
||||
</a>
|
||||
)}
|
||||
{ablauf.laeuft && (
|
||||
<p role="status" className="text-muted text-[14px]">
|
||||
{ablauf.laeuft}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<Play className="size-4" />}
|
||||
disabled={Boolean(ablauf.laeuft)}
|
||||
onClick={() => void ausfuehren()}
|
||||
>
|
||||
{ablauf.schluesselId ? "Fortsetzen" : "Vorbereiten"}
|
||||
</Button>
|
||||
</div>
|
||||
</Karte>
|
||||
)}
|
||||
|
||||
{schritt === 4 && ablauf.schluesselId && (
|
||||
<>
|
||||
<Karte
|
||||
titel="Dry-Run und Ausrollen"
|
||||
untertitel="Prüfen Sie die Änderungen auf A und B, dann anwenden."
|
||||
>
|
||||
<AusrollenPanel auswahl={{ schluesselId: ablauf.schluesselId }} offen={2} />
|
||||
</Karte>
|
||||
<Karte
|
||||
titel="Verbindung testen"
|
||||
untertitel={`Auf ${quelle?.name} als ${w.quelleBenutzer}: ssh ${w.alias} true`}
|
||||
>
|
||||
{ablauf.privatVerwendungId && (
|
||||
<VorgangsKnopf
|
||||
aktion={verbindungTestenAktion.bind(null, ablauf.privatVerwendungId)}
|
||||
beschriftung="Verbindung von A nach B testen"
|
||||
variante="primaer"
|
||||
/>
|
||||
)}
|
||||
<p className="text-muted mt-3 text-[13.5px]">
|
||||
Zum Schlüssel:{" "}
|
||||
<a className="text-accent" href={`/schluessel/${ablauf.schluesselId}`}>
|
||||
{w.zweck}
|
||||
</a>
|
||||
</p>
|
||||
</Karte>
|
||||
</>
|
||||
)}
|
||||
|
||||
{schritt < 3 && (
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<Button
|
||||
icon={<ArrowLeft className="size-4" />}
|
||||
disabled={schritt === 0}
|
||||
onClick={() => setSchritt(schritt - 1)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
<Button
|
||||
variante="primaer"
|
||||
icon={<ArrowRight className="size-4" />}
|
||||
disabled={!schrittOk[schritt]}
|
||||
onClick={() => {
|
||||
if (schritt === 1 && !w.pfad) setze("pfad", `~/.ssh/id_ed25519_${kurz(w.zweck)}`);
|
||||
setSchritt(schritt + 1);
|
||||
}}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{schritt === 3 && !ablauf.schluesselId && (
|
||||
<div>
|
||||
<Button
|
||||
icon={<ArrowLeft className="size-4" />}
|
||||
disabled={Boolean(ablauf.laeuft)}
|
||||
onClick={() => setSchritt(2)}
|
||||
>
|
||||
Zurück
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user