import {zodResolver} from "@hookform/resolvers/zod" import {Controller, useForm} from "react-hook-form" import {z} from "zod" import {Button} from "@/components/ui/button.tsx" import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form.tsx"; import {Input} from "@/components/ui/input.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} from "@/components/ui/alert.tsx"; import {toast} from "sonner"; import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts'; import {useTranslation} from "react-i18next"; import {CredentialSelector} from "@/components/CredentialSelector.tsx"; interface SSHHost { id: number; name: string; ip: string; port: number; username: string; folder: string; tags: string[]; pin: boolean; authType: string; password?: string; key?: string; keyPassword?: string; keyType?: string; enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; defaultPath: string; tunnelConnections: any[]; createdAt: string; updatedAt: string; credentialId?: number; } interface SSHManagerHostEditorProps { editingHost?: SSHHost | null; onFormSubmit?: () => void; } export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) { const {t} = useTranslation(); const [hosts, setHosts] = useState([]); const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); const [loading, setLoading] = useState(true); const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password'); // Ref for the IP address input to manage focus const ipInputRef = useRef(null); useEffect(() => { const fetchData = async () => { try { setLoading(true); const hostsData = await getSSHHosts(); setHosts(hostsData); 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) { } finally { setLoading(false); } }; fetchData(); }, []); const formSchema = z.object({ name: z.string().optional(), ip: z.string().min(1), port: z.coerce.number().min(1).max(65535), username: z.string().min(1), folder: z.string().optional(), tags: z.array(z.string().min(1)).default([]), pin: z.boolean().default(false), authType: z.enum(['password', 'key', 'credential']), credentialId: z.number().optional().nullable(), 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({ sourcePort: z.coerce.number().min(1).max(65535), endpointPort: z.coerce.number().min(1).max(65535), endpointHost: z.string().min(1), 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(), }).superRefine((data, ctx) => { if (data.authType === 'password') { if (!data.password || data.password.trim() === '') { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('hosts.passwordRequired'), path: ['password'] }); } } else if (data.authType === 'key') { if (!data.key) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('hosts.sshKeyRequired'), path: ['key'] }); } if (!data.keyType) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('hosts.keyTypeRequired'), path: ['keyType'] }); } } else if (data.authType === 'credential') { if (!data.credentialId) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: t('hosts.credentialRequired'), 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, defaultValues: { name: editingHost?.name || "", ip: editingHost?.ip || "", port: editingHost?.port || 22, username: editingHost?.username || "", folder: editingHost?.folder || "", tags: editingHost?.tags || [], pin: editingHost?.pin || false, authType: (editingHost?.authType as 'password' | 'key' | 'credential') || "password", credentialId: editingHost?.credentialId || null, password: "", key: null, keyPassword: "", keyType: "auto", enableTerminal: editingHost?.enableTerminal !== false, enableTunnel: editingHost?.enableTunnel !== false, enableFileManager: editingHost?.enableFileManager !== false, defaultPath: editingHost?.defaultPath || "/", tunnelConnections: editingHost?.tunnelConnections || [], } }); useEffect(() => { if (editingHost) { const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password'); setAuthTab(defaultAuthType); form.reset({ name: editingHost.name || "", ip: editingHost.ip || "", port: editingHost.port || 22, username: editingHost.username || "", folder: editingHost.folder || "", tags: editingHost.tags || [], pin: editingHost.pin || false, authType: defaultAuthType as 'password' | 'key' | 'credential', credentialId: editingHost.credentialId || null, password: editingHost.password || "", key: null, keyPassword: editingHost.keyPassword || "", keyType: (editingHost.keyType as any) || "auto", enableTerminal: editingHost.enableTerminal !== false, enableTunnel: editingHost.enableTunnel !== false, enableFileManager: editingHost.enableFileManager !== false, defaultPath: editingHost.defaultPath || "/", tunnelConnections: editingHost.tunnelConnections || [], }); } else { setAuthTab('password'); form.reset({ name: "", ip: "", port: 22, username: "", folder: "", tags: [], pin: false, authType: "password", credentialId: null, password: "", key: null, keyPassword: "", keyType: "auto", enableTerminal: true, enableTunnel: true, enableFileManager: true, defaultPath: "/", tunnelConnections: [], }); } }, [editingHost, form]); // Focus the IP address field when the component mounts or when editingHost changes useEffect(() => { const focusTimer = setTimeout(() => { if (ipInputRef.current) { ipInputRef.current.focus(); } }, 300); return () => clearTimeout(focusTimer); }, []); // Focus on mount // Also focus when editingHost changes (for tab switching) useEffect(() => { const focusTimer = setTimeout(() => { if (ipInputRef.current) { ipInputRef.current.focus(); } }, 300); return () => clearTimeout(focusTimer); }, [editingHost]); const onSubmit = async (data: any) => { try { const formData = data as FormData; if (!formData.name || formData.name.trim() === '') { formData.name = `${formData.username}@${formData.ip}`; } const submitData: any = { name: formData.name, ip: formData.ip, port: formData.port, username: formData.username, folder: formData.folder, tags: formData.tags, pin: formData.pin, authType: formData.authType, enableTerminal: formData.enableTerminal, enableTunnel: formData.enableTunnel, enableFileManager: formData.enableFileManager, defaultPath: formData.defaultPath, tunnelConnections: formData.tunnelConnections }; if (formData.authType === 'credential') { submitData.credentialId = formData.credentialId; submitData.password = null; submitData.key = null; submitData.keyPassword = null; submitData.keyType = null; } else if (formData.authType === 'password') { submitData.credentialId = null; submitData.password = formData.password; submitData.key = null; submitData.keyPassword = null; submitData.keyType = null; } else if (formData.authType === 'key') { submitData.credentialId = null; submitData.password = null; if (formData.key instanceof File) { const keyContent = await formData.key.text(); submitData.key = keyContent; } else { submitData.key = formData.key; } submitData.keyPassword = formData.keyPassword; submitData.keyType = formData.keyType; } if (editingHost) { await updateSSHHost(editingHost.id, submitData); toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name })); } else { await createSSHHost(submitData); toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name })); } if (onFormSubmit) { onFormSubmit(); } window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (error) { toast.error(t('hosts.failedToSaveHost')); } }; 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 currentHostName = form.watch('name') || `${form.watch('username')}@${form.watch('ip')}`; let 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 (
{t('hosts.general')} {t('hosts.terminal')} {t('hosts.tunnel')} {t('hosts.fileManager')} {t('hosts.connectionDetails')}
( {t('hosts.ipAddress')} { field.ref(e); ipInputRef.current = e; }} /> )} /> ( {t('hosts.port')} )} /> ( {t('hosts.username')} )} />
{t('hosts.organization')}
( {t('hosts.name')} )} /> ( {t('hosts.folder')} setFolderDropdownOpen(true)} onChange={e => { field.onChange(e); setFolderDropdownOpen(true); }} /> {folderDropdownOpen && filteredFolders.length > 0 && (
{filteredFolders.map((folder) => ( ))}
)}
)} /> ( {t('hosts.tags')}
{field.value.map((tag: string, idx: number) => ( {tag} ))} setTagInput(e.target.value)} onKeyDown={e => { if (e.key === " " && tagInput.trim() !== "") { e.preventDefault(); if (!field.value.includes(tagInput.trim())) { field.onChange([...field.value, tagInput.trim()]); } setTagInput(""); } else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) { field.onChange(field.value.slice(0, -1)); } }} placeholder={t('hosts.addTagsSpaceToAdd')} />
)} /> ( {t('hosts.pin')} )} />
{t('hosts.authentication')} { setAuthTab(value as 'password' | 'key' | 'credential'); form.setValue('authType', value as 'password' | 'key' | 'credential'); // Clear other auth fields when switching if (value === 'password') { form.setValue('key', null); form.setValue('keyPassword', ''); form.setValue('credentialId', null); } else if (value === 'key') { form.setValue('password', ''); form.setValue('credentialId', null); } else if (value === 'credential') { form.setValue('password', ''); form.setValue('key', null); form.setValue('keyPassword', ''); } }} className="flex-1 flex flex-col h-full min-h-0" > {t('hosts.password')} {t('hosts.key')} {t('hosts.credential')} ( {t('hosts.password')} )} />
( {t('hosts.sshPrivateKey')}
{ const file = e.target.files?.[0]; field.onChange(file || null); }} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
)} /> ( {t('hosts.keyPassword')} )} /> ( {t('hosts.keyType')}
{keyTypeDropdownOpen && (
{keyTypeOptions.map((opt) => ( ))}
)}
)} />
( )} />
( {t('hosts.enableTerminal')} {t('hosts.enableTerminalDesc')} )} /> ( {t('hosts.enableTunnel')} {t('hosts.enableTunnelDesc')} )} /> {form.watch('enableTunnel') && ( <> {t('hosts.sshpassRequired')}
{t('hosts.sshpassRequiredDesc')} sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS.
{t('hosts.otherInstallMethods')}
• {t('hosts.centosRhelFedora')} sudo yum install sshpass or sudo dnf install sshpass
• {t('hosts.macos')} brew install hudochenkov/sshpass/sshpass
• {t('hosts.windows')}
{t('hosts.sshServerConfigRequired')}
{t('hosts.sshServerConfigDesc')}
GatewayPorts yes {t('hosts.gatewayPortsYes')}
AllowTcpForwarding yes {t('hosts.allowTcpForwardingYes')}
PermitRootLogin yes {t('hosts.permitRootLoginYes')}
{t('hosts.editSshConfig')}
( {t('hosts.tunnelConnections')}
{field.value.map((connection, index) => (

{t('hosts.connection')} {index + 1}

( {t('hosts.sourcePort')} {t('hosts.sourcePortDesc')} )} /> ( {t('hosts.endpointPort')} )} /> ( {t('hosts.endpointSshConfig')} { sshConfigInputRefs.current[index] = el; }} placeholder={t('placeholders.sshConfig')} className="min-h-[40px]" autoComplete="off" value={endpointHostField.value} onFocus={() => setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true }))} onChange={e => { endpointHostField.onChange(e); setSshConfigDropdownOpen(prev => ({ ...prev, [index]: true })); }} /> {sshConfigDropdownOpen[index] && getFilteredSshConfigs(index).length > 0 && (
{ sshConfigDropdownRefs.current[index] = el; }} className="absolute top-full left-0 z-50 mt-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1" >
{getFilteredSshConfigs(index).map((config) => ( ))}
)}
)} />

{t('hosts.tunnelForwardDescription', { sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22', endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224' })}

( {t('hosts.maxRetries')} {t('hosts.maxRetriesDescription')} )} /> ( {t('hosts.retryInterval')} {t('hosts.retryIntervalDescription')} )} /> ( {t('hosts.autoStartContainer')} {t('hosts.autoStartDesc')} )} />
))}
)} /> )}
( {t('hosts.enableFileManager')} {t('hosts.enableFileManagerDesc')} )} /> {form.watch('enableFileManager') && (
( {t('hosts.defaultPath')} {t('hosts.defaultPathDesc')} )} />
)}
); }