Files
Termix/src/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx

1148 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
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<HTMLInputElement>(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<typeof formSchema>;
const form = useForm<FormData>({
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<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(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 (
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0 h-full">
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">{t('hosts.general')}</TabsTrigger>
<TabsTrigger value="terminal">{t('hosts.terminal')}</TabsTrigger>
<TabsTrigger value="tunnel">{t('hosts.tunnel')}</TabsTrigger>
<TabsTrigger value="file_manager">{t('hosts.fileManager')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">{t('hosts.connectionDetails')}</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="ip"
render={({field}) => (
<FormItem className="col-span-5">
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.ipAddress')}
{...field}
ref={(e) => {
field.ref(e);
ipInputRef.current = e;
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="port"
render={({field}) => (
<FormItem className="col-span-1">
<FormLabel>{t('hosts.port')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.port')} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>{t('hosts.username')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.username')} {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.organization')}</FormLabel>
<div className="grid grid-cols-26 gap-4">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem className="col-span-10">
<FormLabel>{t('hosts.name')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.hostname')} {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({field}) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t('hosts.folder')}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t('placeholders.folder')}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={e => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
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"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({field}) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t('hosts.tags')}</FormLabel>
<FormControl>
<div
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
{field.value.map((tag: string, idx: number) => (
<span key={tag + idx}
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs">
{tag}
<button
type="button"
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
onClick={() => {
const newTags = field.value.filter((_: string, i: number) => i !== idx);
field.onChange(newTags);
}}
>
×
</button>
</span>
))}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
value={tagInput}
onChange={e => 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')}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="pin"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>{t('hosts.pin')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.authentication')}</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
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"
>
<TabsList>
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
<TabsTrigger value="credential">{t('hosts.credential')}</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t('hosts.password')}</FormLabel>
<FormControl>
<Input type="password" placeholder={t('placeholders.password')} {...field} />
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<div className="grid grid-cols-15 gap-4">
<Controller
control={form.control}
name="key"
render={({field}) => (
<FormItem className="col-span-4 overflow-hidden min-w-0">
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel>
<FormControl>
<div className="relative min-w-0">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
>
<span className="block w-full truncate"
title={field.value?.name || t('hosts.upload')}>
{field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPassword"
render={({field}) => (
<FormItem className="col-span-8">
<FormLabel>{t('hosts.keyPassword')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.keyPassword')}
type="password"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({field}) => (
<FormItem className="relative col-span-3">
<FormLabel>{t('hosts.keyType')}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('hosts.autoDetect')}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-[#18181b] border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-[#18181b] text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="credential">
<FormField
control={form.control}
name="credentialId"
render={({ field }) => (
<CredentialSelector
value={field.value}
onValueChange={field.onChange}
/>
)}
/>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="terminal">
<FormField
control={form.control}
name="enableTerminal"
render={({field}) => (
<FormItem>
<FormLabel>{t('hosts.enableTerminal')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t('hosts.enableTerminalDesc')}
</FormDescription>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="tunnel">
<FormField
control={form.control}
name="enableTunnel"
render={({field}) => (
<FormItem>
<FormLabel>{t('hosts.enableTunnel')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t('hosts.enableTunnelDesc')}
</FormDescription>
</FormItem>
)}
/>
{form.watch('enableTunnel') && (
<>
<Alert className="mt-4">
<AlertDescription>
<strong>{t('hosts.sshpassRequired')}</strong>
<div>
{t('hosts.sshpassRequiredDesc')} <code
className="bg-muted px-1 rounded inline">sudo apt install
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
</div>
<div className="mt-2">
<strong>{t('hosts.otherInstallMethods')}</strong>
<div> {t('hosts.centosRhelFedora')} <code
className="bg-muted px-1 rounded inline">sudo yum install
sshpass</code> or <code
className="bg-muted px-1 rounded inline">sudo dnf install
sshpass</code></div>
<div> {t('hosts.macos')} <code className="bg-muted px-1 rounded inline">brew
install hudochenkov/sshpass/sshpass</code></div>
<div> {t('hosts.windows')}</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>{t('hosts.sshServerConfigRequired')}</strong>
<div>{t('hosts.sshServerConfigDesc')}</div>
<div> <code className="bg-muted px-1 rounded inline">GatewayPorts
yes</code> {t('hosts.gatewayPortsYes')}
</div>
<div> <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
yes</code> {t('hosts.allowTcpForwardingYes')}
</div>
<div> <code className="bg-muted px-1 rounded inline">PermitRootLogin
yes</code> {t('hosts.permitRootLoginYes')}
</div>
<div className="mt-2">{t('hosts.editSshConfig')}</div>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="tunnelConnections"
render={({field}) => (
<FormItem className="mt-4">
<FormLabel>{t('hosts.tunnelConnections')}</FormLabel>
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
<div key={index}
className="p-4 border rounded-lg bg-muted/50">
<div
className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">{t('hosts.connection')} {index + 1}</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const newConnections = field.value.filter((_, i) => i !== index);
field.onChange(newConnections);
}}
>
{t('hosts.remove')}
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.sourcePort`}
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>{t('hosts.sourcePort')}
{t('hosts.sourcePortDesc')}</FormLabel>
<FormControl>
<Input
placeholder="22" {...sourcePortField} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointPort`}
render={({field: endpointPortField}) => (
<FormItem className="col-span-4">
<FormLabel>{t('hosts.endpointPort')}</FormLabel>
<FormControl>
<Input
placeholder="224" {...endpointPortField} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.endpointHost`}
render={({field: endpointHostField}) => (
<FormItem
className="col-span-4 relative">
<FormLabel>{t('hosts.endpointSshConfig')}</FormLabel>
<FormControl>
<Input
ref={(el) => {
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
}));
}}
/>
</FormControl>
{sshConfigDropdownOpen[index] && getFilteredSshConfigs(index).length > 0 && (
<div
ref={(el) => {
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"
>
<div
className="grid grid-cols-1 gap-1 p-0">
{getFilteredSshConfigs(index).map((config) => (
<Button
key={config}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleSshConfigClick(config, index)}
>
{config}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground mt-2">
{t('hosts.tunnelForwardDescription', {
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22',
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224'
})}
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
<FormField
control={form.control}
name={`tunnelConnections.${index}.maxRetries`}
render={({field: maxRetriesField}) => (
<FormItem className="col-span-4">
<FormLabel>{t('hosts.maxRetries')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.maxRetries')} {...maxRetriesField} />
</FormControl>
<FormDescription>
{t('hosts.maxRetriesDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.retryInterval`}
render={({field: retryIntervalField}) => (
<FormItem className="col-span-4">
<FormLabel>{t('hosts.retryInterval')}</FormLabel>
<FormControl>
<Input
placeholder={t('placeholders.retryInterval')} {...retryIntervalField} />
</FormControl>
<FormDescription>
{t('hosts.retryIntervalDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`tunnelConnections.${index}.autoStart`}
render={({field}) => (
<FormItem className="col-span-4">
<FormLabel>{t('hosts.autoStartContainer')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t('hosts.autoStartDesc')}
</FormDescription>
</FormItem>
)}
/>
</div>
</div>
))}
<Button
type="button"
variant="outline"
onClick={() => {
field.onChange([...field.value, {
sourcePort: 22,
endpointPort: 224,
endpointHost: "",
maxRetries: 3,
retryInterval: 10,
autoStart: false,
}]);
}}
>
{t('hosts.addConnection')}
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</>
)}
</TabsContent>
<TabsContent value="file_manager">
<FormField
control={form.control}
name="enableFileManager"
render={({field}) => (
<FormItem>
<FormLabel>{t('hosts.enableFileManager')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
{t('hosts.enableFileManagerDesc')}
</FormDescription>
</FormItem>
)}
/>
{form.watch('enableFileManager') && (
<div className="mt-4">
<FormField
control={form.control}
name="defaultPath"
render={({field}) => (
<FormItem>
<FormLabel>{t('hosts.defaultPath')}</FormLabel>
<FormControl>
<Input placeholder={t('placeholders.homePath')} {...field} />
</FormControl>
<FormDescription>{t('hosts.defaultPathDesc')}</FormDescription>
</FormItem>
)}
/>
</div>
)}
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25"/>
<Button
className=""
type="submit"
variant="outline"
style={{
transform: 'translateY(8px)'
}}
>
{editingHost ? t('hosts.updateHost') : t('hosts.addHost')}
</Button>
</footer>
</form>
</Form>
</div>
);
}