import { Select2 } from "@/components/select2"; import React, { useEffect, 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, Shield, Upload, X } from "lucide-react"; import { toast } from "sonner"; import { listSnippets } from "@/lib/snippet-provider"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; import { createSSHHost, updateSSHHost, getUserInfo, getHostPassword, adminCreateUserHost, adminUpdateUserHost, adminGetHostPassword, createCredential, adminCreateUserCredential, } from "@/main-axios"; import { getHostDefaults } from "@/api/settings-api"; import type { Host } from "@/types/ui-types"; import type { SSHHost } from "@/types"; import { useTabsSafe } from "@/shell/TabContext"; import { updatePluginHostSettings } from "@/api/plugins-api"; import { applyHostDraft, buildHostEditorPayload, createHostEditorForm, mapSnippetResponse, omitOwnerSshAuthFromSharedEdit, terminalAppearanceKeys, type HostAuthType, type HostProtocols, } from "./HostEditorData"; import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; import { HostEditorGeneralTab } from "./HostEditorGeneralTab"; import { withProtocolSettings } from "./host-protocols"; import { usePluginHostSections, withNewHostDefaults, } from "@/settings/HostPluginSections"; import { canEditHost } from "./host-permissions"; import { getHostEditorSection, isSshGroupTab, makeHostTabs, } from "./HostManagerTabs"; import { useSshAuthEditors } from "@/plugin-host/auth-registry"; import { useSshAuthProviders } from "@/hooks/useSshAuthProviders"; import { SshAuthProviderFields } from "./SshAuthProviderFields"; import type { PluginSettingsField } from "@termix/plugin-sdk/manifest"; import type { HostDraft } from "@termix/plugin-sdk/frontend"; import { PluginComponent } from "@/plugin-host/component-registry"; import { toCredentialOption, type CredentialOption, } from "./quick-created-credential"; export function HostEditor({ host, draft, activeTab, onBack, onSave, protocols, onProtocolChange, onDirtyChange, onTabChange, hosts, credentials, adminTargetUserId, simpleMode = false, onEditCredential, }: { host: Host | null; /** Fields a plugin filled in for a new host. */ draft?: HostDraft; activeTab: string; /** Collapses the General tab to the fields needed to reach a host. */ simpleMode?: boolean; onBack: () => void; onSave: (saved: SSHHost) => void; protocols: HostProtocols; onProtocolChange: (p: Partial) => void; onDirtyChange?: (dirty: boolean) => void; onTabChange: (tab: string) => 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; }) { const { t } = useTranslation(); const hostSettingPlugins = usePluginHostSections(); const { setPreviewTerminalTheme } = useTabsSafe(); const connectionDefaults = useConnectionDefaults(); const [form, setForm] = useState(() => applyHostDraft( createHostEditorForm(host, undefined, connectionDefaults), host ? undefined : draft, ), ); const setField = (k: K, v: (typeof form)[K]) => { onDirtyChange?.(true); setForm((p) => ({ ...p, [k]: v, ...(terminalAppearanceKeys.includes( k as (typeof terminalAppearanceKeys)[number], ) ? { inheritTerminalAppearance: false } : {}), })); }; /** Sets several fields at once, from the latest form. For plugin sections. */ const updateForm = ( patch: (current: Record) => Record, ) => { onDirtyChange?.(true); setForm((current) => ({ ...current, ...patch(current as unknown as Record), })); }; const [saving, setSaving] = useState(false); const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]); const [isOidcUser, setIsOidcUser] = 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) => setIsOidcUser(info.is_oidc)) .catch(() => {}); }, []); useEffect(() => { listSnippets( adminTargetUserId ? { targetUserId: adminTargetUserId } : undefined, ) .then((res) => setSnippets(mapSnippetResponse(res))) .catch(() => {}); }, [adminTargetUserId]); useEffect(() => { if (!connectionDefaults.ready) return; if (host) { setForm(createHostEditorForm(host, undefined, connectionDefaults)); return; } getHostDefaults() .then((d) => setForm(createHostEditorForm(null, d, connectionDefaults))) .catch(() => {}); }, [host, connectionDefaults]); useEffect(() => { if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return; let cancelled = false; const loadVncPassword = adminTargetUserId ? adminGetHostPassword(adminTargetUserId, Number(host.id), "vncPassword") : getHostPassword(Number(host.id), "vncPassword"); loadVncPassword.then((password) => { if (cancelled || !password) return; setForm((prev) => prev.vncPassword ? prev : { ...prev, vncPassword: password }, ); }); return () => { cancelled = true; }; }, [form.vncAuthType, form.vncPassword, host?.id, adminTargetUserId]); /** * 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, ) => { if (!values || !Number.isInteger(hostId)) return; for (const [pluginId, fields] of Object.entries(values)) { if (!fields || Object.keys(fields).length === 0) continue; try { await updatePluginHostSettings(pluginId, hostId, fields); } catch { toast.error(t("settings.pluginSettingsSaveFailed")); } } }; const handleSave = async () => { setSaving(true); try { const fullData = buildHostEditorPayload(form, 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. const pluginValues = withProtocolSettings(form.pluginSettings, protocols); await savePluginHostSettings( Number(saved.id), host ? pluginValues : withNewHostDefaults(pluginValues ?? {}, hostSettingPlugins), ); toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated")); setPreviewTerminalTheme(null); 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; }; const selectableAuthMethods = sshAuthProviders.providers .filter((option) => option.available) .map((option) => option.type); 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 }); // Tabs are shown per protocol: SSH owns its group, plugins decide their // own visibility. Jump to a tab the change reveals, and away from one it // hides. 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 visibleAfter = makeHostTabs((k) => k, after).map((tab) => tab.id); if (!value) { if (proto === "enableSsh" && isSshGroupTab(activeTab)) { onTabChange("general"); } else if (!visibleAfter.includes(activeTab)) { onTabChange("general"); } return; } if (proto === "enableSsh") { onTabChange("ssh"); return; } const revealed = visibleAfter.find( (id) => id !== "ssh" && !visibleBefore.has(id), ); if (revealed) onTabChange(revealed); }; return (
{isSharedHost && (
{readOnly ? t("hosts.sharing.viewOnlyBanner", { owner: host?.ownerUsername || "?", }) : t("hosts.sharing.sharedEditBanner", { owner: host?.ownerUsername || "?", })}
)}
{activeTab === "general" && ( )} {activeTab === "ssh" && ( <> } >
setField("sshPort", Number(e.target.value)) } className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
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" />
} action={ !isSharedHost && 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 (form.username === "root") setField("username", ""); }} onBlur={() => { if (form.username === "") setField("username", "root"); }} onChange={(e) => setField("username", e.target.value)} /> {isOidcUser && (

{t("hosts.oidcUsernameHint")}

)} {activeAuthEditor?.hintKey && (

{t(activeAuthEditor.hintKey)}

)}
{authMethod === "password" && (
{ if (form.password === "existing_password") setField("password", ""); }} onChange={(e) => 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")}
)}