Improve logging frontend/backend, fix host form being reversed.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user