feat: Add file permissions dialog for SSH file manager

Add a comprehensive file permissions modification feature accessible via right-click context menu:

- Add PermissionsDialog component with read/write/execute checkboxes for owner/group/others
- Display current and new permissions side-by-side with real-time octal calculation
- Support both numeric (755) and symbolic (rwxr-xr-x) permission formats
- Implement backend API endpoint POST /ssh/file_manager/ssh/changePermissions
- Add frontend API function changeSSHPermissions with complete logging
- Integrate dialog with FileManager via onProperties callback
- Add i18n translations for English and Chinese
- Include path escaping and comprehensive error handling
This commit is contained in:
ZacharyZcR
2025-11-09 15:39:52 +08:00
parent 417df31aa7
commit ae3b8bba12
6 changed files with 504 additions and 2 deletions

View File

@@ -2490,6 +2490,103 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
});
});
app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
fileLogger.error(
"SSH connection not found or not connected for changePermissions",
{
operation: "change_permissions",
sessionId,
hasConnection: !!sshConn,
isConnected: sshConn?.isConnected,
},
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)"
});
}
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}'`;
fileLogger.info("Changing file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
sshConn.client.exec(command, (err, stream) => {
if (err) {
fileLogger.error("SSH changePermissions exec error:", err, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
return res.status(500).json({ error: "Failed to change permissions" });
}
let errorOutput = "";
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
fileLogger.error("chmod command failed", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
exitCode: code,
error: errorOutput,
});
return res.status(500).json({
error: errorOutput || "Failed to change permissions"
});
}
fileLogger.success("File permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
res.json({
success: true,
message: "Permissions changed successfully"
});
});
stream.on("error", (streamErr) => {
fileLogger.error("SSH changePermissions stream error:", streamErr, {
operation: "change_permissions",
sessionId,
path,
permissions: octalPerms,
});
if (!res.headersSent) {
res.status(500).json({ error: "Stream error while changing permissions" });
}
});
});
});
process.on("SIGINT", () => {
Object.keys(sshSessions).forEach(cleanupSession);
process.exit(0);

View File

@@ -1169,7 +1169,19 @@
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
"loadFileFailed": "Failed to load file: {{error}}",
"connectedSuccessfully": "Connected successfully",
"totpVerificationFailed": "TOTP verification failed"
"totpVerificationFailed": "TOTP verification failed",
"changePermissions": "Change Permissions",
"changePermissionsDesc": "Modify file permissions for",
"currentPermissions": "Current Permissions",
"newPermissions": "New Permissions",
"owner": "Owner",
"group": "Group",
"others": "Others",
"read": "Read",
"write": "Write",
"execute": "Execute",
"permissionsChangedSuccessfully": "Permissions changed successfully",
"failedToChangePermissions": "Failed to change permissions"
},
"tunnels": {
"title": "SSH Tunnels",

View File

@@ -1151,7 +1151,19 @@
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
"loadFileFailed": "加载文件失败:{{error}}",
"connectedSuccessfully": "连接成功",
"totpVerificationFailed": "TOTP 验证失败"
"totpVerificationFailed": "TOTP 验证失败",
"changePermissions": "修改权限",
"changePermissionsDesc": "修改文件权限",
"currentPermissions": "当前权限",
"newPermissions": "新权限",
"owner": "所有者",
"group": "组",
"others": "其他",
"read": "读取",
"write": "写入",
"execute": "执行",
"permissionsChangedSuccessfully": "权限修改成功",
"failedToChangePermissions": "权限修改失败"
},
"tunnels": {
"title": "SSH 隧道",

View File

@@ -16,6 +16,7 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
import { PermissionsDialog } from "./components/PermissionsDialog";
import {
Upload,
FolderPlus,
@@ -49,6 +50,7 @@ import {
addFolderShortcut,
getPinnedFiles,
logActivity,
changeSSHPermissions,
} from "@/ui/main-axios.ts";
import type { SidebarItem } from "./FileManagerSidebar";
@@ -146,6 +148,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
const [permissionsDialogFile, setPermissionsDialogFile] = useState<FileItem | null>(null);
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
@@ -1180,6 +1183,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(file);
}
function handleOpenPermissionsDialog(file: FileItem) {
setPermissionsDialogFile(file);
}
async function handleSavePermissions(file: FileItem, permissions: string) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
try {
await changeSSHPermissions(
sshSessionId,
file.path,
permissions,
currentHost?.id,
currentHost?.userId?.toString(),
);
toast.success(t("fileManager.permissionsChangedSuccessfully"));
await handleRefreshDirectory();
} catch (error: unknown) {
console.error("Failed to change permissions:", error);
toast.error(t("fileManager.failedToChangePermissions"));
throw error;
}
}
async function ensureSSHConnection() {
if (!sshSessionId || !currentHost || isReconnecting) return;
@@ -1968,6 +1999,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onAddShortcut={handleAddShortcut}
isPinned={isPinnedFile}
currentPath={currentPath}
onProperties={handleOpenPermissionsDialog}
/>
</div>
</div>
@@ -1993,6 +2025,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}}
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
</div>
);
}

View File

@@ -0,0 +1,295 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { useTranslation } from "react-i18next";
import { Shield } from "lucide-react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
permissions?: string;
owner?: string;
group?: string;
}
interface PermissionsDialogProps {
file: FileItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
// Parse permissions like "rwxr-xr-x" or "755" to individual bits
const parsePermissions = (perms: string): { owner: number; group: number; other: number } => {
if (!perms) {
return { owner: 0, group: 0, other: 0 };
}
// If numeric format like "755"
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
owner: parseInt(numStr[0] || "0", 10),
group: parseInt(numStr[1] || "0", 10),
other: parseInt(numStr[2] || "0", 10),
};
}
// If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x"
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
const calcBits = (str: string): number => {
let value = 0;
if (str[0] === "r") value += 4;
if (str[1] === "w") value += 2;
if (str[2] === "x") value += 1;
return value;
};
return {
owner: calcBits(cleanPerms.substring(0, 3)),
group: calcBits(cleanPerms.substring(3, 6)),
other: calcBits(cleanPerms.substring(6, 9)),
};
};
// Convert individual bits to numeric format
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
export function PermissionsDialog({
file,
open,
onOpenChange,
onSave,
}: PermissionsDialogProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const initialPerms = parsePermissions(file?.permissions || "644");
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
const [ownerExecute, setOwnerExecute] = useState((initialPerms.owner & 1) !== 0);
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
const [groupExecute, setGroupExecute] = useState((initialPerms.group & 1) !== 0);
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
const [otherExecute, setOtherExecute] = useState((initialPerms.other & 1) !== 0);
// Reset when file changes
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");
setOwnerRead((perms.owner & 4) !== 0);
setOwnerWrite((perms.owner & 2) !== 0);
setOwnerExecute((perms.owner & 1) !== 0);
setGroupRead((perms.group & 4) !== 0);
setGroupWrite((perms.group & 2) !== 0);
setGroupExecute((perms.group & 1) !== 0);
setOtherRead((perms.other & 4) !== 0);
setOtherWrite((perms.other & 2) !== 0);
setOtherExecute((perms.other & 1) !== 0);
}
}, [file]);
const calculateOctal = (): string => {
const owner = (ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
const group = (groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
const other = (otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
return toNumeric(owner, group, other);
};
const handleSave = async () => {
if (!file) return;
setLoading(true);
try {
const permissions = calculateOctal();
await onSave(file, permissions);
onOpenChange(false);
} catch (error) {
console.error("Failed to update permissions:", error);
} finally {
setLoading(false);
}
};
if (!file) return null;
const octal = calculateOctal();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
{t("fileManager.changePermissions")}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{t("fileManager.changePermissionsDesc")}: <span className="font-mono text-foreground">{file.name}</span>
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Current info */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<Label className="text-gray-400">{t("fileManager.currentPermissions")}</Label>
<p className="font-mono text-lg mt-1">{file.permissions || "644"}</p>
</div>
<div>
<Label className="text-gray-400">{t("fileManager.newPermissions")}</Label>
<p className="font-mono text-lg mt-1">{octal}</p>
</div>
</div>
{/* Owner permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="owner-read"
checked={ownerRead}
onCheckedChange={(checked) => setOwnerRead(checked === true)}
/>
<label htmlFor="owner-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="owner-write"
checked={ownerWrite}
onCheckedChange={(checked) => setOwnerWrite(checked === true)}
/>
<label htmlFor="owner-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="owner-execute"
checked={ownerExecute}
onCheckedChange={(checked) => setOwnerExecute(checked === true)}
/>
<label htmlFor="owner-execute" className="text-sm cursor-pointer">
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
{/* Group permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.group")} {file.group && `(${file.group})`}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="group-read"
checked={groupRead}
onCheckedChange={(checked) => setGroupRead(checked === true)}
/>
<label htmlFor="group-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="group-write"
checked={groupWrite}
onCheckedChange={(checked) => setGroupWrite(checked === true)}
/>
<label htmlFor="group-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="group-execute"
checked={groupExecute}
onCheckedChange={(checked) => setGroupExecute(checked === true)}
/>
<label htmlFor="group-execute" className="text-sm cursor-pointer">
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
{/* Others permissions */}
<div className="space-y-3">
<Label className="text-base font-semibold text-foreground">
{t("fileManager.others")}
</Label>
<div className="flex gap-6 ml-4">
<div className="flex items-center space-x-2">
<Checkbox
id="other-read"
checked={otherRead}
onCheckedChange={(checked) => setOtherRead(checked === true)}
/>
<label htmlFor="other-read" className="text-sm cursor-pointer">
{t("fileManager.read")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="other-write"
checked={otherWrite}
onCheckedChange={(checked) => setOtherWrite(checked === true)}
/>
<label htmlFor="other-write" className="text-sm cursor-pointer">
{t("fileManager.write")}
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="other-execute"
checked={otherExecute}
onCheckedChange={(checked) => setOtherExecute(checked === true)}
/>
<label htmlFor="other-execute" className="text-sm cursor-pointer">
{t("fileManager.execute")}
</label>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={loading}>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1515,6 +1515,51 @@ export async function moveSSHItem(
}
}
export async function changeSSHPermissions(
sessionId: string,
path: string,
permissions: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string }> {
try {
fileLogger.info("Changing SSH file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions,
hostId,
userId,
});
const response = await fileManagerApi.post("/ssh/changePermissions", {
sessionId,
path,
permissions,
hostId,
userId,
});
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to change SSH file permissions", error, {
operation: "change_permissions",
sessionId,
path,
permissions,
});
handleApiError(error, "change SSH permissions");
throw error;
}
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================