Improve logging more, fix credentials sync issues, migrate more to be toasts
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
import {Shield, Trash2, Users} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useConfirmation} from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
@@ -43,6 +44,7 @@ interface AdminSettingsProps {
|
||||
|
||||
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const {confirmWithToast} = useConfirmation();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
@@ -80,7 +82,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
.then(res => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch OIDC config:', err);
|
||||
toast.error(t('admin.failedToFetchOidcConfig'));
|
||||
});
|
||||
fetchUsers();
|
||||
}, []);
|
||||
@@ -92,7 +96,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
setAllowRegistration(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch registration status:', err);
|
||||
toast.error(t('admin.failedToFetchRegistrationStatus'));
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -166,29 +172,38 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
};
|
||||
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
||||
}
|
||||
confirmWithToast(
|
||||
t('admin.removeAdminStatus', { username }),
|
||||
async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
if (!confirm(t('admin.deleteUser', { username }))) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
toast.error(t('admin.failedToDeleteUser'));
|
||||
}
|
||||
confirmWithToast(
|
||||
t('admin.deleteUser', { username }),
|
||||
async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
toast.error(t('admin.failedToDeleteUser'));
|
||||
}
|
||||
},
|
||||
'destructive'
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
|
||||
@@ -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, CredentialData } from '../../../types/index.js'
|
||||
import type { Credential, CredentialEditorProps, CredentialData } from '../../../../types/index.js'
|
||||
|
||||
export function CredentialEditor({ editingCredential, onFormSubmit }: CredentialEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -120,12 +120,12 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
name: editingCredential?.name || "",
|
||||
description: editingCredential?.description || "",
|
||||
folder: editingCredential?.folder || "",
|
||||
tags: editingCredential?.tags || [],
|
||||
authType: editingCredential?.authType || "password",
|
||||
username: editingCredential?.username || "",
|
||||
name: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
@@ -138,18 +138,33 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
const defaultAuthType = fullCredentialDetails.authType;
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
form.reset({
|
||||
name: fullCredentialDetails.name || "",
|
||||
description: fullCredentialDetails.description || "",
|
||||
folder: fullCredentialDetails.folder || "",
|
||||
tags: fullCredentialDetails.tags || [],
|
||||
authType: defaultAuthType as 'password' | 'key',
|
||||
username: fullCredentialDetails.username || "",
|
||||
password: fullCredentialDetails.password || "",
|
||||
key: null,
|
||||
keyPassword: fullCredentialDetails.keyPassword || "",
|
||||
keyType: (fullCredentialDetails.keyType as any) || "auto",
|
||||
});
|
||||
// Force form reset with a small delay to ensure proper rendering
|
||||
setTimeout(() => {
|
||||
const formData = {
|
||||
name: fullCredentialDetails.name || "",
|
||||
description: fullCredentialDetails.description || "",
|
||||
folder: fullCredentialDetails.folder || "",
|
||||
tags: fullCredentialDetails.tags || [],
|
||||
authType: defaultAuthType as 'password' | 'key',
|
||||
username: fullCredentialDetails.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
};
|
||||
|
||||
// Only set the relevant authentication fields based on authType
|
||||
if (defaultAuthType === 'password') {
|
||||
formData.password = fullCredentialDetails.password || "";
|
||||
} else if (defaultAuthType === 'key') {
|
||||
formData.key = "existing_key"; // Placeholder to indicate existing key
|
||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||
formData.keyType = (fullCredentialDetails.keyType as any) || "auto" as const;
|
||||
}
|
||||
|
||||
form.reset(formData);
|
||||
setTagInput("");
|
||||
}, 100);
|
||||
} else if (!editingCredential) {
|
||||
setAuthTab('password');
|
||||
form.reset({
|
||||
@@ -164,8 +179,9 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
keyPassword: "",
|
||||
keyType: "auto",
|
||||
});
|
||||
setTagInput("");
|
||||
}
|
||||
}, [editingCredential?.id, fullCredentialDetails]);
|
||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
@@ -183,14 +199,24 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
keyType: data.keyType
|
||||
};
|
||||
|
||||
submitData.password = null;
|
||||
submitData.key = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
|
||||
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;
|
||||
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.password = undefined;
|
||||
submitData.keyType = data.keyType;
|
||||
}
|
||||
|
||||
if (editingCredential) {
|
||||
@@ -206,6 +232,9 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
|
||||
// Reset form after successful submission
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error(t('credentials.failedToSaveCredential'));
|
||||
}
|
||||
@@ -285,7 +314,7 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 w-full" key={editingCredential?.id || 'new'}>
|
||||
<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">
|
||||
@@ -392,15 +421,17 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<FormControl>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
||||
{field.value.map((tag: string, idx: number) => (
|
||||
<span key={tag + idx}
|
||||
{(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);
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newTags = (field.value || []).filter((_: string, i: number) => i !== idx);
|
||||
field.onChange(newTags);
|
||||
}}
|
||||
>
|
||||
@@ -410,18 +441,27 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6"
|
||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
|
||||
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()]);
|
||||
const currentTags = field.value || [];
|
||||
if (!currentTags.includes(tagInput.trim())) {
|
||||
field.onChange([...currentTags, tagInput.trim()]);
|
||||
}
|
||||
setTagInput("");
|
||||
} else if (e.key === "Backspace" && tagInput === "" && field.value.length > 0) {
|
||||
field.onChange(field.value.slice(0, -1));
|
||||
} else if (e.key === "Enter" && tagInput.trim() !== "") {
|
||||
e.preventDefault();
|
||||
const currentTags = field.value || [];
|
||||
if (!currentTags.includes(tagInput.trim())) {
|
||||
field.onChange([...currentTags, tagInput.trim()]);
|
||||
}
|
||||
setTagInput("");
|
||||
} else if (e.key === "Backspace" && tagInput === "" && (field.value || []).length > 0) {
|
||||
const currentTags = field.value || [];
|
||||
field.onChange(currentTags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder={t('credentials.addTagsSpaceToAdd')}
|
||||
@@ -442,13 +482,17 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
setAuthTab(newAuthType);
|
||||
form.setValue('authType', newAuthType);
|
||||
|
||||
// Clear other auth fields when switching
|
||||
// Clear ALL authentication fields first
|
||||
form.setValue('password', '');
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
form.setValue('keyType', 'auto');
|
||||
|
||||
// Then set only the relevant fields based on auth type
|
||||
if (newAuthType === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
form.setValue('keyType', 'auto');
|
||||
// Password fields will be filled by user
|
||||
} else if (newAuthType === 'key') {
|
||||
form.setValue('password', '');
|
||||
// Key fields will be filled by user
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
@@ -490,40 +534,41 @@ export function CredentialEditor({ editingCredential, onFormSubmit }: Credential
|
||||
<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>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>{t('credentials.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('credentials.upload')}>
|
||||
{field.value === "existing_key" ? t('hosts.existingKey') :
|
||||
field.value ? (editingCredential ? t('credentials.updateKey') : field.value.name) : t('credentials.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -14,22 +14,34 @@ import {
|
||||
Shield,
|
||||
Pin,
|
||||
Tag,
|
||||
Info
|
||||
Info,
|
||||
FolderMinus,
|
||||
Pencil,
|
||||
X,
|
||||
Check
|
||||
} from 'lucide-react';
|
||||
import { getCredentials, deleteCredential } from '@/ui/main-axios';
|
||||
import { getCredentials, deleteCredential, updateCredential, renameCredentialFolder } from '@/ui/main-axios';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation.ts';
|
||||
import CredentialViewer from './CredentialViewer';
|
||||
import type { Credential, CredentialsManagerProps } from '../../../types/index.js';
|
||||
import type { Credential, CredentialsManagerProps } from '../../../../types/index.js';
|
||||
|
||||
export function CredentialsManager({ onEditCredential }: CredentialsManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(null);
|
||||
const [draggedCredential, setDraggedCredential] = useState<Credential | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
@@ -58,19 +70,139 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
|
||||
|
||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||
if (window.confirm(t('credentials.confirmDeleteCredential', { name: credentialName }))) {
|
||||
try {
|
||||
await deleteCredential(credentialId);
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.details) {
|
||||
toast.error(`${err.response.data.error}\n${err.response.data.details}`);
|
||||
} else {
|
||||
toast.error(t('credentials.failedToDeleteCredential'));
|
||||
confirmWithToast(
|
||||
t('credentials.confirmDeleteCredential', { name: credentialName }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteCredential(credentialId);
|
||||
toast.success(t('credentials.credentialDeletedSuccessfully', { name: credentialName }));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.details) {
|
||||
toast.error(`${err.response.data.error}\n${err.response.data.details}`);
|
||||
} else {
|
||||
toast.error(t('credentials.failedToDeleteCredential'));
|
||||
}
|
||||
}
|
||||
},
|
||||
'destructive'
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const handleRemoveFromFolder = async (credential: Credential) => {
|
||||
confirmWithToast(
|
||||
t('credentials.confirmRemoveFromFolder', { name: credential.name || credential.username, folder: credential.folder }),
|
||||
async () => {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedCredential = { ...credential, folder: '' };
|
||||
await updateCredential(credential.id, updatedCredential);
|
||||
toast.success(t('credentials.removedFromFolder', { name: credential.name || credential.username }));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('credentials.failedToRemoveFromFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleFolderRename = async (oldName: string) => {
|
||||
if (!editingFolderName.trim() || editingFolderName === oldName) {
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName('');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
await renameCredentialFolder(oldName, editingFolderName.trim());
|
||||
toast.success(t('credentials.folderRenamed', { oldName, newName: editingFolderName.trim() }));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName('');
|
||||
} catch (err) {
|
||||
toast.error(t('credentials.failedToRenameFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startFolderEdit = (folderName: string) => {
|
||||
setEditingFolder(folderName);
|
||||
setEditingFolderName(folderName);
|
||||
};
|
||||
|
||||
const cancelFolderEdit = () => {
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName('');
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, credential: Credential) => {
|
||||
setDraggedCredential(credential);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedCredential(null);
|
||||
setDragOverFolder(null);
|
||||
dragCounter.current = 0;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent, folderName: string) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current++;
|
||||
setDragOverFolder(folderName);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragOverFolder(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetFolder: string) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setDragOverFolder(null);
|
||||
|
||||
if (!draggedCredential) return;
|
||||
|
||||
const newFolder = targetFolder === t('credentials.uncategorized') ? '' : targetFolder;
|
||||
|
||||
if (draggedCredential.folder === newFolder) {
|
||||
setDraggedCredential(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedCredential = { ...draggedCredential, folder: newFolder };
|
||||
await updateCredential(draggedCredential.id, updatedCredential);
|
||||
toast.success(t('credentials.movedToFolder', {
|
||||
name: draggedCredential.name || draggedCredential.username,
|
||||
folder: targetFolder
|
||||
}));
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent('credentials:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('credentials.failedToMoveToFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
setDraggedCredential(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -150,13 +282,29 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('credentials.noCredentialsMessage')}
|
||||
</p>
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{t('credentials.sshCredentials')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t('credentials.credentialsCount', { count: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
{t('credentials.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center">
|
||||
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('credentials.noCredentials')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('credentials.noCredentialsMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -191,14 +339,90 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(credentialsByFolder).map(([folder, folderCredentials]) => (
|
||||
<div key={folder} className="border rounded-md">
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, folder)}
|
||||
>
|
||||
<Accordion type="multiple" defaultValue={Object.keys(credentialsByFolder)}>
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger
|
||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4"/>
|
||||
<span className="font-medium">{folder}</span>
|
||||
{editingFolder === folder ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) => setEditingFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleFolderRename(folder);
|
||||
if (e.key === 'Escape') cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 text-sm px-2 flex-1"
|
||||
autoFocus
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFolderRename(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<Check className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t('credentials.uncategorized')) {
|
||||
startFolderEdit(folder);
|
||||
}
|
||||
}}
|
||||
title={folder !== t('credentials.uncategorized') ? 'Click to rename folder' : ''}
|
||||
>
|
||||
{folder}
|
||||
</span>
|
||||
{folder !== t('credentials.uncategorized') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startFolderEdit(folder);
|
||||
}}
|
||||
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
title="Rename folder"
|
||||
>
|
||||
<Pencil className="h-3 w-3"/>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderCredentials.length}
|
||||
</Badge>
|
||||
@@ -207,87 +431,138 @@ export function CredentialsManager({ onEditCredential }: CredentialsManagerProps
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderCredentials.map((credential) => (
|
||||
<div
|
||||
key={credential.id}
|
||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
||||
onClick={() => handleEdit(credential)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{credential.name || `${credential.username}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
<TooltipProvider key={credential.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, credential)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-[#222225] border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-[#2a2a2d] transition-all duration-200 p-3 group relative ${
|
||||
draggedCredential?.id === credential.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
onClick={() => handleEdit(credential)}
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(credential.id, credential.name || credential.username);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{credential.name || `${credential.username}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === 'password' ? t('credentials.password') : t('credentials.sshKey')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{credential.folder && credential.folder !== '' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderMinus className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove from folder "{credential.folder}"</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit credential</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(credential.id, credential.name || credential.username);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete credential</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{credential.tags && credential.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{credential.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{credential.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{credential.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="mt-2 space-y-1">
|
||||
{credential.tags && credential.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{credential.tags.slice(0, 6).map((tag, index) => (
|
||||
<Badge key={index} variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
<Tag className="h-2 w-2 mr-0.5"/>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{credential.tags.length > 6 && (
|
||||
<Badge variant="outline"
|
||||
className="text-xs px-1 py-0">
|
||||
+{credential.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-2 w-2 mr-0.5"/>
|
||||
) : (
|
||||
<Shield className="h-2 w-2 mr-0.5"/>
|
||||
)}
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === 'key' && credential.keyType && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.authType === 'password' ? (
|
||||
<Key className="h-2 w-2 mr-0.5"/>
|
||||
) : (
|
||||
<Shield className="h-2 w-2 mr-0.5"/>
|
||||
)}
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === 'key' && credential.keyType && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Click to edit credential</p>
|
||||
<p className="text-xs text-muted-foreground">Drag to move between folders</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
@@ -377,7 +377,12 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
loading: false
|
||||
} : t));
|
||||
|
||||
toast.success(t('fileManager.fileSavedSuccessfully'));
|
||||
// Handle toast notification from backend
|
||||
if (result?.toast) {
|
||||
toast[result.toast.type](result.toast.message);
|
||||
} else {
|
||||
toast.success(t('fileManager.fileSavedSuccessfully'));
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
@@ -390,12 +395,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
hostId: currentHost.id
|
||||
});
|
||||
} catch (recentErr) {
|
||||
console.error('Failed to add recent file:', recentErr);
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
try {
|
||||
await fetchHomeData();
|
||||
} catch (refreshErr) {
|
||||
console.error('Failed to refresh home data:', refreshErr);
|
||||
}
|
||||
})()
|
||||
]).then(() => {
|
||||
@@ -451,8 +458,15 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
|
||||
try {
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
|
||||
const response = await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
|
||||
}
|
||||
|
||||
setDeletingItem(null);
|
||||
handleOperationComplete();
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -71,16 +71,33 @@ export function FileManagerOperations({
|
||||
if (!uploadFile || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.uploadingFile', { name: uploadFile.name }));
|
||||
|
||||
try {
|
||||
const content = await uploadFile.text();
|
||||
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
|
||||
const response = await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||
|
||||
// Dismiss loading toast and show success
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
|
||||
}
|
||||
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -91,15 +108,32 @@ export function FileManagerOperations({
|
||||
if (!newFileName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.creatingFile', { name: newFileName.trim() }));
|
||||
|
||||
try {
|
||||
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() }));
|
||||
const response = await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||
|
||||
// Dismiss loading toast
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() }));
|
||||
}
|
||||
|
||||
setShowCreateFile(false);
|
||||
setNewFileName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -110,15 +144,32 @@ export function FileManagerOperations({
|
||||
if (!newFolderName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.creatingFolder', { name: newFolderName.trim() }));
|
||||
|
||||
try {
|
||||
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() }));
|
||||
const response = await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||
|
||||
// Dismiss loading toast
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() }));
|
||||
}
|
||||
|
||||
setShowCreateFolder(false);
|
||||
setNewFolderName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -129,16 +180,36 @@ export function FileManagerOperations({
|
||||
if (!deletePath || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.deletingItem', {
|
||||
type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file'),
|
||||
name: deletePath.split('/').pop()
|
||||
}));
|
||||
|
||||
try {
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
const response = await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||
|
||||
// Dismiss loading toast
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
}
|
||||
|
||||
setShowDelete(false);
|
||||
setDeletePath('');
|
||||
setDeleteIsDirectory(false);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -149,17 +220,38 @@ export function FileManagerOperations({
|
||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Show loading toast
|
||||
const {toast} = await import('sonner');
|
||||
const loadingToast = toast.loading(t('fileManager.renamingItem', {
|
||||
type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file'),
|
||||
oldName: renamePath.split('/').pop(),
|
||||
newName: newName.trim()
|
||||
}));
|
||||
|
||||
try {
|
||||
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
const response = await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||
|
||||
// Dismiss loading toast
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
// Handle toast notification from backend
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
}
|
||||
|
||||
setShowRename(false);
|
||||
setRenamePath('');
|
||||
setRenameIsDirectory(false);
|
||||
setNewName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
// Dismiss loading toast and show error
|
||||
toast.dismiss(loadingToast);
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -23,11 +23,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
};
|
||||
|
||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||
if (updatedHost) {
|
||||
setEditingHost(updatedHost);
|
||||
} else {
|
||||
setEditingHost(null);
|
||||
}
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
@@ -44,10 +40,11 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value === "host_viewer") {
|
||||
// Reset editing states when switching away from edit tabs
|
||||
if (value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value === "credentials") {
|
||||
if (value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import React, {useEffect, useRef, useState} from "react";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts, getCredentials} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {CredentialSelector} from "@/components/CredentialSelector.tsx";
|
||||
|
||||
@@ -58,6 +58,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
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');
|
||||
@@ -71,8 +72,12 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const hostsData = await getSSHHosts();
|
||||
const [hostsData, credentialsData] = await Promise.all([
|
||||
getSSHHosts(),
|
||||
getCredentials()
|
||||
]);
|
||||
setHosts(hostsData);
|
||||
setCredentials(credentialsData);
|
||||
|
||||
const uniqueFolders = [...new Set(
|
||||
hostsData
|
||||
@@ -97,6 +102,43 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
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),
|
||||
@@ -143,7 +185,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
});
|
||||
}
|
||||
} else if (data.authType === 'key') {
|
||||
if (!data.key) {
|
||||
if (!data.key || (typeof data.key === 'string' && data.key.trim() === '')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('hosts.sshKeyRequired'),
|
||||
@@ -158,7 +200,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
});
|
||||
}
|
||||
} else if (data.authType === 'credential') {
|
||||
if (!data.credentialId) {
|
||||
if (!data.credentialId || (typeof data.credentialId === 'string' && data.credentialId.trim() === '')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('hosts.credentialRequired'),
|
||||
@@ -204,31 +246,66 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
}
|
||||
});
|
||||
|
||||
// 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 defaultAuthType = editingHost.credentialId ? 'credential' : (editingHost.key ? 'key' : 'password');
|
||||
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: editingHost.name || "",
|
||||
ip: editingHost.ip || "",
|
||||
port: editingHost.port || 22,
|
||||
username: editingHost.username || "",
|
||||
folder: editingHost.folder || "",
|
||||
tags: editingHost.tags || [],
|
||||
pin: Boolean(editingHost.pin),
|
||||
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: editingHost.credentialId || null,
|
||||
password: editingHost.password || "",
|
||||
credentialId: null,
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: editingHost.keyPassword || "",
|
||||
keyType: (editingHost.keyType as any) || "auto",
|
||||
enableTerminal: Boolean(editingHost.enableTerminal),
|
||||
enableTunnel: Boolean(editingHost.enableTunnel),
|
||||
enableFileManager: Boolean(editingHost.enableFileManager),
|
||||
defaultPath: editingHost.defaultPath || "/",
|
||||
tunnelConnections: editingHost.tunnelConnections || [],
|
||||
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 {
|
||||
@@ -292,24 +369,26 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
tunnelConnections: data.tunnelConnections || []
|
||||
};
|
||||
|
||||
submitData.credentialId = null;
|
||||
submitData.password = null;
|
||||
submitData.key = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
|
||||
if (data.authType === 'credential') {
|
||||
submitData.credentialId = data.credentialId;
|
||||
submitData.password = null;
|
||||
submitData.key = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
if (data.credentialId === "existing_credential") {
|
||||
delete submitData.credentialId;
|
||||
} else {
|
||||
submitData.credentialId = data.credentialId;
|
||||
}
|
||||
} else if (data.authType === 'password') {
|
||||
submitData.credentialId = null;
|
||||
submitData.password = data.password;
|
||||
submitData.key = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
} else if (data.authType === 'key') {
|
||||
submitData.credentialId = null;
|
||||
submitData.password = null;
|
||||
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;
|
||||
}
|
||||
@@ -334,6 +413,9 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
|
||||
// Reset form after successful submission
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error(t('hosts.failedToSaveHost'));
|
||||
} finally {
|
||||
@@ -663,7 +745,7 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
setAuthTab(newAuthType);
|
||||
form.setValue('authType', newAuthType);
|
||||
|
||||
// Clear other auth fields when switching
|
||||
// Clear authentication fields based on what we're switching away from
|
||||
if (newAuthType === 'password') {
|
||||
form.setValue('key', null);
|
||||
form.setValue('keyPassword', '');
|
||||
@@ -744,7 +826,8 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
>
|
||||
<span className="truncate"
|
||||
title={field.value?.name || t('hosts.upload')}>
|
||||
{field.value ? (editingHost ? t('hosts.updateKey') : 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>
|
||||
@@ -843,10 +926,21 @@ export function HostManagerEditor({editingHost, onFormSubmit}: SSHManagerHostEdi
|
||||
control={form.control}
|
||||
name="credentialId"
|
||||
render={({ field }) => (
|
||||
<CredentialSelector
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/compon
|
||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useConfirmation} from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -24,13 +25,15 @@ import {
|
||||
Info,
|
||||
X,
|
||||
Check,
|
||||
Pencil
|
||||
Pencil,
|
||||
FolderMinus
|
||||
} from "lucide-react";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import type { SSHHost, SSHManagerHostViewerProps } from '../../../types/index.js';
|
||||
import type { SSHHost, SSHManagerHostViewerProps } from '../../../../types/index.js';
|
||||
|
||||
export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
const {confirmWithToast} = useConfirmation();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -66,7 +69,25 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getSSHHosts();
|
||||
setHosts(data);
|
||||
|
||||
const cleanedHosts = data.map(host => {
|
||||
const cleanedHost = { ...host };
|
||||
if (cleanedHost.credentialId && cleanedHost.key) {
|
||||
cleanedHost.key = undefined;
|
||||
cleanedHost.keyPassword = undefined;
|
||||
cleanedHost.keyType = undefined;
|
||||
cleanedHost.authType = 'credential';
|
||||
} else if (cleanedHost.credentialId && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
cleanedHost.authType = 'credential';
|
||||
} else if (cleanedHost.key && cleanedHost.password) {
|
||||
cleanedHost.password = undefined;
|
||||
cleanedHost.authType = 'key';
|
||||
}
|
||||
return cleanedHost;
|
||||
});
|
||||
|
||||
setHosts(cleanedHosts);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(t('hosts.failedToLoadHosts'));
|
||||
@@ -76,47 +97,92 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToDeleteHost'));
|
||||
}
|
||||
}
|
||||
confirmWithToast(
|
||||
t('hosts.confirmDelete', { name: hostName }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToDeleteHost'));
|
||||
}
|
||||
},
|
||||
'destructive'
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = (host: SSHHost) => {
|
||||
const exportData = {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: host.pin,
|
||||
enableTerminal: host.enableTerminal,
|
||||
enableTunnel: host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager,
|
||||
defaultPath: host.defaultPath,
|
||||
tunnelConnections: host.tunnelConnections,
|
||||
const actualAuthType = host.credentialId ? 'credential' : (host.key ? 'key' : 'password');
|
||||
|
||||
// Check if host uses sensitive authentication data
|
||||
if (actualAuthType === 'credential') {
|
||||
const confirmMessage = t('hosts.exportCredentialWarning', {
|
||||
name: host.name || `${host.username}@${host.ip}`
|
||||
});
|
||||
|
||||
confirmWithToast(confirmMessage, () => {
|
||||
performExport(host, actualAuthType);
|
||||
});
|
||||
return;
|
||||
} else if (actualAuthType === 'password' || actualAuthType === 'key') {
|
||||
const confirmMessage = t('hosts.exportSensitiveDataWarning', {
|
||||
name: host.name || `${host.username}@${host.ip}`
|
||||
});
|
||||
|
||||
confirmWithToast(confirmMessage, () => {
|
||||
performExport(host, actualAuthType);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No sensitive data, proceed directly
|
||||
performExport(host, actualAuthType);
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${host.name || host.username + '@' + host.ip}-credentials.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
const performExport = (host: SSHHost, actualAuthType: string) => {
|
||||
|
||||
toast.success(`Exported credentials for ${host.name || host.username}@${host.ip}`);
|
||||
};
|
||||
// Create export data with sensitive fields excluded
|
||||
const exportData: any = {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: actualAuthType, // Use the determined authType, not the stored one
|
||||
folder: host.folder,
|
||||
tags: host.tags,
|
||||
pin: host.pin,
|
||||
enableTerminal: host.enableTerminal,
|
||||
enableTunnel: host.enableTunnel,
|
||||
enableFileManager: host.enableFileManager,
|
||||
defaultPath: host.defaultPath,
|
||||
tunnelConnections: host.tunnelConnections,
|
||||
};
|
||||
|
||||
// Only include credentialId if actualAuthType is credential, but set it to null for security
|
||||
if (actualAuthType === 'credential') {
|
||||
exportData.credentialId = null; // Set to null instead of undefined so it's included but empty
|
||||
}
|
||||
|
||||
// Remove undefined values from export, but keep null values
|
||||
const cleanExportData = Object.fromEntries(
|
||||
Object.entries(exportData).filter(([_, value]) => value !== undefined)
|
||||
);
|
||||
|
||||
|
||||
const blob = new Blob([JSON.stringify(cleanExportData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${host.name || host.username + '@' + host.ip}-host-config.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`Exported host configuration for ${host.name || host.username}@${host.ip}`);
|
||||
};
|
||||
|
||||
|
||||
const handleEdit = (host: SSHHost) => {
|
||||
@@ -126,20 +192,23 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
};
|
||||
|
||||
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||
if (window.confirm(t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }))) {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedHost = { ...host, folder: '' };
|
||||
await updateSSHHost(host.id, updatedHost);
|
||||
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToRemoveFromFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
confirmWithToast(
|
||||
t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }),
|
||||
async () => {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedHost = { ...host, folder: '' };
|
||||
await updateSSHHost(host.id, updatedHost);
|
||||
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error(t('hosts.failedToRemoveFromFolder'));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleFolderRename = async (oldName: string) => {
|
||||
@@ -400,51 +469,66 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
const sampleData = {
|
||||
hosts: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/var/www"
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: false,
|
||||
tunnelConnections: [
|
||||
{
|
||||
name: "Web Server - Production",
|
||||
ip: "192.168.1.100",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authType: "password",
|
||||
password: "your_secure_password_here",
|
||||
folder: "Production",
|
||||
tags: ["web", "production", "nginx"],
|
||||
pin: true,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/var/www"
|
||||
},
|
||||
{
|
||||
name: "Database Server",
|
||||
ip: "192.168.1.101",
|
||||
port: 22,
|
||||
username: "dbadmin",
|
||||
authType: "key",
|
||||
key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----",
|
||||
keyPassword: "optional_key_passphrase",
|
||||
keyType: "ssh-ed25519",
|
||||
folder: "Production",
|
||||
tags: ["database", "production", "postgresql"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: true,
|
||||
enableFileManager: false,
|
||||
tunnelConnections: [
|
||||
{
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true
|
||||
}
|
||||
]
|
||||
sourcePort: 5432,
|
||||
endpointPort: 5432,
|
||||
endpointHost: "Web Server - Production",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: true
|
||||
}
|
||||
]
|
||||
};
|
||||
},
|
||||
{
|
||||
name: "Development Server",
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
username: "developer",
|
||||
authType: "credential",
|
||||
credentialId: 1,
|
||||
folder: "Development",
|
||||
tags: ["dev", "testing"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/home/developer"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(sampleData, null, 2)], {type: 'application/json'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -478,6 +562,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<input
|
||||
id="json-import-input"
|
||||
type="file"
|
||||
@@ -493,9 +578,6 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t('hosts.noHostsMessage')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('hosts.getStartedMessage', { defaultValue: 'Use the Import JSON button above to add hosts from a JSON file.' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -583,6 +665,21 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
autoStart: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Development Server",
|
||||
ip: "192.168.1.102",
|
||||
port: 2222,
|
||||
username: "developer",
|
||||
authType: "credential",
|
||||
credentialId: 1,
|
||||
folder: "Development",
|
||||
tags: ["dev", "testing"],
|
||||
pin: false,
|
||||
enableTerminal: true,
|
||||
enableTunnel: false,
|
||||
enableFileManager: true,
|
||||
defaultPath: "/home/developer"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -619,6 +716,7 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<input
|
||||
id="json-import-input"
|
||||
type="file"
|
||||
@@ -732,25 +830,27 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, host)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-[#222225] border border-input rounded cursor-move hover:shadow-md transition-all p-2 ${
|
||||
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.pin && <Pin
|
||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
</div>
|
||||
<TooltipProvider key={host.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, host)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`bg-[#222225] border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-[#2a2a2d] transition-all duration-200 p-3 group relative ${
|
||||
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
||||
}`}
|
||||
onClick={() => handleEdit(host)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.pin && <Pin
|
||||
className="h-3 w-3 text-yellow-500 flex-shrink-0"/>}
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
</p>
|
||||
@@ -760,53 +860,80 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== '' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700"
|
||||
title={`Remove from folder "${host.folder}"`}
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderMinus className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Remove from folder "{host.folder}"</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
<Upload className="h-3 w-3"/>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||
>
|
||||
<Edit className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(host.id, host.name || `${host.username}@${host.ip}`);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExport(host);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-blue-500 hover:text-blue-700 hover:bg-blue-500/10"
|
||||
>
|
||||
<Upload className="h-3 w-3"/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Export host</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -856,6 +983,15 @@ export function HostManagerViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">Click to edit host</p>
|
||||
<p className="text-xs text-muted-foreground">Drag to move between folders</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {Tunnel} from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
@@ -47,6 +48,8 @@ export function Server({
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest host config:', error);
|
||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -63,6 +66,8 @@ export function Server({
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch updated host config:', error);
|
||||
toast.error(t('serverStats.failedToFetchHostConfig'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -81,8 +86,12 @@ export function Server({
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === 'online' ? 'online' : 'offline');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setServerStatus('offline');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch server status:', error);
|
||||
if (!cancelled) {
|
||||
setServerStatus('offline');
|
||||
toast.error(t('serverStats.failedToFetchStatus'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,8 +100,12 @@ export function Server({
|
||||
try {
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) setMetrics(data);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch server metrics:', error);
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
toast.error(t('serverStats.failedToFetchMetrics'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
@@ -26,7 +27,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
@@ -69,7 +75,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
setIsConnected(false);
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
@@ -118,10 +129,51 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
return getCookie("rightClickCopyPaste") === "true"
|
||||
}
|
||||
|
||||
function attemptReconnection() {
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
toast.error(t('terminal.maxReconnectAttemptsReached'));
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttempts.current++;
|
||||
toast.info(t('terminal.reconnecting', { attempt: reconnectAttempts.current, max: maxReconnectAttempts }));
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (terminal && hostConfig) {
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
connectToHost(cols, rows);
|
||||
}
|
||||
}, 2000 * reconnectAttempts.current); // Exponential backoff
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
const isDev = process.env.NODE_ENV === 'development' &&
|
||||
(window.location.port === '3000' || window.location.port === '5173' || window.location.port === '');
|
||||
|
||||
const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
|
||||
|
||||
const wsUrl = isDev
|
||||
? 'ws://localhost:8082'
|
||||
: isElectron
|
||||
? 'ws://127.0.0.1:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
setConnectionError(null);
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener('open', () => {
|
||||
setIsConnected(true);
|
||||
reconnectAttempts.current = 0; // Reset on successful connection
|
||||
toast.success(t('terminal.connected'));
|
||||
|
||||
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
|
||||
terminal.onData((data) => {
|
||||
@@ -133,32 +185,72 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
ws.send(JSON.stringify({type: 'ping'}));
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
|
||||
else if (msg.type === 'connected') {
|
||||
if (msg.type === 'data') {
|
||||
terminal.write(msg.data);
|
||||
} else if (msg.type === 'error') {
|
||||
// Handle different types of errors
|
||||
const errorMessage = msg.message || t('terminal.unknownError');
|
||||
|
||||
// Check if it's an authentication error
|
||||
if (errorMessage.toLowerCase().includes('auth') ||
|
||||
errorMessage.toLowerCase().includes('password') ||
|
||||
errorMessage.toLowerCase().includes('permission') ||
|
||||
errorMessage.toLowerCase().includes('denied')) {
|
||||
toast.error(t('terminal.authError', { message: errorMessage }));
|
||||
// Close terminal on auth errors
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a connection error that should trigger reconnection
|
||||
if (errorMessage.toLowerCase().includes('connection') ||
|
||||
errorMessage.toLowerCase().includes('timeout') ||
|
||||
errorMessage.toLowerCase().includes('network')) {
|
||||
toast.error(t('terminal.connectionError', { message: errorMessage }));
|
||||
setIsConnected(false);
|
||||
attemptReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// For other errors, show toast but don't close terminal
|
||||
toast.error(t('terminal.error', { message: errorMessage }));
|
||||
} else if (msg.type === 'connected') {
|
||||
setIsConnected(true);
|
||||
toast.success(t('terminal.sshConnected'));
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
|
||||
setIsConnected(false);
|
||||
toast.info(t('terminal.disconnected', { message: msg.message }));
|
||||
// Attempt reconnection for disconnections
|
||||
attemptReconnection();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t('terminal.messageParseError'));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
ws.addEventListener('close', (event) => {
|
||||
setIsConnected(false);
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
||||
toast.warning(t('terminal.connectionClosed'));
|
||||
// Attempt reconnection for unexpected disconnections
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
||||
ws.addEventListener('error', (event) => {
|
||||
setIsConnected(false);
|
||||
setConnectionError(t('terminal.websocketError'));
|
||||
toast.error(t('terminal.websocketError'));
|
||||
// Attempt reconnection for WebSocket errors
|
||||
attemptReconnection();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -288,11 +380,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
? 'ws://127.0.0.1:8082'
|
||||
: `${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
connectToHost(cols, rows);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
@@ -301,6 +389,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
element?.removeEventListener('contextmenu', handleContextMenu);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
@@ -341,16 +430,29 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="h-full w-full relative">
|
||||
{/* Connection Status Indicator */}
|
||||
{!isConnected && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-red-500 text-white px-2 py-1 rounded text-xs">
|
||||
{t('terminal.disconnected')}
|
||||
</div>
|
||||
)}
|
||||
{isConnected && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-green-500 text-white px-2 py-1 rounded text-xs">
|
||||
{t('terminal.connected')}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full m-1"
|
||||
style={{opacity: visible && isVisible ? 1 : 0, overflow: 'hidden'}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ export function LeftSidebar({
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||
|
||||
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
|
||||
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab, updateHostConfig} = useTabs() as any;
|
||||
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
||||
const openSshManagerTab = () => {
|
||||
@@ -171,7 +171,17 @@ export function LeftSidebar({
|
||||
newHost.username !== existingHost.username ||
|
||||
newHost.pin !== existingHost.pin ||
|
||||
newHost.enableTerminal !== existingHost.enableTerminal ||
|
||||
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags)
|
||||
newHost.enableTunnel !== existingHost.enableTunnel ||
|
||||
newHost.enableFileManager !== existingHost.enableFileManager ||
|
||||
newHost.authType !== existingHost.authType ||
|
||||
newHost.password !== existingHost.password ||
|
||||
newHost.key !== existingHost.key ||
|
||||
newHost.keyPassword !== existingHost.keyPassword ||
|
||||
newHost.keyType !== existingHost.keyType ||
|
||||
newHost.credentialId !== existingHost.credentialId ||
|
||||
newHost.defaultPath !== existingHost.defaultPath ||
|
||||
JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags) ||
|
||||
JSON.stringify(newHost.tunnelConnections) !== JSON.stringify(existingHost.tunnelConnections)
|
||||
) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
@@ -183,12 +193,17 @@ export function LeftSidebar({
|
||||
setTimeout(() => {
|
||||
setHosts(newHosts);
|
||||
prevHostsRef.current = newHosts;
|
||||
|
||||
// Update hostConfig in existing tabs
|
||||
newHosts.forEach(newHost => {
|
||||
updateHostConfig(newHost.id, newHost);
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHostsError(t('leftSidebar.failedToLoadHosts'));
|
||||
}
|
||||
}, []);
|
||||
}, [updateHostConfig]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHosts();
|
||||
@@ -200,8 +215,15 @@ export function LeftSidebar({
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
const handleCredentialsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
window.addEventListener('credentials:changed', handleCredentialsChanged as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
|
||||
window.removeEventListener('credentials:changed', handleCredentialsChanged as EventListener);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TabContextType {
|
||||
setCurrentTab: (tabId: number) => void;
|
||||
setSplitScreenTab: (tabId: number) => void;
|
||||
getTab: (tabId: number) => Tab | undefined;
|
||||
updateHostConfig: (hostId: number, newHostConfig: any) => void;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
@@ -111,6 +112,19 @@ export function TabProvider({children}: TabProviderProps) {
|
||||
return tabs.find(tab => tab.id === tabId);
|
||||
};
|
||||
|
||||
const updateHostConfig = (hostId: number, newHostConfig: any) => {
|
||||
setTabs(prev => prev.map(tab => {
|
||||
if (tab.hostConfig && tab.hostConfig.id === hostId) {
|
||||
return {
|
||||
...tab,
|
||||
hostConfig: newHostConfig,
|
||||
title: newHostConfig.name?.trim() ? newHostConfig.name : `${newHostConfig.username}@${newHostConfig.ip}:${newHostConfig.port}`
|
||||
};
|
||||
}
|
||||
return tab;
|
||||
}));
|
||||
};
|
||||
|
||||
const value: TabContextType = {
|
||||
tabs,
|
||||
currentTab,
|
||||
@@ -120,6 +134,7 @@ export function TabProvider({children}: TabProviderProps) {
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
getTab,
|
||||
updateHostConfig,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user