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 { Globe, Layers, // --- tmux-monitor --- Network, Palette, Plus, 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, } from "@/main-axios"; import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api"; import type { Host, VaultProfile } from "@/types/ui-types"; import type { SSHHost, TunnelStatus } from "@/types"; import { useTabsSafe } from "@/shell/TabContext"; import { buildHostEditorPayload, createHostEditorForm, mapSnippetResponse, type HostAuthType, type HostBellStyle, type HostBackspaceMode, type HostCursorStyle, type HostFastScrollModifier, type HostProtocols, } from "./HostEditorData"; 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 { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints"; export function HostEditor({ host, activeTab, onBack, onSave, protocols, onProtocolChange, onTabChange, hosts, credentials, adminTargetUserId, }: { host: Host | null; activeTab: string; onBack: () => void; onSave: (saved: SSHHost) => void; protocols: HostProtocols; onProtocolChange: (p: Partial) => 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; }) { const { t } = useTranslation(); const { setPreviewTerminalTheme } = useTabsSafe(); const [form, setForm] = useState(() => createHostEditorForm(host)); const setField = (k: K, v: (typeof form)[K]) => setForm((p) => ({ ...p, [k]: v })); const setGuacField = (key: string, value: unknown) => setField("guacamoleConfig", { ...form.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 [connectingTunnel, setConnectingTunnel] = useState(null); const [isOidcUser, setIsOidcUser] = useState(false); const [vaultProfiles, setVaultProfiles] = useState([]); const [showVaultManager, setShowVaultManager] = useState(false); 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 (host) return; getHostDefaults() .then((d) => setForm(createHostEditorForm(null, d))) .catch(() => {}); }, [host]); 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); getTailscaleDevices() .then((res) => { setTailscaleDevices(res?.devices ?? []); setTailscaleHasApiKey(res?.hasApiKey ?? false); }) .catch(() => {}) .finally(() => setTailscaleLoading(false)); }, [form.authType]); const handleSave = async () => { setSaving(true); try { const data = buildHostEditorPayload(form, protocols); 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 selectedCredential = credentials.find( (c) => c.id === form.credentialId, ); // 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, ) => { 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" />
} >
{[ "password", "key", "credential", "vault", "none", "opkssh", "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 === "password" && (
setField("password", e.target.value)} />
)} {authMethod === "key" && ( <>
{(["paste", "upload"] as const).map((tab) => ( ))}
{form.keySubTab === "paste" ? (
{form.key === "existing_key" && (
{t("hosts.keySaved")} —{" "} {t("hosts.keyReplaceNotice")}
)}