Ein Schlüssel soll nach Projektende mit einem Klick von allen Hosts verschwinden – aber nie als „widerrufen“ gelten, solange er irgendwo noch liegt. Deshalb ein Zwischenzustand (widerrufEingeleitetAm): Verwendungen werden zum Entfernen vorgemerkt, der bestehende Dry-Run mit Hash-Sperre zeigt und schreibt die Änderungen, und erst wenn keine Verwendung mehr offen ist, setzt der Abschluss WIDERRUFEN (bzw. ROTIERT für #023). Scheitert ein Host, bleibt der Schlüssel im Zwischenzustand und die Seite nennt die offenen Ziele. Zielkonten lassen sich optional mit entfernen, aber erst nach den Schlüsseln und nie, wenn ein anderer aktiver Schlüssel sie noch nutzt. Massenwiderruf (Admin, frische Bestätigung) nutzt einen gemeinsamen Dry-Run und protokolliert je Schlüssel. Abgelaufene Schlüssel erscheinen im Filter „abgelaufen“ und bieten den Widerruf an – entfernt wird nie automatisch. Migration 20261012130000_widerruf_eingeleitet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
606 lines
22 KiB
TypeScript
606 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowLeft, ArrowRight, ExternalLink, Play } from "lucide-react";
|
||
import { useState } from "react";
|
||
import { AusrollenPanel } from "@/components/ausrollen/AusrollenPanel";
|
||
import { Ankreuzfeld, Button, Feld, Formularfehler, Karte } from "@/components/ui";
|
||
import { Auswahlliste, Textbereich } from "@/components/ui/Feld";
|
||
import { Schrittleiste } from "@/components/ui/Schrittleiste";
|
||
import { VorgangsKnopf } from "@/components/vorgang/VorgangsKnopf";
|
||
import { warten } from "@/components/vorgang/warten";
|
||
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"
|
||
);
|
||
}
|
||
|
||
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">
|
||
<Schrittleiste schritte={SCHRITTE} aktuell={schritt} />
|
||
|
||
{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>
|
||
);
|
||
}
|