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

1283 lines
78 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 {PasswordInput} from "@/components/ui/password-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, getCredentials} from '@/ui/main-axios.ts';
import {useTranslation} from "react-i18next";
import {CredentialSelector} from "@/ui/Desktop/Apps/Credentials/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?: (updatedHost?: SSHHost) => void;
}
export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [credentials, setCredentials] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [authTab, setAuthTab] = useState<'password' | 'key' | 'credential'>('password');
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload');
const isSubmittingRef = useRef(false);
// Ref for the IP address input to manage focus
const ipInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const [hostsData, credentialsData] = await Promise.all([
getSSHHosts(),
getCredentials()
]);
setHosts(hostsData);
setCredentials(credentialsData);
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();
}, []);
// Listen for credential changes to refresh the credential list
useEffect(() => {
const handleCredentialChange = 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) {
// Handle error silently
} finally {
setLoading(false);
}
};
window.addEventListener('credentials:changed', handleCredentialChange);
return () => {
window.removeEventListener('credentials:changed', handleCredentialChange);
};
}, []);
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 || (typeof data.key === 'string' && data.key.trim() === '')) {
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 || (typeof data.credentialId === 'string' && data.credentialId.trim() === '')) {
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: "",
ip: "",
port: 22,
username: "",
folder: "",
tags: [],
pin: false,
authType: "password" as const,
credentialId: null,
password: "",
key: null,
keyPassword: "",
keyType: "auto" as const,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
}
});
// Update username when switching to credential tab and a credential is selected
useEffect(() => {
if (authTab === 'credential') {
const currentCredentialId = form.getValues('credentialId');
if (currentCredentialId) {
const selectedCredential = credentials.find(c => c.id === currentCredentialId);
if (selectedCredential) {
form.setValue('username', selectedCredential.username);
}
}
}
}, [authTab, credentials, form]);
useEffect(() => {
if (editingHost) {
const cleanedHost = { ...editingHost };
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.credentialId ? 'credential' : (cleanedHost.key ? 'key' : 'password');
setAuthTab(defaultAuthType);
const formData = {
name: cleanedHost.name || "",
ip: cleanedHost.ip || "",
port: cleanedHost.port || 22,
username: cleanedHost.username || "",
folder: cleanedHost.folder || "",
tags: cleanedHost.tags || [],
pin: Boolean(cleanedHost.pin),
authType: defaultAuthType as 'password' | 'key' | 'credential',
credentialId: null,
password: "",
key: null,
keyPassword: "",
keyType: "auto" as const,
enableTerminal: Boolean(cleanedHost.enableTerminal),
enableTunnel: Boolean(cleanedHost.enableTunnel),
enableFileManager: Boolean(cleanedHost.enableFileManager),
defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [],
};
// Only set the relevant authentication fields based on authType
if (defaultAuthType === 'password') {
formData.password = cleanedHost.password || "";
} else if (defaultAuthType === 'key') {
formData.key = "existing_key"; // Placeholder to indicate existing key
formData.keyPassword = cleanedHost.keyPassword || "";
formData.keyType = (cleanedHost.keyType as any) || "auto";
} else if (defaultAuthType === 'credential') {
formData.credentialId = cleanedHost.credentialId || "existing_credential";
}
form.reset(formData);
} else {
setAuthTab('password');
const defaultFormData = {
name: "",
ip: "",
port: 22,
username: "",
folder: "",
tags: [],
pin: false,
authType: "password" as const,
credentialId: null,
password: "",
key: null,
keyPassword: "",
keyType: "auto" as const,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
};
form.reset(defaultFormData);
}
}, [editingHost?.id]);
useEffect(() => {
const focusTimer = setTimeout(() => {
if (ipInputRef.current) {
ipInputRef.current.focus();
}
}, 300);
return () => clearTimeout(focusTimer);
}, [editingHost]);
const onSubmit = async (data: FormData) => {
try {
isSubmittingRef.current = true;
if (!data.name || data.name.trim() === '') {
data.name = `${data.username}@${data.ip}`;
}
const submitData: any = {
name: data.name,
ip: data.ip,
port: data.port,
username: data.username,
folder: data.folder || "",
tags: data.tags || [],
pin: Boolean(data.pin),
authType: data.authType,
enableTerminal: Boolean(data.enableTerminal),
enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || []
};
submitData.credentialId = null;
submitData.password = null;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType === 'credential') {
if (data.credentialId === "existing_credential") {
delete submitData.credentialId;
} else {
submitData.credentialId = data.credentialId;
}
} else if (data.authType === 'password') {
submitData.password = data.password;
} else if (data.authType === 'key') {
if (data.key instanceof File) {
const keyContent = await data.key.text();
submitData.key = keyContent;
} else if (data.key === "existing_key") {
delete submitData.key;
} else {
submitData.key = data.key;
}
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
if (editingHost) {
const updatedHost = await updateSSHHost(editingHost.id, submitData);
toast.success(t('hosts.hostUpdatedSuccessfully', { name: data.name }));
if (onFormSubmit) {
onFormSubmit(updatedHost);
}
} else {
const newHost = await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: data.name }));
if (onFormSubmit) {
onFormSubmit(newHost);
}
}
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
// Reset form after successful submission
form.reset();
} catch (error) {
toast.error(t('hosts.failedToSaveHost'));
} finally {
isSubmittingRef.current = false;
}
};
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-dark-bg 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-dark-bg-input 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) => {
const newAuthType = value as 'password' | 'key' | 'credential';
setAuthTab(newAuthType);
form.setValue('authType', newAuthType);
// Clear authentication fields based on what we're switching away from
if (newAuthType === 'password') {
form.setValue('key', null);
form.setValue('keyPassword', '');
form.setValue('keyType', 'auto');
form.setValue('credentialId', null);
} else if (newAuthType === 'key') {
form.setValue('password', '');
form.setValue('credentialId', null);
} else if (newAuthType === 'credential') {
form.setValue('password', '');
form.setValue('key', null);
form.setValue('keyPassword', '');
form.setValue('keyType', 'auto');
}
}}
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>
<PasswordInput placeholder={t('placeholders.password')} {...field} />
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<Tabs
value={keyInputMethod}
onValueChange={(value) => {
setKeyInputMethod(value as 'upload' | 'paste');
// Clear the other field when switching
if (value === 'upload') {
form.setValue('key', null);
} else {
form.setValue('key', '');
}
}}
className="w-full"
>
<TabsList className="inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<TabsTrigger value="upload">{t('hosts.uploadFile')}</TabsTrigger>
<TabsTrigger value="paste">{t('hosts.pasteKey')}</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="mt-4">
<Controller
control={form.control}
name="key"
render={({field}) => (
<FormItem className="mb-4">
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel>
<FormControl>
<div className="relative inline-block">
<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="justify-start text-left"
>
<span className="truncate"
title={field.value?.name || t('hosts.upload')}>
{field.value === "existing_key" ? t('hosts.existingKey') :
field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="paste" className="mt-4">
<Controller
control={form.control}
name="key"
render={({field}) => (
<FormItem className="mb-4">
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel>
<FormControl>
<textarea
placeholder={t('placeholders.pastePrivateKey')}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={typeof field.value === 'string' ? field.value : ''}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
</Tabs>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({field}) => (
<FormItem className="col-span-8">
<FormLabel>{t('hosts.keyPassword')}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t('placeholders.keyPassword')}
{...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-dark-bg 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-dark-bg 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-dark-bg 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 }) => (
<FormItem>
<CredentialSelector
value={field.value}
onValueChange={field.onChange}
onCredentialSelect={(credential) => {
if (credential) {
// Update username when credential is selected
form.setValue('username', credential.username);
}
}}
/>
<FormDescription>
{t('hosts.credentialDescription')}
</FormDescription>
</FormItem>
)}
/>
</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-dark-bg 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="translate-y-2"
type="submit"
variant="outline"
>
{editingHost ? t('hosts.updateHost') : t('hosts.addHost')}
</Button>
</footer>
</form>
</Form>
</div>
);
}