import { Select2 } from "@/components/select2"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { PasswordInput } from "@/components/password-input"; import { Globe, Pencil, Plus, Puzzle, Settings, Shield, Terminal, Upload, X, } from "lucide-react"; import { InlineView, useConfirm } from "@/components/surface/surface-scope"; import { EditorPane, EditorSection, LazySection, type EditorNavBand, } from "@/manage/EditorPane"; import { validateHostForm } from "@/manage/host-validation"; import { toast } from "sonner"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; import { createSSHHost, updateSSHHost, getUserInfo, getHostPassword, adminCreateUserHost, adminUpdateUserHost, adminGetHostPassword, createCredential, adminCreateUserCredential, } from "@/main-axios"; import { getHostDefaultsLevel, previewHostDefaultsLevel, resolveHostDefaults, saveHostDefaultsLevel, waitForDefaultsJob, type HostDefaultsChange, type HostDefaultsTarget, } from "@/api/host-defaults-api"; import type { ResolvedHostDefault } from "@/types/host-defaults"; import { HostDefaultBadge, HostDefaultsContext, HostOnly, type HostDefaultsContextValue, } from "@/lib/host-defaults-context"; import { useEditorDefaultKeys } from "./host-defaults/catalog"; import { applyDefaultToForm, changedKeys, classifyForm, draftOwnKeys, formValueForKey, isOwnDefault, ownDefaultKeys, ownPluginValues, withAllNamespaces, withOwnKeys, withoutOwnKey, } from "./host-defaults/form-mapping"; import type { Host } from "@/types/ui-types"; import type { SSHHost } from "@/types"; import { updatePluginHostSettings } from "@/api/plugins-api"; import { applyHostDraft, buildHostEditorPayload, createHostEditorForm, omitOwnerSshAuthFromSharedEdit, type HostAuthType, type HostEditorForm, type HostProtocols, } from "./HostEditorData"; import { HostEditorGeneralTab } from "./HostEditorGeneralTab"; import { withProtocolSettings } from "./host-protocols"; import { canEditHost } from "./host-permissions"; import { getHostEditorSection, makeHostSshSubTabs, makeHostTabs, useHostEditorSections, } from "./HostManagerTabs"; import { useSshAuthEditors } from "@/plugin-host/auth-registry"; import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { SshAuthProviderFields } from "./SshAuthProviderFields"; import type { PluginSettingsField } from "@termix-ssh/plugin-sdk/manifest"; import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend"; import { PluginComponent } from "@/plugin-host/component-registry"; import { toCredentialOption, type CredentialOption, } from "./quick-created-credential"; export function HostEditor({ host, draft, advanced = true, banner, onBack, onSave, protocols, onProtocolChange, onDirtyChange, hosts, credentials, adminTargetUserId, onEditCredential, defaultsTarget, onDefaultsSaved, }: { host: Host | null; /** Fields a plugin filled in for a new host. */ draft?: HostDraft; /** * False keeps to what is needed to reach a host: General and SSH, with the * organizing fields folded away. Hidden fields still save unchanged. */ advanced?: boolean; /** Shown above the form, like the defaults level's description. */ banner?: React.ReactNode; onBack: () => void; onSave: (saved: SSHHost) => void; protocols: HostProtocols; onProtocolChange: (p: Partial) => void; onDirtyChange?: (dirty: boolean) => void; hosts: Host[]; credentials: { id: string; name: string; username: string }[]; // When set, the editor works on another user's host through the admin // impersonation endpoints instead of the signed-in user's own data. adminTargetUserId?: string; // Opens the currently-selected credential for editing (from within the // host editor), so the user can tweak/rotate it without leaving the flow. onEditCredential?: (credentialId: string) => void; /** Edits a level of host defaults instead of a host. */ defaultsTarget?: HostDefaultsTarget; onDefaultsSaved?: () => void; }) { const { t } = useTranslation(); const confirm = useConfirm(); // Re-render when a plugin adds or removes a section. useHostEditorSections(); const simpleMode = !advanced; const defaultsMode = !!defaultsTarget; const revealRef = useRef<((id: string) => void) | null>(null); const [pendingReveal, setPendingReveal] = useState(null); const defaultKeys = useEditorDefaultKeys(); const trackedKeys = useRef([]); trackedKeys.current = [...defaultKeys.keys()]; const [form, setForm] = useState(() => { const initial = applyHostDraft( createHostEditorForm(host), host ? undefined : draft, ); if (defaultsTarget) return { ...initial, username: "" }; // What a plugin filled in for a new host is that host's own. return host ? initial : { ...initial, defaultOverrides: draftOwnKeys(draft) }; }); /** * What each key would be without the host's own value: its defaults, or in * the defaults editor, the levels above the one being edited. */ const [resolved, setResolved] = useState>( {}, ); /** The keys the level held when it was loaded, in the defaults editor. */ const [levelKeys, setLevelKeys] = useState([]); /** A value the user changed is the host's own from then on. */ const markOwn = (before: HostEditorForm, after: HostEditorForm) => { if (after.defaultOverrides === null) return after; const changed = changedKeys(before, after, trackedKeys.current); if (changed.length === 0) return after; return { ...after, defaultOverrides: withOwnKeys(after.defaultOverrides, changed), }; }; const setField = (k: K, v: (typeof form)[K]) => { onDirtyChange?.(true); setForm((p) => markOwn(p, { ...p, [k]: v })); }; /** Sets several fields at once, from the latest form. For plugin sections. */ const updateForm = ( patch: (current: Record) => Record, ) => { onDirtyChange?.(true); setForm((current) => markOwn(current, { ...current, ...patch(current as unknown as Record), } as HostEditorForm), ); }; const [saving, setSaving] = useState(false); const [isExternalUser, setIsExternalUser] = useState(false); const [showSecretSources, setShowSecretSources] = useState(false); const [quickCredentialName, setQuickCredentialName] = useState(""); const [creatingQuickCredential, setCreatingQuickCredential] = useState(false); const [showQuickCredentialDialog, setShowQuickCredentialDialog] = useState(false); const [quickCreatedCredential, setQuickCreatedCredential] = useState(null); useEffect(() => { getUserInfo() .then((info) => setIsExternalUser(info.is_external)) .catch(() => {}); }, []); useEffect(() => { let cancelled = false; if (defaultsTarget) { getHostDefaultsLevel(defaultsTarget) .then((data) => { if (cancelled) return; setResolved(data.inherited); setLevelKeys(Object.keys(data.values)); let next: HostEditorForm = { ...createHostEditorForm(null), username: "", }; for (const [key, entry] of Object.entries(data.inherited)) { next = applyDefaultToForm(next, key, entry.value); } for (const [key, value] of Object.entries(data.values)) { next = applyDefaultToForm(next, key, value); } setForm({ ...next, defaultOverrides: withOwnKeys({}, Object.keys(data.values)), }); }) .catch(() => toast.error(t("hostDefaults.loadFailed"))); return () => { cancelled = true; }; } if (host) { setForm(createHostEditorForm(host)); resolveHostDefaults({ hostId: Number(host.id) }, adminTargetUserId) .then((values) => { if (cancelled) return; setResolved(values); // A host saved before host defaults: whatever matches follows them. setForm((current) => current.defaultOverrides !== null ? current : { ...current, defaultOverrides: classifyForm( current, values, trackedKeys.current, ), }, ); }) .catch(() => {}); } return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [host, defaultsTarget?.level, defaultsTarget?.folderId]); // A new host follows the defaults of wherever it is being placed. const newHostPlacement = !host && !defaultsTarget ? `${form.folder}\u0000${form.parentHostId}` : null; useEffect(() => { if (newHostPlacement === null) return; let cancelled = false; resolveHostDefaults( { folder: form.folder || null, parentHostId: form.parentHostId ? Number(form.parentHostId) : null, }, adminTargetUserId, ) .then((values) => { if (cancelled) return; setResolved(values); setForm((current) => { let next = current; for (const [key, entry] of Object.entries(values)) { if (isOwnDefault(current.defaultOverrides, key)) continue; next = applyDefaultToForm(next, key, entry.value); } return next; }); }) .catch(() => {}); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [newHostPlacement]); const isAvailable = (fullKey: string) => { const entry = defaultKeys.get(fullKey); if (!entry) return false; return !defaultsTarget || entry.levels.includes(defaultsTarget.level); }; const defaultsContext: HostDefaultsContextValue = { mode: defaultsMode ? "defaults" : "host", level: defaultsTarget?.level, isOwn: (fullKey) => form.defaultOverrides === null || isOwnDefault(form.defaultOverrides, fullKey), source: (fullKey) => resolved[fullKey]?.source, isAvailable, reset: (fullKey) => { onDirtyChange?.(true); setForm((current) => { const entry = resolved[fullKey]; const next = entry ? applyDefaultToForm(current, fullKey, entry.value) : current; return { ...next, defaultOverrides: withoutOwnKey( current.defaultOverrides ?? classifyForm(current, resolved, trackedKeys.current), fullKey, ), }; }); }, }; /** * Writes each plugin's host-scope values through its own route. * * One request per plugin rather than one per field, and a plugin that * rejects a value is reported without failing the host save that already * succeeded. */ const savePluginHostSettings = async ( hostId: number, values: Record> | undefined, ): Promise>> => { const saved: Record> = {}; if (!values || !Number.isInteger(hostId)) return saved; for (const [pluginId, fields] of Object.entries(values)) { if (!fields || Object.keys(fields).length === 0) continue; try { saved[pluginId] = await updatePluginHostSettings( pluginId, hostId, fields, ); } catch { toast.error(t("settings.pluginSettingsSaveFailed")); } } return saved; }; const saveDefaults = async () => { if (!defaultsTarget) return; const own = ownDefaultKeys(form.defaultOverrides).filter(isAvailable); const change: HostDefaultsChange = { set: Object.fromEntries( own.map((key) => [key, formValueForKey(form, key)]), ), unset: levelKeys.filter((key) => !own.includes(key)), }; setSaving(true); try { const preview = await previewHostDefaultsLevel(defaultsTarget, change); setSaving(false); const ok = await confirm({ title: t("hostDefaults.confirmTitle"), description: preview.changedHosts > 0 ? t("hostDefaults.confirmChanges", { count: preview.changedHosts }) : t("hostDefaults.confirmNoChanges"), confirmLabel: t("hostDefaults.save"), destructive: false, }); if (ok) await confirmDefaults(change); } catch { toast.error(t("hostDefaults.saveFailed")); } finally { setSaving(false); } }; const confirmDefaults = async (change: HostDefaultsChange) => { if (!defaultsTarget) return; setSaving(true); try { const result = await saveHostDefaultsLevel(defaultsTarget, change); setLevelKeys(Object.keys(change.set)); onDirtyChange?.(false); if (result.jobId) { toast.success(t("hostDefaults.savedInBackground")); void waitForDefaultsJob(result.jobId).then((changed) => { if (changed !== null) { toast.success(t("hostDefaults.appliedToHosts", { count: changed })); } window.dispatchEvent(new CustomEvent("termix:hosts-changed")); }); } else { toast.success( t("hostDefaults.saved", { count: result.changedHosts ?? 0 }), ); window.dispatchEvent(new CustomEvent("termix:hosts-changed")); } onDefaultsSaved?.(); } catch { toast.error(t("hostDefaults.saveFailed")); } finally { setSaving(false); } }; const handleSave = async () => { if (defaultsMode) { await saveDefaults(); return; } setSaving(true); try { const withOverrides = { ...form, defaultOverrides: form.defaultOverrides ? withAllNamespaces(form.defaultOverrides, defaultKeys) : null, }; const fullData = buildHostEditorPayload(withOverrides, protocols); const data = lockAuthReferences ? omitOwnerSshAuthFromSharedEdit(fullData) : fullData; let saved: SSHHost; if (adminTargetUserId) { saved = host ? await adminUpdateUserHost(adminTargetUserId, Number(host.id), data) : await adminCreateUserHost(adminTargetUserId, data); } else { saved = host ? await updateSSHHost(Number(host.id), data) : await createSSHHost(data); } // After the host: a new one has no id to scope settings to until it // exists. A failure here must not claim the host itself failed to save. // Values that follow the defaults were written by the server already. const pluginValues = withProtocolSettings(form.pluginSettings, protocols); const savedPluginValues = await savePluginHostSettings( Number(saved.id), ownPluginValues( pluginValues, withOverrides.defaultOverrides, defaultKeys, ), ); // The host was read back before these were written. saved = { ...saved, pluginSettings: { ...(saved.pluginSettings ?? {}), ...savedPluginValues, }, }; toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated")); onSave(saved); } catch { toast.error(t("hosts.failedToSave")); } finally { setSaving(false); } }; const authMethod = form.authType; // Auth types a plugin adds (Tailscale) bring their own editor and label. const sshAuthEditors = useSshAuthEditors(); const activeAuthEditor = sshAuthEditors.find( (editor) => editor.id === authMethod, ); const ActiveAuthEditor = activeAuthEditor?.component; const sshAuthProviders = useSshAuthProviders(); const authEditorLabel = (method: string) => { const option = sshAuthProviders.find(method); if (option?.editorTitleKey) return t(option.editorTitleKey); if (option?.labelKey) return t(option.labelKey, { defaultValue: method }); return method; }; // A default never carries a login of its own, and a credential belongs to // one user, so the server level cannot hold one. const selectableAuthMethods = sshAuthProviders.providers .filter((option) => option.available) .map((option) => option.type) .filter( (type) => !defaultsMode || (type !== "password" && type !== "key" && (type !== "credential" || defaultsTarget?.level !== "admin")), ); const currentAuthOption = sshAuthProviders.find(authMethod); // The host uses a type nothing here provides (its plugin is off). const missingAuthNotice = sshAuthProviders.loaded && authMethod && !currentAuthOption?.available ? currentAuthOption?.missingPlugin ? t("hosts.authTypeNeedsPlugin", { type: authMethod, plugin: currentAuthOption.missingPlugin.name, }) : t("hosts.authTypeUnknown", { type: authMethod }) : null; const availableCredentials = quickCreatedCredential && !credentials.some( (credential) => credential.id === quickCreatedCredential.id, ) ? [...credentials, quickCreatedCredential] : credentials; const selectedCredential = availableCredentials.find( (c) => String(c.id) === String(form.credentialId), ); const canQuickCreateCredential = (authMethod === "password" || authMethod === "key") && (authMethod === "password" ? !!form.password || !!host?.hasPassword : (!!form.key && form.key !== "existing_key") || !!host?.hasKey); const openQuickCredentialDialog = () => { setQuickCredentialName(form.name || form.username || ""); setShowQuickCredentialDialog(true); }; const handleQuickCreateCredential = async () => { if (!quickCredentialName.trim()) { toast.error(t("hosts.credentialNameRequired")); return; } setCreatingQuickCredential(true); try { const fetchField = (field: "password" | "key" | "keyPassword") => adminTargetUserId ? adminGetHostPassword(adminTargetUserId, Number(host?.id), field) : getHostPassword(Number(host?.id), field); const data: Record = { name: quickCredentialName, username: form.username || null, folder: form.folder || null, }; if (authMethod === "password") { data.authType = "password"; data.password = form.password && form.password !== "existing_password" ? form.password : host?.hasPassword ? await fetchField("password") : null; } else { const key = form.key && form.key !== "existing_key" ? form.key : host?.hasKey ? await fetchField("key") : null; const keyPassword = form.keyPassword && form.keyPassword !== "existing_key_password" ? form.keyPassword : host?.hasKeyPassword ? await fetchField("keyPassword") : null; data.authType = "key"; data.key = key; data.keyPassword = keyPassword; data.password = form.password && form.password !== "existing_password" ? form.password : null; } const created = adminTargetUserId ? await adminCreateUserCredential(adminTargetUserId, data) : await createCredential(data); const credential = toCredentialOption(created); if (!credential) throw new Error(t("hosts.failedToSaveCredential")); setQuickCreatedCredential(credential); setForm((current) => ({ ...current, authType: "credential", credentialId: credential.id, })); toast.success(t("hosts.credentialCreated")); if (!adminTargetUserId) { window.dispatchEvent(new CustomEvent("termix:credentials-changed")); } setShowQuickCredentialDialog(false); } catch (err) { const msg = err instanceof Error ? err.message : null; toast.error(msg || t("hosts.failedToSaveCredential")); } finally { setCreatingQuickCredential(false); } }; // Shared hosts: view-level recipients see a read-only editor; edit-level // recipients may change the host but never its credential references // or auth type (owner-only, enforced server-side too). const isSharedHost = !!host?.isShared; const readOnly = isSharedHost && host !== null && !canEditHost(host); const lockAuthReferences = isSharedHost && !readOnly; const handleProtocolToggle = ( proto: keyof typeof protocols, value: boolean, ) => { onDirtyChange?.(true); onProtocolChange({ [proto]: value }); if (!value) return; if (proto === "enableSsh") { setPendingReveal("ssh"); return; } const before = protocols as unknown as Record; const after = { ...before, [proto]: value }; const visibleBefore = new Set( makeHostTabs((k) => k, before).map((tab) => tab.id), ); const revealed = makeHostTabs((k) => k, after) .map((tab) => tab.id) .find((id) => id !== "ssh" && !visibleBefore.has(id)); if (revealed) setPendingReveal(revealed); }; useEffect(() => { if (!pendingReveal) return; const id = pendingReveal; setPendingReveal(null); requestAnimationFrame(() => revealRef.current?.(id)); }, [pendingReveal]); const flags = protocols as unknown as Record; // The editor is one scrolling form. Sections follow the old tab order: // General, then SSH and its plugin sections, then other plugins. const sectionBands = useMemo(() => { const top = makeHostTabs(t, flags, defaultsMode).filter( (tab) => tab.id !== "general" && tab.id !== "ssh", ); const sshGroup = makeHostSshSubTabs(t, flags, defaultsMode).filter( (tab) => tab.id !== "ssh", ); const bands: EditorNavBand[] = [ { id: "host", label: t("manage.bandHost"), items: [ { id: "general", label: t("hosts.tabGeneral"), icon: , }, ], }, ]; if (protocols.enableSsh || defaultsMode) { bands.push({ id: "ssh", label: t("hosts.tabSsh"), items: [ { id: "ssh", label: t("manage.sshConnection"), icon: , }, ...(simpleMode ? [] : sshGroup), ], }); } if (!simpleMode && top.length > 0) { bands.push({ id: "features", label: t("manage.bandFeatures"), items: top.map((tab) => ({ ...tab, icon: tab.icon ?? , })), }); } return bands; // eslint-disable-next-line react-hooks/exhaustive-deps }, [t, JSON.stringify(flags), defaultsMode, simpleMode]); const errors = defaultsMode ? {} : validateHostForm(form, protocols); const errorCount = Object.keys(errors).length; const errorSections = new Set( Object.keys(errors).map((key) => (key === "sshPort" ? "ssh" : "general")), ); const bands = sectionBands.map((band) => ({ ...band, items: band.items.map((item) => ({ ...item, error: errorSections.has(item.id), })), })); const pluginSectionIds = bands .flatMap((band) => band.items.map((item) => item.id)) .filter((id) => id !== "general" && id !== "ssh"); const renderPluginSection = (id: string) => { const section = getHostEditorSection(id); if (!section) return null; const Section = section.component; return (
void } updateForm={updateForm} host={host} credentials={availableCredentials} adminTargetUserId={adminTargetUserId} protocols={flags} mode={defaultsMode ? "defaults" : "host"} /> ); }; return ( {banner} {isSharedHost && (
{readOnly ? t("hosts.sharing.viewOnlyBanner", { owner: host?.ownerUsername || "?", }) : t("hosts.sharing.sharedEditBanner", { owner: host?.ownerUsername || "?", })}
)} } footer={ <> {!readOnly && ( )} } >
{(protocols.enableSsh || defaultsMode) && ( } >
setField("sshPort", Number(e.target.value)) } className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> {errors.sshPort && ( {t(errors.sshPort)} )}
setField("keepaliveInterval", Number(e.target.value)) } className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
setField("keepaliveCountMax", Number(e.target.value)) } className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
setField("agentForwarding", v)} />
{t("hosts.environmentVariablesLabel")}
{form.environmentVariables.length === 0 && (

{t("hosts.noEnvVars")}

)}
{form.environmentVariables.map((ev, i) => (
{ const updated = [...form.environmentVariables]; updated[i] = { ...updated[i], key: e.target.value, }; setField("environmentVariables", updated); }} /> { const updated = [...form.environmentVariables]; updated[i] = { ...updated[i], value: e.target.value, }; setField("environmentVariables", updated); }} />
))}
} action={ !isSharedHost && !defaultsMode && canQuickCreateCredential && ( ) } > {isSharedHost && (
{t( host?.shareSshAuth ? "hosts.sharing.ownerAuthShared" : "hosts.sharing.ownerAuthPrivate", )}
)}

{t("hosts.authenticationMethodDesc")}

{selectableAuthMethods.map((m) => ( ))}
{lockAuthReferences && (

{t("hosts.sharing.ownerOnlyControl")}

)} {missingAuthNotice && (

{missingAuthNotice}

)}
{ if (!defaultsMode && form.username === "root") setField("username", ""); }} onBlur={() => { if (!defaultsMode && form.username === "") setField("username", "root"); }} onChange={(e) => setField("username", e.target.value)} /> {isExternalUser && (

{t("hosts.externalUsernameHint")}

)} {activeAuthEditor?.hintKey && (

{t(activeAuthEditor.hintKey)}

)}
{authMethod === "password" && (
setField("password", e.target.value)} /> setShowSecretSources((v) => !v)} />
)} {(authMethod === "password" || authMethod === "key") && showSecretSources && ( setShowSecretSources(false)} /> )} {authMethod === "key" && ( <>
{(["paste", "upload"] as const).map((tab) => ( ))}
{form.keySubTab === "paste" ? (
{form.key === "existing_key" && (
{t("hosts.keySaved")}.{" "} {t("hosts.keyReplaceNotice")}
)}