* Feature request: Add delete confirmation dialog to file manager (#344)

* Feature request: Add delete confirmation dialog to file manager

- Added confirmation dialog before deleting files/folders
- Users must confirm deletion with a warning message
- Works for both Delete key and right-click delete
- Shows different messages for single file, folder, or multiple items
- Includes permanent deletion warning
- Follows existing design patterns using confirmWithToast

* Adds confirmation for deletion of items including folders

Updates the file deletion confirmation logic to distinguish between
deleting multiple items with or without folders. Introduces a new
translation string for a clearer user prompt when folders and their
contents are included in the deletion.

Improves clarity and reduces user error when performing bulk deletions.

* feat: Add Chinese translations for delete confirmation messages

* Adds camelCase support for encrypted field mappings (#342)

Extends encrypted field mappings to include camelCase variants
to support consistency and compatibility with different naming
conventions. Updates reverse mappings for Drizzle ORM to allow
conversion between camelCase and snake_case field names.

Improves integration with systems using mixed naming styles.

* Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password.

* Fix snake case mismatching

* Add real client IP

* Fix OIDC credential persistence issue

The issue was that OIDC users were getting a new random Data Encryption Key (DEK)
on every login, which made previously encrypted credentials inaccessible.

Changes:
- Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key
- Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK
- Ensured OIDC users now have the same encryption persistence as password-based users

This fix ensures that credentials created by OIDC users remain accessible across
multiple login sessions.

* Fix race condition and remove redundant kekSalt for OIDC users

Critical fixes:

1. Race Condition Mitigation:
   - Added read-after-write verification in setupOIDCUserEncryption()
   - Ensures session uses the DEK that's actually in the database
   - Prevents data loss when concurrent logins occur for new OIDC users
   - If race is detected, discards generated DEK and uses stored one

2. Remove Redundant kekSalt Logic:
   - Removed unnecessary kekSalt generation and checks for OIDC users
   - kekSalt is not used in OIDC key derivation (uses userId as salt)
   - Reduces database operations from 4 to 2 per authentication
   - Simplifies code and removes potential confusion

3. Improved Error Handling:
   - systemKey cleanup moved to finally block
   - Ensures sensitive key material is always cleared from memory

These changes ensure data consistency and prevent potential data loss
in high-concurrency scenarios.

* Cleanup OIDC pr and run prettier

---------

Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com>
This commit was merged in pull request #364.
This commit is contained in:
Karmaa
2025-10-06 10:11:25 -05:00
committed by GitHub
parent 937e04fa5c
commit 2bf61bda4d
14 changed files with 199 additions and 92 deletions

View File

@@ -10,6 +10,9 @@ http {
keepalive_timeout 65;
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
@@ -23,7 +26,6 @@ http {
return 301 https://$host:${SSL_PORT}$request_uri;
}
# HTTPS Server
server {
listen ${SSL_PORT} ssl;
server_name _;
@@ -41,7 +43,6 @@ http {
index index.html index.htm;
}
# Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;

View File

@@ -10,6 +10,9 @@ http {
keepalive_timeout 65;
client_header_timeout 300s;
set_real_ip_from 127.0.0.1;
real_ip_header X-Forwarded-For;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off;
@@ -29,7 +32,6 @@ http {
index index.html index.htm;
}
# Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;

View File

@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
"version": "1.7.1",
"version": "1.7.2",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",

View File

@@ -46,7 +46,7 @@ export const sshData = sqliteTable("ssh_data", {
password: text("password"),
key: text("key", { length: 8192 }),
keyPassword: text("key_password"),
key_password: text("key_password"),
keyType: text("key_type"),
autostartPassword: text("autostart_password"),
@@ -142,9 +142,9 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
privateKey: text("private_key", { length: 16384 }),
publicKey: text("public_key", { length: 4096 }),
keyPassword: text("key_password"),
private_key: text("private_key", { length: 16384 }),
public_key: text("public_key", { length: 4096 }),
key_password: text("key_password"),
keyType: text("key_type"),
detectedKeyType: text("detected_key_type"),
usageCount: integer("usage_count").notNull().default(0),

View File

@@ -174,9 +174,9 @@ router.post(
username: username.trim(),
password: plainPassword,
key: plainKey,
privateKey: keyInfo?.privateKey || plainKey,
publicKey: keyInfo?.publicKey || null,
keyPassword: plainKeyPassword,
private_key: keyInfo?.privateKey || plainKey,
public_key: keyInfo?.publicKey || null,
key_password: plainKeyPassword,
keyType: keyType || null,
detectedKeyType: keyInfo?.keyType || null,
usageCount: 0,
@@ -424,13 +424,13 @@ router.put(
error: `Invalid SSH key: ${keyInfo.error}`,
});
}
updateFields.privateKey = keyInfo.privateKey;
updateFields.publicKey = keyInfo.publicKey;
updateFields.private_key = keyInfo.privateKey;
updateFields.public_key = keyInfo.publicKey;
updateFields.detectedKeyType = keyInfo.keyType;
}
}
if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null;
updateFields.key_password = updateData.keyPassword || null;
}
if (Object.keys(updateFields).length === 0) {
@@ -537,7 +537,7 @@ router.delete(
credentialId: null,
password: null,
key: null,
keyPassword: null,
key_password: null,
authType: "password",
})
.where(
@@ -633,7 +633,7 @@ router.post(
authType: credential.auth_type || credential.authType,
password: null,
key: null,
keyPassword: null,
key_password: null,
keyType: null,
updatedAt: new Date().toISOString(),
})

View File

@@ -91,7 +91,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
username: host.username,
password: host.autostartPassword,
key: host.autostartKey,
keyPassword: host.autostartKeyPassword,
key_password: host.autostartKeyPassword,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -151,7 +151,7 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => {
username: host.username,
password: host.autostartPassword || host.password,
key: host.autostartKey || host.key,
keyPassword: host.autostartKeyPassword || host.keyPassword,
key_password: host.autostartKeyPassword || host.key_password,
autostartPassword: host.autostartPassword,
autostartKey: host.autostartKey,
autostartKeyPassword: host.autostartKeyPassword,
@@ -226,7 +226,7 @@ router.post(
authType,
credentialId,
key,
keyPassword,
key_password,
keyType,
pin,
enableTerminal,
@@ -274,17 +274,17 @@ router.post(
if (effectiveAuthType === "password") {
sshDataObj.password = password || null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
sshDataObj.key = key || null;
sshDataObj.keyPassword = keyPassword || null;
sshDataObj.key_password = key_password || null;
sshDataObj.keyType = keyType;
sshDataObj.password = null;
} else {
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
}
@@ -407,7 +407,7 @@ router.put(
authType,
credentialId,
key,
keyPassword,
key_password,
keyType,
pin,
enableTerminal,
@@ -458,14 +458,14 @@ router.put(
sshDataObj.password = password;
}
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
} else if (effectiveAuthType === "key") {
if (key) {
sshDataObj.key = key;
}
if (keyPassword !== undefined) {
sshDataObj.keyPassword = keyPassword || null;
if (key_password !== undefined) {
sshDataObj.key_password = key_password || null;
}
if (keyType) {
sshDataObj.keyType = keyType;
@@ -474,7 +474,7 @@ router.put(
} else {
sshDataObj.password = null;
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.key_password = null;
sshDataObj.keyType = null;
}
@@ -711,7 +711,7 @@ router.get(
authType: resolvedHost.authType,
password: resolvedHost.password || null,
key: resolvedHost.key || null,
keyPassword: resolvedHost.keyPassword || null,
key_password: resolvedHost.key_password || null,
keyType: resolvedHost.keyType || null,
folder: resolvedHost.folder,
tags:
@@ -1234,7 +1234,7 @@ async function resolveHostCredentials(host: any): Promise<any> {
authType: credential.auth_type || credential.authType,
password: credential.password,
key: credential.key,
keyPassword: credential.key_password || credential.keyPassword,
key_password: credential.key_password || credential.key_password,
keyType: credential.key_type || credential.keyType,
};
}
@@ -1404,8 +1404,8 @@ router.post(
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword:
hostData.authType === "key" ? hostData.keyPassword : null,
key_password:
hostData.authType === "key" ? hostData.key_password : null,
keyType:
hostData.authType === "key" ? hostData.keyType || "auto" : null,
pin: hostData.pin || false,
@@ -1540,7 +1540,7 @@ router.post(
...tunnel,
endpointPassword: decryptedEndpoint.password || null,
endpointKey: decryptedEndpoint.key || null,
endpointKeyPassword: decryptedEndpoint.keyPassword || null,
endpointKeyPassword: decryptedEndpoint.key_password || null,
endpointAuthType: endpointHost.authType,
};
}
@@ -1563,7 +1563,7 @@ router.post(
.set({
autostartPassword: decryptedConfig.password || null,
autostartKey: decryptedConfig.key || null,
autostartKeyPassword: decryptedConfig.keyPassword || null,
autostartKeyPassword: decryptedConfig.key_password || null,
tunnelConnections: updatedTunnelConnections,
})
.where(eq(sshData.id, sshConfigId));

View File

@@ -6,6 +6,20 @@ export class LazyFieldEncryption {
key_password: "keyPassword",
private_key: "privateKey",
public_key: "publicKey",
password_hash: "passwordHash",
client_secret: "clientSecret",
totp_secret: "totpSecret",
totp_backup_codes: "totpBackupCodes",
oidc_identifier: "oidcIdentifier",
keyPassword: "key_password",
privateKey: "private_key",
publicKey: "public_key",
passwordHash: "password_hash",
clientSecret: "client_secret",
totpSecret: "totp_secret",
totpBackupCodes: "totp_backup_codes",
oidcIdentifier: "oidc_identifier",
};
static isPlaintextField(value: string): boolean {

View File

@@ -70,7 +70,36 @@ class UserCrypto {
}
async setupOIDCUserEncryption(userId: string): Promise<void> {
const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
let DEK: Buffer;
if (existingEncryptedDEK) {
const systemKey = this.deriveOIDCSystemKey(userId);
DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
systemKey.fill(0);
} else {
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const systemKey = this.deriveOIDCSystemKey(userId);
try {
const encryptedDEK = this.encryptDEK(DEK, systemKey);
await this.storeEncryptedDEK(userId, encryptedDEK);
const storedEncryptedDEK = await this.getEncryptedDEK(userId);
if (
storedEncryptedDEK &&
storedEncryptedDEK.data !== encryptedDEK.data
) {
DEK.fill(0);
DEK = this.decryptDEK(storedEncryptedDEK, systemKey);
} else if (!storedEncryptedDEK) {
throw new Error("Failed to store and retrieve user encryption key.");
}
} finally {
systemKey.fill(0);
}
}
const now = Date.now();
this.userSessions.set(userId, {
@@ -134,20 +163,14 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise<boolean> {
try {
const kekSalt = await this.getKEKSalt(userId);
if (!kekSalt) {
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
await this.setupOIDCUserEncryption(userId);
return true;
}
const systemKey = this.deriveOIDCSystemKey(userId);
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
systemKey.fill(0);
await this.setupOIDCUserEncryption(userId);
return true;
}
const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0);

View File

@@ -844,8 +844,13 @@
"selectServerToEdit": "Select a server from the sidebar to start editing files",
"fileOperations": "File Operations",
"confirmDeleteMessage": "Are you sure you want to delete <strong>{{name}}</strong>?",
"confirmDeleteSingleItem": "Are you sure you want to permanently delete \"{{name}}\"?",
"confirmDeleteMultipleItems": "Are you sure you want to permanently delete {{count}} items?",
"confirmDeleteMultipleItemsWithFolders": "Are you sure you want to permanently delete {{count}} items? This includes folders and their contents.",
"confirmDeleteFolder": "Are you sure you want to permanently delete the folder \"{{name}}\" and all its contents?",
"deleteDirectoryWarning": "This will delete the folder and all its contents.",
"actionCannotBeUndone": "This action cannot be undone.",
"permanentDeleteWarning": "This action cannot be undone. The item(s) will be permanently deleted from the server.",
"recent": "Recent",
"pinned": "Pinned",
"folderShortcuts": "Folder Shortcuts",

View File

@@ -852,8 +852,13 @@
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
"fileOperations": "文件操作",
"confirmDeleteMessage": "确定要删除 <strong>{{name}}</strong> 吗?",
"confirmDeleteSingleItem": "确定要永久删除 \"{{name}}\" 吗?",
"confirmDeleteMultipleItems": "确定要永久删除 {{count}} 个项目吗?",
"confirmDeleteMultipleItemsWithFolders": "确定要永久删除 {{count}} 个项目吗?这包括文件夹及其内容。",
"confirmDeleteFolder": "确定要永久删除文件夹 \"{{name}}\" 及其所有内容吗?",
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
"actionCannotBeUndone": "此操作无法撤销。",
"permanentDeleteWarning": "此操作无法撤销。项目将从服务器永久删除。",
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
"openTerminalHere": "在此处打开终端",

View File

@@ -9,6 +9,7 @@ import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
@@ -82,6 +83,7 @@ function formatFileSize(bytes?: number): string {
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { openWindow } = useWindowManager();
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [currentHost, setCurrentHost] = useState<SSHHost | null>(
initialHost || null,
@@ -587,54 +589,85 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
try {
await ensureSSHConnection();
for (const file of files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
);
}
const deletedFiles = files.map((file) => ({
path: file.path,
name: file.name,
}));
const undoAction: UndoAction = {
type: "delete",
description: t("fileManager.deletedItems", { count: files.length }),
data: {
operation: "cut",
deletedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
toast.success(
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
);
handleRefreshDirectory();
clearSelection();
} catch (error: any) {
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
);
let confirmMessage: string;
if (files.length === 1) {
const file = files[0];
if (file.type === "directory") {
confirmMessage = t("fileManager.confirmDeleteFolder", {
name: file.name,
});
} else {
toast.error(t("fileManager.failedToDeleteItems"));
confirmMessage = t("fileManager.confirmDeleteSingleItem", {
name: file.name,
});
}
console.error("Delete failed:", error);
} else {
const hasDirectory = files.some((file) => file.type === "directory");
const translationKey = hasDirectory
? "fileManager.confirmDeleteMultipleItemsWithFolders"
: "fileManager.confirmDeleteMultipleItems";
confirmMessage = t(translationKey, {
count: files.length,
});
}
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
confirmWithToast(
fullMessage,
async () => {
try {
await ensureSSHConnection();
for (const file of files) {
await deleteSSHItem(
sshSessionId,
file.path,
file.type === "directory",
currentHost?.id,
currentHost?.userId?.toString(),
);
}
const deletedFiles = files.map((file) => ({
path: file.path,
name: file.name,
}));
const undoAction: UndoAction = {
type: "delete",
description: t("fileManager.deletedItems", { count: files.length }),
data: {
operation: "cut",
deletedFiles,
targetDirectory: currentPath,
},
timestamp: Date.now(),
};
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
toast.success(
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
);
handleRefreshDirectory();
clearSelection();
} catch (error: any) {
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
);
} else {
toast.error(t("fileManager.failedToDeleteItems"));
}
console.error("Delete failed:", error);
}
},
"destructive",
);
}
function handleCreateNewFolder() {

View File

@@ -210,7 +210,18 @@ export function HostManagerEditor({
defaultPath: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "key") {
if (data.authType === "password") {
if (
!data.password ||
(typeof data.password === "string" && data.password.trim() === "")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.passwordRequired"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (
!data.key ||
(typeof data.key === "string" && data.key.trim() === "")

View File

@@ -24,7 +24,10 @@ function AppContent() {
const [isAdmin, setIsAdmin] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [showVersionCheck, setShowVersionCheck] = useState(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true);
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(() => {
const saved = localStorage.getItem("topNavbarOpen");
return saved !== null ? JSON.parse(saved) : true;
});
const { currentTab, tabs } = useTabs();
useEffect(() => {
@@ -64,6 +67,10 @@ function AppContent() {
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
useEffect(() => {
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
}, [isTopbarOpen]);
const handleSelectView = (nextView: string) => {
setMountedViews((prev) => {
if (prev.has(nextView)) return prev;

View File

@@ -101,7 +101,10 @@ export function LeftSidebar({
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
const saved = localStorage.getItem("leftSidebarOpen");
return saved !== null ? JSON.parse(saved) : true;
});
const {
tabs: tabList,
@@ -181,7 +184,6 @@ export function LeftSidebar({
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) ||
@@ -247,6 +249,10 @@ export function LeftSidebar({
return () => clearTimeout(handler);
}, [search]);
React.useEffect(() => {
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
}, [isSidebarOpen]);
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();