import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { TERMINAL_THEMES, TERMINAL_FONTS, BELL_STYLES, FAST_SCROLL_MODIFIERS, CURSOR_STYLES, } from "@/lib/terminal-themes"; import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { PasswordInput } from "@/components/password-input"; import { Slider } from "@/components/slider"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/tooltip"; import { TERMINAL_FONT_ZOOM_MIN, TERMINAL_FONT_ZOOM_MAX, } from "@/features/terminal/terminal-font-zoom"; import { Globe, Info, Layers, // --- tmux-monitor --- LayoutGrid, Network, Palette, Pencil, Plus, Settings, Shield, Upload, X, Zap, } from "lucide-react"; import { toast } from "sonner"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; import { TerminalPreview } from "@/features/terminal/TerminalPreview"; import { createSSHHost, updateSSHHost, getSnippets, subscribeTunnelStatuses, connectTunnel, disconnectTunnel, getUserInfo, getVaultProfiles, getHostPassword, adminCreateUserHost, adminUpdateUserHost, adminGetHostPassword, adminGetUserSnippets, createCredential, adminCreateUserCredential, } from "@/main-axios"; import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api"; import { getUserPreferences, saveUserPreferences, parseCustomThemes, type SavedCustomTheme, } from "@/api/open-tabs-api"; import type { Host, VaultProfile } from "@/types/ui-types"; import type { SSHHost, TunnelStatus } from "@/types"; import { useTabsSafe } from "@/shell/TabContext"; import { buildHostEditorPayload, createHostEditorForm, mapSnippetResponse, omitOwnerSshAuthFromSharedEdit, terminalAppearanceKeys, type HostAuthType, type HostBellStyle, type HostBackspaceMode, type HostCursorStyle, type HostFastScrollModifier, type HostProtocols, } from "./HostEditorData"; import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext"; import { HostDockerTab, HostProxmoxTab, HostFilesTab, } from "./HostEditorFeatureTabs"; import { HostEditorGeneralTab } from "./HostEditorGeneralTab"; import { canEditHost } from "./host-permissions"; import { HostEditorRdpTab, HostEditorTelnetTab, HostEditorVncTab, } from "./HostEditorGuacamoleTabs"; import { HostStatsTab } from "./HostEditorStatsTab"; import { VaultProfileManager } from "./VaultProfileManager"; import { SecretReferenceHint, SecretSourceManager, } from "./SecretSourceManager"; import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints"; import { toCredentialOption, type CredentialOption, } from "./quick-created-credential"; const CUSTOM_FONT_OPTION = "__custom__"; export function HostEditor({ host, activeTab, onBack, onSave, protocols, onProtocolChange, onDirtyChange, onTabChange, hosts, credentials, adminTargetUserId, simpleMode = false, onEditCredential, }: { host: Host | null; 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 { setPreviewTerminalTheme } = useTabsSafe(); const connectionDefaults = useConnectionDefaults(); const [form, setForm] = useState(() => createHostEditorForm(host, undefined, connectionDefaults), ); const [isCustomFont, setIsCustomFont] = useState( () => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily), ); const setField = (k: K, v: (typeof form)[K]) => { onDirtyChange?.(true); setForm((p) => ({ ...p, [k]: v, ...(terminalAppearanceKeys.includes( k as (typeof terminalAppearanceKeys)[number], ) ? { inheritTerminalAppearance: false } : {}), })); }; const setGuacField = (key: string, value: unknown) => { onDirtyChange?.(true); setForm((current) => ({ ...current, inheritRemoteDesktopDefaults: false, guacamoleConfig: { ...current.guacamoleConfig, [key]: value }, })); }; const [saving, setSaving] = useState(false); const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]); const [tunnelStatuses, setTunnelStatuses] = useState< Record >({}); const [tailscaleDevices, setTailscaleDevices] = useState< Array<{ id: string; name: string; hostname: string; addresses: string[]; os: string; lastSeen: string; }> >([]); const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false); const [tailscaleLoading, setTailscaleLoading] = useState(false); const [tailscaleDeviceError, setTailscaleDeviceError] = useState(false); const [connectingTunnel, setConnectingTunnel] = useState(null); const [isOidcUser, setIsOidcUser] = useState(false); const [vaultProfiles, setVaultProfiles] = useState([]); const [showVaultManager, setShowVaultManager] = 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); const [savedThemes, setSavedThemes] = useState([]); const [savingTheme, setSavingTheme] = useState(false); const reloadSavedThemes = () => { getUserPreferences() .then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes))) .catch(() => {}); }; useEffect(() => { reloadSavedThemes(); }, []); const handleSaveAsGlobalTheme = async () => { const colors = form.customThemeColors; if (!colors) return; const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt")); if (!name || !name.trim()) return; setSavingTheme(true); try { const newTheme: SavedCustomTheme = { id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, name: name.trim(), colors, }; const updated = [...savedThemes, newTheme]; await saveUserPreferences({ customThemes: JSON.stringify(updated), }); setSavedThemes(updated); toast.success(t("hosts.saveGlobalThemeSuccess")); } catch { toast.error(t("hosts.saveGlobalThemeError")); } finally { setSavingTheme(false); } }; const handleDeleteGlobalTheme = async (id: string) => { const updated = savedThemes.filter((theme) => theme.id !== id); try { await saveUserPreferences({ customThemes: JSON.stringify(updated), }); setSavedThemes(updated); } catch { toast.error(t("hosts.saveGlobalThemeError")); } }; const handleApplyGlobalTheme = (id: string) => { const theme = savedThemes.find((entry) => entry.id === id); if (!theme) return; setField("customThemeColors", { ...theme.colors }); }; const reloadVaultProfiles = () => { getVaultProfiles() .then((res) => setVaultProfiles(res as unknown as VaultProfile[])) .catch(() => {}); }; useEffect(() => { getUserInfo() .then((info) => setIsOidcUser(info.is_oidc)) .catch(() => {}); }, []); useEffect(() => { const loadSnippets = adminTargetUserId ? adminGetUserSnippets(adminTargetUserId) : getSnippets(); loadSnippets .then((res) => setSnippets(mapSnippetResponse(res))) .catch(() => {}); reloadVaultProfiles(); }, [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]); useEffect(() => { if (activeTab !== "tunnels") return; const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s)); return unsub; }, [activeTab]); useEffect(() => { if (form.authType !== "tailscale") return; setTailscaleLoading(true); setTailscaleDeviceError(false); getTailscaleDevices() .then((res) => { setTailscaleDevices(res?.devices ?? []); setTailscaleHasApiKey(res?.hasApiKey ?? false); setTailscaleDeviceError(!!res?.error); }) .catch(() => setTailscaleDeviceError(true)) .finally(() => setTailscaleLoading(false)); }, [form.authType]); 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); } 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; 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/vault 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 }); const tabForProto: Record = { enableSsh: "ssh", enableRdp: "rdp", enableVnc: "vnc", enableTelnet: "telnet", }; const sshGroupTabs = [ "ssh", "terminal", "tunnels", "docker", "files", "host-metrics", ]; if (!value) { if (proto === "enableSsh" && sshGroupTabs.includes(activeTab)) { onTabChange("general"); } else if (activeTab === tabForProto[proto]) { onTabChange("general"); } } if (value && tabForProto[proto]) onTabChange(tabForProto[proto]); }; 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" />
} action={ !isSharedHost && canQuickCreateCredential && ( ) } > {isSharedHost && (
{t( host?.shareSshAuth ? "hosts.sharing.ownerAuthShared" : "hosts.sharing.ownerAuthPrivate", )}
)}

{t("hosts.authenticationMethodDesc")}

{[ "password", "key", "credential", "vault", "none", "opkssh", "stepca", "tailscale", "agent", ].map((m) => ( ))}
{lockAuthReferences && (

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

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

{t("hosts.oidcUsernameHint")}

)} {authMethod === "stepca" && (
{t("hosts.stepcaLabel")} {t("hosts.docsLink")}

{t("hosts.stepcaDesc")}

)} {authMethod === "tailscale" && (

{t("hosts.tailscaleUsernameHint")}

)}
{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")}
)}