From 606f7eeae1a77a3788d7db91e31d48df1a817936 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 15 Jan 2026 01:35:07 +0800 Subject: [PATCH 1/2] feat: add sudo support for file manager operations --- src/backend/ssh/file-manager.ts | 246 ++++++++++++------ src/locales/en.json | 5 + .../features/file-manager/FileManager.tsx | 70 ++++- .../file-manager/SudoPasswordDialog.tsx | 98 +++++++ src/ui/main-axios.ts | 14 + 5 files changed, 356 insertions(+), 77 deletions(-) create mode 100644 src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 2e5ade28..0860a472 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -315,6 +315,7 @@ interface SSHSession { lastActive: number; timeout?: NodeJS.Timeout; activeOperations: number; + sudoPassword?: string; } interface PendingTOTPSession { @@ -337,6 +338,45 @@ interface PendingTOTPSession { const sshSessions: Record = {}; const pendingTOTPSessions: Record = {}; +function execWithSudo( + client: SSHClient, + command: string, + sudoPassword: string, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve) => { + const escapedPassword = sudoPassword.replace(/'/g, "'\"'\"'"); + const sudoCommand = `echo '${escapedPassword}' | sudo -S ${command} 2>&1`; + + client.exec(sudoCommand, (err, stream) => { + if (err) { + resolve({ stdout: "", stderr: err.message, code: 1 }); + return; + } + + let stdout = ""; + let stderr = ""; + + stream.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + stream.on("close", (code: number) => { + // Filter out sudo password prompt from output + stdout = stdout.replace(/\[sudo\] password for .+?:\s*/g, ""); + resolve({ stdout, stderr, code: code || 0 }); + }); + + stream.on("error", (streamErr: Error) => { + resolve({ stdout, stderr: streamErr.message, code: 1 }); + }); + }); + }); +} + function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { @@ -1205,6 +1245,42 @@ app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { res.json({ status: "success", message: "SSH connection disconnected" }); }); +/** + * @openapi + * /ssh/file_manager/sudo-password: + * post: + * summary: Set sudo password for session + * description: Stores sudo password temporarily in session for elevated operations. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * password: + * type: string + * responses: + * 200: + * description: Sudo password set successfully. + * 400: + * description: Invalid session. + */ +app.post("/ssh/file_manager/sudo-password", (req, res) => { + const { sessionId, password } = req.body; + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "Invalid or disconnected session" }); + } + session.sudoPassword = password; + session.lastActive = Date.now(); + res.json({ status: "success", message: "Sudo password set" }); +}); + /** * @openapi * /ssh/file_manager/ssh/status: @@ -2657,86 +2733,106 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { const escapedPath = itemPath.replace(/'/g, "'\"'\"'"); const deleteCommand = isDirectory - ? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0` - : `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`; + ? `rm -rf '${escapedPath}'` + : `rm -f '${escapedPath}'`; - sshConn.client.exec(deleteCommand, (err, stream) => { - if (err) { - fileLogger.error("SSH deleteItem error:", err); - if (!res.headersSent) { - return res.status(500).json({ error: err.message }); - } - return; - } - - let outputData = ""; - let errorData = ""; - - stream.on("data", (chunk: Buffer) => { - outputData += chunk.toString(); - }); - - stream.stderr.on("data", (chunk: Buffer) => { - errorData += chunk.toString(); - - if (chunk.toString().includes("Permission denied")) { - fileLogger.error(`Permission denied deleting: ${itemPath}`); - if (!res.headersSent) { - return res.status(403).json({ - error: `Permission denied: Cannot delete ${itemPath}. Check file permissions.`, - }); - } - return; - } - }); - - stream.on("close", (code) => { - if (outputData.includes("SUCCESS")) { - if (!res.headersSent) { - res.json({ - message: "Item deleted successfully", - path: itemPath, - toast: { - type: "success", - message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, - }, - }); - } - return; - } - - if (code !== 0) { - fileLogger.error( - `SSH deleteItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, - ); - if (!res.headersSent) { - return res.status(500).json({ - error: `Command failed: ${errorData}`, - toast: { type: "error", message: `Delete failed: ${errorData}` }, - }); - } - return; - } - - if (!res.headersSent) { - res.json({ - message: "Item deleted successfully", - path: itemPath, - toast: { - type: "success", - message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + const executeDelete = (useSudo: boolean): Promise => { + return new Promise((resolve) => { + if (useSudo && sshConn.sudoPassword) { + execWithSudo(sshConn.client, deleteCommand, sshConn.sudoPassword).then( + (result) => { + if ( + result.code === 0 || + (!result.stderr.includes("Permission denied") && + !result.stdout.includes("Permission denied")) + ) { + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } else { + res.status(500).json({ + error: `Delete failed: ${result.stderr || result.stdout}`, + }); + } + resolve(); }, - }); + ); + return; } - }); - stream.on("error", (streamErr) => { - fileLogger.error("SSH deleteItem stream error:", streamErr); - if (!res.headersSent) { - res.status(500).json({ error: `Stream error: ${streamErr.message}` }); - } + sshConn.client.exec( + `${deleteCommand} && echo "SUCCESS"`, + (err, stream) => { + if (err) { + fileLogger.error("SSH deleteItem error:", err); + res.status(500).json({ error: err.message }); + resolve(); + return; + } + + let outputData = ""; + let errorData = ""; + let permissionDenied = false; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + if (chunk.toString().includes("Permission denied")) { + permissionDenied = true; + } + }); + + stream.on("close", (code) => { + if (permissionDenied) { + if (sshConn.sudoPassword) { + executeDelete(true).then(resolve); + return; + } + fileLogger.error(`Permission denied deleting: ${itemPath}`); + res.status(403).json({ + error: `Permission denied: Cannot delete ${itemPath}.`, + needsSudo: true, + }); + resolve(); + return; + } + + if (outputData.includes("SUCCESS") || code === 0) { + res.json({ + message: "Item deleted successfully", + path: itemPath, + toast: { + type: "success", + message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`, + }, + }); + } else { + res.status(500).json({ + error: `Command failed: ${errorData}`, + }); + } + resolve(); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH deleteItem stream error:", streamErr); + res.status(500).json({ error: `Stream error: ${streamErr.message}` }); + resolve(); + }); + }, + ); }); - }); + }; + + await executeDelete(false); }); /** diff --git a/src/locales/en.json b/src/locales/en.json index 812ea17e..c74eb3e7 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1375,6 +1375,11 @@ "itemDeletedSuccessfully": "{{type}} deleted successfully", "itemsDeletedSuccessfully": "{{count}} items deleted successfully", "failedToDeleteItems": "Failed to delete items", + "sudoPasswordRequired": "Administrator Password Required", + "enterSudoPassword": "Enter sudo password to continue this operation", + "sudoPassword": "Sudo password", + "sudoOperationFailed": "Sudo operation failed", + "deleteOperation": "Delete files/folders", "dragFilesToUpload": "Drop files here to upload", "emptyFolder": "This folder is empty", "itemCount": "{{count}} items", diff --git a/src/ui/desktop/apps/features/file-manager/FileManager.tsx b/src/ui/desktop/apps/features/file-manager/FileManager.tsx index 1a25c73e..145c3bf0 100644 --- a/src/ui/desktop/apps/features/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/features/file-manager/FileManager.tsx @@ -21,6 +21,7 @@ import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { PermissionsDialog } from "./components/PermissionsDialog.tsx"; import { CompressDialog } from "./components/CompressDialog.tsx"; +import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx"; import { Upload, FolderPlus, @@ -57,6 +58,7 @@ import { changeSSHPermissions, extractSSHArchive, compressSSHFiles, + setSudoPassword, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar.tsx"; @@ -163,6 +165,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { [], ); + const [sudoDialogOpen, setSudoDialogOpen] = useState(false); + const [pendingSudoOperation, setPendingSudoOperation] = useState<{ + type: "delete"; + files: FileItem[]; + } | null>(null); + const { selectedFiles, clearSelection, setSelection } = useFileSelection(); const { dragHandlers } = useDragAndDrop({ @@ -720,9 +728,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { handleRefreshDirectory(); clearSelection(); } catch (error: unknown) { + const axiosError = error as { + response?: { data?: { needsSudo?: boolean; error?: string } }; + message?: string; + }; + if (axiosError.response?.data?.needsSudo) { + setPendingSudoOperation({ type: "delete", files }); + setSudoDialogOpen(true); + return; + } if ( - error.message?.includes("connection") || - error.message?.includes("established") + axiosError.message?.includes("connection") || + axiosError.message?.includes("established") ) { toast.error( `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, @@ -737,6 +754,41 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); } + async function handleSudoPasswordSubmit(password: string) { + if (!sshSessionId || !pendingSudoOperation) return; + + try { + await setSudoPassword(sshSessionId, password); + setSudoDialogOpen(false); + + if (pendingSudoOperation.type === "delete") { + for (const file of pendingSudoOperation.files) { + await deleteSSHItem( + sshSessionId, + file.path, + file.type === "directory", + currentHost?.id, + currentHost?.userId?.toString(), + ); + } + toast.success( + t("fileManager.itemsDeletedSuccessfully", { + count: pendingSudoOperation.files.length, + }), + ); + handleRefreshDirectory(); + clearSelection(); + } + + setPendingSudoOperation(null); + } catch (error: unknown) { + const axiosError = error as { message?: string }; + toast.error( + axiosError.message || t("fileManager.sudoOperationFailed"), + ); + } + } + function handleCreateNewFolder() { const defaultName = generateUniqueName( t("fileManager.newFolderDefault"), @@ -2173,6 +2225,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { }} onSave={handleSavePermissions} /> + + { + setSudoDialogOpen(open); + if (!open) setPendingSudoOperation(null); + }} + onSubmit={handleSudoPasswordSubmit} + operation={ + pendingSudoOperation?.type === "delete" + ? t("fileManager.deleteOperation") + : undefined + } + /> ); } diff --git a/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx b/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx new file mode 100644 index 00000000..469d367e --- /dev/null +++ b/src/ui/desktop/apps/features/file-manager/SudoPasswordDialog.tsx @@ -0,0 +1,98 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { PasswordInput } from "@/components/ui/password-input.tsx"; +import { useTranslation } from "react-i18next"; +import { ShieldAlert } from "lucide-react"; + +interface SudoPasswordDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (password: string) => void; + operation?: string; +} + +export function SudoPasswordDialog({ + open, + onOpenChange, + onSubmit, + operation, +}: SudoPasswordDialogProps) { + const { t } = useTranslation(); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) { + setPassword(""); + setLoading(false); + } + }, [open]); + + const handleSubmit = async (e?: React.FormEvent) => { + if (e) { + e.preventDefault(); + } + + if (!password.trim()) { + return; + } + + setLoading(true); + onSubmit(password); + }; + + return ( + + +
+ + + + {t("fileManager.sudoPasswordRequired")} + + + {t("fileManager.enterSudoPassword")} + {operation && ( + + {operation} + + )} + + + +
+ setPassword(e.target.value)} + placeholder={t("fileManager.sudoPassword")} + autoFocus + disabled={loading} + /> +
+ + + + + +
+
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 1a747c16..534aea8b 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1641,6 +1641,20 @@ export async function deleteSSHItem( } } +export async function setSudoPassword( + sessionId: string, + password: string, +): Promise { + try { + await fileManagerApi.post("/sudo-password", { + sessionId, + password, + }); + } catch (error) { + handleApiError(error, "set sudo password"); + } +} + export async function copySSHItem( sessionId: string, sourcePath: string, -- 2.49.1 From 42149e6960df82c100de542c13b6d42edb38e26c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 15 Jan 2026 05:24:14 +0800 Subject: [PATCH 2/2] fix: add sudo support for listFiles and improve permission error handling --- src/backend/ssh/file-manager.ts | 150 +++++++++++++++++- src/locales/translated/en.json | 4 + src/locales/translated/zh.json | 6 +- .../features/file-manager/FileManager.tsx | 92 ++++++++--- 4 files changed, 229 insertions(+), 23 deletions(-) diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 0860a472..39405257 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -1512,8 +1512,34 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { }); stream.on("close", (code) => { - sshConn.activeOperations--; if (code !== 0) { + const isPermissionDenied = + errorData.toLowerCase().includes("permission denied") || + errorData.toLowerCase().includes("access denied"); + + if (isPermissionDenied) { + // If we have sudo password, retry with sudo + if (sshConn.sudoPassword) { + fileLogger.info( + `Permission denied for listFiles, retrying with sudo: ${sshPath}`, + ); + tryWithSudo(); + return; + } + + // No sudo password - tell frontend to request one + sshConn.activeOperations--; + fileLogger.warn( + `Permission denied for listFiles, sudo required: ${sshPath}`, + ); + return res.status(403).json({ + error: `Permission denied: Cannot access ${sshPath}`, + needsSudo: true, + path: sshPath, + }); + } + + sshConn.activeOperations--; fileLogger.error( `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, ); @@ -1521,6 +1547,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { .status(500) .json({ error: `Command failed: ${errorData}` }); } + sshConn.activeOperations--; const lines = data.split("\n").filter((line) => line.trim()); const files = []; @@ -1578,6 +1605,127 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { }); }; + const tryWithSudo = () => { + const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); + const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); + const sudoCommand = `echo '${escapedPassword}' | sudo -S ls -la '${escapedPath}' 2>&1`; + + sshConn.client.exec(sudoCommand, (err, stream) => { + if (err) { + sshConn.activeOperations--; + fileLogger.error("SSH sudo listFiles error:", err); + return res.status(500).json({ error: err.message }); + } + + let data = ""; + let errorData = ""; + + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + + stream.stderr.on("data", (chunk: Buffer) => { + errorData += chunk.toString(); + }); + + stream.on("close", (code) => { + sshConn.activeOperations--; + + // Filter out sudo password prompt from output + data = data.replace(/\[sudo\] password for .+?:\s*/g, ""); + + // Check for sudo authentication failure + if ( + data.toLowerCase().includes("sorry, try again") || + data.toLowerCase().includes("incorrect password") || + errorData.toLowerCase().includes("sorry, try again") + ) { + // Clear invalid sudo password + sshConn.sudoPassword = undefined; + return res.status(403).json({ + error: "Sudo authentication failed. Please try again.", + needsSudo: true, + sudoFailed: true, + path: sshPath, + }); + } + + if (code !== 0 && !data.trim()) { + fileLogger.error( + `SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, + ); + return res + .status(500) + .json({ error: `Sudo command failed: ${errorData || data}` }); + } + + const lines = data.split("\n").filter((line) => line.trim()); + const files: Array<{ + name: string; + type: string; + size: number | undefined; + modified: string; + permissions: string; + owner: string; + group: string; + linkTarget: string | undefined; + path: string; + executable: boolean; + }> = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const parts = line.split(/\s+/); + if (parts.length >= 9) { + const permissions = parts[0]; + const owner = parts[2]; + const group = parts[3]; + const size = parseInt(parts[4], 10); + + let dateStr = ""; + const nameStartIndex = 8; + + if (parts[5] && parts[6] && parts[7]) { + dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`; + } + + const name = parts.slice(nameStartIndex).join(" "); + const isDirectory = permissions.startsWith("d"); + const isLink = permissions.startsWith("l"); + + if (name === "." || name === "..") continue; + + let actualName = name; + let linkTarget = undefined; + if (isLink && name.includes(" -> ")) { + const linkParts = name.split(" -> "); + actualName = linkParts[0]; + linkTarget = linkParts[1]; + } + + files.push({ + name: actualName, + type: isDirectory ? "directory" : isLink ? "link" : "file", + size: isDirectory ? undefined : size, + modified: dateStr, + permissions, + owner, + group, + linkTarget, + path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`, + executable: + !isDirectory && !isLink + ? isExecutableFile(permissions, actualName) + : false, + }); + } + } + + res.json({ files, path: sshPath }); + }); + }); + }; + trySFTP(); }); diff --git a/src/locales/translated/en.json b/src/locales/translated/en.json index e319cfd7..3eebfd6d 100644 --- a/src/locales/translated/en.json +++ b/src/locales/translated/en.json @@ -1371,6 +1371,10 @@ "downloadSuccess": "File downloaded successfully", "downloadFailed": "File download failed", "permissionDenied": "Permission denied", + "sudoAuthFailed": "Sudo authentication failed. Please check your password.", + "accessDirectory": "access this directory", + "deleteOperation": "delete these items", + "sudoOperationFailed": "Sudo operation failed", "checkDockerLogs": "Check the Docker logs for detailed error information", "internalServerError": "Internal server error occurred", "serverError": "Server Error", diff --git a/src/locales/translated/zh.json b/src/locales/translated/zh.json index 8aeb6027..dd4fd94f 100644 --- a/src/locales/translated/zh.json +++ b/src/locales/translated/zh.json @@ -1370,7 +1370,11 @@ "uploadFailed": "文件上傳失敗", "downloadSuccess": "文件下載成功", "downloadFailed": "文件下載失敗", - "permissionDenied": "沒有權限", + "permissionDenied": "没有权限", + "sudoAuthFailed": "Sudo 认证失败,请检查密码", + "accessDirectory": "访问此目录", + "deleteOperation": "删除这些项目", + "sudoOperationFailed": "Sudo 操作失败", "checkDockerLogs": "查看 Docker 日誌以取得詳細的錯誤訊息", "internalServerError": "發生內部伺服器錯誤", "serverError": "伺服器錯誤", diff --git a/src/ui/desktop/apps/features/file-manager/FileManager.tsx b/src/ui/desktop/apps/features/file-manager/FileManager.tsx index 145c3bf0..7028e09b 100644 --- a/src/ui/desktop/apps/features/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/features/file-manager/FileManager.tsx @@ -166,10 +166,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); const [sudoDialogOpen, setSudoDialogOpen] = useState(false); - const [pendingSudoOperation, setPendingSudoOperation] = useState<{ - type: "delete"; - files: FileItem[]; - } | null>(null); + const [pendingSudoOperation, setPendingSudoOperation] = useState< + | { type: "delete"; files: FileItem[] } + | { type: "navigate"; path: string } + | null + >(null); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); @@ -400,14 +401,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } const loadDirectory = useCallback( - async (path: string) => { + async (path: string): Promise => { if (!sshSessionId) { console.error("Cannot load directory: no SSH session ID"); - return; + return false; } if (isLoading && currentLoadingPathRef.current !== path) { - return; + return false; } currentLoadingPathRef.current = path; @@ -419,7 +420,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const response = await listSSHFiles(sshSessionId, path); if (currentLoadingPathRef.current !== path) { - return; + return false; } const files = Array.isArray(response) @@ -428,29 +429,55 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setFiles(files); clearSelection(); + return true; } catch (error: unknown) { if (currentLoadingPathRef.current === path) { + const axiosError = error as { + response?: { + status?: number; + data?: { needsSudo?: boolean; error?: string; sudoFailed?: boolean }; + }; + message?: string; + }; + + // Check if this is a permission denied error that needs sudo + if (axiosError.response?.data?.needsSudo) { + console.log("Permission denied, sudo required for:", path); + + // Only show dialog if not already in a sudo retry flow + if (!sudoDialogOpen) { + setPendingSudoOperation({ type: "navigate", path }); + setSudoDialogOpen(true); + } + + if (axiosError.response.data.sudoFailed) { + toast.error(t("fileManager.sudoAuthFailed")); + } else { + toast.error(t("fileManager.permissionDenied")); + } + return false; + } + console.error("Failed to load directory:", error); + // Show more specific error message + const errorMessage = + axiosError.response?.data?.error || axiosError.message || String(error); + if (initialLoadDoneRef.current) { - toast.error( - t("fileManager.failedToLoadDirectory") + - ": " + - (error.message || error), - ); + toast.error(t("fileManager.failedToLoadDirectory") + ": " + errorMessage); } if ( - error.message?.includes("connection") || - error.message?.includes("SSH") + errorMessage?.includes("connection") || + errorMessage?.includes("SSH") ) { handleCloseWithError( - t("fileManager.failedToLoadDirectory") + - ": " + - (error.message || error), + t("fileManager.failedToLoadDirectory") + ": " + errorMessage, ); } } + return false; } finally { if (currentLoadingPathRef.current === path) { setIsLoading(false); @@ -458,7 +485,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } }, - [sshSessionId, isLoading, clearSelection, t], + [sshSessionId, isLoading, clearSelection, t, sudoDialogOpen], ); const debouncedLoadDirectory = useCallback( @@ -778,14 +805,35 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { ); handleRefreshDirectory(); clearSelection(); + } else if (pendingSudoOperation.type === "navigate") { + // Retry navigation with sudo password now set + const success = await loadDirectory(pendingSudoOperation.path); + if (success) { + setCurrentPath(pendingSudoOperation.path); + setPendingSudoOperation(null); + } + // If failed, loadDirectory already handles showing the error/dialog + return; } setPendingSudoOperation(null); } catch (error: unknown) { - const axiosError = error as { message?: string }; + const axiosError = error as { + response?: { data?: { needsSudo?: boolean; sudoFailed?: boolean } }; + message?: string; + }; + + // If sudo auth failed, keep dialog open for retry + if (axiosError.response?.data?.sudoFailed) { + toast.error(t("fileManager.sudoAuthFailed")); + setSudoDialogOpen(true); + return; + } + toast.error( axiosError.message || t("fileManager.sudoOperationFailed"), ); + setPendingSudoOperation(null); } } @@ -2236,7 +2284,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { operation={ pendingSudoOperation?.type === "delete" ? t("fileManager.deleteOperation") - : undefined + : pendingSudoOperation?.type === "navigate" + ? t("fileManager.accessDirectory") + : undefined } /> -- 2.49.1