Improve logging frontend/backend, fix host form being reversed.

This commit is contained in:
LukeGus
2025-09-09 15:38:29 -05:00
parent 67dd87fc55
commit 4c33b43a0f
21 changed files with 658 additions and 669 deletions

View File

@@ -21,7 +21,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"
import { toast } from "sonner"
import { createCredential, updateCredential, getCredentials, getCredentialDetails } from '@/ui/main-axios'
import { useTranslation } from "react-i18next"
import type { Credential, CredentialEditorProps } from '../../../types/index.js'
import type { Credential, CredentialEditorProps, CredentialData } from '../../../types/index.js'
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
const { t } = useTranslation();
@@ -31,6 +31,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
const [fullCredentialDetails, setFullCredentialDetails] = useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<'password' | 'key'>('password');
const [keyInputMethod, setKeyInputMethod] = useState<'upload' | 'paste'>('upload');
useEffect(() => {
const fetchData = async () => {
@@ -84,9 +85,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
key: z.any().optional().nullable(),
keyPassword: z.string().optional(),
keyType: z.enum([
'rsa',
'ecdsa',
'ed25519'
'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(),
}).superRefine((data, ctx) => {
if (data.authType === 'password') {
@@ -122,14 +129,13 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
password: "",
key: null,
keyPassword: "",
keyType: "rsa",
keyType: "auto",
}
});
useEffect(() => {
if (editingCredential && fullCredentialDetails) {
const defaultAuthType = fullCredentialDetails.authType;
setAuthTab(defaultAuthType);
form.reset({
@@ -142,11 +148,10 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
password: fullCredentialDetails.password || "",
key: null,
keyPassword: fullCredentialDetails.keyPassword || "",
keyType: (fullCredentialDetails.keyType as any) || "rsa",
keyType: (fullCredentialDetails.keyType as any) || "auto",
});
} else if (!editingCredential) {
setAuthTab('password');
form.reset({
name: "",
description: "",
@@ -157,52 +162,43 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
password: "",
key: null,
keyPassword: "",
keyType: "rsa",
keyType: "auto",
});
}
}, [editingCredential, fullCredentialDetails, form]);
}, [editingCredential?.id, fullCredentialDetails]);
const onSubmit = async (data: any) => {
const onSubmit = async (data: FormData) => {
try {
const formData = data as FormData;
if (!formData.name || formData.name.trim() === '') {
formData.name = formData.username;
if (!data.name || data.name.trim() === '') {
data.name = data.username;
}
const submitData: any = {
name: formData.name,
description: formData.description,
folder: formData.folder,
tags: formData.tags,
authType: formData.authType,
username: formData.username,
keyType: formData.keyType
const submitData: CredentialData = {
name: data.name,
description: data.description,
folder: data.folder,
tags: data.tags,
authType: data.authType,
username: data.username,
keyType: data.keyType
};
if (formData.password !== undefined) {
submitData.password = formData.password;
}
if (formData.key !== undefined) {
if (formData.key instanceof File) {
const keyContent = await formData.key.text();
submitData.key = keyContent;
} else {
submitData.key = formData.key;
}
}
if (formData.keyPassword !== undefined) {
submitData.keyPassword = formData.keyPassword;
if (data.authType === 'password') {
submitData.password = data.password;
submitData.key = undefined;
submitData.keyPassword = undefined;
} else if (data.authType === 'key') {
submitData.key = data.key instanceof File ? await data.key.text() : data.key;
submitData.keyPassword = data.keyPassword;
submitData.password = undefined;
}
if (editingCredential) {
await updateCredential(editingCredential.id, submitData);
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: formData.name }));
toast.success(t('credentials.credentialUpdatedSuccessfully', { name: data.name }));
} else {
await createCredential(submitData);
toast.success(t('credentials.credentialAddedSuccessfully', { name: formData.name }));
toast.success(t('credentials.credentialAddedSuccessfully', { name: data.name }));
}
if (onFormSubmit) {
@@ -256,9 +252,15 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
}, [folderDropdownOpen]);
const keyTypeOptions = [
{ value: 'rsa', label: t('credentials.keyTypeRSA') },
{ value: 'ecdsa', label: t('credentials.keyTypeECDSA') },
{ value: 'ed25519', label: t('credentials.keyTypeEd25519') },
{ 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);
@@ -436,13 +438,16 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
<Tabs
value={authTab}
onValueChange={(value) => {
setAuthTab(value as 'password' | 'key');
form.setValue('authType', value as 'password' | 'key');
const newAuthType = value as 'password' | 'key';
setAuthTab(newAuthType);
form.setValue('authType', newAuthType);
// Clear other auth fields when switching
if (value === 'password') {
if (newAuthType === 'password') {
form.setValue('key', null);
form.setValue('keyPassword', '');
} else if (value === 'key') {
form.setValue('keyType', 'auto');
} else if (newAuthType === 'key') {
form.setValue('password', '');
}
}}
@@ -467,103 +472,206 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
/>
</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('credentials.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('credentials.upload')}>
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t('credentials.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('credentials.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('credentials.keyTypeRSA')}
</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>
<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 h-10 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">
<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('credentials.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('credentials.upload')}>
{field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
</span>
</Button>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>{t('credentials.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('credentials.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('credentials.keyTypeRSA')}
</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="paste" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>{t('credentials.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>
)}
/>
<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('credentials.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('credentials.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('credentials.keyTypeRSA')}
</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>
</Tabs>
</TabsContent>
</Tabs>
</TabsContent>

View File

@@ -19,7 +19,6 @@ import {
import { getCredentials, deleteCredential } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import {CredentialEditor} from './CredentialEditor';
import CredentialViewer from './CredentialViewer';
import type { Credential, CredentialsManagerProps } from '../../../types/index.js';

View File

@@ -1,243 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Folder,
Edit,
Search,
Trash2,
Users
} from 'lucide-react';
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import type { FolderManagerProps } from '../../../types/index.js';
interface FolderStats {
name: string;
hostCount: number;
hosts: Array<{
id: number;
name?: string;
ip: string;
}>;
}
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
const { t } = useTranslation();
const [folders, setFolders] = useState<FolderStats[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
// Rename state
const [renameLoading, setRenameLoading] = useState(false);
useEffect(() => {
fetchFolders();
}, []);
const fetchFolders = async () => {
try {
setLoading(true);
const data = await getFoldersWithStats();
setFolders(data || []);
setError(null);
} catch (err) {
setError('Failed to fetch folder statistics');
} finally {
setLoading(false);
}
};
const handleRename = async (folder: FolderStats) => {
const newName = prompt(
`Enter new name for folder "${folder.name}":\n\nThis will update ${folder.hostCount} host(s) that use this folder.`,
folder.name
);
if (!newName || newName.trim() === '' || newName === folder.name) {
return;
}
if (window.confirm(
`Are you sure you want to rename folder "${folder.name}" to "${newName.trim()}"?\n\n` +
`This will update ${folder.hostCount} host(s) that currently use this folder.`
)) {
try {
setRenameLoading(true);
await renameFolder(folder.name, newName.trim());
toast.success(`Folder renamed from "${folder.name}" to "${newName.trim()}"`, {
description: `Updated ${folder.hostCount} host(s)`
});
// Refresh folder list
await fetchFolders();
// Notify parent component about folder change
if (onFolderChanged) {
onFolderChanged();
}
// Emit event for other components to refresh
window.dispatchEvent(new CustomEvent('folders:changed'));
} catch (err) {
toast.error('Failed to rename folder');
} finally {
setRenameLoading(false);
}
}
};
const filteredFolders = useMemo(() => {
if (!searchQuery.trim()) {
return folders;
}
const query = searchQuery.toLowerCase();
return folders.filter(folder =>
folder.name.toLowerCase().includes(query) ||
folder.hosts.some(host =>
(host.name?.toLowerCase().includes(query)) ||
host.ip.toLowerCase().includes(query)
)
);
}, [folders, searchQuery]);
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading folders...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchFolders} variant="outline">
Retry
</Button>
</div>
</div>
);
}
if (folders.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
<h3 className="text-lg font-semibold mb-2">No Folders Found</h3>
<p className="text-muted-foreground mb-4">
Create some hosts with folders to manage them here
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-xl font-semibold">Folder Management</h2>
<p className="text-muted-foreground">
{filteredFolders.length} folder(s) found
</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={fetchFolders} variant="outline" size="sm">
Refresh
</Button>
</div>
</div>
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search folders..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-3 pb-20">
{filteredFolders.map((folder) => (
<div
key={folder.name}
className="bg-[#222225] border border-input rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Folder className="h-5 w-5 text-blue-500" />
<h3 className="font-medium text-lg truncate">
{folder.name}
</h3>
<Badge variant="secondary" className="ml-auto">
<Users className="h-3 w-3 mr-1" />
{folder.hostCount} host(s)
</Badge>
</div>
</div>
<div className="flex gap-1 flex-shrink-0 ml-2">
<Button
size="sm"
variant="ghost"
onClick={() => handleRename(folder)}
className="h-8 w-8 p-0"
title="Rename folder"
disabled={renameLoading}
>
{renameLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
) : (
<Edit className="h-4 w-4" />
)}
</Button>
</div>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground mb-2">
Hosts using this folder:
</p>
<div className="grid grid-cols-1 gap-1 max-h-32 overflow-y-auto">
{folder.hosts.slice(0, 5).map((host) => (
<div key={host.id} className="flex items-center gap-2 text-sm bg-muted/20 rounded px-2 py-1">
<span className="font-medium">
{host.name || host.ip}
</span>
{host.name && (
<span className="text-muted-foreground">
({host.ip})
</span>
)}
</div>
))}
{folder.hosts.length > 5 && (
<div className="text-sm text-muted-foreground px-2 py-1">
... and {folder.hosts.length - 5} more host(s)
</div>
)}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -1,18 +1,19 @@
import React, {useState} from "react";
import {HostManagerHostViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx"
import {HostManagerViewer} from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {HostManagerHostEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerHostEditor.tsx";
import {HostManagerEditor} from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
import {CredentialsManager} from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
import {CredentialEditor} from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {useTranslation} from "react-i18next";
import type { SSHHost, HostManagerProps } from '../../../types/index.js';
import type { SSHHost, HostManagerProps } from '../../../types/index';
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const {t} = useTranslation();
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
const [editingCredential, setEditingCredential] = useState<any | null>(null);
const {state: sidebarState} = useSidebar();
@@ -21,8 +22,12 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
setActiveTab("add_host");
};
const handleFormSubmit = () => {
setEditingHost(null);
const handleFormSubmit = (updatedHost?: SSHHost) => {
if (updatedHost) {
setEditingHost(updatedHost);
} else {
setEditingHost(null);
}
setActiveTab("host_viewer");
};
@@ -71,19 +76,20 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
<TabsTrigger value="add_host">
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
</TabsTrigger>
<TabsTrigger value="credentials">{t('credentials.credentialsManager')}</TabsTrigger>
<div className="h-6 w-px bg-[#303032] mx-1"></div>
<TabsTrigger value="credentials">{t('credentials.credentialsViewer')}</TabsTrigger>
<TabsTrigger value="add_credential">
{editingCredential ? t('credentials.editCredential') : t('credentials.addCredential')}
</TabsTrigger>
</TabsList>
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<HostManagerHostViewer onEditHost={handleEditHost}/>
<HostManagerViewer onEditHost={handleEditHost}/>
</TabsContent>
<TabsContent value="add_host" className="flex-1 flex flex-col h-full min-h-0">
<Separator className="p-0.25 -mt-0.5 mb-1"/>
<div className="flex flex-col h-full min-h-0">
<HostManagerHostEditor
<HostManagerEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>

View File

@@ -50,10 +50,10 @@ interface SSHHost {
interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: () => void;
onFormSubmit?: (updatedHost?: SSHHost) => void;
}
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
@@ -62,6 +62,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
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);
@@ -182,24 +183,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
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,
name: "",
ip: "",
port: 22,
username: "",
folder: "",
tags: [],
pin: false,
authType: "password" as const,
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 || [],
keyType: "auto" as const,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
}
});
@@ -207,30 +208,32 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (editingHost) {
const defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
setAuthTab(defaultAuthType);
form.reset({
const formData = {
name: editingHost.name || "",
ip: editingHost.ip || "",
port: editingHost.port || 22,
username: editingHost.username || "",
folder: editingHost.folder || "",
tags: editingHost.tags || [],
pin: editingHost.pin || false,
pin: Boolean(editingHost.pin),
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,
enableTerminal: Boolean(editingHost.enableTerminal),
enableTunnel: Boolean(editingHost.enableTunnel),
enableFileManager: Boolean(editingHost.enableFileManager),
defaultPath: editingHost.defaultPath || "/",
tunnelConnections: editingHost.tunnelConnections || [],
});
};
form.reset(formData);
} else {
setAuthTab('password');
form.reset({
const defaultFormData = {
name: "",
ip: "",
port: 22,
@@ -238,22 +241,23 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
folder: "",
tags: [],
pin: false,
authType: "password",
authType: "password" as const,
credentialId: null,
password: "",
key: null,
keyPassword: "",
keyType: "auto",
keyType: "auto" as const,
enableTerminal: true,
enableTunnel: true,
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
});
};
form.reset(defaultFormData);
}
}, [editingHost, form]);
}, [editingHost?.id]);
// Focus the IP address field when the component mounts or when editingHost changes
useEffect(() => {
const focusTimer = setTimeout(() => {
if (ipInputRef.current) {
@@ -261,83 +265,79 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}
}, 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) => {
const onSubmit = async (data: FormData) => {
try {
const formData = data as FormData;
if (!formData.name || formData.name.trim() === '') {
formData.name = `${formData.username}@${formData.ip}`;
isSubmittingRef.current = true;
if (!data.name || data.name.trim() === '') {
data.name = `${data.username}@${data.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
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 || []
};
if (formData.authType === 'credential') {
submitData.credentialId = formData.credentialId;
if (data.authType === 'credential') {
submitData.credentialId = data.credentialId;
submitData.password = null;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
} else if (formData.authType === 'password') {
} else if (data.authType === 'password') {
submitData.credentialId = null;
submitData.password = formData.password;
submitData.password = data.password;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
} else if (formData.authType === 'key') {
} else if (data.authType === 'key') {
submitData.credentialId = null;
submitData.password = null;
if (formData.key instanceof File) {
const keyContent = await formData.key.text();
if (data.key instanceof File) {
const keyContent = await data.key.text();
submitData.key = keyContent;
} else {
submitData.key = formData.key;
submitData.key = data.key;
}
submitData.keyPassword = formData.keyPassword;
submitData.keyType = formData.keyType;
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
if (editingHost) {
await updateSSHHost(editingHost.id, submitData);
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
const updatedHost = await updateSSHHost(editingHost.id, submitData);
toast.success(t('hosts.hostUpdatedSuccessfully', { name: data.name }));
if (onFormSubmit) {
onFormSubmit(updatedHost);
}
} else {
await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
}
if (onFormSubmit) {
onFormSubmit();
const newHost = await createSSHHost(submitData);
toast.success(t('hosts.hostAddedSuccessfully', { name: data.name }));
if (onFormSubmit) {
onFormSubmit(newHost);
}
}
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
toast.error(t('hosts.failedToSaveHost'));
} finally {
isSubmittingRef.current = false;
}
};
@@ -659,20 +659,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<Tabs
value={authTab}
onValueChange={(value) => {
setAuthTab(value as 'password' | 'key' | 'credential');
form.setValue('authType', value as 'password' | 'key' | 'credential');
const newAuthType = value as 'password' | 'key' | 'credential';
setAuthTab(newAuthType);
form.setValue('authType', newAuthType);
// Clear other auth fields when switching
if (value === 'password') {
if (newAuthType === 'password') {
form.setValue('key', null);
form.setValue('keyPassword', '');
form.setValue('keyType', 'auto');
form.setValue('credentialId', null);
} else if (value === 'key') {
} else if (newAuthType === 'key') {
form.setValue('password', '');
form.setValue('credentialId', null);
} else if (value === 'credential') {
} 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"
@@ -710,7 +714,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}}
className="w-full"
>
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<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>

View File

@@ -29,7 +29,7 @@ import {
import {Separator} from "@/components/ui/separator.tsx";
import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js';
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);