From 8aa2ee67ae0cab911da75db438061a9df9117bd5 Mon Sep 17 00:00:00 2001
From: Ved Prakash <54140516+thorved@users.noreply.github.com>
Date: Sun, 5 Oct 2025 05:38:55 +0530
Subject: [PATCH 1/8] 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
---
src/locales/en/translation.json | 5 +
src/locales/zh/translation.json | 5 +
.../Desktop/Apps/File Manager/FileManager.tsx | 126 +++++++++++-------
3 files changed, 91 insertions(+), 45 deletions(-)
diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json
index 0e6d735e..d3aff79d 100644
--- a/src/locales/en/translation.json
+++ b/src/locales/en/translation.json
@@ -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 {{name}}?",
+ "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",
diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json
index 62069e11..0cd92395 100644
--- a/src/locales/zh/translation.json
+++ b/src/locales/zh/translation.json
@@ -852,8 +852,13 @@
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
"fileOperations": "文件操作",
"confirmDeleteMessage": "确定要删除 {{name}} 吗?",
+ "confirmDeleteSingleItem": "确定要永久删除 \"{{name}}\" 吗?",
+ "confirmDeleteMultipleItems": "确定要永久删除 {{count}} 个项目吗?",
+ "confirmDeleteMultipleItemsWithFolders": "确定要永久删除 {{count}} 个项目吗?这包括文件夹及其内容。",
+ "confirmDeleteFolder": "确定要永久删除文件夹 \"{{name}}\" 及其所有内容吗?",
"deleteDirectoryWarning": "这将删除文件夹及其所有内容。",
"actionCannotBeUndone": "此操作无法撤销。",
+ "permanentDeleteWarning": "此操作无法撤销。项目将从服务器永久删除。",
"dragSystemFilesToUpload": "拖拽系统文件到此处上传",
"dragFilesToWindowToDownload": "拖拽文件到窗口外下载",
"openTerminalHere": "在此处打开终端",
diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx
index 0f05c589..73ac9bc6 100644
--- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx
+++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx
@@ -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(
initialHost || null,
@@ -587,54 +589,88 @@ 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})`,
- );
+ // Determine the confirmation message based on file count and type
+ 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,
+ });
}
+
+ // Add permanent deletion warning
+ const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
+
+ // Show confirmation dialog
+ 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() {
--
2.49.1
From aaec940b1ba6975978c7fb0af77bb51f12208b3c Mon Sep 17 00:00:00 2001
From: Ved Prakash <54140516+thorved@users.noreply.github.com>
Date: Sun, 5 Oct 2025 05:39:10 +0530
Subject: [PATCH 2/8] 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.
---
src/backend/utils/field-crypto.ts | 15 ++++++++++++++-
src/backend/utils/lazy-field-encryption.ts | 4 ++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts
index 098b5b8e..2be3935e 100644
--- a/src/backend/utils/field-crypto.ts
+++ b/src/backend/utils/field-crypto.ts
@@ -17,18 +17,31 @@ class FieldCrypto {
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
"password_hash",
+ "passwordHash",
"client_secret",
+ "clientSecret",
"totp_secret",
+ "totpSecret",
"totp_backup_codes",
+ "totpBackupCodes",
"oidc_identifier",
+ "oidcIdentifier",
+ ]),
+ ssh_data: new Set([
+ "password",
+ "key",
+ "key_password",
+ "keyPassword",
]),
- ssh_data: new Set(["password", "key", "key_password"]),
ssh_credentials: new Set([
"password",
"private_key",
+ "privateKey",
"key_password",
+ "keyPassword",
"key",
"public_key",
+ "publicKey",
]),
};
diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts
index 8eae9193..06c43d8c 100644
--- a/src/backend/utils/lazy-field-encryption.ts
+++ b/src/backend/utils/lazy-field-encryption.ts
@@ -6,6 +6,10 @@ export class LazyFieldEncryption {
key_password: "keyPassword",
private_key: "privateKey",
public_key: "publicKey",
+ // Reverse mappings for Drizzle ORM (camelCase -> snake_case)
+ keyPassword: "key_password",
+ privateKey: "private_key",
+ publicKey: "public_key",
};
static isPlaintextField(value: string): boolean {
--
2.49.1
From 75e973f3a211fc6fdeaf9181750029524ec31ccf Mon Sep 17 00:00:00 2001
From: LukeGus
Date: Sun, 5 Oct 2025 21:48:32 -0500
Subject: [PATCH 3/8] Run code cleanup, add sidebar persistence, fix OIDC
credentials, force SSH password.
---
docker/nginx-https.conf | 2 --
docker/nginx.conf | 1 -
package.json | 2 +-
src/backend/utils/field-crypto.ts | 7 +------
src/backend/utils/lazy-field-encryption.ts | 2 +-
src/ui/Desktop/Apps/File Manager/FileManager.tsx | 3 ---
.../Desktop/Apps/Host Manager/HostManagerEditor.tsx | 13 ++++++++++++-
src/ui/Desktop/DesktopApp.tsx | 9 ++++++++-
src/ui/Desktop/Navigation/LeftSidebar.tsx | 10 ++++++++--
9 files changed, 31 insertions(+), 18 deletions(-)
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index c2f90f35..aaf5e739 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -23,7 +23,6 @@ http {
return 301 https://$host:${SSL_PORT}$request_uri;
}
- # HTTPS Server
server {
listen ${SSL_PORT} ssl;
server_name _;
@@ -41,7 +40,6 @@ http {
index index.html index.htm;
}
- # Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index b78418b7..9f37c80a 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -29,7 +29,6 @@ http {
index index.html index.htm;
}
- # Handle missing source map files gracefully
location ~* \.map$ {
return 404;
access_log off;
diff --git a/package.json b/package.json
index d8c1fa4b..3151adaa 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts
index 2be3935e..88a1cddf 100644
--- a/src/backend/utils/field-crypto.ts
+++ b/src/backend/utils/field-crypto.ts
@@ -27,12 +27,7 @@ class FieldCrypto {
"oidc_identifier",
"oidcIdentifier",
]),
- ssh_data: new Set([
- "password",
- "key",
- "key_password",
- "keyPassword",
- ]),
+ ssh_data: new Set(["password", "key", "key_password", "keyPassword"]),
ssh_credentials: new Set([
"password",
"private_key",
diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts
index 06c43d8c..3d18ff5e 100644
--- a/src/backend/utils/lazy-field-encryption.ts
+++ b/src/backend/utils/lazy-field-encryption.ts
@@ -6,7 +6,7 @@ export class LazyFieldEncryption {
key_password: "keyPassword",
private_key: "privateKey",
public_key: "publicKey",
- // Reverse mappings for Drizzle ORM (camelCase -> snake_case)
+
keyPassword: "key_password",
privateKey: "private_key",
publicKey: "public_key",
diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx
index 73ac9bc6..ea991003 100644
--- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx
+++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx
@@ -589,7 +589,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return;
- // Determine the confirmation message based on file count and type
let confirmMessage: string;
if (files.length === 1) {
const file = files[0];
@@ -613,10 +612,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
});
}
- // Add permanent deletion warning
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
- // Show confirmation dialog
confirmWithToast(
fullMessage,
async () => {
diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
index 08a41724..fe0a2c0b 100644
--- a/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
+++ b/src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
@@ -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() === "")
diff --git a/src/ui/Desktop/DesktopApp.tsx b/src/ui/Desktop/DesktopApp.tsx
index 3c1487f6..add9611d 100644
--- a/src/ui/Desktop/DesktopApp.tsx
+++ b/src/ui/Desktop/DesktopApp.tsx
@@ -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(true);
+ const [isTopbarOpen, setIsTopbarOpen] = useState(() => {
+ 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;
diff --git a/src/ui/Desktop/Navigation/LeftSidebar.tsx b/src/ui/Desktop/Navigation/LeftSidebar.tsx
index 2f7ef7ee..72ae3bef 100644
--- a/src/ui/Desktop/Navigation/LeftSidebar.tsx
+++ b/src/ui/Desktop/Navigation/LeftSidebar.tsx
@@ -101,7 +101,10 @@ export function LeftSidebar({
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState(null);
- const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+ const [isSidebarOpen, setIsSidebarOpen] = useState(() => {
+ 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();
--
2.49.1
From 85fec49bd5d35f3b1bd83821f6d0c40949076195 Mon Sep 17 00:00:00 2001
From: LukeGus
Date: Sun, 5 Oct 2025 23:13:41 -0500
Subject: [PATCH 4/8] Fix snake case mismatching
---
src/backend/database/db/schema.ts | 8 ++---
src/backend/database/routes/credentials.ts | 16 +++++-----
src/backend/database/routes/ssh.ts | 34 +++++++++++-----------
src/backend/utils/field-crypto.ts | 10 +------
src/backend/utils/lazy-field-encryption.ts | 10 +++++++
5 files changed, 40 insertions(+), 38 deletions(-)
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index bc2bb4d8..eeac5c34 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -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),
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index 9c6dc909..c856322f 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -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(),
})
diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts
index 3cb76e67..9304a647 100644
--- a/src/backend/database/routes/ssh.ts
+++ b/src/backend/database/routes/ssh.ts
@@ -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 {
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));
diff --git a/src/backend/utils/field-crypto.ts b/src/backend/utils/field-crypto.ts
index 88a1cddf..098b5b8e 100644
--- a/src/backend/utils/field-crypto.ts
+++ b/src/backend/utils/field-crypto.ts
@@ -17,26 +17,18 @@ class FieldCrypto {
private static readonly ENCRYPTED_FIELDS = {
users: new Set([
"password_hash",
- "passwordHash",
"client_secret",
- "clientSecret",
"totp_secret",
- "totpSecret",
"totp_backup_codes",
- "totpBackupCodes",
"oidc_identifier",
- "oidcIdentifier",
]),
- ssh_data: new Set(["password", "key", "key_password", "keyPassword"]),
+ ssh_data: new Set(["password", "key", "key_password"]),
ssh_credentials: new Set([
"password",
"private_key",
- "privateKey",
"key_password",
- "keyPassword",
"key",
"public_key",
- "publicKey",
]),
};
diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts
index 3d18ff5e..efe5ea75 100644
--- a/src/backend/utils/lazy-field-encryption.ts
+++ b/src/backend/utils/lazy-field-encryption.ts
@@ -6,10 +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 {
--
2.49.1
From a728ff227e1601172f97c77bfee5a6c52d57409c Mon Sep 17 00:00:00 2001
From: LukeGus
Date: Sun, 5 Oct 2025 23:51:53 -0500
Subject: [PATCH 5/8] Add real client IP
---
docker/nginx-https.conf | 3 +++
docker/nginx.conf | 3 +++
2 files changed, 6 insertions(+)
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index aaf5e739..f64e8e4e 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -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;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 9f37c80a..c180c180 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -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;
--
2.49.1
From 772afb1bc7eb0f1fffbd3c083d161ef0dc38e37b Mon Sep 17 00:00:00 2001
From: thorved <54140516+thorved@users.noreply.github.com>
Date: Mon, 6 Oct 2025 11:47:12 +0530
Subject: [PATCH 6/8] 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.
---
src/backend/utils/user-crypto.ts | 46 +++++++++++++++++++++++++-------
1 file changed, 37 insertions(+), 9 deletions(-)
diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts
index 4164ece4..b9010456 100644
--- a/src/backend/utils/user-crypto.ts
+++ b/src/backend/utils/user-crypto.ts
@@ -70,7 +70,34 @@ class UserCrypto {
}
async setupOIDCUserEncryption(userId: string): Promise {
- const DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
+ // Check if DEK already exists for this OIDC user
+ const existingKEKSalt = await this.getKEKSalt(userId);
+ const existingEncryptedDEK = await this.getEncryptedDEK(userId);
+
+ let DEK: Buffer;
+
+ if (existingKEKSalt && existingEncryptedDEK) {
+ // User already has a persisted DEK, retrieve it
+ const systemKey = this.deriveOIDCSystemKey(userId);
+ DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
+ systemKey.fill(0);
+ } else {
+ // First time setup - create and persist new DEK
+ DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
+
+ // Generate a KEK salt for OIDC user (using a deterministic approach)
+ const kekSalt = await this.generateKEKSalt();
+ await this.storeKEKSalt(userId, kekSalt);
+
+ // Derive system key for OIDC user
+ const systemKey = this.deriveOIDCSystemKey(userId);
+
+ // Encrypt and store the DEK
+ const encryptedDEK = this.encryptDEK(DEK, systemKey);
+ await this.storeEncryptedDEK(userId, encryptedDEK);
+
+ systemKey.fill(0);
+ }
const now = Date.now();
this.userSessions.set(userId, {
@@ -135,34 +162,34 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise {
try {
const kekSalt = await this.getKEKSalt(userId);
- if (!kekSalt) {
- await this.setupOIDCUserEncryption(userId);
- return true;
- }
-
- const systemKey = this.deriveOIDCSystemKey(userId);
const encryptedDEK = await this.getEncryptedDEK(userId);
- if (!encryptedDEK) {
- systemKey.fill(0);
+
+ if (!kekSalt || !encryptedDEK) {
+ // First time login or missing encryption data - set up encryption
await this.setupOIDCUserEncryption(userId);
return true;
}
+ // Retrieve persisted DEK
+ const systemKey = this.deriveOIDCSystemKey(userId);
const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0);
if (!DEK || DEK.length === 0) {
+ // DEK decryption failed - recreate encryption
await this.setupOIDCUserEncryption(userId);
return true;
}
const now = Date.now();
+ // Clear any existing session
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
+ // Create new session with the persisted DEK
this.userSessions.set(userId, {
dataKey: Buffer.from(DEK),
lastActivity: now,
@@ -173,6 +200,7 @@ class UserCrypto {
return true;
} catch (error) {
+ // On error, set up fresh encryption
await this.setupOIDCUserEncryption(userId);
return true;
}
--
2.49.1
From c9474c4c4319a2649fc0bcde5e3c46216a2a9962 Mon Sep 17 00:00:00 2001
From: thorved <54140516+thorved@users.noreply.github.com>
Date: Mon, 6 Oct 2025 12:24:44 +0530
Subject: [PATCH 7/8] 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.
---
src/backend/utils/user-crypto.ts | 38 +++++++++++++++++++-------------
1 file changed, 23 insertions(+), 15 deletions(-)
diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts
index b9010456..5b7394a9 100644
--- a/src/backend/utils/user-crypto.ts
+++ b/src/backend/utils/user-crypto.ts
@@ -70,13 +70,11 @@ class UserCrypto {
}
async setupOIDCUserEncryption(userId: string): Promise {
- // Check if DEK already exists for this OIDC user
- const existingKEKSalt = await this.getKEKSalt(userId);
const existingEncryptedDEK = await this.getEncryptedDEK(userId);
let DEK: Buffer;
- if (existingKEKSalt && existingEncryptedDEK) {
+ if (existingEncryptedDEK) {
// User already has a persisted DEK, retrieve it
const systemKey = this.deriveOIDCSystemKey(userId);
DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
@@ -84,19 +82,30 @@ class UserCrypto {
} else {
// First time setup - create and persist new DEK
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
-
- // Generate a KEK salt for OIDC user (using a deterministic approach)
- const kekSalt = await this.generateKEKSalt();
- await this.storeKEKSalt(userId, kekSalt);
-
- // Derive system key for OIDC user
const systemKey = this.deriveOIDCSystemKey(userId);
- // Encrypt and store the DEK
- const encryptedDEK = this.encryptDEK(DEK, systemKey);
- await this.storeEncryptedDEK(userId, encryptedDEK);
+ try {
+ const encryptedDEK = this.encryptDEK(DEK, systemKey);
+ await this.storeEncryptedDEK(userId, encryptedDEK);
- systemKey.fill(0);
+ // MITIGATION: Read back the stored DEK to ensure we use the one that won the race.
+ const storedEncryptedDEK = await this.getEncryptedDEK(userId);
+ if (
+ storedEncryptedDEK &&
+ storedEncryptedDEK.data !== encryptedDEK.data
+ ) {
+ // We lost the race. Use the DEK from the database.
+ DEK.fill(0); // Discard our generated DEK.
+ DEK = this.decryptDEK(storedEncryptedDEK, systemKey);
+ } else if (!storedEncryptedDEK) {
+ // This is an unexpected state; the store operation should have worked.
+ throw new Error(
+ "Failed to store and retrieve user encryption key.",
+ );
+ }
+ } finally {
+ systemKey.fill(0);
+ }
}
const now = Date.now();
@@ -161,10 +170,9 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise {
try {
- const kekSalt = await this.getKEKSalt(userId);
const encryptedDEK = await this.getEncryptedDEK(userId);
- if (!kekSalt || !encryptedDEK) {
+ if (!encryptedDEK) {
// First time login or missing encryption data - set up encryption
await this.setupOIDCUserEncryption(userId);
return true;
--
2.49.1
From 04db792f5609002d55cf5d182a22eb7f70c7a0d5 Mon Sep 17 00:00:00 2001
From: LukeGus
Date: Mon, 6 Oct 2025 09:24:21 -0500
Subject: [PATCH 8/8] Cleanup OIDC pr and run prettier
---
src/backend/utils/user-crypto.ts | 17 ++---------------
1 file changed, 2 insertions(+), 15 deletions(-)
diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts
index 5b7394a9..73a2e39f 100644
--- a/src/backend/utils/user-crypto.ts
+++ b/src/backend/utils/user-crypto.ts
@@ -75,12 +75,10 @@ class UserCrypto {
let DEK: Buffer;
if (existingEncryptedDEK) {
- // User already has a persisted DEK, retrieve it
const systemKey = this.deriveOIDCSystemKey(userId);
DEK = this.decryptDEK(existingEncryptedDEK, systemKey);
systemKey.fill(0);
} else {
- // First time setup - create and persist new DEK
DEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const systemKey = this.deriveOIDCSystemKey(userId);
@@ -88,20 +86,15 @@ class UserCrypto {
const encryptedDEK = this.encryptDEK(DEK, systemKey);
await this.storeEncryptedDEK(userId, encryptedDEK);
- // MITIGATION: Read back the stored DEK to ensure we use the one that won the race.
const storedEncryptedDEK = await this.getEncryptedDEK(userId);
if (
storedEncryptedDEK &&
storedEncryptedDEK.data !== encryptedDEK.data
) {
- // We lost the race. Use the DEK from the database.
- DEK.fill(0); // Discard our generated DEK.
+ DEK.fill(0);
DEK = this.decryptDEK(storedEncryptedDEK, systemKey);
} else if (!storedEncryptedDEK) {
- // This is an unexpected state; the store operation should have worked.
- throw new Error(
- "Failed to store and retrieve user encryption key.",
- );
+ throw new Error("Failed to store and retrieve user encryption key.");
}
} finally {
systemKey.fill(0);
@@ -173,31 +166,26 @@ class UserCrypto {
const encryptedDEK = await this.getEncryptedDEK(userId);
if (!encryptedDEK) {
- // First time login or missing encryption data - set up encryption
await this.setupOIDCUserEncryption(userId);
return true;
}
- // Retrieve persisted DEK
const systemKey = this.deriveOIDCSystemKey(userId);
const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0);
if (!DEK || DEK.length === 0) {
- // DEK decryption failed - recreate encryption
await this.setupOIDCUserEncryption(userId);
return true;
}
const now = Date.now();
- // Clear any existing session
const oldSession = this.userSessions.get(userId);
if (oldSession) {
oldSession.dataKey.fill(0);
}
- // Create new session with the persisted DEK
this.userSessions.set(userId, {
dataKey: Buffer.from(DEK),
lastActivity: now,
@@ -208,7 +196,6 @@ class UserCrypto {
return true;
} catch (error) {
- // On error, set up fresh encryption
await this.setupOIDCUserEncryption(userId);
return true;
}
--
2.49.1