* 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; keepalive_timeout 65;
client_header_timeout 300s; 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_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_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;
@@ -23,7 +26,6 @@ http {
return 301 https://$host:${SSL_PORT}$request_uri; return 301 https://$host:${SSL_PORT}$request_uri;
} }
# HTTPS Server
server { server {
listen ${SSL_PORT} ssl; listen ${SSL_PORT} ssl;
server_name _; server_name _;
@@ -41,7 +43,6 @@ http {
index index.html index.htm; index index.html index.htm;
} }
# Handle missing source map files gracefully
location ~* \.map$ { location ~* \.map$ {
return 404; return 404;
access_log off; access_log off;

View File

@@ -10,6 +10,9 @@ http {
keepalive_timeout 65; keepalive_timeout 65;
client_header_timeout 300s; 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_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_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
ssl_prefer_server_ciphers off; ssl_prefer_server_ciphers off;
@@ -29,7 +32,6 @@ http {
index index.html index.htm; index index.html index.htm;
} }
# Handle missing source map files gracefully
location ~* \.map$ { location ~* \.map$ {
return 404; return 404;
access_log off; access_log off;

View File

@@ -1,7 +1,7 @@
{ {
"name": "termix", "name": "termix",
"private": true, "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", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa", "author": "Karmaa",
"main": "electron/main.cjs", "main": "electron/main.cjs",

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,20 @@ export class LazyFieldEncryption {
key_password: "keyPassword", key_password: "keyPassword",
private_key: "privateKey", private_key: "privateKey",
public_key: "publicKey", 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 { static isPlaintextField(value: string): boolean {

View File

@@ -70,7 +70,36 @@ class UserCrypto {
} }
async setupOIDCUserEncryption(userId: string): Promise<void> { 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(); const now = Date.now();
this.userSessions.set(userId, { this.userSessions.set(userId, {
@@ -134,20 +163,14 @@ class UserCrypto {
async authenticateOIDCUser(userId: string): Promise<boolean> { async authenticateOIDCUser(userId: string): Promise<boolean> {
try { try {
const kekSalt = await this.getKEKSalt(userId); const encryptedDEK = await this.getEncryptedDEK(userId);
if (!kekSalt) {
if (!encryptedDEK) {
await this.setupOIDCUserEncryption(userId); await this.setupOIDCUserEncryption(userId);
return true; return true;
} }
const systemKey = this.deriveOIDCSystemKey(userId); 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); const DEK = this.decryptDEK(encryptedDEK, systemKey);
systemKey.fill(0); systemKey.fill(0);

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { FileWindow } from "./components/FileWindow";
import { DiffWindow } from "./components/DiffWindow"; import { DiffWindow } from "./components/DiffWindow";
import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; import { useDragToDesktop } from "../../../hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -82,6 +83,7 @@ function formatFileSize(bytes?: number): string {
function FileManagerContent({ initialHost, onClose }: FileManagerProps) { function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { openWindow } = useWindowManager(); const { openWindow } = useWindowManager();
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [currentHost, setCurrentHost] = useState<SSHHost | null>( const [currentHost, setCurrentHost] = useState<SSHHost | null>(
initialHost || null, initialHost || null,
@@ -587,6 +589,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleDeleteFiles(files: FileItem[]) { async function handleDeleteFiles(files: FileItem[]) {
if (!sshSessionId || files.length === 0) return; if (!sshSessionId || files.length === 0) return;
let confirmMessage: string;
if (files.length === 1) {
const file = files[0];
if (file.type === "directory") {
confirmMessage = t("fileManager.confirmDeleteFolder", {
name: file.name,
});
} else {
confirmMessage = t("fileManager.confirmDeleteSingleItem", {
name: file.name,
});
}
} 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 { try {
await ensureSSHConnection(); await ensureSSHConnection();
@@ -635,6 +665,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} }
console.error("Delete failed:", error); console.error("Delete failed:", error);
} }
},
"destructive",
);
} }
function handleCreateNewFolder() { function handleCreateNewFolder() {

View File

@@ -210,7 +210,18 @@ export function HostManagerEditor({
defaultPath: z.string().optional(), defaultPath: z.string().optional(),
}) })
.superRefine((data, ctx) => { .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 ( if (
!data.key || !data.key ||
(typeof data.key === "string" && data.key.trim() === "") (typeof data.key === "string" && data.key.trim() === "")

View File

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

View File

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