v1.7.2 (#364)
* 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:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "在此处打开终端",
|
||||||
|
|||||||
@@ -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,54 +589,85 @@ 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;
|
||||||
|
|
||||||
try {
|
let confirmMessage: string;
|
||||||
await ensureSSHConnection();
|
if (files.length === 1) {
|
||||||
|
const file = files[0];
|
||||||
for (const file of files) {
|
if (file.type === "directory") {
|
||||||
await deleteSSHItem(
|
confirmMessage = t("fileManager.confirmDeleteFolder", {
|
||||||
sshSessionId,
|
name: file.name,
|
||||||
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 {
|
} 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() {
|
function handleCreateNewFolder() {
|
||||||
|
|||||||
@@ -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() === "")
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user