import { zodResolver } from "@hookform/resolvers/zod"; import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { cn } from "@/lib/utils.ts"; import { Button } from "@/components/ui/button.tsx"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, } from "@/components/ui/form.tsx"; import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Badge } from "@/components/ui/badge.tsx"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table.tsx"; import { Textarea } from "@/components/ui/textarea.tsx"; import { ScrollArea } from "@/components/ui/scroll-area.tsx"; import { Separator } from "@/components/ui/separator.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger, } from "@/components/ui/tabs.tsx"; import React, { useEffect, useRef, useState } from "react"; import { Switch } from "@/components/ui/switch.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; import { toast } from "sonner"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; import { createSSHHost, getCredentials, getSSHHosts, updateSSHHost, enableAutoStart, disableAutoStart, getSnippets, getRoles, getUserList, getUserInfo, shareHost, getHostAccess, revokeHostAccess, getSSHHostById, notifyHostCreatedOrUpdated, getGuacamoleSettings, type Role, type AccessRecord, } from "@/ui/main-axios.ts"; import { useTranslation } from "react-i18next"; import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx"; import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { githubLight } from "@uiw/codemirror-theme-github"; import { EditorView } from "@codemirror/view"; import { useTheme } from "@/components/theme-provider.tsx"; import type { StatsConfig } from "@/types/stats-widgets.ts"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts"; import { Checkbox } from "@/components/ui/checkbox.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select.tsx"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, } from "@/components/ui/command.tsx"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover.tsx"; import { Slider } from "@/components/ui/slider.tsx"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion.tsx"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog.tsx"; import { TERMINAL_THEMES, TERMINAL_FONTS, CURSOR_STYLES, BELL_STYLES, FAST_SCROLL_MODIFIERS, DEFAULT_TERMINAL_CONFIG, } from "@/constants/terminal-themes.ts"; import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx"; import type { TerminalConfig, SSHHost, Credential } from "@/types"; import { Plus, X, Check, ChevronsUpDown, Save, AlertCircle, Trash2, Users, Shield, Clock, UserCircle, ArrowLeft, } from "lucide-react"; import { HostGeneralTab } from "./tabs/HostGeneralTab"; import { HostTerminalTab } from "./tabs/HostTerminalTab"; import { HostDockerTab } from "./tabs/HostDockerTab"; import { HostTunnelTab } from "./tabs/HostTunnelTab"; import { HostFileManagerTab } from "./tabs/HostFileManagerTab"; import { HostStatisticsTab } from "./tabs/HostStatisticsTab"; import { HostStatusTab } from "./tabs/HostStatusTab"; import { HostSharingTab } from "./tabs/HostSharingTab"; import { HostRemoteDesktopTab } from "./tabs/HostRemoteDesktopTab"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; interface User { id: string; username: string; is_admin: boolean; } interface SSHManagerHostEditorProps { editingHost?: SSHHost | null; onFormSubmit?: (updatedHost?: SSHHost) => void; onBack?: () => void; } export function HostManagerEditor({ editingHost, onFormSubmit, onBack, }: SSHManagerHostEditorProps) { const { t } = useTranslation(); const { theme: appTheme } = useTheme(); const isDarkMode = appTheme === "dark" || (appTheme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); const editorTheme = isDarkMode ? oneDark : githubLight; const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); const [hosts, setHosts] = useState([]); const [credentials, setCredentials] = useState([]); const [snippets, setSnippets] = useState< Array<{ id: number; name: string; content: string }> >([]); const [proxyMode, setProxyMode] = useState<"single" | "chain">("single"); const [authTab, setAuthTab] = useState< "password" | "key" | "credential" | "none" | "opkssh" >("password"); const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">( "upload", ); const [isSubmitting, setIsSubmitting] = useState(false); const [activeTab, setActiveTab] = useState("general"); const [formError, setFormError] = useState(null); const [guacEnabled, setGuacEnabled] = useState(true); useEffect(() => { setFormError(null); }, [activeTab]); useEffect(() => { getGuacamoleSettings() .then((data) => setGuacEnabled(data.enabled)) .catch(() => {}); }, []); const [statusIntervalUnit, setStatusIntervalUnit] = useState< "seconds" | "minutes" >("seconds"); const [metricsIntervalUnit, setMetricsIntervalUnit] = useState< "seconds" | "minutes" >("seconds"); const ipInputRef = useRef(null); useEffect(() => { const fetchData = async () => { try { const [hostsData, credentialsData, snippetsData] = await Promise.all([ getSSHHosts(), getCredentials(), getSnippets(), ]); setHosts(hostsData); setCredentials(credentialsData as Credential[]); setSnippets(Array.isArray(snippetsData) ? snippetsData : []); const uniqueFolders = [ ...new Set( hostsData .filter((host) => host.folder && host.folder.trim() !== "") .map((host) => host.folder), ), ].sort(); const uniqueConfigurations = [ ...new Set( hostsData .filter((host) => host.name && host.name.trim() !== "") .map((host) => host.name), ), ].sort(); setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); } catch (error) { console.error("Host manager operation failed:", error); } }; fetchData(); }, []); useEffect(() => { const handleCredentialChange = async () => { try { const hostsData = await getSSHHosts(); const uniqueFolders = [ ...new Set( hostsData .filter((host) => host.folder && host.folder.trim() !== "") .map((host) => host.folder), ), ].sort(); const uniqueConfigurations = [ ...new Set( hostsData .filter((host) => host.name && host.name.trim() !== "") .map((host) => host.name), ), ].sort(); setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); } catch (error) { console.error("Host manager operation failed:", error); } }; window.addEventListener("credentials:changed", handleCredentialChange); return () => { window.removeEventListener("credentials:changed", handleCredentialChange); }; }, []); const formSchema = z .object({ connectionType: z.enum(["ssh", "rdp", "vnc", "telnet"]).default("ssh"), name: z.string().optional(), ip: z.string().min(1, t("hosts.ipRequired", "IP address is required")), port: z.coerce.number().min(1).max(65535), username: z.string().optional(), folder: z.string().optional(), tags: z.array(z.string().min(1)).default([]), pin: z.boolean().default(false), authType: z.enum(["password", "key", "credential", "none", "opkssh"]), credentialId: z.number().optional().nullable(), overrideCredentialUsername: z.boolean().optional(), password: z.string().optional(), key: z.any().optional().nullable(), keyPassword: z.string().optional(), keyType: z .enum([ "auto", "ssh-rsa", "ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-dss", "ssh-rsa-sha2-256", "ssh-rsa-sha2-512", ]) .optional(), enableTerminal: z.boolean().default(true), enableTunnel: z.boolean().default(true), tunnelConnections: z .array( z.object({ tunnelType: z .enum(["local", "remote"]) .default("remote") .optional(), sourcePort: z.coerce.number().min(1).max(65535), endpointPort: z.coerce.number().min(1).max(65535), endpointHost: z.string().min(1), endpointPassword: z.string().optional(), endpointKey: z.string().optional(), endpointKeyPassword: z.string().optional(), endpointAuthType: z.string().optional(), endpointKeyType: z.string().optional(), maxRetries: z.coerce.number().min(0).max(100).default(3), retryInterval: z.coerce.number().min(1).max(3600).default(10), autoStart: z.boolean().default(false), }), ) .default([]), enableFileManager: z.boolean().default(true), defaultPath: z.string().optional(), statsConfig: z .object({ enabledWidgets: z .array( z.enum([ "cpu", "memory", "disk", "network", "uptime", "processes", "system", "login_stats", "ports", "firewall", ]), ) .default([ "cpu", "memory", "disk", "network", "uptime", "system", "login_stats", "ports", "firewall", ]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), useGlobalStatusInterval: z.boolean().default(true), metricsEnabled: z.boolean().default(true), metricsInterval: z.number().min(5).max(3600).default(30), useGlobalMetricsInterval: z.boolean().default(true), disableTcpPing: z.boolean().default(false), }) .default({ enabledWidgets: [ "cpu", "memory", "disk", "network", "uptime", "system", "login_stats", "ports", "firewall", ], statusCheckEnabled: true, statusCheckInterval: 30, useGlobalStatusInterval: true, metricsEnabled: true, metricsInterval: 30, useGlobalMetricsInterval: true, disableTcpPing: false, }), terminalConfig: z .object({ cursorBlink: z.boolean(), cursorStyle: z.enum(["block", "underline", "bar"]), fontSize: z.number().min(8).max(24), fontFamily: z.string(), letterSpacing: z.number().min(-2).max(10), lineHeight: z.number().min(1.0).max(2.0), theme: z.string(), scrollback: z.number().min(1000).max(50000), bellStyle: z.enum(["none", "sound", "visual", "both"]), rightClickSelectsWord: z.boolean(), fastScrollModifier: z.enum(["alt", "ctrl", "shift"]), fastScrollSensitivity: z.number().min(1).max(10), minimumContrastRatio: z.number().min(1).max(21), backspaceMode: z.enum(["normal", "control-h"]), agentForwarding: z.boolean(), environmentVariables: z.array( z.object({ key: z.string(), value: z.string(), }), ), startupSnippetId: z.number().nullable(), autoMosh: z.boolean(), moshCommand: z.string(), sudoPasswordAutoFill: z.boolean(), sudoPassword: z.string().optional(), keepaliveInterval: z.number().min(0).max(300000).optional(), keepaliveCountMax: z.number().min(0).max(100).optional(), }) .optional(), forceKeyboardInteractive: z.boolean().optional(), jumpHosts: z .array( z.object({ hostId: z.number().min(1), }), ) .default([]), quickActions: z .array( z.object({ name: z.string().min(1), snippetId: z.number().min(1), }), ) .default([]), notes: z.string().optional(), useSocks5: z.boolean().optional(), socks5Host: z.string().optional(), socks5Port: z.coerce.number().min(1).max(65535).optional(), socks5Username: z.string().optional(), socks5Password: z.string().optional(), socks5ProxyChain: z .array( z.object({ host: z.string().min(1), port: z.number().min(1).max(65535), type: z.union([z.literal(4), z.literal(5)]), username: z.string().optional(), password: z.string().optional(), }), ) .optional(), enableDocker: z.boolean().default(false), domain: z.string().optional(), security: z.string().optional(), ignoreCert: z.boolean().default(true), guacamoleConfig: z.record(z.string(), z.unknown()).optional(), showTerminalInSidebar: z.boolean().default(true), showFileManagerInSidebar: z.boolean().default(false), showTunnelInSidebar: z.boolean().default(false), showDockerInSidebar: z.boolean().default(false), showServerStatsInSidebar: z.boolean().default(false), }) .superRefine((data, ctx) => { if (data.connectionType !== "ssh") { return; } if (!data.username || data.username.trim() === "") { if (data.authType !== "credential") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.usernameRequired", "Username is required"), path: ["username"], }); } } if (data.authType === "none") { return; } if (data.authType === "opkssh") { return; } if (data.authType === "password") { if ( !data.password || (typeof data.password === "string" && data.password.trim() === "") ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.passwordRequired", "Password is required"), path: ["password"], }); } } else if (data.authType === "key") { if ( !data.key || (typeof data.key === "string" && data.key.trim() === "" && data.key !== "existing_key") ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.sshKeyRequired", "SSH key is required"), path: ["key"], }); } if (!data.keyType) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.keyTypeRequired", "Key type is required"), path: ["keyType"], }); } } else if (data.authType === "credential") { if (!data.credentialId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.credentialRequired", "Credential is required"), path: ["credentialId"], }); } } data.tunnelConnections.forEach((connection, index) => { if ( connection.endpointHost && !sshConfigurations.includes(connection.endpointHost) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t("hosts.mustSelectValidSshConfig"), path: ["tunnelConnections", index, "endpointHost"], }); } }); }); type FormData = z.infer; const form = useForm({ resolver: zodResolver(formSchema) as any, mode: "all", defaultValues: { connectionType: "ssh" as const, name: "", ip: "", port: 22, username: "", folder: "", tags: [], pin: false, authType: "password" as const, credentialId: null, overrideCredentialUsername: false, password: "", key: null, keyPassword: "", keyType: "auto" as const, enableTerminal: true, enableTunnel: true, enableFileManager: true, showTerminalInSidebar: true, showFileManagerInSidebar: false, showTunnelInSidebar: false, showDockerInSidebar: false, showServerStatsInSidebar: false, defaultPath: "/", tunnelConnections: [], jumpHosts: [], quickActions: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, notes: "", useSocks5: false, socks5Host: "", socks5Port: 1080, socks5Username: "", socks5Password: "", socks5ProxyChain: [], enableDocker: false, domain: "", security: "any", ignoreCert: true, guacamoleConfig: {}, }, }); const watchedFields = form.watch(); const formState = form.formState; const watchedConnectionType = form.watch("connectionType") || "ssh"; const prevConnectionTypeRef = useRef("ssh"); useEffect(() => { const prev = prevConnectionTypeRef.current; const current = watchedConnectionType; if (prev === current) return; prevConnectionTypeRef.current = current; const portDefaults: Record = { ssh: 22, rdp: 3389, vnc: 5900, telnet: 23, }; const currentPort = form.getValues("port"); const oldDefault = portDefaults[prev] || 22; if (currentPort === oldDefault) { form.setValue("port", portDefaults[current] || 22); } if (current !== "ssh") { const currentStatsConfig = form.getValues("statsConfig"); form.setValue("statsConfig", { ...currentStatsConfig, metricsEnabled: false, disableTcpPing: false, }); } if (activeTab !== "general" && current !== "ssh") { setActiveTab("general"); } }, [watchedConnectionType]); const isFormValid = React.useMemo(() => { const errors = formState.errors; if (!watchedFields.ip) return false; if (watchedFields.connectionType !== "ssh") { const port = Number(watchedFields.port); return !errors.ip && port >= 1 && port <= 65535; } if (!watchedFields.username || watchedFields.username.trim() === "") return false; if (authTab === "password") { if (!watchedFields.password || watchedFields.password.trim() === "") return false; } else if (authTab === "key") { if (!watchedFields.key || !watchedFields.keyType) return false; } else if (authTab === "credential") { if (!watchedFields.credentialId) return false; } else if (authTab === "none") { // No auth required } else if (authTab === "opkssh") { // No auth required } else { return false; } return Object.keys(errors).length === 0; }, [watchedFields, authTab, formState.errors]); useEffect(() => { const updateAuthFields = async () => { form.setValue("authType", authTab, { shouldValidate: true }); if (authTab === "password") { form.setValue("key", null, { shouldValidate: true }); form.setValue("keyPassword", "", { shouldValidate: true }); form.setValue("keyType", "auto", { shouldValidate: true }); form.setValue("credentialId", null, { shouldValidate: true }); } else if (authTab === "key") { form.setValue("password", "", { shouldValidate: true }); form.setValue("credentialId", null, { shouldValidate: true }); } else if (authTab === "credential") { form.setValue("password", "", { shouldValidate: true }); form.setValue("key", null, { shouldValidate: true }); form.setValue("keyPassword", "", { shouldValidate: true }); form.setValue("keyType", "auto", { shouldValidate: true }); const currentCredentialId = form.getValues("credentialId"); const overrideUsername = form.getValues("overrideCredentialUsername"); if (currentCredentialId && !overrideUsername) { const selectedCredential = credentials.find( (c) => c.id === currentCredentialId, ); if (selectedCredential?.username) { form.setValue("username", selectedCredential.username, { shouldValidate: true, }); } } } else if (authTab === "none") { const connectionType = form.getValues("connectionType"); if (connectionType === "ssh") { form.setValue("password", "", { shouldValidate: true }); } form.setValue("key", null, { shouldValidate: true }); form.setValue("keyPassword", "", { shouldValidate: true }); form.setValue("keyType", "auto", { shouldValidate: true }); form.setValue("credentialId", null, { shouldValidate: true }); } else if (authTab === "opkssh") { form.setValue("password", "", { shouldValidate: true }); form.setValue("key", null, { shouldValidate: true }); form.setValue("keyPassword", "", { shouldValidate: true }); form.setValue("keyType", "auto", { shouldValidate: true }); form.setValue("credentialId", null, { shouldValidate: true }); } await form.trigger(); }; updateAuthFields(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [authTab, credentials]); useEffect(() => { if (editingHost) { const cleanedHost = { ...editingHost }; if ((cleanedHost as any).connectionType === "ssh") { if (cleanedHost.credentialId && cleanedHost.key) { cleanedHost.key = undefined; cleanedHost.keyPassword = undefined; cleanedHost.keyType = undefined; } else if (cleanedHost.credentialId && cleanedHost.password) { cleanedHost.password = undefined; } else if (cleanedHost.key && cleanedHost.password) { cleanedHost.password = undefined; } } const defaultAuthType = (cleanedHost.authType || (cleanedHost.credentialId ? "credential" : cleanedHost.key ? "key" : cleanedHost.password ? "password" : "none")) as | "password" | "key" | "credential" | "none" | "opkssh"; setAuthTab(defaultAuthType); let parsedStatsConfig: StatsConfig = DEFAULT_STATS_CONFIG; try { if (cleanedHost.statsConfig) { parsedStatsConfig = typeof cleanedHost.statsConfig === "string" ? JSON.parse(cleanedHost.statsConfig) : (cleanedHost.statsConfig as StatsConfig); } } catch (error) { console.error("Failed to parse statsConfig:", error); } parsedStatsConfig = { ...DEFAULT_STATS_CONFIG, ...parsedStatsConfig }; const formData: Partial = { connectionType: (cleanedHost as any).connectionType || "ssh", name: cleanedHost.name || "", ip: cleanedHost.ip || "", port: cleanedHost.port || 22, username: cleanedHost.username || "", folder: cleanedHost.folder || "", tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [], pin: Boolean(cleanedHost.pin), authType: defaultAuthType as | "password" | "key" | "credential" | "none" | "opkssh", credentialId: cleanedHost.credentialId, overrideCredentialUsername: Boolean( cleanedHost.overrideCredentialUsername, ), password: "", key: null, keyPassword: "", keyType: "auto" as const, enableTerminal: Boolean(cleanedHost.enableTerminal), enableTunnel: Boolean(cleanedHost.enableTunnel), enableFileManager: Boolean(cleanedHost.enableFileManager), defaultPath: cleanedHost.defaultPath || "/", tunnelConnections: Array.isArray(cleanedHost.tunnelConnections) ? cleanedHost.tunnelConnections.map((conn: any) => ({ ...conn, tunnelType: conn.tunnelType || "remote", })) : [], jumpHosts: Array.isArray(cleanedHost.jumpHosts) ? cleanedHost.jumpHosts : [], quickActions: Array.isArray(cleanedHost.quickActions) ? cleanedHost.quickActions : [], statsConfig: parsedStatsConfig, terminalConfig: { ...DEFAULT_TERMINAL_CONFIG, ...(cleanedHost.terminalConfig || {}), environmentVariables: Array.isArray( cleanedHost.terminalConfig?.environmentVariables, ) ? cleanedHost.terminalConfig.environmentVariables : [], sudoPassword: cleanedHost.sudoPassword || cleanedHost.terminalConfig?.sudoPassword || "", sudoPasswordAutoFill: cleanedHost.terminalConfig?.sudoPasswordAutoFill ?? Boolean(cleanedHost.sudoPassword), }, forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), notes: cleanedHost.notes || "", useSocks5: Boolean(cleanedHost.useSocks5), socks5Host: cleanedHost.socks5Host || "", socks5Port: cleanedHost.socks5Port || 1080, socks5Username: cleanedHost.socks5Username || "", socks5Password: cleanedHost.socks5Password || "", socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain) ? cleanedHost.socks5ProxyChain : [], enableDocker: Boolean(cleanedHost.enableDocker), domain: (cleanedHost as any).domain || "", security: (cleanedHost as any).security || "any", ignoreCert: (cleanedHost as any).ignoreCert ?? true, guacamoleConfig: (() => { const cfg = (cleanedHost as any).guacamoleConfig; if (!cfg) return {}; if (typeof cfg === "string") { try { return JSON.parse(cfg); } catch { return {}; } } return cfg; })(), showTerminalInSidebar: cleanedHost.showTerminalInSidebar ?? true, showFileManagerInSidebar: cleanedHost.showFileManagerInSidebar ?? false, showTunnelInSidebar: cleanedHost.showTunnelInSidebar ?? false, showDockerInSidebar: cleanedHost.showDockerInSidebar ?? false, showServerStatsInSidebar: cleanedHost.showServerStatsInSidebar ?? false, }; if ( Array.isArray(cleanedHost.socks5ProxyChain) && cleanedHost.socks5ProxyChain.length > 0 ) { setProxyMode("chain"); } else { setProxyMode("single"); } if (cleanedHost.connectionType !== "ssh") { if (cleanedHost.password) { formData.password = cleanedHost.password; } } else if (defaultAuthType === "password") { formData.password = cleanedHost.password || ""; } else if (defaultAuthType === "key") { formData.key = editingHost.id ? "existing_key" : editingHost.key; formData.keyPassword = cleanedHost.keyPassword || ""; formData.keyType = (cleanedHost.keyType as | "auto" | "ssh-rsa" | "ssh-ed25519" | "ecdsa-sha2-nistp256" | "ecdsa-sha2-nistp384" | "ecdsa-sha2-nistp521" | "ssh-dss" | "ssh-rsa-sha2-256" | "ssh-rsa-sha2-512") || "auto"; } else if (defaultAuthType === "credential") { formData.credentialId = cleanedHost.credentialId; } form.reset(formData as FormData); } else { setAuthTab("password"); const defaultFormData: Partial = { connectionType: "ssh" as const, name: "", ip: "", port: 22, username: "", folder: "", tags: [], pin: false, authType: "password" as const, credentialId: null, overrideCredentialUsername: false, password: "", key: null, keyPassword: "", keyType: "auto" as const, enableTerminal: true, enableTunnel: true, enableFileManager: true, defaultPath: "/", tunnelConnections: [], jumpHosts: [], quickActions: [], statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, enableDocker: false, domain: "", security: "any", ignoreCert: true, guacamoleConfig: {}, showTerminalInSidebar: true, showFileManagerInSidebar: false, showTunnelInSidebar: false, showDockerInSidebar: false, showServerStatsInSidebar: false, }; form.reset(defaultFormData as FormData); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [editingHost]); useEffect(() => { const focusTimer = setTimeout(() => { if (ipInputRef.current) { ipInputRef.current.focus(); } }, 300); return () => clearTimeout(focusTimer); }, [editingHost]); const onSubmit = async (data: FormData) => { try { setIsSubmitting(true); setFormError(null); if (!data.name || data.name.trim() === "") { data.name = `${data.username}@${data.ip}`; } const submitData: Partial = { ...data, }; (submitData as any).connectionType = data.connectionType; (submitData as any).domain = data.domain; (submitData as any).security = data.security; (submitData as any).ignoreCert = data.ignoreCert; (submitData as any).guacamoleConfig = data.guacamoleConfig; if (data.connectionType !== "ssh") { submitData.authType = "none"; submitData.key = undefined; submitData.keyPassword = undefined; submitData.keyType = undefined; submitData.credentialId = undefined; submitData.tunnelConnections = []; submitData.jumpHosts = []; (submitData as any).useSocks5 = false; (submitData as any).socks5ProxyChain = []; submitData.forceKeyboardInteractive = false; submitData.enableTunnel = false; submitData.enableFileManager = false; submitData.enableDocker = false; submitData.enableTerminal = true; } else { if ( data.terminalConfig?.sudoPasswordAutoFill && data.terminalConfig?.sudoPassword ) { submitData.sudoPassword = data.terminalConfig.sudoPassword; } if (data.authType !== "credential") { submitData.credentialId = undefined; } if (data.authType !== "password") { submitData.password = undefined; } if (data.authType !== "key") { submitData.key = undefined; submitData.keyPassword = undefined; submitData.keyType = undefined; } } if (data.authType === "key") { if (data.key instanceof File) { submitData.key = await data.key.text(); } else if (data.key === "existing_key") { delete submitData.key; } } let savedHost; if (editingHost && editingHost.id) { savedHost = await updateSSHHost(editingHost.id, submitData as any); toast.success(t("hosts.hostUpdatedSuccessfully", { name: data.name })); } else { savedHost = await createSSHHost(submitData as any); toast.success(t("hosts.hostAddedSuccessfully", { name: data.name })); } if (savedHost && savedHost.id && data.tunnelConnections) { const hasAutoStartTunnels = data.tunnelConnections.some( (tunnel) => tunnel.autoStart, ); if (hasAutoStartTunnels) { try { await enableAutoStart(savedHost.id); } catch (error) { console.warn( `Failed to enable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error, ); toast.warning( t("hosts.autoStartEnableFailed", { name: data.name }), ); } } else { try { await disableAutoStart(savedHost.id); } catch (error) { console.warn( `Failed to disable AutoStart plaintext cache for SSH host ${savedHost.id}:`, error, ); } } } if (onFormSubmit) { onFormSubmit(savedHost); } window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); if (savedHost?.id) { notifyHostCreatedOrUpdated(savedHost.id); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); toast.error(t("hosts.failedToSaveHost") + ": " + errorMessage); console.error("Failed to save host:", error); } finally { setIsSubmitting(false); } }; const TAB_PRIORITY = [ "general", "terminal", "tunnel", "file_manager", "docker", "statistics", ] as const; const FIELD_TO_TAB_MAP: Record = { ip: "general", port: "general", username: "general", name: "general", folder: "general", tags: "general", pin: "general", password: "general", key: "general", keyPassword: "general", keyType: "general", credentialId: "general", overrideCredentialUsername: "general", forceKeyboardInteractive: "general", jumpHosts: "general", authType: "general", notes: "general", useSocks5: "general", socks5Host: "general", socks5Port: "general", socks5Username: "general", socks5Password: "general", socks5ProxyChain: "general", quickActions: "general", enableTerminal: "terminal", terminalConfig: "terminal", enableDocker: "docker", domain: "general", security: "general", ignoreCert: "general", guacamoleConfig: "remote_desktop", connectionType: "general", enableTunnel: "tunnel", tunnelConnections: "tunnel", enableFileManager: "file_manager", defaultPath: "file_manager", statsConfig: "statistics", }; const handleFormError = async () => { await new Promise((resolve) => setTimeout(resolve, 0)); const errors = form.formState.errors; const errorFields = Object.keys(errors); if (errorFields.length === 0) return; const firstErrorField = errorFields[0]; const firstError = errors[firstErrorField as keyof typeof errors]; const errorMessage = firstError && typeof firstError === "object" && "message" in firstError ? (firstError.message as string) : t("hosts.failedToSaveHost"); toast.error(errorMessage); for (const tab of TAB_PRIORITY) { const hasErrorInTab = errorFields.some((field) => { const baseField = field.split(".")[0].split("[")[0]; return FIELD_TO_TAB_MAP[baseField] === tab; }); if (hasErrorInTab) { setActiveTab(tab); return; } } }; const [tagInput, setTagInput] = useState(""); const [folderDropdownOpen, setFolderDropdownOpen] = useState(false); const folderInputRef = useRef(null); const folderDropdownRef = useRef(null); const folderValue = form.watch("folder"); const filteredFolders = React.useMemo(() => { if (!folderValue) return folders; return folders.filter((f) => f.toLowerCase().includes(folderValue.toLowerCase()), ); }, [folderValue, folders]); const handleFolderClick = (folder: string) => { form.setValue("folder", folder); setFolderDropdownOpen(false); }; useEffect(() => { function handleClickOutside(event: MouseEvent) { if ( folderDropdownRef.current && !folderDropdownRef.current.contains(event.target as Node) && folderInputRef.current && !folderInputRef.current.contains(event.target as Node) ) { setFolderDropdownOpen(false); } } if (folderDropdownOpen) { document.addEventListener("mousedown", handleClickOutside); } else { document.removeEventListener("mousedown", handleClickOutside); } return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [folderDropdownOpen]); const keyTypeOptions = [ { value: "auto", label: t("hosts.autoDetect") }, { value: "ssh-rsa", label: t("hosts.rsa") }, { value: "ssh-ed25519", label: t("hosts.ed25519") }, { value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") }, { value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") }, { value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") }, { value: "ssh-dss", label: t("hosts.dsa") }, { value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") }, { value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") }, ]; const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false); const keyTypeButtonRef = useRef(null); const keyTypeDropdownRef = useRef(null); useEffect(() => { function onClickOutside(event: MouseEvent) { if ( keyTypeDropdownOpen && keyTypeDropdownRef.current && !keyTypeDropdownRef.current.contains(event.target as Node) && keyTypeButtonRef.current && !keyTypeButtonRef.current.contains(event.target as Node) ) { setKeyTypeDropdownOpen(false); } } document.addEventListener("mousedown", onClickOutside); return () => document.removeEventListener("mousedown", onClickOutside); }, [keyTypeDropdownOpen]); const [sshConfigDropdownOpen, setSshConfigDropdownOpen] = useState<{ [key: number]: boolean; }>({}); const sshConfigInputRefs = useRef<{ [key: number]: HTMLInputElement | null }>( {}, ); const sshConfigDropdownRefs = useRef<{ [key: number]: HTMLDivElement | null; }>({}); const getFilteredSshConfigs = (index: number) => { const value = form.watch(`tunnelConnections.${index}.endpointHost`); const currentHostId = editingHost?.id; let filtered = sshConfigurations; if (currentHostId) { const currentHostName = hosts.find((h) => h.id === currentHostId)?.name; if (currentHostName) { filtered = sshConfigurations.filter( (config) => config !== currentHostName, ); } } else { const currentHostName = form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`; filtered = sshConfigurations.filter( (config) => config !== currentHostName, ); } if (value) { filtered = filtered.filter((config) => config.toLowerCase().includes(value.toLowerCase()), ); } return filtered; }; const handleSshConfigClick = (config: string, index: number) => { form.setValue(`tunnelConnections.${index}.endpointHost`, config); setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false })); }; useEffect(() => { function handleSshConfigClickOutside(event: MouseEvent) { const openDropdowns = Object.keys(sshConfigDropdownOpen).filter( (key) => sshConfigDropdownOpen[parseInt(key)], ); openDropdowns.forEach((indexStr: string) => { const index = parseInt(indexStr); if ( sshConfigDropdownRefs.current[index] && !sshConfigDropdownRefs.current[index]?.contains( event.target as Node, ) && sshConfigInputRefs.current[index] && !sshConfigInputRefs.current[index]?.contains(event.target as Node) ) { setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false })); } }); } const hasOpenDropdowns = Object.values(sshConfigDropdownOpen).some( (open) => open, ); if (hasOpenDropdowns) { document.addEventListener("mousedown", handleSshConfigClickOutside); } else { document.removeEventListener("mousedown", handleSshConfigClickOutside); } return () => { document.removeEventListener("mousedown", handleSshConfigClickOutside); }; }, [sshConfigDropdownOpen]); return (
{formError && ( {formError} )}
{onBack && ( )}

{editingHost ? editingHost.id ? t("hosts.editHost") : t("hosts.cloneHost") : t("hosts.addHost")}

{guacEnabled && ( ( {t("hosts.ssh")} {t("hosts.rdp")} {t("hosts.vnc")} {t("hosts.telnet")} )} /> )} {t("hosts.general")} {watchedConnectionType === "ssh" && ( <> {t("hosts.terminal")} Docker {t("hosts.tunnel")} {t("hosts.fileManager")} )} {watchedConnectionType === "ssh" ? t("hosts.statistics") : t("hosts.status")} {watchedConnectionType !== "ssh" && ( {t("hosts.remoteDesktop")} )} {watchedConnectionType === "ssh" && !editingHost?.isShared && ( {t("rbac.sharing")} )} {watchedConnectionType === "ssh" && ( <> )} {watchedConnectionType === "ssh" ? ( ) : ( )} {watchedConnectionType !== "ssh" && ( )} {watchedConnectionType === "ssh" && ( )}
); }