diff --git a/.env b/.env index 99ffac00..0e92ae62 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ VERSION=1.6.0 VITE_API_HOST=localhost +CREDENTIAL_ENCRYPTION_KEY=98fbfabe84b125db7cbbb5168eb584aaecc2f3779a2aaa955c57bdd305071a84 diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 74c22a7e..a81cde26 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -431,7 +431,14 @@ "terminal": "Terminal", "tunnel": "Tunnel", "fileManager": "File Manager", - "hostViewer": "Host Viewer" + "hostViewer": "Host Viewer", + "confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".", + "removedFromFolder": "Host \"{{name}}\" removed from folder successfully", + "failedToRemoveFromFolder": "Failed to remove host from folder", + "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", + "failedToRenameFolder": "Failed to rename folder", + "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully", + "failedToMoveToFolder": "Failed to move host to folder" }, "terminal": { "title": "Terminal", diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index d417b67f..6fed4a73 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -469,7 +469,14 @@ "general": "常规", "terminal": "终端", "tunnel": "隧道", - "fileManager": "文件管理器" + "fileManager": "文件管理器", + "confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。", + "removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除", + "failedToRemoveFromFolder": "从文件夹中移除主机失败", + "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", + "failedToRenameFolder": "重命名文件夹失败", + "movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"", + "failedToMoveToFolder": "移动主机到文件夹失败" }, "terminal": { "title": "终端", diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index a49b9956..5941b4ab 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -402,6 +402,112 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) = } }); +// Route: Get all folders with usage statistics for the authenticated user (requires JWT) +// GET /ssh/folders/with-stats +router.get('/db/folders/with-stats', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + if (!isNonEmptyString(userId)) { + logger.warn('Invalid userId for SSH folder stats fetch'); + return res.status(400).json({error: 'Invalid userId'}); + } + try { + const data = await db + .select({ + folder: sshData.folder, + hostId: sshData.id, + hostName: sshData.name, + hostIp: sshData.ip + }) + .from(sshData) + .where(eq(sshData.userId, userId)); + + const folderStats: Record; + }> = {}; + + data.forEach(d => { + if (d.folder && d.folder.trim() !== '') { + if (!folderStats[d.folder]) { + folderStats[d.folder] = { + name: d.folder, + hostCount: 0, + hosts: [] + }; + } + folderStats[d.folder].hostCount++; + folderStats[d.folder].hosts.push({ + id: d.hostId, + name: d.hostName || undefined, + ip: d.hostIp + }); + } + }); + + const result = Object.values(folderStats).sort((a, b) => a.name.localeCompare(b.name)); + + res.json(result); + } catch (err) { + logger.error('Failed to fetch SSH folder statistics', err); + res.status(500).json({error: 'Failed to fetch SSH folder statistics'}); + } +}); + +// Route: Rename folder across all hosts for the authenticated user (requires JWT) +// PUT /ssh/folders/rename +router.put('/db/folders/rename', authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as any).userId; + const {oldName, newName} = req.body; + + if (!isNonEmptyString(userId) || !isNonEmptyString(oldName) || !isNonEmptyString(newName)) { + logger.warn('Invalid parameters for folder rename'); + return res.status(400).json({error: 'userId, oldName, and newName are required'}); + } + + if (oldName === newName) { + logger.warn('Attempt to rename folder to the same name'); + return res.status(400).json({error: 'New folder name must be different from old name'}); + } + + try { + // Check if the old folder exists + const existingHosts = await db + .select({id: sshData.id}) + .from(sshData) + .where(and( + eq(sshData.userId, userId), + eq(sshData.folder, oldName) + )); + + if (existingHosts.length === 0) { + logger.warn(`Attempt to rename non-existent folder: ${oldName}`); + return res.status(404).json({error: 'Folder not found'}); + } + + // Update all hosts using this folder name + const result = await db + .update(sshData) + .set({folder: newName}) + .where(and( + eq(sshData.userId, userId), + eq(sshData.folder, oldName) + )); + + logger.success(`Renamed folder "${oldName}" to "${newName}" for ${existingHosts.length} hosts`); + + res.json({ + message: `Folder renamed successfully`, + oldName, + newName, + affectedHostsCount: existingHosts.length + }); + } catch (err) { + logger.error('Failed to rename SSH folder', err); + res.status(500).json({error: 'Failed to rename SSH folder'}); + } +}); + // Route: Delete SSH host by id (requires JWT) // DELETE /ssh/host/:id router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => { diff --git a/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx b/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx new file mode 100644 index 00000000..eb0ec554 --- /dev/null +++ b/src/ui/Desktop/Apps/Host Manager/FolderManager.tsx @@ -0,0 +1,246 @@ +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'; + +interface FolderStats { + name: string; + hostCount: number; + hosts: Array<{ + id: number; + name?: string; + ip: string; + }>; +} + +interface FolderManagerProps { + onFolderChanged?: () => void; +} + +export function FolderManager({ onFolderChanged }: FolderManagerProps) { + const { t } = useTranslation(); + const [folders, setFolders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
+
+

Loading folders...

+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (folders.length === 0) { + return ( +
+
+ +

No Folders Found

+

+ Create some hosts with folders to manage them here +

+
+
+ ); + } + + return ( +
+
+
+

Folder Management

+

+ {filteredFolders.length} folder(s) found +

+
+
+ +
+
+ +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + +
+ {filteredFolders.map((folder) => ( +
+
+
+
+ +

+ {folder.name} +

+ + + {folder.hostCount} host(s) + +
+
+
+ +
+
+ +
+

+ Hosts using this folder: +

+
+ {folder.hosts.slice(0, 5).map((host) => ( +
+ + {host.name || host.ip} + + {host.name && ( + + ({host.ip}) + + )} +
+ ))} + {folder.hosts.length > 5 && ( +
+ ... and {folder.hosts.length - 5} more host(s) +
+ )} +
+
+
+ ))} +
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx index d75823b0..f2b19982 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManager.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManager.tsx @@ -63,6 +63,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea setActiveTab("credentials"); }; + const handleTabChange = (value: string) => { setActiveTab(value); if (value === "host_viewer") { diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx index 9d81bd80..3f355415 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerHostViewer.tsx @@ -1,4 +1,4 @@ -import React, {useState, useEffect, useMemo} from "react"; +import React, {useState, useEffect, useMemo, useRef} from "react"; import {Card, CardContent} from "@/components/ui/card.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Badge} from "@/components/ui/badge.tsx"; @@ -6,7 +6,7 @@ import {ScrollArea} from "@/components/ui/scroll-area.tsx"; import {Input} from "@/components/ui/input.tsx"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx"; import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx"; -import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts"; +import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts"; import {toast} from "sonner"; import {useTranslation} from "react-i18next"; import { @@ -21,7 +21,10 @@ import { FileEdit, Search, Upload, - Info + Info, + X, + Check, + Pencil } from "lucide-react"; import {Separator} from "@/components/ui/separator.tsx"; @@ -55,9 +58,30 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [importing, setImporting] = useState(false); + const [draggedHost, setDraggedHost] = useState(null); + const [dragOverFolder, setDragOverFolder] = useState(null); + const [editingFolder, setEditingFolder] = useState(null); + const [editingFolderName, setEditingFolderName] = useState(""); + const [operationLoading, setOperationLoading] = useState(false); + const dragCounter = useRef(0); useEffect(() => { fetchHosts(); + + // Listen for refresh events from other components + const handleHostsRefresh = () => { + fetchHosts(); + }; + + window.addEventListener('hosts:refresh', handleHostsRefresh); + window.addEventListener('ssh-hosts:changed', handleHostsRefresh); + window.addEventListener('folders:changed', handleHostsRefresh); + + return () => { + window.removeEventListener('hosts:refresh', handleHostsRefresh); + window.removeEventListener('ssh-hosts:changed', handleHostsRefresh); + window.removeEventListener('folders:changed', handleHostsRefresh); + }; }, []); const fetchHosts = async () => { @@ -92,6 +116,118 @@ export function HostManagerHostViewer({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); + } + } + }; + + const handleFolderRename = async (oldName: string) => { + if (!editingFolderName.trim() || editingFolderName === oldName) { + setEditingFolder(null); + setEditingFolderName(''); + return; + } + + try { + setOperationLoading(true); + await renameFolder(oldName, editingFolderName.trim()); + toast.success(t('hosts.folderRenamed', { oldName, newName: editingFolderName.trim() })); + await fetchHosts(); + window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); + setEditingFolder(null); + setEditingFolderName(''); + } catch (err) { + toast.error(t('hosts.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, host: SSHHost) => { + setDraggedHost(host); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', ''); // Required for Firefox + }; + + const handleDragEnd = () => { + setDraggedHost(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 (!draggedHost) return; + + const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder; + + if (draggedHost.folder === newFolder) { + setDraggedHost(null); + return; + } + + try { + setOperationLoading(true); + const updatedHost = { ...draggedHost, folder: newFolder }; + await updateSSHHost(draggedHost.id, updatedHost); + toast.success(t('hosts.movedToFolder', { + name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`, + folder: targetFolder + })); + await fetchHosts(); + window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); + } catch (err) { + toast.error(t('hosts.failedToMoveToFolder')); + } finally { + setOperationLoading(false); + setDraggedHost(null); + } + }; + const handleJsonImport = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; @@ -495,14 +631,90 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => ( -
+
handleDragEnter(e, folder)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, folder)} + > -
+
- {folder} + {editingFolder === folder ? ( +
e.stopPropagation()}> + 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} + /> + + +
+ ) : ( + <> + { + e.stopPropagation(); + if (folder !== t('hosts.uncategorized')) { + startFolderEdit(folder); + } + }} + title={folder !== t('hosts.uncategorized') ? 'Click to rename folder' : ''} + > + {folder} + + {folder !== t('hosts.uncategorized') && ( + + )} + + )} {folderHosts.length} @@ -513,7 +725,12 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {folderHosts.map((host) => (
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)} >
@@ -533,6 +750,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {

+ {host.folder && host.folder !== '' && ( + + )}