From bccfd596b8d7f95b5fdf47938dfebd7304696843 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 6 Nov 2025 11:21:21 +0800 Subject: [PATCH 01/34] fix: Resolve database encryption atomicity issues and enhance debugging (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Resolve database encryption atomicity issues and enhance debugging This commit addresses critical data corruption issues caused by non-atomic file writes during database encryption, and adds comprehensive diagnostic logging to help debug encryption-related failures. **Problem:** Users reported "Unsupported state or unable to authenticate data" errors when starting the application after system crashes or Docker container restarts. The root cause was non-atomic writes of encrypted database files: 1. Encrypted data file written (step 1) 2. Metadata file written (step 2) → If process crashes between steps 1 and 2, files become inconsistent → New IV/tag in data file, old IV/tag in metadata → GCM authentication fails on next startup → User data permanently inaccessible **Solution - Atomic Writes:** 1. Write-to-temp + atomic-rename pattern: - Write to temporary files (*.tmp-timestamp-pid) - Perform atomic rename operations - Clean up temp files on failure 2. Data integrity validation: - Add dataSize field to metadata - Verify file size before decryption - Early detection of corrupted writes 3. Enhanced error diagnostics: - Key fingerprints (SHA256 prefix) for verification - File modification timestamps - Detailed GCM auth failure messages - Automatic diagnostic info generation **Changes:** database-file-encryption.ts: - Implement atomic write pattern in encryptDatabaseFromBuffer - Implement atomic write pattern in encryptDatabaseFile - Add dataSize field to EncryptedFileMetadata interface - Validate file size before decryption in decryptDatabaseToBuffer - Enhanced error messages for GCM auth failures - Add getDiagnosticInfo() function for comprehensive debugging - Add debug logging for all encryption/decryption operations system-crypto.ts: - Add detailed logging for DATABASE_KEY initialization - Log key source (env var vs .env file) - Add key fingerprints to all log messages - Better error messages when key loading fails db/index.ts: - Automatically generate diagnostic info on decryption failure - Log detailed debugging information to help users troubleshoot **Debugging Info Added:** - Key initialization: source, fingerprint, length, path - Encryption: original size, encrypted size, IV/tag prefixes, temp paths - Decryption: file timestamps, metadata content, key fingerprint matching - Auth failures: .env file status, key availability, file consistency - File diagnostics: existence, readability, size validation, mtime comparison **Backward Compatibility:** - dataSize field is optional (metadata.dataSize?: number) - Old encrypted files without dataSize continue to work - No migration required **Testing:** - Compiled successfully - No breaking changes to existing APIs - Graceful handling of legacy v1 encrypted files Fixes data loss issues reported by users experiencing container restarts and system crashes during database saves. * fix: Cleanup PR * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: LukeGus Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/electron.yml | 1 - src/backend/database/db/index.ts | 26 ++ src/backend/utils/database-file-encryption.ts | 295 +++++++++++++++++- src/backend/utils/system-crypto.ts | 34 +- src/ui/desktop/authentication/Auth.tsx | 46 ++- 5 files changed, 366 insertions(+), 36 deletions(-) diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 62bf5769..bd1a5b4c 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -746,7 +746,6 @@ jobs: mkdir -p homebrew-submission/Casks/t cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb - cp homebrew/README.md homebrew-submission/ sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 7b3b6138..8310ed6c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -95,6 +95,32 @@ async function initializeDatabaseAsync(): Promise { databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); + try { + databaseLogger.info( + "Generating diagnostic information for database encryption failure", + { + operation: "db_encryption_diagnostic", + }, + ); + const diagnosticInfo = + DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath); + databaseLogger.error( + "Database encryption diagnostic completed - check logs above for details", + null, + { + operation: "db_encryption_diagnostic_completed", + filesConsistent: diagnosticInfo.validation.filesConsistent, + sizeMismatch: diagnosticInfo.validation.sizeMismatch, + }, + ); + } catch (diagError) { + databaseLogger.warn("Failed to generate diagnostic information", { + operation: "db_diagnostic_failed", + error: + diagError instanceof Error ? diagError.message : "Unknown error", + }); + } + throw new Error( `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, ); diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 002464b0..7bbb406b 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -12,6 +12,7 @@ interface EncryptedFileMetadata { algorithm: string; keySource?: string; salt?: string; + dataSize?: number; } class DatabaseFileEncryption { @@ -25,6 +26,10 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { + const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; + const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; + try { const key = await this.systemCrypto.getDatabaseKey(); @@ -38,6 +43,12 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -45,14 +56,35 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; - fs.writeFileSync(targetPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, targetPath); + fs.renameSync(tmpMetadataPath, metadataPath); return targetPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database buffer", error, { operation: "database_buffer_encryption_failed", targetPath, @@ -74,6 +106,8 @@ class DatabaseFileEncryption { const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; try { const sourceData = fs.readFileSync(sourcePath); @@ -93,6 +127,12 @@ class DatabaseFileEncryption { ]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -100,10 +140,14 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - fs.writeFileSync(encryptedPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, encryptedPath); + fs.renameSync(tmpMetadataPath, metadataPath); databaseLogger.info("Database file encrypted successfully", { operation: "database_file_encryption", @@ -111,11 +155,30 @@ class DatabaseFileEncryption { encryptedPath, fileSize: sourceData.length, encryptedSize: encrypted.length, + keyFingerprint, fingerprintPrefix: metadata.fingerprint, }); return encryptedPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database file", error, { operation: "database_file_encryption_failed", sourcePath, @@ -140,11 +203,34 @@ class DatabaseFileEncryption { } try { + const dataFileStats = fs.statSync(encryptedPath); + const metaFileStats = fs.statSync(metadataPath); + const metadataContent = fs.readFileSync(metadataPath, "utf8"); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + difference: encryptedData.length - metadata.dataSize, + dataFileMtime: dataFileStats.mtime.toISOString(), + metaFileMtime: metaFileStats.mtime.toISOString(), + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -167,6 +253,12 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -181,13 +273,64 @@ class DatabaseFileEncryption { return decryptedBuffer; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + const isAuthError = + errorMessage.includes("Unsupported state") || + errorMessage.includes("authenticate data") || + errorMessage.includes("auth"); + + if (isAuthError) { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + let envFileExists = false; + let envFileReadable = false; + try { + envFileExists = fs.existsSync(envPath); + if (envFileExists) { + fs.accessSync(envPath, fs.constants.R_OK); + envFileReadable = true; + } + } catch {} + + databaseLogger.error( + "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", + error, + { + operation: "database_buffer_decryption_auth_failed", + encryptedPath, + metadataPath, + dataDir, + envPath, + envFileExists, + envFileReadable, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + suggestion: + "Check if DATABASE_KEY in .env matches the key used for encryption", + }, + ); + throw new Error( + `Database decryption authentication failed. This usually means:\n` + + `1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` + + `2. Encrypted file was corrupted during write (system crash/restart)\n` + + `3. Metadata file does not match encrypted data\n` + + `\nDebug info:\n` + + `- DATA_DIR: ${dataDir}\n` + + `- .env file exists: ${envFileExists}\n` + + `- .env file readable: ${envFileReadable}\n` + + `- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` + + `Original error: ${errorMessage}`, + ); + } + databaseLogger.error("Failed to decrypt database to buffer", error, { operation: "database_buffer_decryption_failed", encryptedPath, + errorMessage, }); - throw new Error( - `Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new Error(`Database buffer decryption failed: ${errorMessage}`); } } @@ -215,6 +358,23 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -322,6 +482,125 @@ class DatabaseFileEncryption { } } + static getDiagnosticInfo(encryptedPath: string): { + dataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + }; + metadataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + content?: EncryptedFileMetadata; + }; + environment: { + dataDir: string; + envPath: string; + envFileExists: boolean; + envFileReadable: boolean; + hasEnvKey: boolean; + envKeyLength: number; + }; + validation: { + filesConsistent: boolean; + sizeMismatch?: boolean; + expectedSize?: number; + actualSize?: number; + }; + } { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + const result: ReturnType = { + dataFile: { exists: false }, + metadataFile: { exists: false }, + environment: { + dataDir, + envPath, + envFileExists: false, + envFileReadable: false, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + }, + validation: { + filesConsistent: false, + }, + }; + + try { + result.dataFile.exists = fs.existsSync(encryptedPath); + if (result.dataFile.exists) { + try { + fs.accessSync(encryptedPath, fs.constants.R_OK); + result.dataFile.readable = true; + const stats = fs.statSync(encryptedPath); + result.dataFile.size = stats.size; + result.dataFile.mtime = stats.mtime.toISOString(); + } catch { + result.dataFile.readable = false; + } + } + + result.metadataFile.exists = fs.existsSync(metadataPath); + if (result.metadataFile.exists) { + try { + fs.accessSync(metadataPath, fs.constants.R_OK); + result.metadataFile.readable = true; + const stats = fs.statSync(metadataPath); + result.metadataFile.size = stats.size; + result.metadataFile.mtime = stats.mtime.toISOString(); + + const content = fs.readFileSync(metadataPath, "utf8"); + result.metadataFile.content = JSON.parse(content); + } catch { + result.metadataFile.readable = false; + } + } + + result.environment.envFileExists = fs.existsSync(envPath); + if (result.environment.envFileExists) { + try { + fs.accessSync(envPath, fs.constants.R_OK); + result.environment.envFileReadable = true; + } catch {} + } + + if ( + result.dataFile.exists && + result.metadataFile.exists && + result.metadataFile.content + ) { + result.validation.filesConsistent = true; + + if (result.metadataFile.content.dataSize !== undefined) { + result.validation.expectedSize = result.metadataFile.content.dataSize; + result.validation.actualSize = result.dataFile.size; + result.validation.sizeMismatch = + result.metadataFile.content.dataSize !== result.dataFile.size; + if (result.validation.sizeMismatch) { + result.validation.filesConsistent = false; + } + } + } + } catch (error) { + databaseLogger.error("Failed to generate diagnostic info", error, { + operation: "diagnostic_info_failed", + encryptedPath, + }); + } + + databaseLogger.info("Database encryption diagnostic info", { + operation: "diagnostic_info_generated", + ...result, + }); + + return result; + } + static async createEncryptedBackup( databasePath: string, backupDir: string, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index bd6aa727..156d0b33 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -51,17 +51,8 @@ class SystemCrypto { }, ); } - } catch (fileError) { - databaseLogger.warn("Failed to read .env file for JWT secret", { - operation: "jwt_init_file_read_failed", - error: - fileError instanceof Error ? fileError.message : "Unknown error", - }); - } + } catch (fileError) {} - databaseLogger.warn("Generating new JWT secret", { - operation: "jwt_generating_new_secret", - }); await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { @@ -80,29 +71,44 @@ class SystemCrypto { async initializeDatabaseKey(): Promise { try { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, "hex"); + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; } - const dataDir = process.env.DATA_DIR || "./db/data"; - const envPath = path.join(dataDir, ".env"); - try { const envContent = await fs.readFile(envPath, "utf8"); const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m); if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) { this.databaseKey = Buffer.from(dbKeyMatch[1], "hex"); process.env.DATABASE_KEY = dbKeyMatch[1]; + + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; + } else { } - } catch {} + } catch (fileError) {} await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { operation: "db_key_init_failed", + dataDir: process.env.DATA_DIR || "./db/data", }); throw new Error("Database key initialization failed"); } diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index fb894adc..997d712b 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -24,11 +24,20 @@ import { verifyTOTPLogin, getServerConfig, isElectron, - logoutUser, } from "../../main-axios.ts"; import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx"; import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx"; +function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} + +interface ExtendedWindow extends Window { + IS_ELECTRON_WEBVIEW?: boolean; +} + interface AuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; @@ -37,7 +46,6 @@ interface AuthProps extends React.ComponentProps<"div"> { loggedIn: boolean; authLoading: boolean; setDbError: (error: string | null) => void; - dbError?: string | null; onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; @@ -54,21 +62,20 @@ export function Auth({ loggedIn, authLoading, setDbError, - dbError, onAuthSuccess, ...props }: AuthProps) { const { t } = useTranslation(); const isInElectronWebView = () => { - if ((window as any).IS_ELECTRON_WEBVIEW) { + if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) { return true; } try { if (window.self !== window.top) { return true; } - } catch (e) { + } catch (_e) { return false; } return false; @@ -126,7 +133,7 @@ export function Auth({ userId: meRes.userId || null, }); toast.success(t("messages.loginSuccess")); - } catch (err) { + } catch (_err) { toast.error(t("errors.failedUserInfo")); } }, [ @@ -206,7 +213,7 @@ export function Auth({ .finally(() => { setDbHealthChecking(false); }); - }, [setDbError, firstUserToastShown, showServerConfig]); + }, [setDbError, firstUserToastShown, showServerConfig, t]); useEffect(() => { if (!registrationAllowed && !internalLoggedIn) { @@ -282,9 +289,9 @@ export function Auth({ ); setWebviewAuthSuccess(true); setTimeout(() => window.location.reload(), 100); - setLoading(false); - return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } const [meRes] = await Promise.all([getUserInfo()]); @@ -461,7 +468,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setTotpLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } setInternalLoggedIn(true); @@ -569,7 +578,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setOidcLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } } @@ -607,7 +618,16 @@ export function Auth({ setOidcLoading(false); }); } - }, []); + }, [ + onAuthSuccess, + setDbError, + setIsAdmin, + setLoggedIn, + setUserId, + setUsername, + t, + isInElectronWebView, + ]); const Spinner = ( Date: Wed, 5 Nov 2025 21:32:07 -0600 Subject: [PATCH 02/34] fix: Merge metadata and DB into 1 file --- src/backend/utils/database-file-encryption.ts | 150 +++++++++++++----- 1 file changed, 106 insertions(+), 44 deletions(-) diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 7bbb406b..b4afbe7a 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -27,14 +27,11 @@ class DatabaseFileEncryption { targetPath: string, ): Promise { const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; - const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; try { const key = await this.systemCrypto.getDatabaseKey(); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv( this.ALGORITHM, key, @@ -43,12 +40,6 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); - const keyFingerprint = crypto - .createHash("sha256") - .update(key) - .digest("hex") - .substring(0, 16); - const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -59,11 +50,34 @@ class DatabaseFileEncryption { dataSize: encrypted.length, }; - fs.writeFileSync(tmpPath, encrypted); - fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + const metadataJson = JSON.stringify(metadata, null, 2); + const metadataBuffer = Buffer.from(metadataJson, "utf8"); + const metadataLengthBuffer = Buffer.alloc(4); + metadataLengthBuffer.writeUInt32BE(metadataBuffer.length, 0); + const finalBuffer = Buffer.concat([ + metadataLengthBuffer, + metadataBuffer, + encrypted, + ]); + + fs.writeFileSync(tmpPath, finalBuffer); fs.renameSync(tmpPath, targetPath); - fs.renameSync(tmpMetadataPath, metadataPath); + + try { + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup old metadata file", { + operation: "old_meta_cleanup_failed", + path: metadataPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } return targetPath; } catch (error) { @@ -71,9 +85,6 @@ class DatabaseFileEncryption { if (fs.existsSync(tmpPath)) { fs.unlinkSync(tmpPath); } - if (fs.existsSync(tmpMetadataPath)) { - fs.unlinkSync(tmpMetadataPath); - } } catch (cleanupError) { databaseLogger.warn("Failed to cleanup temporary files", { operation: "temp_file_cleanup_failed", @@ -197,21 +208,54 @@ class DatabaseFileEncryption { ); } - const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; - if (!fs.existsSync(metadataPath)) { - throw new Error(`Metadata file does not exist: ${metadataPath}`); + let metadata: EncryptedFileMetadata; + let encryptedData: Buffer; + + const fileBuffer = fs.readFileSync(encryptedPath); + + try { + const metadataLength = fileBuffer.readUInt32BE(0); + const metadataEnd = 4 + metadataLength; + + if ( + metadataLength <= 0 || + metadataEnd > fileBuffer.length || + metadataEnd <= 4 + ) { + throw new Error("Invalid metadata length in single-file format"); + } + + const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); + metadata = JSON.parse(metadataJson); + encryptedData = fileBuffer.slice(metadataEnd); + + if (!metadata.iv || !metadata.tag || !metadata.version) { + throw new Error("Invalid metadata structure in single-file format"); + } + } catch (singleFileError) { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + if (!fs.existsSync(metadataPath)) { + throw new Error( + `Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`, + ); + } + + try { + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + metadata = JSON.parse(metadataContent); + encryptedData = fileBuffer; + } catch (twoFileError) { + throw new Error( + `Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`, + ); + } } try { - const dataFileStats = fs.statSync(encryptedPath); - const metaFileStats = fs.statSync(metadataPath); - - const metadataContent = fs.readFileSync(metadataPath, "utf8"); - const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); - - const encryptedData = fs.readFileSync(encryptedPath); - - if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + if ( + metadata.dataSize !== undefined && + encryptedData.length !== metadata.dataSize + ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, @@ -220,9 +264,6 @@ class DatabaseFileEncryption { encryptedPath, actualSize: encryptedData.length, expectedSize: metadata.dataSize, - difference: encryptedData.length - metadata.dataSize, - dataFileMtime: dataFileStats.mtime.toISOString(), - metaFileMtime: metaFileStats.mtime.toISOString(), }, ); throw new Error( @@ -253,12 +294,6 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } - const keyFingerprint = crypto - .createHash("sha256") - .update(key) - .digest("hex") - .substring(0, 16); - const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -300,7 +335,6 @@ class DatabaseFileEncryption { { operation: "database_buffer_decryption_auth_failed", encryptedPath, - metadataPath, dataDir, envPath, envFileExists, @@ -358,7 +392,10 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); - if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + if ( + metadata.dataSize !== undefined && + encryptedData.length !== metadata.dataSize + ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, @@ -434,18 +471,43 @@ class DatabaseFileEncryption { } static isEncryptedDatabaseFile(filePath: string): boolean { - const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; - - if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) { + if (!fs.existsSync(filePath)) { return false; } + const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; + if (fs.existsSync(metadataPath)) { + try { + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + return ( + metadata.version === this.VERSION && + metadata.algorithm === this.ALGORITHM + ); + } catch { + return false; + } + } + try { - const metadataContent = fs.readFileSync(metadataPath, "utf8"); - const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + const fileBuffer = fs.readFileSync(filePath); + if (fileBuffer.length < 4) return false; + + const metadataLength = fileBuffer.readUInt32BE(0); + const metadataEnd = 4 + metadataLength; + + if (metadataLength <= 0 || metadataEnd > fileBuffer.length) { + return false; + } + + const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); + const metadata: EncryptedFileMetadata = JSON.parse(metadataJson); + return ( metadata.version === this.VERSION && - metadata.algorithm === this.ALGORITHM + metadata.algorithm === this.ALGORITHM && + !!metadata.iv && + !!metadata.tag ); } catch { return false; -- 2.49.1 From 7d69a4b5a5ea86685de8b67bbe92f4b603ff6a74 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 6 Nov 2025 00:20:38 -0600 Subject: [PATCH 03/34] fix: Add initial command palette --- package-lock.json | 17 ++ package.json | 1 + src/components/ui/command.tsx | 184 ++++++++++++++++++ src/components/ui/dialog.tsx | 141 ++++++++++++++ src/ui/desktop/DesktopApp.tsx | 30 ++- .../apps/command-palette/CommandPalette.tsx | 88 +++++++++ 6 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/ui/desktop/apps/command-palette/CommandPalette.tsx diff --git a/package-lock.json b/package-lock.json index 011b4f07..61e5b7c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -6936,6 +6937,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/codem-isoboxer": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.10.tgz", diff --git a/package.json b/package.json index bd71b467..81d6971f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 00000000..ee7450af --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..9ff99906 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 1bedd2c1..39aa9540 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; @@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { Toaster } from "@/components/ui/sonner.tsx"; +import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { getUserInfo } from "@/ui/main-axios.ts"; function AppContent() { @@ -23,6 +24,29 @@ function AppContent() { return saved !== null ? JSON.parse(saved) : true; }); const { currentTab, tabs } = useTabs(); + const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); + + const lastShiftPressTime = useRef(0); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === "ShiftLeft") { + const now = Date.now(); + if (now - lastShiftPressTime.current < 300) { + setIsCommandPaletteOpen((isOpen) => !isOpen); + } + lastShiftPressTime.current = now; + } + if (event.key === "Escape") { + setIsCommandPaletteOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); useEffect(() => { const checkAuth = () => { @@ -93,6 +117,10 @@ function AppContent() { return (
+ {!isAuthenticated && !authLoading && (
void; +}) { + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + return ( +
setIsOpen(false)} + > + e.stopPropagation()} + > + + + + + + Calendar + + + + Search Emoji + + + + Calculator + + + + + + + Profile + ⌘P + + + + Billing + ⌘B + + + + Settings + ⌘S + + + + +
+ ); +} -- 2.49.1 From 4bd6e6c6faf3a8587b8ee2f8df23976c1ca61808 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 7 Nov 2025 05:50:37 +0100 Subject: [PATCH 04/34] Feature/german language support (#431) * Update translation.json Fixed some translation issues for German, made it more user friendly and common. * Update translation.json added updated block for serverStats * Update translation.json Added translations * Update translation.json Removed duplicate of "free":"Free" --- src/locales/de/translation.json | 45 ++++++++++++++++++++++++++------- src/locales/en/translation.json | 1 - 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index c407191b..ed288f3e 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -187,7 +187,7 @@ "commandsWillBeSent": "Befehle werden an {{count}} ausgewählte Terminals gesendet.", "settings": "Einstellungen", "enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren", - "shareIdeas": "Haben Sie Ideen, was als nächstes für SSH-Tools kommen sollte? Teilen Sie diese mit uns" + "shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf" }, "homepage": { "loggedInTitle": "Eingeloggt!", @@ -355,7 +355,7 @@ "tabNavigation": "Registerkarte Navigation" }, "admin": { - "title": "Administratoreinstellungen", + "title": "Admin-Einstellungen", "oidc": "OIDC", "users": "Benutzer", "userManagement": "Benutzerverwaltung", @@ -390,11 +390,11 @@ "actions": "Aktionen", "external": "Extern", "local": "Lokal", - "adminManagement": "Verwaltung von Administratoren", + "adminManagement": "Admin Verwaltung", "makeUserAdmin": "Benutzer zum Administrator machen", "adding": "Hinzufügen...", "currentAdmins": "Aktuelle Administratoren", - "adminBadge": "Administrator", + "adminBadge": "Admin", "removeAdminButton": "Administrator entfernen", "general": "Allgemein", "userRegistration": "Benutzerregistrierung", @@ -416,6 +416,13 @@ "userDeletedSuccessfully": "Benutzer {{username}} wurde erfolgreich gelöscht", "failedToDeleteUser": "Benutzer konnte nicht gelöscht werden", "overrideUserInfoUrl": "URL für Benutzerinformationen überschreiben (nicht erforderlich)", + "failedToFetchSessions": "Fehler beim Abrufen der Sitzungen", + "sessionRevokedSuccessfully": "Sitzung erfolgreich widerrufen", + "failedToRevokeSession": "Sitzung konnte nicht widerrufen werden", + "confirmRevokeSession": "Möchten Sie diese Sitzung wirklich beenden?", + "confirmRevokeAllSessions": "Möchten Sie wirklich alle Sitzungen dieses Benutzers beenden?", + "failedToRevokeSessions": "Sitzungen konnten nicht widerrufen werden", + "sessionsRevokedSuccessfully": "Sitzungen erfolgreich beendet", "databaseSecurity": "Datenbanksicherheit", "encryptionStatus": "Verschlüsselungsstatus", "encryptionEnabled": "Verschlüsselung aktiviert", @@ -620,7 +627,7 @@ "autoStartContainer": "Automatischer Start beim Container-Start", "autoStartDesc": "Diesen Tunnel beim Start des Containers automatisch starten", "addConnection": "Tunnelverbindung hinzufügen", - "sshpassRequired": "Sshpass erforderlich für die Passwort-Authentifizierung", + "sshpassRequired": "sshpass erforderlich für die Passwort-Authentifizierung", "sshpassRequiredDesc": "Für die Passwortauthentifizierung in Tunneln muss sshpass auf dem System installiert sein.", "otherInstallMethods": "Andere Installationsmethoden:", "debianUbuntuEquivalent": "(Debian\/Ubuntu) oder das entsprechende Pendant für Ihr Betriebssystem.", @@ -1072,7 +1079,7 @@ "used": "Gebraucht", "percentage": "Prozentsatz", "refreshStatusAndMetrics": "Aktualisierungsstatus und Metriken", - "refreshStatus": "Aktualisierungsstatus", + "refreshStatus": "Aktualisieren", "fileManagerAlreadyOpen": "Der Dateimanager ist für diesen Host bereits geöffnet", "openFileManager": "Dateimanager öffnen", "cpuCores_one": "{{count}} CPU", @@ -1081,9 +1088,10 @@ "loadAverageNA": "Durchschnitt: N\/A", "cpuUsage": "CPU-Auslastung", "memoryUsage": "Speicherauslastung", + "diskUsage": "Festplattennutzung", "rootStorageSpace": "Root-Speicherplatz", "of": "von", - "feedbackMessage": "Haben Sie Ideen für die nächsten Schritte im Bereich der Serververwaltung? Teilen Sie diese mit uns", + "feedbackMessage": "Haben Sie Ideen, wie es bei der Serververwaltung weitergehen könnte? Dann teilen Sie diese gerne mit uns auf", "failedToFetchHostConfig": "Abrufen der Hostkonfiguration fehlgeschlagen", "failedToFetchStatus": "Abrufen des Serverstatus fehlgeschlagen", "failedToFetchMetrics": "Abrufen der Servermetriken fehlgeschlagen", @@ -1092,7 +1100,26 @@ "refreshing": "Aktualisieren...", "serverOffline": "Server offline", "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden", - "load": "Laden" + "load": "Last" + "available": "Verfügbar", + "editLayout": "Layout anpassen", + "cancelEdit": "Abbrechen", + "addWidget": "Widget hinzufügen", + "saveLayout": "Layout speichern", + "unsavedChanges": "Ungespeicherte Änderungen", + "layoutSaved": "Layout erfolgreich gespeichert", + "failedToSaveLayout": "Speichern des Layout fehlgeschlagen", + "systemInfo": "System Information", + "hostname": "Hostname", + "operatingSystem": "Betriebssystem", + "kernel": "Kernel", + "totalUptime": "Gesamte Betriebszeit", + "seconds": "Sekunden", + "networkInterfaces": "Netzwerkschnittstellen", + "noInterfacesFound": "Keine Netzwerkschnittstellen gefunden", + "totalProcesses": "Gesamtprozesse", + "running": "läuft", + "noProcessesFound": "Keine Prozesse gefunden" }, "auth": { "loginTitle": "Melden Sie sich bei Termix an", @@ -1372,7 +1399,7 @@ "disconnected": "Getrennt", "maxRetriesExhausted": "Maximale Wiederholungsversuche ausgeschöpft", "endpointHostNotFound": "Endpunkthost nicht gefunden", - "administrator": "Administrator", + "administrator": "Admin", "user": "Benutzer", "external": "Extern", "local": "Lokal", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3de6628c..8012ad05 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1210,7 +1210,6 @@ "totpRequired": "TOTP Authentication Required", "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers", "load": "Load", - "free": "Free", "available": "Available", "editLayout": "Edit Layout", "cancelEdit": "Cancel", -- 2.49.1 From c69d31062ea42700f8655832f3ab1ce650788b78 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 7 Nov 2025 00:28:58 -0600 Subject: [PATCH 05/34] feat: Finalize command palette --- src/locales/de/translation.json | 2 +- src/ui/desktop/DesktopApp.tsx | 1 + .../apps/command-palette/CommandPalette.tsx | 361 ++++++++++++++++-- src/ui/desktop/apps/dashboard/Dashboard.tsx | 6 +- .../desktop/apps/host-manager/HostManager.tsx | 24 +- src/ui/desktop/apps/tools/ToolsMenu.tsx | 18 +- src/ui/desktop/navigation/TopNavbar.tsx | 3 + 7 files changed, 356 insertions(+), 59 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index ed288f3e..37fff37f 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1100,7 +1100,7 @@ "refreshing": "Aktualisieren...", "serverOffline": "Server offline", "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden", - "load": "Last" + "load": "Last", "available": "Verfügbar", "editLayout": "Layout anpassen", "cancelEdit": "Abbrechen", diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 39aa9540..b7bcb6de 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -185,6 +185,7 @@ function AppContent() { setIsCommandPaletteOpen(true)} /> )} diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 0d647735..fd898db0 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -3,21 +3,59 @@ import { CommandInput, CommandItem, CommandList, - CommandShortcut, CommandGroup, CommandSeparator, } from "@/components/ui/command.tsx"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { - Calculator, - Calendar, - CreditCard, + Key, + Server, Settings, - Smile, User, + Github, + Terminal, + FolderOpen, + Pencil, + EllipsisVertical, } from "lucide-react"; -import { CommandEmpty } from "cmdk"; +import { BiMoney, BiSupport } from "react-icons/bi"; +import { BsDiscord } from "react-icons/bs"; +import { GrUpdate } from "react-icons/gr"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; +import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts"; +import type { RecentActivityItem } from "@/ui/main-axios.ts"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button.tsx"; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: unknown[]; + createdAt: string; + updatedAt: string; +} + export function CommandPalette({ isOpen, setIsOpen, @@ -26,59 +64,316 @@ export function CommandPalette({ setIsOpen: (isOpen: boolean) => void; }) { const inputRef = useRef(null); + const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); + const [recentActivity, setRecentActivity] = useState( + [], + ); + const [hosts, setHosts] = useState([]); useEffect(() => { if (isOpen) { inputRef.current?.focus(); + getRecentActivity(50).then((activity) => { + setRecentActivity(activity.slice(0, 5)); + }); + getSSHHosts().then((allHosts) => { + setHosts(allHosts); + }); } }, [isOpen]); + + const handleAddHost = () => { + const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); + if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_host" }); + setCurrentTab(sshManagerTab.id); + } else { + const id = addTab({ + type: "ssh_manager", + title: "Host Manager", + initialTab: "add_host", + }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleAddCredential = () => { + const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); + if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_credential" }); + setCurrentTab(sshManagerTab.id); + } else { + const id = addTab({ + type: "ssh_manager", + title: "Host Manager", + initialTab: "add_credential", + }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleOpenAdminSettings = () => { + const adminTab = tabList.find((t) => t.type === "admin"); + if (adminTab) { + setCurrentTab(adminTab.id); + } else { + const id = addTab({ type: "admin", title: "Admin Settings" }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleOpenUserProfile = () => { + const userProfileTab = tabList.find((t) => t.type === "user_profile"); + if (userProfileTab) { + setCurrentTab(userProfileTab.id); + } else { + const id = addTab({ type: "user_profile", title: "User Profile" }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleOpenUpdateLog = () => { + window.open("https://github.com/Termix-SSH/Termix/releases", "_blank"); + setIsOpen(false); + }; + + const handleGitHub = () => { + window.open("https://github.com/Termix-SSH/Termix", "_blank"); + setIsOpen(false); + }; + + const handleSupport = () => { + window.open("https://github.com/Termix-SSH/Support/issues/new", "_blank"); + setIsOpen(false); + }; + + const handleDiscord = () => { + window.open("https://discord.com/invite/jVQGdvHDrf", "_blank"); + setIsOpen(false); + }; + + const handleDonate = () => { + window.open("https://github.com/sponsors/LukeGus", "_blank"); + setIsOpen(false); + }; + + const handleActivityClick = (item: RecentActivityItem) => { + getSSHHosts().then((hosts) => { + const host = hosts.find((h: { id: number }) => h.id === item.hostId); + if (!host) return; + + if (item.type === "terminal") { + addTab({ + type: "terminal", + title: item.hostName, + hostConfig: host, + }); + } else if (item.type === "file_manager") { + addTab({ + type: "file_manager", + title: item.hostName, + hostConfig: host, + }); + } + }); + setIsOpen(false); + }; + + const handleHostTerminalClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "terminal", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostFileManagerClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "file_manager", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostServerDetailsClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "server", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostEditClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ + type: "ssh_manager", + title: "Host Manager", + hostConfig: host, + initialTab: "add_host", + }); + setIsOpen(false); + }; + return (
setIsOpen(false)} > e.stopPropagation()} > - - - - - Calendar + + {recentActivity.length > 0 && ( + <> + + {recentActivity.map((item, index) => ( + handleActivityClick(item)} + > + {item.type === "terminal" ? : } + {item.hostName} + + ))} + + + + )} + + + + Add Host - - - Search Emoji + + + Add Credential - - - Calculator + + + Admin Settings + + + + User Profile + + + + Update Log - - - - Profile - ⌘P + + {hosts.map((host, index) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + return ( + { + if (host.enableTerminal) { + handleHostTerminalClick(host); + } + }} + className="flex items-center justify-between" + > +
+ + {title} +
+
e.stopPropagation()} + > + + + + + + { + e.stopPropagation(); + handleHostServerDetailsClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open Server Details + + { + e.stopPropagation(); + handleHostFileManagerClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open File Manager + + { + e.stopPropagation(); + handleHostEditClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Edit + + + +
+
+ ); + })} +
+ + + + + GitHub - - - Billing - ⌘B + + + Support - - - Settings - ⌘S + + + Discord + + + + Donate
diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 39742b77..dd6d3826 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -85,7 +85,7 @@ export function Dashboard({ >([]); const [serverStatsLoading, setServerStatsLoading] = useState(true); - const { addTab, setCurrentTab, tabs: tabList } = useTabs(); + const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); let sidebarState: "expanded" | "collapsed" = "expanded"; try { @@ -264,6 +264,7 @@ export function Dashboard({ const handleAddHost = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_host" }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -278,6 +279,7 @@ export function Dashboard({ const handleAddCredential = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_credential" }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -671,7 +673,7 @@ export function Dashboard({ {server.name}

-
+
{t("dashboard.cpu")}:{" "} {server.cpu !== null diff --git a/src/ui/desktop/apps/host-manager/HostManager.tsx b/src/ui/desktop/apps/host-manager/HostManager.tsx index 04da3788..dff7b923 100644 --- a/src/ui/desktop/apps/host-manager/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/HostManager.tsx @@ -35,28 +35,10 @@ export function HostManager({ const lastProcessedHostIdRef = useRef(undefined); useEffect(() => { - if (ignoreNextHostConfigChangeRef.current) { - ignoreNextHostConfigChangeRef.current = false; - return; + if (initialTab) { + setActiveTab(initialTab); } - - if (hostConfig && initialTab === "add_host") { - const currentHostId = hostConfig.id; - - if (currentHostId !== lastProcessedHostIdRef.current) { - setEditingHost(hostConfig); - setActiveTab("add_host"); - lastProcessedHostIdRef.current = currentHostId; - } else if ( - activeTab === "host_viewer" || - activeTab === "credentials" || - activeTab === "add_credential" - ) { - setEditingHost(hostConfig); - setActiveTab("add_host"); - } - } - }, [hostConfig, initialTab]); + }, [initialTab]); const handleEditHost = (host: SSHHost) => { setEditingHost(host); diff --git a/src/ui/desktop/apps/tools/ToolsMenu.tsx b/src/ui/desktop/apps/tools/ToolsMenu.tsx index 5f6bf96e..f4028f1b 100644 --- a/src/ui/desktop/apps/tools/ToolsMenu.tsx +++ b/src/ui/desktop/apps/tools/ToolsMenu.tsx @@ -6,17 +6,19 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu.tsx"; import { Button } from "@/components/ui/button.tsx"; -import { Hammer, Wrench, FileText } from "lucide-react"; +import { Hammer, Wrench, FileText, Command } from "lucide-react"; import { useTranslation } from "react-i18next"; interface ToolsMenuProps { onOpenSshTools: () => void; onOpenSnippets: () => void; + onOpenCommandPalette: () => void; } export function ToolsMenu({ onOpenSshTools, onOpenSnippets, + onOpenCommandPalette, }: ToolsMenuProps): React.ReactElement { const { t } = useTranslation(); @@ -33,7 +35,7 @@ export function ToolsMenu({ {t("snippets.title")} + + +
+ Command Palette + + LShift LShift + +
+
); diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 7a7bb34d..2ad540f8 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -26,11 +26,13 @@ interface TabData { interface TopNavbarProps { isTopbarOpen: boolean; setIsTopbarOpen: (open: boolean) => void; + onOpenCommandPalette: () => void; } export function TopNavbar({ isTopbarOpen, setIsTopbarOpen, + onOpenCommandPalette, }: TopNavbarProps): React.ReactElement { const { state } = useSidebar(); const { @@ -476,6 +478,7 @@ export function TopNavbar({ setToolsSheetOpen(true)} onOpenSnippets={() => setSnippetsSidebarOpen(true)} + onOpenCommandPalette={onOpenCommandPalette} />
@@ -1263,29 +1282,60 @@ export function HostManagerEditor({
- ( - - { - if (credential) { - form.setValue( - "username", - credential.username, - ); - } - }} - /> - - {t("hosts.credentialDescription")} - - +
+ ( + + { + if ( + credential && + !form.getValues( + "overrideCredentialUsername", + ) + ) { + form.setValue( + "username", + credential.username, + ); + } + }} + /> + + {t("hosts.credentialDescription")} + + + )} + /> + {form.watch("credentialId") && ( + ( + +
+ + {t("hosts.overrideCredentialUsername")} + + + {t("hosts.overrideCredentialUsernameDesc")} + +
+ + + +
+ )} + /> )} - /> +
diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 4a35f9f8..a65baaaa 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -112,6 +112,7 @@ export const Terminal = forwardRef( const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] = useState(false); const isVisibleRef = useRef(false); + const isReadyRef = useRef(false); const isFittingRef = useRef(false); const reconnectTimeoutRef = useRef(null); const reconnectAttempts = useRef(0); @@ -157,6 +158,10 @@ export const Terminal = forwardRef( isVisibleRef.current = isVisible; }, [isVisible]); + useEffect(() => { + isReadyRef.current = isReady; + }, [isReady]); + useEffect(() => { const checkAuth = () => { const jwtToken = getCookie("jwt"); @@ -507,6 +512,9 @@ export const Terminal = forwardRef( }), ); terminal.onData((data) => { + if (data === "\x00" || data === "\u0000") { + return; + } ws.send(JSON.stringify({ type: "input", data })); }); @@ -915,15 +923,21 @@ export const Terminal = forwardRef( element?.addEventListener("keydown", handleMacKeyboard, true); - const resizeObserver = new ResizeObserver(() => { + const handleResize = () => { if (resizeTimeout.current) clearTimeout(resizeTimeout.current); resizeTimeout.current = setTimeout(() => { - if (!isVisibleRef.current || !isReady) return; + if (!isVisibleRef.current || !isReadyRef.current) return; performFit(); - }, 50); - }); + }, 100); + }; - resizeObserver.observe(xtermRef.current); + const resizeObserver = new ResizeObserver(handleResize); + + if (xtermRef.current) { + resizeObserver.observe(xtermRef.current); + } + + window.addEventListener("resize", handleResize); setVisible(true); @@ -936,6 +950,7 @@ export const Terminal = forwardRef( setIsReady(false); isFittingRef.current = false; resizeObserver.disconnect(); + window.removeEventListener("resize", handleResize); element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("keydown", handleMacKeyboard, true); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 997d712b..51ccf418 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -558,65 +558,61 @@ export function Auth({ if (success) { setOidcLoading(true); - getUserInfo() - .then((meRes) => { - if (isInElectronWebView()) { - const token = getCookie("jwt") || localStorage.getItem("jwt"); - if (token) { - try { - window.parent.postMessage( - { - type: "AUTH_SUCCESS", - token: token, - source: "oidc_callback", - platform: "desktop", - timestamp: Date.now(), - }, - "*", - ); - setWebviewAuthSuccess(true); - setTimeout(() => window.location.reload(), 100); - setOidcLoading(false); - return; - } catch (e) { - console.error("Error posting auth success message:", e); + // Clear the success parameter first to prevent re-processing + window.history.replaceState({}, document.title, window.location.pathname); + + setTimeout(() => { + getUserInfo() + .then((meRes) => { + if (isInElectronWebView()) { + const token = getCookie("jwt") || localStorage.getItem("jwt"); + if (token) { + try { + window.parent.postMessage( + { + type: "AUTH_SUCCESS", + token: token, + source: "oidc_callback", + platform: "desktop", + timestamp: Date.now(), + }, + "*", + ); + setWebviewAuthSuccess(true); + setTimeout(() => window.location.reload(), 100); + setOidcLoading(false); + return; + } catch (e) { + console.error("Error posting auth success message:", e); + } } } - } - setInternalLoggedIn(true); - setLoggedIn(true); - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, + setInternalLoggedIn(true); + setLoggedIn(true); + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + setInternalLoggedIn(true); + }) + .catch((err) => { + console.error("Failed to get user info after OIDC callback:", err); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + }) + .finally(() => { + setOidcLoading(false); }); - setInternalLoggedIn(true); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .finally(() => { - setOidcLoading(false); - }); + }, 200); } }, [ onAuthSuccess, diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f27aa014..a524ed14 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -318,34 +318,37 @@ function createApiInstance( const errorMessage = (error.response?.data as Record) ?.error; const isSessionExpired = errorCode === "SESSION_EXPIRED"; + const isSessionNotFound = errorCode === "SESSION_NOT_FOUND"; const isInvalidToken = errorCode === "AUTH_REQUIRED" || errorMessage === "Invalid token" || errorMessage === "Authentication required"; - if (isElectron()) { - localStorage.removeItem("jwt"); - } else { - localStorage.removeItem("jwt"); - } + if (isSessionExpired || isSessionNotFound) { + if (isElectron()) { + localStorage.removeItem("jwt"); + } else { + localStorage.removeItem("jwt"); + } - if ( - (isSessionExpired || isInvalidToken) && - typeof window !== "undefined" - ) { + if (typeof window !== "undefined") { + console.warn("Session expired or not found - please log in again"); + + document.cookie = + "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + + import("sonner").then(({ toast }) => { + toast.warning("Session expired. Please log in again."); + window.location.reload(); + }); + + setTimeout(() => window.location.reload(), 1000); + } + } else if (isInvalidToken && typeof window !== "undefined") { console.warn( - "Session expired or invalid token - please log in again", + "Authentication error - token may be invalid", + errorMessage, ); - - document.cookie = - "jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; - - import("sonner").then(({ toast }) => { - toast.warning("Session expired. Please log in again."); - window.location.reload(); - }); - - setTimeout(() => window.location.reload(), 1000); } } @@ -792,6 +795,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), @@ -855,6 +859,7 @@ export async function updateSSHHost( keyType: hostData.authType === "key" ? hostData.keyType : null, credentialId: hostData.authType === "credential" ? hostData.credentialId : null, + overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername), enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 3751224b..792f72f7 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -504,55 +504,45 @@ export function Auth({ setOidcLoading(true); setError(null); - getUserInfo() - .then((meRes) => { - setIsAdmin(!!meRes.is_admin); - setUsername(meRes.username || null); - setUserId(meRes.userId || null); - setDbError(null); - postJWTToWebView(); + window.history.replaceState({}, document.title, window.location.pathname); - if (isReactNativeWebView()) { - setMobileAuthSuccess(true); + setTimeout(() => { + getUserInfo() + .then((meRes) => { + setIsAdmin(!!meRes.is_admin); + setUsername(meRes.username || null); + setUserId(meRes.userId || null); + setDbError(null); + postJWTToWebView(); + + if (isReactNativeWebView()) { + setMobileAuthSuccess(true); + setOidcLoading(false); + return; + } + + setLoggedIn(true); + onAuthSuccess({ + isAdmin: !!meRes.is_admin, + username: meRes.username || null, + userId: meRes.userId || null, + }); + + setInternalLoggedIn(true); + }) + .catch((err) => { + console.error("Failed to get user info after OIDC callback:", err); + setError(t("errors.failedUserInfo")); + setInternalLoggedIn(false); + setLoggedIn(false); + setIsAdmin(false); + setUsername(null); + setUserId(null); + }) + .finally(() => { setOidcLoading(false); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - return; - } - - setLoggedIn(true); - onAuthSuccess({ - isAdmin: !!meRes.is_admin, - username: meRes.username || null, - userId: meRes.userId || null, }); - - setInternalLoggedIn(true); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .catch(() => { - setError(t("errors.failedUserInfo")); - setInternalLoggedIn(false); - setLoggedIn(false); - setIsAdmin(false); - setUsername(null); - setUserId(null); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); - }) - .finally(() => { - setOidcLoading(false); - }); + }, 200); } }, []); -- 2.49.1 From 5fc2ec3dc0f59f5919a45fd433279d5ae417011e Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:48:32 +0800 Subject: [PATCH 07/34] feat: Enhanced security, UI improvements, and animations (#432) * fix: Remove empty catch blocks and add error logging * refactor: Modularize server stats widget collectors * feat: Add i18n support for terminal customization and login stats - Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages - Add SSH login statistics translations - Update HostManagerEditor to use i18n for all terminal customization UI elements - Update LoginStatsWidget to use i18n for all UI text - Add missing logger imports in backend files for improved debugging * feat: Add keyboard shortcut enhancements with Kbd component - Add shadcn kbd component for displaying keyboard shortcuts - Enhance file manager context menu to display shortcuts with Kbd component - Add 5 new keyboard shortcuts to file manager: - Ctrl+D: Download selected files - Ctrl+N: Create new file - Ctrl+Shift+N: Create new folder - Ctrl+U: Upload files - Enter: Open/run selected file - Add keyboard shortcut hints to command palette footer - Create helper function to parse and render keyboard shortcuts * feat: Add i18n support for command palette - Add commandPalette translation section with 22 keys to all 4 languages - Update CommandPalette component to use i18n for all UI text - Translate search placeholder, group headings, menu items, and shortcut hints - Support multilingual command palette interface * feat: Add smooth transitions and animations to UI - Add fade-in/fade-out transition to command palette (200ms) - Add scale animation to command palette on open/close - Add smooth popup animation to context menu (150ms) - Add visual feedback for file selection with ring effect - Add hover scale effect to file grid items - Add transition-all to list view items for consistent behavior - Zero JavaScript overhead, pure CSS transitions - All animations under 200ms for instant feel * feat: Add button active state and dashboard card animations - Add active:scale-95 to all buttons for tactile click feedback - Add hover border effect to dashboard cards (150ms transition) - Add pulse animation to dashboard loading states - Pure CSS transitions with zero JavaScript overhead - Improves enterprise-level feel of UI * feat: Add smooth macOS-style page transitions - Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in) - Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile) - Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json - Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX * fix: Add key prop to force animation re-trigger on tab switch Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation * revert: Remove page transition animations Page switching animations were not noticeable enough and felt unnecessary. Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes * feat: Add ripple effect to login/logout transitions Add three-layer expanding ripple animation during fadeOut phase: - Ripples expand from screen center using primary theme color - Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect - Ripples fade out as they expand to create elegant visual feedback - Uses pure CSS keyframe animation, no external libraries Total animation: 800ms ripple + 300ms screen fade * feat: Add smooth TERMIX logo animation to transitions Changes: - Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel - Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety - Slow down ripple animation from 0.8s to 2s with cubic-bezier easing - Add centered TERMIX logo with monospace font and subtitle - Logo fades in from 80% scale, holds, then fades out at 110% scale - Total effect: 1.2s logo animation synced with 2s ripple waves Creates a premium, branded transition experience * feat: Enhance transition animation with premium details Timing adjustments: - Extend fadeOut from 800ms to 1200ms - Extend fadeIn from 600ms to 800ms - Slow background fade to 700ms for elegance Visual enhancements: - Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays - Ripple animation extended to 2.5s with refined opacity curve - Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px - Logo glow effect: triple-layer text-shadow using primary theme color - Increase logo size from text-6xl to text-7xl - Subtitle delayed fade-in from bottom with smooth slide animation Creates a cinematic, polished brand experience * feat: Redesign login page with split-screen cinematic layout Major redesign of authentication page: Left Side (40% width): - Full-height gradient background using primary theme color - Large TERMIX logo with glow effect - Subtitle and tagline - Infinite animated ripple waves (3 layers) - Hidden on mobile, shows brand identity Right Side (60% width): - Centered glassmorphism card with backdrop blur - Refined tab switcher with pill-style active state - Enlarged title with gradient text effect - Added welcome subtitles for better UX - Card slides in from bottom on load - All existing functionality preserved Visual enhancements: - Tab navigation: segmented control style in muted container - Active tab: white background with subtle shadow - Smooth 200ms transitions on all interactions - Card: rounded-2xl, shadow-xl, semi-transparent border Creates premium, modern login experience matching transition animations * feat: Update login page theme colors and add i18n support - Changed login page gradient from blue to match dark theme colors - Updated ripple effects to use theme primary color - Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal) - Updated all language files (en, zh, de, ru, pt-BR) with new translations - Fixed TypeScript compilation issues by clearing build cache * refactor: Use shadcn Tabs component and fix modal styling - Replace custom tab navigation with shadcn Tabs component - Restore border-2 border-dark-border for modal consistency - Remove circular icon from login success message - Simplify authentication success display * refactor: Remove ripple effects and gradient from login page - Remove animated ripple background effects - Remove gradient background, use solid color (bg-dark-bg-darker) - Remove text-shadow glow effect from logo - Simplify brand showcase to clean, minimal design * feat: Add decorative slash and remove subtitle from login page - Add decorative slash divider with gradient lines below TERMIX logo - Remove subtitle text (welcomeBack and createAccount) - Simplify page title to show only the main heading * feat: Add diagonal line pattern background to login page - Replace decorative slash with subtle diagonal line pattern background - Use repeating-linear-gradient at 45deg angle - Set very low opacity (0.03) for subtle effect - Pattern uses theme primary color * fix: Display diagonal line pattern on login background - Combine background color and pattern in single style attribute - Use white semi-transparent lines (rgba 0.03 opacity) - 45deg angle, 35px spacing, 2px width - Remove separate overlay div to ensure pattern visibility * security: Fix user enumeration vulnerability in login - Unify error messages for invalid username and incorrect password - Both return 401 status with 'Invalid username or password' - Prevent attackers from enumerating valid usernames - Maintain detailed logging for debugging purposes - Changed from 404 'User not found' to generic auth failure message * security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits --- package-lock.json | 60 +-- src/backend/database/database.ts | 8 +- src/backend/database/routes/users.ts | 51 ++- src/backend/ssh/file-manager.ts | 10 +- src/backend/ssh/server-stats.ts | 400 +++--------------- src/backend/ssh/tunnel.ts | 18 +- src/backend/ssh/widgets/common-utils.ts | 39 ++ src/backend/ssh/widgets/cpu-collector.ts | 83 ++++ src/backend/ssh/widgets/disk-collector.ts | 67 +++ .../ssh/widgets/login-stats-collector.ts | 117 +++++ src/backend/ssh/widgets/memory-collector.ts | 41 ++ src/backend/ssh/widgets/network-collector.ts | 79 ++++ .../ssh/widgets/processes-collector.ts | 69 +++ src/backend/ssh/widgets/system-collector.ts | 37 ++ src/backend/ssh/widgets/uptime-collector.ts | 35 ++ src/backend/starter.ts | 6 +- src/backend/utils/auto-ssl-setup.ts | 6 +- src/backend/utils/database-file-encryption.ts | 12 +- src/backend/utils/lazy-field-encryption.ts | 12 +- src/backend/utils/login-rate-limiter.ts | 146 +++++++ src/backend/utils/ssh-key-utils.ts | 25 +- src/backend/utils/system-crypto.ts | 26 +- src/components/ui/button.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/kbd.tsx | 28 ++ src/components/ui/tabs.tsx | 10 +- src/locales/de/translation.json | 100 ++++- src/locales/en/translation.json | 100 ++++- src/locales/pt-BR/translation.json | 5 + src/locales/ru/translation.json | 100 ++++- src/locales/zh/translation.json | 100 ++++- src/types/stats-widgets.ts | 13 +- src/ui/desktop/DesktopApp.tsx | 192 ++++++++- .../apps/command-palette/CommandPalette.tsx | 71 ++-- src/ui/desktop/apps/dashboard/Dashboard.tsx | 18 +- .../desktop/apps/file-manager/FileManager.tsx | 2 + .../file-manager/FileManagerContextMenu.tsx | 46 +- .../apps/file-manager/FileManagerGrid.tsx | 54 ++- .../apps/host-manager/HostManagerEditor.tsx | 144 ++++--- src/ui/desktop/apps/server/Server.tsx | 6 + .../apps/server/widgets/LoginStatsWidget.tsx | 138 ++++++ src/ui/desktop/apps/server/widgets/index.ts | 1 + src/ui/desktop/apps/terminal/Terminal.tsx | 20 +- src/ui/desktop/authentication/Auth.tsx | 174 ++++---- .../authentication/ElectronLoginForm.tsx | 12 +- .../authentication/ElectronServerConfig.tsx | 4 +- src/ui/desktop/navigation/LeftSidebar.tsx | 4 +- src/ui/desktop/navigation/SSHAuthDialog.tsx | 4 +- src/ui/desktop/navigation/TOTPDialog.tsx | 4 +- src/ui/desktop/user/UserProfile.tsx | 4 +- src/ui/hooks/useDragToSystemDesktop.ts | 4 +- src/ui/mobile/apps/terminal/Terminal.tsx | 12 +- .../mobile/apps/terminal/TerminalKeyboard.tsx | 4 +- src/ui/mobile/authentication/Auth.tsx | 4 +- tsconfig.node.json | 1 + 55 files changed, 2081 insertions(+), 649 deletions(-) create mode 100644 src/backend/ssh/widgets/common-utils.ts create mode 100644 src/backend/ssh/widgets/cpu-collector.ts create mode 100644 src/backend/ssh/widgets/disk-collector.ts create mode 100644 src/backend/ssh/widgets/login-stats-collector.ts create mode 100644 src/backend/ssh/widgets/memory-collector.ts create mode 100644 src/backend/ssh/widgets/network-collector.ts create mode 100644 src/backend/ssh/widgets/processes-collector.ts create mode 100644 src/backend/ssh/widgets/system-collector.ts create mode 100644 src/backend/ssh/widgets/uptime-collector.ts create mode 100644 src/backend/utils/login-rate-limiter.ts create mode 100644 src/components/ui/kbd.tsx create mode 100644 src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx diff --git a/package-lock.json b/package-lock.json index 61e5b7c6..a00f83c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,7 +154,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -440,7 +439,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -489,7 +487,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -516,7 +513,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -544,7 +540,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -745,7 +740,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -822,7 +816,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -844,7 +837,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1172,7 +1164,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1559,6 +1550,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1580,6 +1572,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2536,8 +2529,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -2577,7 +2569,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -2609,7 +2600,6 @@ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -2632,7 +2622,6 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -4856,7 +4845,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5014,7 +5002,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5137,7 +5124,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5180,7 +5166,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5191,7 +5176,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5359,7 +5343,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -5736,8 +5719,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -5772,7 +5754,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6195,7 +6176,6 @@ "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6330,7 +6310,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7334,7 +7313,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7411,7 +7389,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -7870,7 +7849,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -7968,7 +7946,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8320,6 +8299,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8340,6 +8320,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8355,6 +8336,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8365,6 +8347,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8627,7 +8610,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10226,7 +10208,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -11768,6 +11749,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13795,6 +13777,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13812,6 +13795,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14263,7 +14247,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14273,7 +14256,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14300,7 +14282,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14448,7 +14429,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14657,8 +14637,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -15979,6 +15958,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -16019,6 +15999,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16033,6 +16014,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -16137,7 +16119,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16343,7 +16324,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16756,7 +16736,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16848,7 +16827,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 661549b5..83280beb 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1480,13 +1480,17 @@ app.get( if (status.hasUnencryptedDb) { try { unencryptedSize = fs.statSync(dbPath).size; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { error }); + } } if (status.hasEncryptedDb) { try { encryptedSize = fs.statSync(encryptedDbPath).size; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { error }); + } } res.json({ diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 4c1c8685..c16cbf96 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -22,11 +22,12 @@ import { nanoid } from "nanoid"; import speakeasy from "speakeasy"; import QRCode from "qrcode"; import type { Request, Response } from "express"; -import { authLogger } from "../../utils/logger.js"; +import { authLogger, databaseLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; const authManager = AuthManager.getInstance(); @@ -885,6 +886,7 @@ router.get("/oidc/callback", async (req, res) => { // POST /users/login router.post("/login", async (req, res) => { const { username, password } = req.body; + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; if (!isNonEmptyString(username) || !isNonEmptyString(password)) { authLogger.warn("Invalid traditional login attempt", { @@ -895,6 +897,21 @@ router.post("/login", async (req, res) => { return res.status(400).json({ error: "Invalid username or password" }); } + // Check rate limiting + const lockStatus = loginRateLimiter.isLocked(clientIp, username); + if (lockStatus.locked) { + authLogger.warn("Login attempt blocked due to rate limiting", { + operation: "user_login_blocked", + username, + ip: clientIp, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: "Too many login attempts. Please try again later.", + remainingTime: lockStatus.remainingTime, + }); + } + try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") @@ -919,11 +936,14 @@ router.post("/login", async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { - authLogger.warn(`User not found: ${username}`, { + loginRateLimiter.recordFailedAttempt(clientIp, username); + authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); - return res.status(404).json({ error: "User not found" }); + return res.status(401).json({ error: "Invalid username or password" }); } const userRecord = user[0]; @@ -941,12 +961,15 @@ router.post("/login", async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { - authLogger.warn(`Incorrect password for user: ${username}`, { + loginRateLimiter.recordFailedAttempt(clientIp, username); + authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); - return res.status(401).json({ error: "Incorrect password" }); + return res.status(401).json({ error: "Invalid username or password" }); } try { @@ -958,7 +981,9 @@ router.post("/login", async (req, res) => { if (kekSalt.length === 0) { await authManager.registerUser(userRecord.id, password); } - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { error }); + } const dataUnlocked = await authManager.authenticateUser( userRecord.id, @@ -986,6 +1011,9 @@ router.post("/login", async (req, res) => { deviceInfo: deviceInfo.deviceInfo, }); + // Reset rate limiter on successful login + loginRateLimiter.resetAttempts(clientIp, username); + authLogger.success(`User logged in successfully: ${username}`, { operation: "user_login_success", username, @@ -993,6 +1021,7 @@ router.post("/login", async (req, res) => { dataUnlocked: true, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, + ip: clientIp, }); const response: Record = { @@ -1039,7 +1068,15 @@ router.post("/logout", authenticateJWT, async (req, res) => { try { const payload = await authManager.verifyJWTToken(token); sessionId = payload?.sessionId; - } catch (error) {} + } catch (error) { + authLogger.debug( + "Token verification failed during logout (expected if token expired)", + { + operation: "logout_token_verify_failed", + userId, + }, + ); + } } await authManager.logoutUser(userId, sessionId); diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 98f8a8a6..0eb17e2d 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -6,7 +6,7 @@ import { Client as SSHClient } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshCredentials, sshData } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; -import { fileLogger } from "../utils/logger.js"; +import { fileLogger, sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../types/index.js"; @@ -120,7 +120,9 @@ function cleanupSession(sessionId: string) { if (session) { try { session.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } clearTimeout(session.timeout); delete sshSessions[sessionId]; } @@ -663,7 +665,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { delete pendingTOTPSessions[sessionId]; try { session.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } fileLogger.warn("TOTP session timeout before code submission", { operation: "file_totp_verify", sessionId, diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 7a104a8f..a0a666f5 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -6,10 +6,18 @@ import { Client, type ConnectConfig } from "ssh2"; import { getDb } from "../database/db/index.js"; import { sshData, sshCredentials } from "../database/db/schema.js"; import { eq, and } from "drizzle-orm"; -import { statsLogger } from "../utils/logger.js"; +import { statsLogger, sshLogger } from "../utils/logger.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { AuthManager } from "../utils/auth-manager.js"; import type { AuthenticatedRequest } from "../../types/index.js"; +import { collectCpuMetrics } from "./widgets/cpu-collector.js"; +import { collectMemoryMetrics } from "./widgets/memory-collector.js"; +import { collectDiskMetrics } from "./widgets/disk-collector.js"; +import { collectNetworkMetrics } from "./widgets/network-collector.js"; +import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; +import { collectProcessesMetrics } from "./widgets/processes-collector.js"; +import { collectSystemMetrics } from "./widgets/system-collector.js"; +import { collectLoginStats } from "./widgets/login-stats-collector.js"; interface PooledConnection { client: Client; @@ -156,7 +164,9 @@ class SSHConnectionPool { if (!conn.inUse && now - conn.lastUsed > maxAge) { try { conn.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } return false; } return true; @@ -176,7 +186,9 @@ class SSHConnectionPool { for (const conn of connections) { try { conn.client.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } } } this.connections.clear(); @@ -214,7 +226,9 @@ class RequestQueue { if (request) { try { await request(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } } } @@ -518,7 +532,14 @@ class PollingManager { data: metrics, timestamp: Date.now(), }); - } catch (error) {} + } catch (error) { + statsLogger.warn("Failed to collect metrics for host", { + operation: "metrics_poll_failed", + hostId: host.id, + hostName: host.name, + error: error instanceof Error ? error.message : String(error), + }); + } } stopPollingForHost(hostId: number, clearData = true): void { @@ -932,59 +953,6 @@ async function withSshConnection( } } -function execCommand( - client: Client, - command: string, -): Promise<{ - stdout: string; - stderr: string; - code: number | null; -}> { - return new Promise((resolve, reject) => { - client.exec(command, { pty: false }, (err, stream) => { - if (err) return reject(err); - let stdout = ""; - let stderr = ""; - let exitCode: number | null = null; - stream - .on("close", (code: number | undefined) => { - exitCode = typeof code === "number" ? code : null; - resolve({ stdout, stderr, code: exitCode }); - }) - .on("data", (data: Buffer) => { - stdout += data.toString("utf8"); - }) - .stderr.on("data", (data: Buffer) => { - stderr += data.toString("utf8"); - }); - }); - }); -} - -function parseCpuLine( - cpuLine: string, -): { total: number; idle: number } | undefined { - const parts = cpuLine.trim().split(/\s+/); - if (parts[0] !== "cpu") return undefined; - const nums = parts - .slice(1) - .map((n) => Number(n)) - .filter((n) => Number.isFinite(n)); - if (nums.length < 4) return undefined; - const idle = (nums[3] ?? 0) + (nums[4] ?? 0); - const total = nums.reduce((a, b) => a + b, 0); - return { total, idle }; -} - -function toFixedNum(n: number | null | undefined, digits = 2): number | null { - if (typeof n !== "number" || !Number.isFinite(n)) return null; - return Number(n.toFixed(digits)); -} - -function kibToGiB(kib: number): number { - return kib / (1024 * 1024); -} - async function collectMetrics(host: SSHHostWithCredentials): Promise<{ cpu: { percent: number | null; @@ -1047,298 +1015,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ return requestQueue.queueRequest(host.id, async () => { try { return await withSshConnection(host, async (client) => { - let cpuPercent: number | null = null; - let cores: number | null = null; - let loadTriplet: [number, number, number] | null = null; + const cpu = await collectCpuMetrics(client); + const memory = await collectMemoryMetrics(client); + const disk = await collectDiskMetrics(client); + const network = await collectNetworkMetrics(client); + const uptime = await collectUptimeMetrics(client); + const processes = await collectProcessesMetrics(client); + const system = await collectSystemMetrics(client); + let login_stats = { + recentLogins: [], + failedLogins: [], + totalLogins: 0, + uniqueIPs: 0, + }; try { - const [stat1, loadAvgOut, coresOut] = await Promise.all([ - execCommand(client, "cat /proc/stat"), - execCommand(client, "cat /proc/loadavg"), - execCommand( - client, - "nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo", - ), - ]); - - await new Promise((r) => setTimeout(r, 500)); - const stat2 = await execCommand(client, "cat /proc/stat"); - - const cpuLine1 = ( - stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" - ).trim(); - const cpuLine2 = ( - stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" - ).trim(); - const a = parseCpuLine(cpuLine1); - const b = parseCpuLine(cpuLine2); - if (a && b) { - const totalDiff = b.total - a.total; - const idleDiff = b.idle - a.idle; - const used = totalDiff - idleDiff; - if (totalDiff > 0) - cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); - } - - const laParts = loadAvgOut.stdout.trim().split(/\s+/); - if (laParts.length >= 3) { - loadTriplet = [ - Number(laParts[0]), - Number(laParts[1]), - Number(laParts[2]), - ].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [ - number, - number, - number, - ]; - } - - const coresNum = Number((coresOut.stdout || "").trim()); - cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; + login_stats = await collectLoginStats(client); } catch (e) { - cpuPercent = null; - cores = null; - loadTriplet = null; + statsLogger.debug("Failed to collect login stats", { + operation: "login_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); } - let memPercent: number | null = null; - let usedGiB: number | null = null; - let totalGiB: number | null = null; - try { - const memInfo = await execCommand(client, "cat /proc/meminfo"); - const lines = memInfo.stdout.split("\n"); - const getVal = (key: string) => { - const line = lines.find((l) => l.startsWith(key)); - if (!line) return null; - const m = line.match(/\d+/); - return m ? Number(m[0]) : null; - }; - const totalKb = getVal("MemTotal:"); - const availKb = getVal("MemAvailable:"); - if (totalKb && availKb && totalKb > 0) { - const usedKb = totalKb - availKb; - memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); - usedGiB = kibToGiB(usedKb); - totalGiB = kibToGiB(totalKb); - } - } catch (e) { - memPercent = null; - usedGiB = null; - totalGiB = null; - } - - let diskPercent: number | null = null; - let usedHuman: string | null = null; - let totalHuman: string | null = null; - let availableHuman: string | null = null; - try { - const [diskOutHuman, diskOutBytes] = await Promise.all([ - execCommand(client, "df -h -P / | tail -n +2"), - execCommand(client, "df -B1 -P / | tail -n +2"), - ]); - - const humanLine = - diskOutHuman.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)[0] || ""; - const bytesLine = - diskOutBytes.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)[0] || ""; - - const humanParts = humanLine.split(/\s+/); - const bytesParts = bytesLine.split(/\s+/); - - if (humanParts.length >= 6 && bytesParts.length >= 6) { - totalHuman = humanParts[1] || null; - usedHuman = humanParts[2] || null; - availableHuman = humanParts[3] || null; - - const totalBytes = Number(bytesParts[1]); - const usedBytes = Number(bytesParts[2]); - - if ( - Number.isFinite(totalBytes) && - Number.isFinite(usedBytes) && - totalBytes > 0 - ) { - diskPercent = Math.max( - 0, - Math.min(100, (usedBytes / totalBytes) * 100), - ); - } - } - } catch (e) { - diskPercent = null; - usedHuman = null; - totalHuman = null; - availableHuman = null; - } - - const interfaces: Array<{ - name: string; - ip: string; - state: string; - rxBytes: string | null; - txBytes: string | null; - }> = []; - try { - const ifconfigOut = await execCommand( - client, - "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'", - ); - const netStatOut = await execCommand( - client, - "ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'", - ); - - const addrs = ifconfigOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - const states = netStatOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - - const ifMap = new Map(); - for (const line of addrs) { - const parts = line.split(/\s+/); - if (parts.length >= 2) { - const name = parts[0]; - const ip = parts[1].split("/")[0]; - if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" }); - } - } - for (const line of states) { - const parts = line.split(/\s+/); - if (parts.length >= 2) { - const name = parts[0]; - const state = parts[1]; - const existing = ifMap.get(name); - if (existing) { - existing.state = state; - } - } - } - - for (const [name, data] of ifMap.entries()) { - interfaces.push({ - name, - ip: data.ip, - state: data.state, - rxBytes: null, - txBytes: null, - }); - } - } catch (e) {} - - let uptimeSeconds: number | null = null; - let uptimeFormatted: string | null = null; - try { - const uptimeOut = await execCommand(client, "cat /proc/uptime"); - const uptimeParts = uptimeOut.stdout.trim().split(/\s+/); - if (uptimeParts.length >= 1) { - uptimeSeconds = Number(uptimeParts[0]); - if (Number.isFinite(uptimeSeconds)) { - const days = Math.floor(uptimeSeconds / 86400); - const hours = Math.floor((uptimeSeconds % 86400) / 3600); - const minutes = Math.floor((uptimeSeconds % 3600) / 60); - uptimeFormatted = `${days}d ${hours}h ${minutes}m`; - } - } - } catch (e) {} - - let totalProcesses: number | null = null; - let runningProcesses: number | null = null; - const topProcesses: Array<{ - pid: string; - user: string; - cpu: string; - mem: string; - command: string; - }> = []; - try { - const psOut = await execCommand( - client, - "ps aux --sort=-%cpu | head -n 11", - ); - const psLines = psOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - if (psLines.length > 1) { - for (let i = 1; i < Math.min(psLines.length, 11); i++) { - const parts = psLines[i].split(/\s+/); - if (parts.length >= 11) { - topProcesses.push({ - pid: parts[1], - user: parts[0], - cpu: parts[2], - mem: parts[3], - command: parts.slice(10).join(" ").substring(0, 50), - }); - } - } - } - - const procCount = await execCommand(client, "ps aux | wc -l"); - const runningCount = await execCommand( - client, - "ps aux | grep -c ' R '", - ); - totalProcesses = Number(procCount.stdout.trim()) - 1; - runningProcesses = Number(runningCount.stdout.trim()); - } catch (e) {} - - let hostname: string | null = null; - let kernel: string | null = null; - let os: string | null = null; - try { - const hostnameOut = await execCommand(client, "hostname"); - const kernelOut = await execCommand(client, "uname -r"); - const osOut = await execCommand( - client, - "cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2", - ); - - hostname = hostnameOut.stdout.trim() || null; - kernel = kernelOut.stdout.trim() || null; - os = osOut.stdout.trim() || null; - } catch (e) {} - const result = { - cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet }, - memory: { - percent: toFixedNum(memPercent, 0), - usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, - totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, - }, - disk: { - percent: toFixedNum(diskPercent, 0), - usedHuman, - totalHuman, - availableHuman, - }, - network: { - interfaces, - }, - uptime: { - seconds: uptimeSeconds, - formatted: uptimeFormatted, - }, - processes: { - total: totalProcesses, - running: runningProcesses, - top: topProcesses, - }, - system: { - hostname, - kernel, - os, - }, + cpu, + memory, + disk, + network, + uptime, + processes, + system, + login_stats, }; metricsCache.set(host.id, result); @@ -1386,7 +1094,9 @@ function tcpPing( settled = true; try { socket.destroy(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } resolve(result); }; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 6262af86..9d584ea2 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -15,7 +15,7 @@ import type { ErrorType, } from "../../types/index.js"; import { CONNECTION_STATES } from "../../types/index.js"; -import { tunnelLogger } from "../utils/logger.js"; +import { tunnelLogger, sshLogger } from "../utils/logger.js"; import { SystemCrypto } from "../utils/system-crypto.js"; import { SimpleDBOps } from "../utils/simple-db-ops.js"; import { DataCrypto } from "../utils/data-crypto.js"; @@ -217,7 +217,9 @@ function cleanupTunnelResources( if (verification?.timeout) clearTimeout(verification.timeout); try { verification?.conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } tunnelVerifications.delete(tunnelName); } @@ -282,7 +284,9 @@ function handleDisconnect( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } tunnelVerifications.delete(tunnelName); } @@ -638,7 +642,9 @@ async function connectSSHTunnel( try { conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } activeTunnels.delete(tunnelName); @@ -778,7 +784,9 @@ async function connectSSHTunnel( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { error }); + } tunnelVerifications.delete(tunnelName); } diff --git a/src/backend/ssh/widgets/common-utils.ts b/src/backend/ssh/widgets/common-utils.ts new file mode 100644 index 00000000..802c8571 --- /dev/null +++ b/src/backend/ssh/widgets/common-utils.ts @@ -0,0 +1,39 @@ +import type { Client } from "ssh2"; + +export function execCommand( + client: Client, + command: string, +): Promise<{ + stdout: string; + stderr: string; + code: number | null; +}> { + return new Promise((resolve, reject) => { + client.exec(command, { pty: false }, (err, stream) => { + if (err) return reject(err); + let stdout = ""; + let stderr = ""; + let exitCode: number | null = null; + stream + .on("close", (code: number | undefined) => { + exitCode = typeof code === "number" ? code : null; + resolve({ stdout, stderr, code: exitCode }); + }) + .on("data", (data: Buffer) => { + stdout += data.toString("utf8"); + }) + .stderr.on("data", (data: Buffer) => { + stderr += data.toString("utf8"); + }); + }); + }); +} + +export function toFixedNum(n: number | null | undefined, digits = 2): number | null { + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Number(n.toFixed(digits)); +} + +export function kibToGiB(kib: number): number { + return kib / (1024 * 1024); +} diff --git a/src/backend/ssh/widgets/cpu-collector.ts b/src/backend/ssh/widgets/cpu-collector.ts new file mode 100644 index 00000000..359ae6ad --- /dev/null +++ b/src/backend/ssh/widgets/cpu-collector.ts @@ -0,0 +1,83 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum } from "./common-utils.js"; + +function parseCpuLine( + cpuLine: string, +): { total: number; idle: number } | undefined { + const parts = cpuLine.trim().split(/\s+/); + if (parts[0] !== "cpu") return undefined; + const nums = parts + .slice(1) + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n)); + if (nums.length < 4) return undefined; + const idle = (nums[3] ?? 0) + (nums[4] ?? 0); + const total = nums.reduce((a, b) => a + b, 0); + return { total, idle }; +} + +export async function collectCpuMetrics(client: Client): Promise<{ + percent: number | null; + cores: number | null; + load: [number, number, number] | null; +}> { + let cpuPercent: number | null = null; + let cores: number | null = null; + let loadTriplet: [number, number, number] | null = null; + + try { + const [stat1, loadAvgOut, coresOut] = await Promise.all([ + execCommand(client, "cat /proc/stat"), + execCommand(client, "cat /proc/loadavg"), + execCommand( + client, + "nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo", + ), + ]); + + await new Promise((r) => setTimeout(r, 500)); + const stat2 = await execCommand(client, "cat /proc/stat"); + + const cpuLine1 = ( + stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + ).trim(); + const cpuLine2 = ( + stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || "" + ).trim(); + const a = parseCpuLine(cpuLine1); + const b = parseCpuLine(cpuLine2); + if (a && b) { + const totalDiff = b.total - a.total; + const idleDiff = b.idle - a.idle; + const used = totalDiff - idleDiff; + if (totalDiff > 0) + cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100)); + } + + const laParts = loadAvgOut.stdout.trim().split(/\s+/); + if (laParts.length >= 3) { + loadTriplet = [ + Number(laParts[0]), + Number(laParts[1]), + Number(laParts[2]), + ].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [ + number, + number, + number, + ]; + } + + const coresNum = Number((coresOut.stdout || "").trim()); + cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null; + } catch (e) { + cpuPercent = null; + cores = null; + loadTriplet = null; + } + + return { + percent: toFixedNum(cpuPercent, 0), + cores, + load: loadTriplet, + }; +} diff --git a/src/backend/ssh/widgets/disk-collector.ts b/src/backend/ssh/widgets/disk-collector.ts new file mode 100644 index 00000000..b221cee2 --- /dev/null +++ b/src/backend/ssh/widgets/disk-collector.ts @@ -0,0 +1,67 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum } from "./common-utils.js"; + +export async function collectDiskMetrics(client: Client): Promise<{ + percent: number | null; + usedHuman: string | null; + totalHuman: string | null; + availableHuman: string | null; +}> { + let diskPercent: number | null = null; + let usedHuman: string | null = null; + let totalHuman: string | null = null; + let availableHuman: string | null = null; + + try { + const [diskOutHuman, diskOutBytes] = await Promise.all([ + execCommand(client, "df -h -P / | tail -n +2"), + execCommand(client, "df -B1 -P / | tail -n +2"), + ]); + + const humanLine = + diskOutHuman.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)[0] || ""; + const bytesLine = + diskOutBytes.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean)[0] || ""; + + const humanParts = humanLine.split(/\s+/); + const bytesParts = bytesLine.split(/\s+/); + + if (humanParts.length >= 6 && bytesParts.length >= 6) { + totalHuman = humanParts[1] || null; + usedHuman = humanParts[2] || null; + availableHuman = humanParts[3] || null; + + const totalBytes = Number(bytesParts[1]); + const usedBytes = Number(bytesParts[2]); + + if ( + Number.isFinite(totalBytes) && + Number.isFinite(usedBytes) && + totalBytes > 0 + ) { + diskPercent = Math.max( + 0, + Math.min(100, (usedBytes / totalBytes) * 100), + ); + } + } + } catch (e) { + diskPercent = null; + usedHuman = null; + totalHuman = null; + availableHuman = null; + } + + return { + percent: toFixedNum(diskPercent, 0), + usedHuman, + totalHuman, + availableHuman, + }; +} diff --git a/src/backend/ssh/widgets/login-stats-collector.ts b/src/backend/ssh/widgets/login-stats-collector.ts new file mode 100644 index 00000000..5147b146 --- /dev/null +++ b/src/backend/ssh/widgets/login-stats-collector.ts @@ -0,0 +1,117 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; + +export interface LoginRecord { + user: string; + ip: string; + time: string; + status: "success" | "failed"; +} + +export interface LoginStats { + recentLogins: LoginRecord[]; + failedLogins: LoginRecord[]; + totalLogins: number; + uniqueIPs: number; +} + +export async function collectLoginStats(client: Client): Promise { + const recentLogins: LoginRecord[] = []; + const failedLogins: LoginRecord[] = []; + const ipSet = new Set(); + + try { + const lastOut = await execCommand( + client, + "last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20", + ); + + const lastLines = lastOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + for (const line of lastLines) { + const parts = line.split(/\s+/); + if (parts.length >= 10) { + const user = parts[0]; + const tty = parts[1]; + const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2]; + + const timeStart = parts.indexOf(parts.find(p => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || ""); + if (timeStart > 0 && parts.length > timeStart + 4) { + const timeStr = parts.slice(timeStart, timeStart + 5).join(" "); + + if (user && user !== "wtmp" && tty !== "system") { + recentLogins.push({ + user, + ip, + time: new Date(timeStr).toISOString(), + status: "success", + }); + if (ip !== "local") { + ipSet.add(ip); + } + } + } + } + } + } catch (e) { + // Ignore errors + } + + try { + const failedOut = await execCommand( + client, + "grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''", + ); + + const failedLines = failedOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + for (const line of failedLines) { + let user = "unknown"; + let ip = "unknown"; + let timeStr = ""; + + const userMatch = line.match(/for (?:invalid user )?(\S+)/); + if (userMatch) { + user = userMatch[1]; + } + + const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/); + if (ipMatch) { + ip = ipMatch[1]; + } + + const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/); + if (dateMatch) { + const currentYear = new Date().getFullYear(); + timeStr = `${currentYear} ${dateMatch[1]}`; + } + + if (user && ip) { + failedLogins.push({ + user, + ip, + time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(), + status: "failed", + }); + if (ip !== "unknown") { + ipSet.add(ip); + } + } + } + } catch (e) { + // Ignore errors + } + + return { + recentLogins: recentLogins.slice(0, 10), + failedLogins: failedLogins.slice(0, 10), + totalLogins: recentLogins.length, + uniqueIPs: ipSet.size, + }; +} diff --git a/src/backend/ssh/widgets/memory-collector.ts b/src/backend/ssh/widgets/memory-collector.ts new file mode 100644 index 00000000..3dce5c64 --- /dev/null +++ b/src/backend/ssh/widgets/memory-collector.ts @@ -0,0 +1,41 @@ +import type { Client } from "ssh2"; +import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js"; + +export async function collectMemoryMetrics(client: Client): Promise<{ + percent: number | null; + usedGiB: number | null; + totalGiB: number | null; +}> { + let memPercent: number | null = null; + let usedGiB: number | null = null; + let totalGiB: number | null = null; + + try { + const memInfo = await execCommand(client, "cat /proc/meminfo"); + const lines = memInfo.stdout.split("\n"); + const getVal = (key: string) => { + const line = lines.find((l) => l.startsWith(key)); + if (!line) return null; + const m = line.match(/\d+/); + return m ? Number(m[0]) : null; + }; + const totalKb = getVal("MemTotal:"); + const availKb = getVal("MemAvailable:"); + if (totalKb && availKb && totalKb > 0) { + const usedKb = totalKb - availKb; + memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100)); + usedGiB = kibToGiB(usedKb); + totalGiB = kibToGiB(totalKb); + } + } catch (e) { + memPercent = null; + usedGiB = null; + totalGiB = null; + } + + return { + percent: toFixedNum(memPercent, 0), + usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null, + totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null, + }; +} diff --git a/src/backend/ssh/widgets/network-collector.ts b/src/backend/ssh/widgets/network-collector.ts new file mode 100644 index 00000000..bd3a3bd9 --- /dev/null +++ b/src/backend/ssh/widgets/network-collector.ts @@ -0,0 +1,79 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectNetworkMetrics(client: Client): Promise<{ + interfaces: Array<{ + name: string; + ip: string; + state: string; + rxBytes: string | null; + txBytes: string | null; + }>; +}> { + const interfaces: Array<{ + name: string; + ip: string; + state: string; + rxBytes: string | null; + txBytes: string | null; + }> = []; + + try { + const ifconfigOut = await execCommand( + client, + "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'", + ); + const netStatOut = await execCommand( + client, + "ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'", + ); + + const addrs = ifconfigOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + const states = netStatOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + + const ifMap = new Map(); + for (const line of addrs) { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + const name = parts[0]; + const ip = parts[1].split("/")[0]; + if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" }); + } + } + for (const line of states) { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + const name = parts[0]; + const state = parts[1]; + const existing = ifMap.get(name); + if (existing) { + existing.state = state; + } + } + } + + for (const [name, data] of ifMap.entries()) { + interfaces.push({ + name, + ip: data.ip, + state: data.state, + rxBytes: null, + txBytes: null, + }); + } + } catch (e) { + statsLogger.debug("Failed to collect network interface stats", { + operation: "network_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { interfaces }; +} diff --git a/src/backend/ssh/widgets/processes-collector.ts b/src/backend/ssh/widgets/processes-collector.ts new file mode 100644 index 00000000..02f3ea11 --- /dev/null +++ b/src/backend/ssh/widgets/processes-collector.ts @@ -0,0 +1,69 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectProcessesMetrics(client: Client): Promise<{ + total: number | null; + running: number | null; + top: Array<{ + pid: string; + user: string; + cpu: string; + mem: string; + command: string; + }>; +}> { + let totalProcesses: number | null = null; + let runningProcesses: number | null = null; + const topProcesses: Array<{ + pid: string; + user: string; + cpu: string; + mem: string; + command: string; + }> = []; + + try { + const psOut = await execCommand( + client, + "ps aux --sort=-%cpu | head -n 11", + ); + const psLines = psOut.stdout + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + if (psLines.length > 1) { + for (let i = 1; i < Math.min(psLines.length, 11); i++) { + const parts = psLines[i].split(/\s+/); + if (parts.length >= 11) { + topProcesses.push({ + pid: parts[1], + user: parts[0], + cpu: parts[2], + mem: parts[3], + command: parts.slice(10).join(" ").substring(0, 50), + }); + } + } + } + + const procCount = await execCommand(client, "ps aux | wc -l"); + const runningCount = await execCommand( + client, + "ps aux | grep -c ' R '", + ); + totalProcesses = Number(procCount.stdout.trim()) - 1; + runningProcesses = Number(runningCount.stdout.trim()); + } catch (e) { + statsLogger.debug("Failed to collect process stats", { + operation: "process_stats_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { + total: totalProcesses, + running: runningProcesses, + top: topProcesses, + }; +} diff --git a/src/backend/ssh/widgets/system-collector.ts b/src/backend/ssh/widgets/system-collector.ts new file mode 100644 index 00000000..e62c3ed0 --- /dev/null +++ b/src/backend/ssh/widgets/system-collector.ts @@ -0,0 +1,37 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectSystemMetrics(client: Client): Promise<{ + hostname: string | null; + kernel: string | null; + os: string | null; +}> { + let hostname: string | null = null; + let kernel: string | null = null; + let os: string | null = null; + + try { + const hostnameOut = await execCommand(client, "hostname"); + const kernelOut = await execCommand(client, "uname -r"); + const osOut = await execCommand( + client, + "cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2", + ); + + hostname = hostnameOut.stdout.trim() || null; + kernel = kernelOut.stdout.trim() || null; + os = osOut.stdout.trim() || null; + } catch (e) { + statsLogger.debug("Failed to collect system info", { + operation: "system_info_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { + hostname, + kernel, + os, + }; +} diff --git a/src/backend/ssh/widgets/uptime-collector.ts b/src/backend/ssh/widgets/uptime-collector.ts new file mode 100644 index 00000000..87e8dfcc --- /dev/null +++ b/src/backend/ssh/widgets/uptime-collector.ts @@ -0,0 +1,35 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import { statsLogger } from "../../utils/logger.js"; + +export async function collectUptimeMetrics(client: Client): Promise<{ + seconds: number | null; + formatted: string | null; +}> { + let uptimeSeconds: number | null = null; + let uptimeFormatted: string | null = null; + + try { + const uptimeOut = await execCommand(client, "cat /proc/uptime"); + const uptimeParts = uptimeOut.stdout.trim().split(/\s+/); + if (uptimeParts.length >= 1) { + uptimeSeconds = Number(uptimeParts[0]); + if (Number.isFinite(uptimeSeconds)) { + const days = Math.floor(uptimeSeconds / 86400); + const hours = Math.floor((uptimeSeconds % 86400) / 3600); + const minutes = Math.floor((uptimeSeconds % 3600) / 60); + uptimeFormatted = `${days}d ${hours}h ${minutes}m`; + } + } + } catch (e) { + statsLogger.debug("Failed to collect uptime", { + operation: "uptime_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + + return { + seconds: uptimeSeconds, + formatted: uptimeFormatted, + }; +} diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 4ab019a6..f7cd3b4f 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -21,7 +21,11 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; if (persistentConfig.parsed) { Object.assign(process.env, persistentConfig.parsed); } - } catch {} + } catch (error) { + systemLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } let version = "unknown"; diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/utils/auto-ssl-setup.ts index e45ce2ec..6e441df3 100644 --- a/src/backend/utils/auto-ssl-setup.ts +++ b/src/backend/utils/auto-ssl-setup.ts @@ -233,7 +233,11 @@ IP.3 = 0.0.0.0 let envContent = ""; try { envContent = await fs.readFile(this.ENV_FILE, "utf8"); - } catch {} + } catch (error) { + systemLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } let updatedContent = envContent; let hasChanges = false; diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index b4afbe7a..8ffb2e1f 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -327,7 +327,11 @@ class DatabaseFileEncryption { fs.accessSync(envPath, fs.constants.R_OK); envFileReadable = true; } - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } databaseLogger.error( "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", @@ -628,7 +632,11 @@ class DatabaseFileEncryption { try { fs.accessSync(envPath, fs.constants.R_OK); result.environment.envFileReadable = true; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } if ( diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 6be7b44d..8cd3a4d0 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -82,7 +82,11 @@ export class LazyFieldEncryption { legacyFieldName, ); return decrypted; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } const sensitiveFields = [ @@ -174,7 +178,11 @@ export class LazyFieldEncryption { wasPlaintext: false, wasLegacyEncryption: true, }; - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } return { encrypted: fieldValue, diff --git a/src/backend/utils/login-rate-limiter.ts b/src/backend/utils/login-rate-limiter.ts new file mode 100644 index 00000000..4e5ed704 --- /dev/null +++ b/src/backend/utils/login-rate-limiter.ts @@ -0,0 +1,146 @@ +interface LoginAttempt { + count: number; + firstAttempt: number; + lockedUntil?: number; +} + +class LoginRateLimiter { + private ipAttempts = new Map(); + private usernameAttempts = new Map(); + + private readonly MAX_ATTEMPTS = 5; + private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes + private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes + + // Clean up old entries periodically + constructor() { + setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes + } + + private cleanup(): void { + const now = Date.now(); + + // Clean IP attempts + for (const [ip, attempt] of this.ipAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.ipAttempts.delete(ip); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.ipAttempts.delete(ip); + } + } + + // Clean username attempts + for (const [username, attempt] of this.usernameAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.usernameAttempts.delete(username); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.usernameAttempts.delete(username); + } + } + } + + recordFailedAttempt(ip: string, username?: string): void { + const now = Date.now(); + + // Record IP attempt + const ipAttempt = this.ipAttempts.get(ip); + if (!ipAttempt) { + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else { + ipAttempt.count++; + if (ipAttempt.count >= this.MAX_ATTEMPTS) { + ipAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + + // Record username attempt if provided + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (!userAttempt) { + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else { + userAttempt.count++; + if (userAttempt.count >= this.MAX_ATTEMPTS) { + userAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + } + } + + resetAttempts(ip: string, username?: string): void { + this.ipAttempts.delete(ip); + if (username) { + this.usernameAttempts.delete(username); + } + } + + isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } { + const now = Date.now(); + + // Check IP lockout + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000), + }; + } + + // Check username lockout + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000), + }; + } + } + + return { locked: false }; + } + + getRemainingAttempts(ip: string, username?: string): number { + const now = Date.now(); + let minRemaining = this.MAX_ATTEMPTS; + + // Check IP attempts + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) { + const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count); + minRemaining = Math.min(minRemaining, ipRemaining); + } + + // Check username attempts + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) { + const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count); + minRemaining = Math.min(minRemaining, userRemaining); + } + } + + return minRemaining; + } +} + +// Export singleton instance +export const loginRateLimiter = new LoginRateLimiter(); diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 8cd3d3d3..132d3391 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -1,4 +1,5 @@ import ssh2Pkg from "ssh2"; +import { sshLogger } from "./logger.js"; const ssh2Utils = ssh2Pkg.utils; function detectKeyTypeFromContent(keyContent: string): string { @@ -84,7 +85,11 @@ function detectKeyTypeFromContent(keyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } if (content.length < 800) { return "ssh-ed25519"; @@ -140,7 +145,11 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } if (content.length < 400) { return "ssh-ed25519"; @@ -242,7 +251,11 @@ export function parseSSHKey( useSSH2 = true; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } } if (!useSSH2) { @@ -268,7 +281,11 @@ export function parseSSHKey( success: true, }; } - } catch {} + } catch (error) { + sshLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } return { privateKey: privateKeyData, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 156d0b33..41aa2ff1 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -51,7 +51,15 @@ class SystemCrypto { }, ); } - } catch (fileError) {} + } catch (fileError) { + // OK: .env file not found or unreadable, will generate new JWT secret + databaseLogger.debug( + ".env file not accessible, will generate new JWT secret", + { + operation: "jwt_env_not_found", + }, + ); + } await this.generateAndGuideUser(); } catch (error) { @@ -102,7 +110,15 @@ class SystemCrypto { return; } else { } - } catch (fileError) {} + } catch (fileError) { + // OK: .env file not found or unreadable, will generate new database key + databaseLogger.debug( + ".env file not accessible, will generate new database key", + { + operation: "db_key_env_not_found", + }, + ); + } await this.generateAndGuideDatabaseKey(); } catch (error) { @@ -140,7 +156,11 @@ class SystemCrypto { process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1]; return; } - } catch {} + } catch (error) { + databaseLogger.debug("Operation failed, continuing", { + error: error instanceof Error ? error.message : String(error), + }); + } await this.generateAndGuideInternalAuthToken(); } catch (error) { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 26ee717b..fbdf6b8d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", { variants: { variant: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index b1a060f5..ba0f8921 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx new file mode 100644 index 00000000..253c69f3 --- /dev/null +++ b/src/components/ui/kbd.tsx @@ -0,0 +1,28 @@ +import { cn } from "@/lib/utils" + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ) +} + +function KbdGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ) +} + +export { Kbd, KbdGroup } diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 46295c2f..12b4fc5c 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -40,7 +40,7 @@ function TabsTrigger({ ); diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 37fff37f..c1d6639b 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -706,6 +706,69 @@ "statusMonitoring": "Status", "metricsMonitoring": "Metriken", "terminalCustomizationNotice": "Hinweis: Terminal-Anpassungen funktionieren nur in der Desktop-Website-Version. Mobile und Electron-Apps verwenden die Standard-Terminaleinstellungen des Systems.", + "terminalCustomization": "Terminal-Anpassung", + "appearance": "Aussehen", + "behavior": "Verhalten", + "advanced": "Erweitert", + "themePreview": "Themen-Vorschau", + "theme": "Thema", + "selectTheme": "Thema auswählen", + "chooseColorTheme": "Wählen Sie ein Farbthema für das Terminal", + "fontFamily": "Schriftfamilie", + "selectFont": "Schriftart auswählen", + "selectFontDesc": "Wählen Sie die im Terminal zu verwendende Schriftart", + "fontSize": "Schriftgröße", + "fontSizeValue": "Schriftgröße: {{value}}px", + "adjustFontSize": "Terminal-Schriftgröße anpassen", + "letterSpacing": "Zeichenabstand", + "letterSpacingValue": "Zeichenabstand: {{value}}px", + "adjustLetterSpacing": "Abstand zwischen Zeichen anpassen", + "lineHeight": "Zeilenhöhe", + "lineHeightValue": "Zeilenhöhe: {{value}}", + "adjustLineHeight": "Abstand zwischen Zeilen anpassen", + "cursorStyle": "Cursor-Stil", + "selectCursorStyle": "Cursor-Stil auswählen", + "cursorStyleBlock": "Block", + "cursorStyleUnderline": "Unterstrich", + "cursorStyleBar": "Balken", + "chooseCursorAppearance": "Cursor-Erscheinungsbild wählen", + "cursorBlink": "Cursor-Blinken", + "enableCursorBlink": "Cursor-Blinkanimation aktivieren", + "scrollbackBuffer": "Rückwärts-Puffer", + "scrollbackBufferValue": "Rückwärts-Puffer: {{value}} Zeilen", + "scrollbackBufferDesc": "Anzahl der Zeilen im Rückwärtsverlauf", + "bellStyle": "Signalton-Stil", + "selectBellStyle": "Signalton-Stil auswählen", + "bellStyleNone": "Keine", + "bellStyleSound": "Ton", + "bellStyleVisual": "Visuell", + "bellStyleBoth": "Beides", + "bellStyleDesc": "Behandlung des Terminal-Signaltons (BEL-Zeichen, \\x07). Programme lösen dies aus, wenn Aufgaben abgeschlossen werden, Fehler auftreten oder für Benachrichtigungen. \"Ton\" spielt einen akustischen Signalton ab, \"Visuell\" lässt den Bildschirm kurz aufblinken, \"Beides\" macht beides, \"Keine\" deaktiviert Signalton-Benachrichtigungen.", + "rightClickSelectsWord": "Rechtsklick wählt Wort", + "rightClickSelectsWordDesc": "Rechtsklick wählt das Wort unter dem Cursor aus", + "fastScrollModifier": "Schnellscroll-Modifikator", + "selectModifier": "Modifikator auswählen", + "modifierAlt": "Alt", + "modifierCtrl": "Strg", + "modifierShift": "Umschalt", + "fastScrollModifierDesc": "Modifikatortaste für schnelles Scrollen", + "fastScrollSensitivity": "Schnellscroll-Empfindlichkeit", + "fastScrollSensitivityValue": "Schnellscroll-Empfindlichkeit: {{value}}", + "fastScrollSensitivityDesc": "Scroll-Geschwindigkeitsmultiplikator bei gedrücktem Modifikator", + "minimumContrastRatio": "Minimales Kontrastverhältnis", + "minimumContrastRatioValue": "Minimales Kontrastverhältnis: {{value}}", + "minimumContrastRatioDesc": "Farben automatisch für bessere Lesbarkeit anpassen", + "sshAgentForwarding": "SSH-Agent-Weiterleitung", + "sshAgentForwardingDesc": "SSH-Authentifizierungsagent an Remote-Host weiterleiten", + "backspaceMode": "Rücktaste-Modus", + "selectBackspaceMode": "Rücktaste-Modus auswählen", + "backspaceModeNormal": "Normal (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "Rücktasten-Verhalten für Kompatibilität", + "startupSnippet": "Start-Snippet", + "selectSnippet": "Snippet auswählen", + "searchSnippets": "Snippets durchsuchen...", + "snippetNone": "Keine", "noneAuthTitle": "Keyboard-Interactive-Authentifizierung", "noneAuthDescription": "Diese Authentifizierungsmethode verwendet beim Herstellen der Verbindung zum SSH-Server die Keyboard-Interactive-Authentifizierung.", "noneAuthDetails": "Keyboard-Interactive-Authentifizierung ermöglicht dem Server, Sie während der Verbindung zur Eingabe von Anmeldeinformationen aufzufordern. Dies ist nützlich für Server, die eine Multi-Faktor-Authentifizierung oder eine dynamische Passworteingabe erfordern.", @@ -1119,9 +1182,21 @@ "noInterfacesFound": "Keine Netzwerkschnittstellen gefunden", "totalProcesses": "Gesamtprozesse", "running": "läuft", - "noProcessesFound": "Keine Prozesse gefunden" + "noProcessesFound": "Keine Prozesse gefunden", + "loginStats": "SSH-Anmeldestatistiken", + "totalLogins": "Gesamtanmeldungen", + "uniqueIPs": "Eindeutige IPs", + "recentSuccessfulLogins": "Letzte erfolgreiche Anmeldungen", + "recentFailedAttempts": "Letzte fehlgeschlagene Versuche", + "noRecentLoginData": "Keine aktuellen Anmeldedaten", + "from": "von" }, "auth": { + "tagline": "SSH TERMINAL MANAGER", + "description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung", + "welcomeBack": "Willkommen zurück bei TERMIX", + "createAccount": "Erstellen Sie Ihr TERMIX-Konto", + "continueExternal": "Mit externem Anbieter fortfahren", "loginTitle": "Melden Sie sich bei Termix an", "registerTitle": "Benutzerkonto erstellen", "loginButton": "Anmelden", @@ -1501,5 +1576,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "Nicht verfügbar" + }, + "commandPalette": { + "searchPlaceholder": "Nach Hosts oder Schnellaktionen suchen...", + "recentActivity": "Kürzliche Aktivität", + "navigation": "Navigation", + "addHost": "Host hinzufügen", + "addCredential": "Anmeldedaten hinzufügen", + "adminSettings": "Admin-Einstellungen", + "userProfile": "Benutzerprofil", + "updateLog": "Aktualisierungsprotokoll", + "hosts": "Hosts", + "openServerDetails": "Serverdetails öffnen", + "openFileManager": "Dateimanager öffnen", + "edit": "Bearbeiten", + "links": "Links", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Spenden", + "press": "Drücken Sie", + "toToggle": "zum Umschalten", + "close": "Schließen", + "hostManager": "Host-Manager" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index deb430af..0dffa20a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -779,6 +779,69 @@ "statusMonitoring": "Status", "metricsMonitoring": "Metrics", "terminalCustomizationNotice": "Note: Terminal customizations only work on desktop (website and Electron app). Mobile apps and mobile website use system default terminal settings.", + "terminalCustomization": "Terminal Customization", + "appearance": "Appearance", + "behavior": "Behavior", + "advanced": "Advanced", + "themePreview": "Theme Preview", + "theme": "Theme", + "selectTheme": "Select theme", + "chooseColorTheme": "Choose a color theme for the terminal", + "fontFamily": "Font Family", + "selectFont": "Select font", + "selectFontDesc": "Select the font to use in the terminal", + "fontSize": "Font Size", + "fontSizeValue": "Font Size: {{value}}px", + "adjustFontSize": "Adjust the terminal font size", + "letterSpacing": "Letter Spacing", + "letterSpacingValue": "Letter Spacing: {{value}}px", + "adjustLetterSpacing": "Adjust spacing between characters", + "lineHeight": "Line Height", + "lineHeightValue": "Line Height: {{value}}", + "adjustLineHeight": "Adjust spacing between lines", + "cursorStyle": "Cursor Style", + "selectCursorStyle": "Select cursor style", + "cursorStyleBlock": "Block", + "cursorStyleUnderline": "Underline", + "cursorStyleBar": "Bar", + "chooseCursorAppearance": "Choose the cursor appearance", + "cursorBlink": "Cursor Blink", + "enableCursorBlink": "Enable cursor blinking animation", + "scrollbackBuffer": "Scrollback Buffer", + "scrollbackBufferValue": "Scrollback Buffer: {{value}} lines", + "scrollbackBufferDesc": "Number of lines to keep in scrollback history", + "bellStyle": "Bell Style", + "selectBellStyle": "Select bell style", + "bellStyleNone": "None", + "bellStyleSound": "Sound", + "bellStyleVisual": "Visual", + "bellStyleBoth": "Both", + "bellStyleDesc": "How to handle terminal bell (BEL character, \\x07). Programs trigger this when completing tasks, encountering errors, or for notifications. \"Sound\" plays an audio beep, \"Visual\" flashes the screen briefly, \"Both\" does both, \"None\" disables bell alerts.", + "rightClickSelectsWord": "Right Click Selects Word", + "rightClickSelectsWordDesc": "Right-clicking selects the word under cursor", + "fastScrollModifier": "Fast Scroll Modifier", + "selectModifier": "Select modifier", + "modifierAlt": "Alt", + "modifierCtrl": "Ctrl", + "modifierShift": "Shift", + "fastScrollModifierDesc": "Modifier key for fast scrolling", + "fastScrollSensitivity": "Fast Scroll Sensitivity", + "fastScrollSensitivityValue": "Fast Scroll Sensitivity: {{value}}", + "fastScrollSensitivityDesc": "Scroll speed multiplier when modifier is held", + "minimumContrastRatio": "Minimum Contrast Ratio", + "minimumContrastRatioValue": "Minimum Contrast Ratio: {{value}}", + "minimumContrastRatioDesc": "Automatically adjust colors for better readability", + "sshAgentForwarding": "SSH Agent Forwarding", + "sshAgentForwardingDesc": "Forward SSH authentication agent to remote host", + "backspaceMode": "Backspace Mode", + "selectBackspaceMode": "Select backspace mode", + "backspaceModeNormal": "Normal (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "Backspace key behavior for compatibility", + "startupSnippet": "Startup Snippet", + "selectSnippet": "Select snippet", + "searchSnippets": "Search snippets...", + "snippetNone": "None", "noneAuthTitle": "Keyboard-Interactive Authentication", "noneAuthDescription": "This authentication method will use keyboard-interactive authentication when connecting to the SSH server.", "noneAuthDetails": "Keyboard-interactive authentication allows the server to prompt you for credentials during connection. This is useful for servers that require multi-factor authentication or if you do not want to save credentials locally.", @@ -1231,9 +1294,21 @@ "noInterfacesFound": "No network interfaces found", "totalProcesses": "Total Processes", "running": "Running", - "noProcessesFound": "No processes found" + "noProcessesFound": "No processes found", + "loginStats": "SSH Login Statistics", + "totalLogins": "Total Logins", + "uniqueIPs": "Unique IPs", + "recentSuccessfulLogins": "Recent Successful Logins", + "recentFailedAttempts": "Recent Failed Attempts", + "noRecentLoginData": "No recent login data", + "from": "from" }, "auth": { + "tagline": "SSH TERMINAL MANAGER", + "description": "Secure, powerful, and intuitive SSH connection management", + "welcomeBack": "Welcome back to TERMIX", + "createAccount": "Create your TERMIX account", + "continueExternal": "Continue with external provider", "loginTitle": "Login to Termix", "registerTitle": "Create Account", "loginButton": "Login", @@ -1622,5 +1697,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "N/A" + }, + "commandPalette": { + "searchPlaceholder": "Search for hosts or quick actions...", + "recentActivity": "Recent Activity", + "navigation": "Navigation", + "addHost": "Add Host", + "addCredential": "Add Credential", + "adminSettings": "Admin Settings", + "userProfile": "User Profile", + "updateLog": "Update Log", + "hosts": "Hosts", + "openServerDetails": "Open Server Details", + "openFileManager": "Open File Manager", + "edit": "Edit", + "links": "Links", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Donate", + "press": "Press", + "toToggle": "to toggle", + "close": "Close", + "hostManager": "Host Manager" } } diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index c656e9cb..2af8e80e 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -1143,6 +1143,11 @@ "available": "Disponível" }, "auth": { + "tagline": "GERENCIADOR DE TERMINAL SSH", + "description": "Gerenciamento de conexão SSH seguro, poderoso e intuitivo", + "welcomeBack": "Bem-vindo de volta ao TERMIX", + "createAccount": "Crie sua conta TERMIX", + "continueExternal": "Continuar com provedor externo", "loginTitle": "Entrar no Termix", "registerTitle": "Criar Conta", "loginButton": "Entrar", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 4a757ac7..add3f212 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -766,6 +766,69 @@ "statusMonitoring": "Статус", "metricsMonitoring": "Метрики", "terminalCustomizationNotice": "Примечание: Настройки терминала работают только на рабочем столе (веб-сайт и Electron-приложение). Мобильные приложения и мобильный веб-сайт используют системные настройки терминала по умолчанию.", + "terminalCustomization": "Настройка терминала", + "appearance": "Внешний вид", + "behavior": "Поведение", + "advanced": "Расширенные", + "themePreview": "Предпросмотр темы", + "theme": "Тема", + "selectTheme": "Выбрать тему", + "chooseColorTheme": "Выберите цветовую тему для терминала", + "fontFamily": "Семейство шрифтов", + "selectFont": "Выбрать шрифт", + "selectFontDesc": "Выберите шрифт для использования в терминале", + "fontSize": "Размер шрифта", + "fontSizeValue": "Размер шрифта: {{value}}px", + "adjustFontSize": "Настроить размер шрифта терминала", + "letterSpacing": "Межбуквенный интервал", + "letterSpacingValue": "Межбуквенный интервал: {{value}}px", + "adjustLetterSpacing": "Настроить расстояние между символами", + "lineHeight": "Высота строки", + "lineHeightValue": "Высота строки: {{value}}", + "adjustLineHeight": "Настроить расстояние между строками", + "cursorStyle": "Стиль курсора", + "selectCursorStyle": "Выбрать стиль курсора", + "cursorStyleBlock": "Блок", + "cursorStyleUnderline": "Подчеркивание", + "cursorStyleBar": "Полоса", + "chooseCursorAppearance": "Выбрать внешний вид курсора", + "cursorBlink": "Мигание курсора", + "enableCursorBlink": "Включить анимацию мигания курсора", + "scrollbackBuffer": "Буфер прокрутки", + "scrollbackBufferValue": "Буфер прокрутки: {{value}} строк", + "scrollbackBufferDesc": "Количество строк для хранения в истории прокрутки", + "bellStyle": "Стиль звонка", + "selectBellStyle": "Выбрать стиль звонка", + "bellStyleNone": "Нет", + "bellStyleSound": "Звук", + "bellStyleVisual": "Визуальный", + "bellStyleBoth": "Оба", + "bellStyleDesc": "Как обрабатывать звонок терминала (символ BEL, \\x07). Программы вызывают его при завершении задач, возникновении ошибок или для уведомлений. \"Звук\" воспроизводит звуковой сигнал, \"Визуальный\" кратковременно мигает экран, \"Оба\" делает и то, и другое, \"Нет\" отключает звуковые оповещения.", + "rightClickSelectsWord": "Правый клик выбирает слово", + "rightClickSelectsWordDesc": "Правый клик выбирает слово под курсором", + "fastScrollModifier": "Модификатор быстрой прокрутки", + "selectModifier": "Выбрать модификатор", + "modifierAlt": "Alt", + "modifierCtrl": "Ctrl", + "modifierShift": "Shift", + "fastScrollModifierDesc": "Клавиша-модификатор для быстрой прокрутки", + "fastScrollSensitivity": "Чувствительность быстрой прокрутки", + "fastScrollSensitivityValue": "Чувствительность быстрой прокрутки: {{value}}", + "fastScrollSensitivityDesc": "Множитель скорости прокрутки при удержании модификатора", + "minimumContrastRatio": "Минимальная контрастность", + "minimumContrastRatioValue": "Минимальная контрастность: {{value}}", + "minimumContrastRatioDesc": "Автоматически настраивать цвета для лучшей читаемости", + "sshAgentForwarding": "Переадресация SSH-агента", + "sshAgentForwardingDesc": "Переадресовать агент SSH-аутентификации на удаленный хост", + "backspaceMode": "Режим Backspace", + "selectBackspaceMode": "Выбрать режим Backspace", + "backspaceModeNormal": "Обычный (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "Поведение клавиши Backspace для совместимости", + "startupSnippet": "Сниппет запуска", + "selectSnippet": "Выбрать сниппет", + "searchSnippets": "Поиск сниппетов...", + "snippetNone": "Нет", "noneAuthTitle": "Интерактивная аутентификация по клавиатуре", "noneAuthDescription": "Этот метод аутентификации будет использовать интерактивную аутентификацию по клавиатуре при подключении к SSH-серверу.", "noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля." @@ -1215,9 +1278,21 @@ "noInterfacesFound": "Сетевые интерфейсы не найдены", "totalProcesses": "Всего процессов", "running": "Запущено", - "noProcessesFound": "Процессы не найдены" + "noProcessesFound": "Процессы не найдены", + "loginStats": "Статистика входов SSH", + "totalLogins": "Всего входов", + "uniqueIPs": "Уникальные IP", + "recentSuccessfulLogins": "Последние успешные входы", + "recentFailedAttempts": "Последние неудачные попытки", + "noRecentLoginData": "Нет данных о недавних входах", + "from": "с" }, "auth": { + "tagline": "SSH ТЕРМИНАЛ МЕНЕДЖЕР", + "description": "Безопасное, мощное и интуитивное управление SSH-соединениями", + "welcomeBack": "Добро пожаловать обратно в TERMIX", + "createAccount": "Создайте вашу учетную запись TERMIX", + "continueExternal": "Продолжить с внешним провайдером", "loginTitle": "Вход в Termix", "registerTitle": "Создать учетную запись", "loginButton": "Войти", @@ -1587,5 +1662,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "N/A" + }, + "commandPalette": { + "searchPlaceholder": "Поиск хостов или быстрых действий...", + "recentActivity": "Недавняя активность", + "navigation": "Навигация", + "addHost": "Добавить хост", + "addCredential": "Добавить учетные данные", + "adminSettings": "Настройки администратора", + "userProfile": "Профиль пользователя", + "updateLog": "Журнал обновлений", + "hosts": "Хосты", + "openServerDetails": "Открыть детали сервера", + "openFileManager": "Открыть файловый менеджер", + "edit": "Редактировать", + "links": "Ссылки", + "github": "GitHub", + "support": "Поддержка", + "discord": "Discord", + "donate": "Пожертвовать", + "press": "Нажмите", + "toToggle": "для переключения", + "close": "Закрыть", + "hostManager": "Менеджер хостов" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index e9c7c14e..3495f971 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -790,6 +790,69 @@ "statusMonitoring": "状态", "metricsMonitoring": "指标", "terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。", + "terminalCustomization": "终端自定义", + "appearance": "外观", + "behavior": "行为", + "advanced": "高级", + "themePreview": "主题预览", + "theme": "主题", + "selectTheme": "选择主题", + "chooseColorTheme": "选择终端的颜色主题", + "fontFamily": "字体系列", + "selectFont": "选择字体", + "selectFontDesc": "选择终端使用的字体", + "fontSize": "字体大小", + "fontSizeValue": "字体大小:{{value}}px", + "adjustFontSize": "调整终端字体大小", + "letterSpacing": "字母间距", + "letterSpacingValue": "字母间距:{{value}}px", + "adjustLetterSpacing": "调整字符之间的间距", + "lineHeight": "行高", + "lineHeightValue": "行高:{{value}}", + "adjustLineHeight": "调整行之间的间距", + "cursorStyle": "光标样式", + "selectCursorStyle": "选择光标样式", + "cursorStyleBlock": "块状", + "cursorStyleUnderline": "下划线", + "cursorStyleBar": "竖线", + "chooseCursorAppearance": "选择光标外观", + "cursorBlink": "光标闪烁", + "enableCursorBlink": "启用光标闪烁动画", + "scrollbackBuffer": "回滚缓冲区", + "scrollbackBufferValue": "回滚缓冲区:{{value}} 行", + "scrollbackBufferDesc": "保留在回滚历史记录中的行数", + "bellStyle": "铃声样式", + "selectBellStyle": "选择铃声样式", + "bellStyleNone": "无", + "bellStyleSound": "声音", + "bellStyleVisual": "视觉", + "bellStyleBoth": "两者", + "bellStyleDesc": "如何处理终端铃声(BEL字符,\\x07)。程序在完成任务、遇到错误或通知时会触发此功能。\"声音\"播放音频提示音,\"视觉\"短暂闪烁屏幕,\"两者\"同时执行,\"无\"禁用铃声提醒。", + "rightClickSelectsWord": "右键选择单词", + "rightClickSelectsWordDesc": "右键单击选择光标下的单词", + "fastScrollModifier": "快速滚动修饰键", + "selectModifier": "选择修饰键", + "modifierAlt": "Alt", + "modifierCtrl": "Ctrl", + "modifierShift": "Shift", + "fastScrollModifierDesc": "快速滚动的修饰键", + "fastScrollSensitivity": "快速滚动灵敏度", + "fastScrollSensitivityValue": "快速滚动灵敏度:{{value}}", + "fastScrollSensitivityDesc": "按住修饰键时的滚动速度倍数", + "minimumContrastRatio": "最小对比度", + "minimumContrastRatioValue": "最小对比度:{{value}}", + "minimumContrastRatioDesc": "自动调整颜色以获得更好的可读性", + "sshAgentForwarding": "SSH 代理转发", + "sshAgentForwardingDesc": "将 SSH 身份验证代理转发到远程主机", + "backspaceMode": "退格模式", + "selectBackspaceMode": "选择退格模式", + "backspaceModeNormal": "正常 (DEL)", + "backspaceModeControlH": "Control-H (^H)", + "backspaceModeDesc": "退格键行为兼容性", + "startupSnippet": "启动代码片段", + "selectSnippet": "选择代码片段", + "searchSnippets": "搜索代码片段...", + "snippetNone": "无", "noneAuthTitle": "键盘交互式认证", "noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。", "noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。", @@ -1199,9 +1262,21 @@ "noInterfacesFound": "未找到网络接口", "totalProcesses": "总进程数", "running": "运行中", - "noProcessesFound": "未找到进程" + "noProcessesFound": "未找到进程", + "loginStats": "SSH 登录统计", + "totalLogins": "总登录次数", + "uniqueIPs": "唯一 IP 数", + "recentSuccessfulLogins": "最近成功登录", + "recentFailedAttempts": "最近失败尝试", + "noRecentLoginData": "无最近登录数据", + "from": "来自" }, "auth": { + "tagline": "SSH 终端管理器", + "description": "安全、强大、直观的 SSH 连接管理", + "welcomeBack": "欢迎回到 TERMIX", + "createAccount": "创建您的 TERMIX 账户", + "continueExternal": "使用外部提供商继续", "loginTitle": "登录 Termix", "registerTitle": "创建账户", "loginButton": "登录", @@ -1512,5 +1587,28 @@ "cpu": "CPU", "ram": "内存", "notAvailable": "不可用" + }, + "commandPalette": { + "searchPlaceholder": "搜索主机或快速操作...", + "recentActivity": "最近活动", + "navigation": "导航", + "addHost": "添加主机", + "addCredential": "添加凭据", + "adminSettings": "管理员设置", + "userProfile": "用户资料", + "updateLog": "更新日志", + "hosts": "主机", + "openServerDetails": "打开服务器详情", + "openFileManager": "打开文件管理器", + "edit": "编辑", + "links": "链接", + "github": "GitHub", + "support": "支持", + "discord": "Discord", + "donate": "捐赠", + "press": "按下", + "toToggle": "来切换", + "close": "关闭", + "hostManager": "主机管理器" } } diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index eb450aa7..f7040ae4 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -5,7 +5,8 @@ export type WidgetType = | "network" | "uptime" | "processes" - | "system"; + | "system" + | "login_stats"; export interface StatsConfig { enabledWidgets: WidgetType[]; @@ -16,7 +17,15 @@ export interface StatsConfig { } export const DEFAULT_STATS_CONFIG: StatsConfig = { - enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"], + enabledWidgets: [ + "cpu", + "memory", + "disk", + "network", + "uptime", + "system", + "login_stats", + ], statusCheckEnabled: true, statusCheckInterval: 30, metricsEnabled: true, diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index b7bcb6de..bd652536 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -23,6 +23,8 @@ function AppContent() { const saved = localStorage.getItem("topNavbarOpen"); return saved !== null ? JSON.parse(saved) : true; }); + const [isTransitioning, setIsTransitioning] = useState(false); + const [transitionPhase, setTransitionPhase] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle'); const { currentTab, tabs } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); @@ -98,13 +100,44 @@ function AppContent() { username: string | null; userId: string | null; }) => { - setIsAuthenticated(true); - setIsAdmin(authData.isAdmin); - setUsername(authData.username); + setIsTransitioning(true); + setTransitionPhase('fadeOut'); + + setTimeout(() => { + setIsAuthenticated(true); + setIsAdmin(authData.isAdmin); + setUsername(authData.username); + setTransitionPhase('fadeIn'); + + setTimeout(() => { + setIsTransitioning(false); + setTransitionPhase('idle'); + }, 800); + }, 1200); }, [], ); + const handleLogout = useCallback(async () => { + setIsTransitioning(true); + setTransitionPhase('fadeOut'); + + setTimeout(async () => { + try { + const { logoutUser, isElectron } = await import("@/ui/main-axios.ts"); + await logoutUser(); + + if (isElectron()) { + localStorage.removeItem("jwt"); + } + } catch (error) { + console.error("Logout failed:", error); + } + + window.location.reload(); + }, 1200); + }, []); + const currentTabData = tabs.find((tab) => tab.id === currentTab); const showTerminalView = currentTabData?.type === "terminal" || @@ -135,11 +168,12 @@ function AppContent() { {isAuthenticated && ( + onSelectView={handleSelectView} + disabled={!isAuthenticated || authLoading} + isAdmin={isAdmin} + username={username} + onLogout={handleLogout} + >
)} + + {isTransitioning && ( +
+ {transitionPhase === 'fadeOut' && ( + <> +
+
+
+
+
+
+
+ TERMIX +
+
+ SSH TERMINAL MANAGER +
+
+
+ + + )} +
+ )} + void; }) { + const { t } = useTranslation(); const inputRef = useRef(null); const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); const [recentActivity, setRecentActivity] = useState( @@ -90,7 +93,7 @@ export function CommandPalette({ } else { const id = addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), initialTab: "add_host", }); setCurrentTab(id); @@ -106,7 +109,7 @@ export function CommandPalette({ } else { const id = addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), initialTab: "add_credential", }); setCurrentTab(id); @@ -119,7 +122,7 @@ export function CommandPalette({ if (adminTab) { setCurrentTab(adminTab.id); } else { - const id = addTab({ type: "admin", title: "Admin Settings" }); + const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") }); setCurrentTab(id); } setIsOpen(false); @@ -130,7 +133,7 @@ export function CommandPalette({ if (userProfileTab) { setCurrentTab(userProfileTab.id); } else { - const id = addTab({ type: "user_profile", title: "User Profile" }); + const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") }); setCurrentTab(id); } setIsOpen(false); @@ -213,7 +216,7 @@ export function CommandPalette({ : `${host.username}@${host.ip}:${host.port}`; addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), hostConfig: host, initialTab: "add_host", }); @@ -223,18 +226,22 @@ export function CommandPalette({ return (
setIsOpen(false)} > e.stopPropagation()} > {recentActivity.length > 0 && ( <> - + {recentActivity.map((item, index) => ( )} - + - Add Host + {t("commandPalette.addHost")} - Add Credential + {t("commandPalette.addCredential")} - Admin Settings + {t("commandPalette.adminSettings")} - User Profile + {t("commandPalette.userProfile")} - Update Log + {t("commandPalette.updateLog")} - + {hosts.map((host, index) => { const title = host.name?.trim() ? host.name @@ -328,7 +335,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Open Server Details + {t("commandPalette.openServerDetails")} { @@ -338,7 +345,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Open File Manager + {t("commandPalette.openFileManager")} { @@ -348,7 +355,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Edit + {t("commandPalette.edit")} @@ -358,25 +365,39 @@ export function CommandPalette({ })} - + - GitHub + {t("commandPalette.github")} - Support + {t("commandPalette.support")} - Discord + {t("commandPalette.discord")} - Donate + {t("commandPalette.donate")} +
+
+ {t("commandPalette.press")} + + Shift + Shift + + {t("commandPalette.toToggle")} +
+
+ {t("commandPalette.close")} + Esc +
+
); diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index dd6d3826..69e2962e 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -91,7 +91,9 @@ export function Dashboard({ try { const sidebar = useSidebar(); sidebarState = sidebar.state; - } catch {} + } catch (error) { + console.error("Dashboard operation failed:", error); + } const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; @@ -173,7 +175,9 @@ export function Dashboard({ if (Array.isArray(tunnelConnections)) { totalTunnelsCount += tunnelConnections.length; } - } catch {} + } catch (error) { + console.error("Dashboard operation failed:", error); + } } } setTotalTunnels(totalTunnelsCount); @@ -525,7 +529,7 @@ export function Dashboard({
-
+

@@ -545,7 +549,7 @@ export function Dashboard({ className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`} > {recentActivityLoading ? ( -

+
{t("dashboard.loadingRecentActivity")}
@@ -577,7 +581,7 @@ export function Dashboard({
-
+

@@ -641,7 +645,7 @@ export function Dashboard({

-
+

@@ -651,7 +655,7 @@ export function Dashboard({ className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`} > {serverStatsLoading ? ( -

+
{t("dashboard.loadingServerStats")}
diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index dde80098..cd1ff279 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -1907,6 +1907,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { createIntent={createIntent} onConfirmCreate={handleConfirmCreate} onCancelCreate={handleCancelCreate} + onNewFile={handleCreateNewFile} + onNewFolder={handleCreateNewFolder} /> { - if (!isVisible) return; + if (!isVisible) { + setIsMounted(false); + return; + } + + setIsMounted(true); const adjustPosition = () => { const menuWidth = 200; @@ -182,8 +189,6 @@ export function FileManagerContextMenu({ }; }, [isVisible, x, y, onClose]); - if (!isVisible) return null; - const isFileContext = files.length > 0; const isSingleFile = files.length === 1; const isMultipleFiles = files.length > 1; @@ -425,13 +430,38 @@ export function FileManagerContextMenu({ return index > 0 && index < filteredMenuItems.length - 1; }); + const renderShortcut = (shortcut: string) => { + const keys = shortcut.split("+"); + if (keys.length === 1) { + return {keys[0]}; + } + return ( + + {keys.map((key, index) => ( + {key} + ))} + + ); + }; + + if (!isVisible && !isMounted) return null; + return ( <> -
+
{item.label}
{item.shortcut && ( - - {item.shortcut} - +
+ {renderShortcut(item.shortcut)} +
)} ); diff --git a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx index 786afa12..8809a7bd 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx @@ -92,6 +92,8 @@ interface FileManagerGridProps { createIntent?: CreateIntent | null; onConfirmCreate?: (name: string) => void; onCancelCreate?: () => void; + onNewFile?: () => void; + onNewFolder?: () => void; } const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { @@ -192,6 +194,8 @@ export function FileManagerGrid({ createIntent, onConfirmCreate, onCancelCreate, + onNewFile, + onNewFolder, }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); @@ -772,6 +776,42 @@ export function FileManagerGrid({ onUndo(); } break; + case "d": + case "D": + if ( + (event.ctrlKey || event.metaKey) && + selectedFiles.length > 0 && + onDownload + ) { + event.preventDefault(); + onDownload(selectedFiles); + } + break; + case "n": + case "N": + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + if (event.shiftKey && onNewFolder) { + onNewFolder(); + } else if (!event.shiftKey && onNewFile) { + onNewFile(); + } + } + break; + case "u": + case "U": + if ((event.ctrlKey || event.metaKey) && onUpload) { + event.preventDefault(); + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) onUpload(files); + }; + input.click(); + } + break; case "Delete": if (selectedFiles.length > 0 && onDelete) { onDelete(selectedFiles); @@ -783,6 +823,12 @@ export function FileManagerGrid({ onStartEdit(selectedFiles[0]); } break; + case "Enter": + if (selectedFiles.length === 1) { + event.preventDefault(); + onFileOpen(selectedFiles[0]); + } + break; case "y": case "Y": if (event.ctrlKey || event.metaKey) { @@ -1003,8 +1049,9 @@ export function FileManagerGrid({ draggable={true} className={cn( "group p-3 rounded-lg cursor-pointer", - "hover:bg-accent hover:text-accent-foreground border-2 border-transparent", - isSelected && "bg-primary/20 border-primary", + "transition-all duration-150 ease-out", + "hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent", + isSelected && "bg-primary/20 border-primary ring-2 ring-primary/20", dragState.target?.path === file.path && "bg-muted border-primary border-dashed relative z-10", dragState.files.some((f) => f.path === file.path) && @@ -1092,8 +1139,9 @@ export function FileManagerGrid({ draggable={true} className={cn( "flex items-center gap-3 p-2 rounded cursor-pointer", + "transition-all duration-150 ease-out", "hover:bg-accent hover:text-accent-foreground", - isSelected && "bg-primary/20", + isSelected && "bg-primary/20 ring-2 ring-primary/20", dragState.target?.path === file.path && "bg-muted border-primary border-dashed relative z-10", dragState.files.some((f) => f.path === file.path) && diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index a322148b..91753301 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -167,7 +167,9 @@ export function HostManagerEditor({ setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); - } catch {} + } catch (error) { + console.error("Host manager operation failed:", error); + } }; fetchData(); @@ -196,7 +198,9 @@ export function HostManagerEditor({ setFolders(uniqueFolders); setSshConfigurations(uniqueConfigurations); - } catch {} + } catch (error) { + console.error("Host manager operation failed:", error); + } }; window.addEventListener("credentials:changed", handleCredentialChange); @@ -262,9 +266,18 @@ export function HostManagerEditor({ "uptime", "processes", "system", + "login_stats", ]), ) - .default(["cpu", "memory", "disk", "network", "uptime", "system"]), + .default([ + "cpu", + "memory", + "disk", + "network", + "uptime", + "system", + "login_stats", + ]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), metricsEnabled: z.boolean().default(true), @@ -278,6 +291,7 @@ export function HostManagerEditor({ "network", "uptime", "system", + "login_stats", ], statusCheckEnabled: true, statusCheckInterval: 30, @@ -1399,15 +1413,15 @@ export function HostManagerEditor({

- Terminal Customization + {t("hosts.terminalCustomization")}

- Appearance + {t("hosts.appearance")}
( - Theme + {t("hosts.theme")} - Choose a color theme for the terminal + {t("hosts.chooseColorTheme")} )} @@ -1463,14 +1477,14 @@ export function HostManagerEditor({ name="terminalConfig.fontFamily" render={({ field }) => ( - Font Family + {t("hosts.fontFamily")} - Select the font to use in the terminal + {t("hosts.selectFontDesc")} )} @@ -1496,7 +1510,7 @@ export function HostManagerEditor({ name="terminalConfig.fontSize" render={({ field }) => ( - Font Size: {field.value}px + {t("hosts.fontSizeValue", { value: field.value })} - Adjust the terminal font size + {t("hosts.adjustFontSize")} )} @@ -1521,7 +1535,7 @@ export function HostManagerEditor({ render={({ field }) => ( - Letter Spacing: {field.value}px + {t("hosts.letterSpacingValue", { value: field.value })} - Adjust spacing between characters + {t("hosts.adjustLetterSpacing")} )} @@ -1546,7 +1560,7 @@ export function HostManagerEditor({ name="terminalConfig.lineHeight" render={({ field }) => ( - Line Height: {field.value} + {t("hosts.lineHeightValue", { value: field.value })} - Adjust spacing between lines + {t("hosts.adjustLineHeight")} )} @@ -1570,26 +1584,26 @@ export function HostManagerEditor({ name="terminalConfig.cursorStyle" render={({ field }) => ( - Cursor Style + {t("hosts.cursorStyle")} - Choose the cursor appearance + {t("hosts.chooseCursorAppearance")} )} @@ -1601,9 +1615,9 @@ export function HostManagerEditor({ render={({ field }) => (
- Cursor Blink + {t("hosts.cursorBlink")} - Enable cursor blinking animation + {t("hosts.enableCursorBlink")}
@@ -1619,7 +1633,7 @@ export function HostManagerEditor({ - Behavior + {t("hosts.behavior")} ( - Scrollback Buffer: {field.value} lines + {t("hosts.scrollbackBufferValue", { value: field.value })} - Number of lines to keep in scrollback history + {t("hosts.scrollbackBufferDesc")} )} @@ -1652,30 +1666,25 @@ export function HostManagerEditor({ name="terminalConfig.bellStyle" render={({ field }) => ( - Bell Style + {t("hosts.bellStyle")} - How to handle terminal bell (BEL character, - \x07). Programs trigger this when completing - tasks, encountering errors, or for - notifications. "Sound" plays an audio beep, - "Visual" flashes the screen briefly, "Both" does - both, "None" disables bell alerts. + {t("hosts.bellStyleDesc")} )} @@ -1687,9 +1696,9 @@ export function HostManagerEditor({ render={({ field }) => (
- Right Click Selects Word + {t("hosts.rightClickSelectsWord")} - Right-clicking selects the word under cursor + {t("hosts.rightClickSelectsWordDesc")}
@@ -1707,24 +1716,24 @@ export function HostManagerEditor({ name="terminalConfig.fastScrollModifier" render={({ field }) => ( - Fast Scroll Modifier + {t("hosts.fastScrollModifier")} - Modifier key for fast scrolling + {t("hosts.fastScrollModifierDesc")} )} @@ -1736,7 +1745,7 @@ export function HostManagerEditor({ render={({ field }) => ( - Fast Scroll Sensitivity: {field.value} + {t("hosts.fastScrollSensitivityValue", { value: field.value })} - Scroll speed multiplier when modifier is held + {t("hosts.fastScrollSensitivityDesc")} )} @@ -1762,7 +1771,7 @@ export function HostManagerEditor({ render={({ field }) => ( - Minimum Contrast Ratio: {field.value} + {t("hosts.minimumContrastRatioValue", { value: field.value })} - Automatically adjust colors for better - readability + {t("hosts.minimumContrastRatioDesc")} )} @@ -1786,7 +1794,7 @@ export function HostManagerEditor({
- Advanced + {t("hosts.advanced")} (
- SSH Agent Forwarding + {t("hosts.sshAgentForwarding")} - Forward SSH authentication agent to remote - host + {t("hosts.sshAgentForwardingDesc")}
@@ -1815,27 +1822,27 @@ export function HostManagerEditor({ name="terminalConfig.backspaceMode" render={({ field }) => ( - Backspace Mode + {t("hosts.backspaceMode")} - Backspace key behavior for compatibility + {t("hosts.backspaceModeDesc")} )} @@ -1846,7 +1853,7 @@ export function HostManagerEditor({ name="terminalConfig.startupSnippetId" render={({ field }) => ( - Startup Snippet + {t("hosts.startupSnippet")} setSnippetSearch(e.target.value) @@ -1875,7 +1882,7 @@ export function HostManagerEditor({ />
- None + {t("hosts.snippetNone")} {snippets .filter((snippet) => snippet.name @@ -2657,6 +2664,7 @@ export function HostManagerEditor({ "uptime", "processes", "system", + "login_stats", ] as const ).map((widget) => (
))} diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server/Server.tsx index 09b37dd4..21879e0f 100644 --- a/src/ui/desktop/apps/server/Server.tsx +++ b/src/ui/desktop/apps/server/Server.tsx @@ -25,6 +25,7 @@ import { UptimeWidget, ProcessesWidget, SystemWidget, + LoginStatsWidget, } from "./widgets"; interface HostConfig { @@ -137,6 +138,11 @@ export function Server({ ); + case "login_stats": + return ( + + ); + default: return null; } diff --git a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx new file mode 100644 index 00000000..ecea0082 --- /dev/null +++ b/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { UserCheck, UserX, MapPin, Activity } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +interface LoginRecord { + user: string; + ip: string; + time: string; + status: "success" | "failed"; +} + +interface LoginStatsMetrics { + recentLogins: LoginRecord[]; + failedLogins: LoginRecord[]; + totalLogins: number; + uniqueIPs: number; +} + +interface ServerMetrics { + login_stats?: LoginStatsMetrics; +} + +interface LoginStatsWidgetProps { + metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; +} + +export function LoginStatsWidget({ + metrics, +}: LoginStatsWidgetProps) { + const { t } = useTranslation(); + + const loginStats = metrics?.login_stats; + const recentLogins = loginStats?.recentLogins || []; + const failedLogins = loginStats?.failedLogins || []; + const totalLogins = loginStats?.totalLogins || 0; + const uniqueIPs = loginStats?.uniqueIPs || 0; + + return ( +
+
+ +

+ {t("serverStats.loginStats")} +

+
+ +
+
+
+
+ + {t("serverStats.totalLogins")} +
+
{totalLogins}
+
+
+
+ + {t("serverStats.uniqueIPs")} +
+
{uniqueIPs}
+
+
+ +
+
+
+ + + {t("serverStats.recentSuccessfulLogins")} + +
+ {recentLogins.length === 0 ? ( +
+ {t("serverStats.noRecentLoginData")} +
+ ) : ( +
+ {recentLogins.slice(0, 5).map((login, idx) => ( +
+
+ + {login.user} + + {t("serverStats.from")} + + {login.ip} + +
+ + {new Date(login.time).toLocaleString()} + +
+ ))} +
+ )} +
+ + {failedLogins.length > 0 && ( +
+
+ + + {t("serverStats.recentFailedAttempts")} + +
+
+ {failedLogins.slice(0, 3).map((login, idx) => ( +
+
+ + {login.user} + + {t("serverStats.from")} + + {login.ip} + +
+ + {new Date(login.time).toLocaleString()} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/server/widgets/index.ts b/src/ui/desktop/apps/server/widgets/index.ts index 2d227299..b72f8a11 100644 --- a/src/ui/desktop/apps/server/widgets/index.ts +++ b/src/ui/desktop/apps/server/widgets/index.ts @@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget"; export { UptimeWidget } from "./UptimeWidget"; export { ProcessesWidget } from "./ProcessesWidget"; export { SystemWidget } from "./SystemWidget"; +export { LoginStatsWidget } from "./LoginStatsWidget"; diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index a65baaaa..0c03136b 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -194,7 +194,9 @@ export const Terminal = forwardRef( terminal as { refresh?: (start: number, end: number) => void } ).refresh(0, terminal.rows - 1); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } } function performFit() { @@ -336,7 +338,9 @@ export const Terminal = forwardRef( scheduleNotify(cols, rows); hardRefresh(); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }, refresh: () => hardRefresh(), }), @@ -746,7 +750,9 @@ export const Terminal = forwardRef( await navigator.clipboard.writeText(text); return; } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; @@ -766,7 +772,9 @@ export const Terminal = forwardRef( if (navigator.clipboard && navigator.clipboard.readText) { return await navigator.clipboard.readText(); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } return ""; } @@ -863,7 +871,9 @@ export const Terminal = forwardRef( const pasteText = await readTextFromClipboard(); if (pasteText) terminal.paste(pasteText); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }; element?.addEventListener("contextmenu", handleContextMenu); diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 51ccf418..12f94aaa 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Label } from "@/components/ui/label.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs.tsx"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx"; import { toast } from "sonner"; @@ -679,7 +680,7 @@ export function Auth({ if (showServerConfig === null && !isInElectronWebView()) { return (
@@ -693,7 +694,7 @@ export function Auth({ if (showServerConfig && !isInElectronWebView()) { return (
@@ -718,7 +719,7 @@ export function Auth({ ) { return (
@@ -751,7 +752,7 @@ export function Auth({ if (dbHealthChecking && !dbConnectionFailed) { return (
@@ -770,7 +771,7 @@ export function Auth({ if (dbConnectionFailed) { return (
@@ -830,10 +831,50 @@ export function Auth({ return (
+ {/* Split Screen Layout */} +
+ + {/* Left Side - Brand Showcase */} +
+ {/* Logo and Branding */} +
+
+ TERMIX +
+
+ {t("auth.tagline") || "SSH TERMINAL MANAGER"} +
+
+ {t("auth.description") || "Secure, powerful, and intuitive SSH connection management"} +
+
+
+ + {/* Right Side - Auth Form */} +
+
{isInElectronWebView() && !webviewAuthSuccess && ( @@ -843,21 +884,6 @@ export function Auth({ )} {isInElectronWebView() && webviewAuthSuccess && (
-
- - - -

{t("messages.loginSuccess")} @@ -944,72 +970,49 @@ export function Auth({ return ( <> -
- {passwordLoginAllowed && ( - - )} - {(passwordLoginAllowed || firstUser) && - registrationAllowed && ( - + )} - {oidcConfigured && ( - - )} -
-
-

+ {oidcConfigured && ( + + {t("auth.external")} + + )} + + + + {/* Page Title */} +
+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1335,6 +1338,9 @@ export function Auth({ })()} )} +

+

+

); } diff --git a/src/ui/desktop/authentication/ElectronLoginForm.tsx b/src/ui/desktop/authentication/ElectronLoginForm.tsx index 7ab2dee8..3b418545 100644 --- a/src/ui/desktop/authentication/ElectronLoginForm.tsx +++ b/src/ui/desktop/authentication/ElectronLoginForm.tsx @@ -63,7 +63,9 @@ export function ElectronLoginForm({ } } } - } catch (err) {} + } catch (err) { + console.error("Authentication operation failed:", err); + } }; window.addEventListener("message", handleMessage); @@ -190,8 +192,12 @@ export function ElectronLoginForm({ ); } } - } catch (err) {} - } catch (err) {} + } catch (err) { + console.error("Authentication operation failed:", err); + } + } catch (err) { + console.error("Authentication operation failed:", err); + } }; const handleError = () => { diff --git a/src/ui/desktop/authentication/ElectronServerConfig.tsx b/src/ui/desktop/authentication/ElectronServerConfig.tsx index ef3b1632..4def9af3 100644 --- a/src/ui/desktop/authentication/ElectronServerConfig.tsx +++ b/src/ui/desktop/authentication/ElectronServerConfig.tsx @@ -37,7 +37,9 @@ export function ElectronServerConfig({ if (config?.serverUrl) { setServerUrl(config.serverUrl); } - } catch {} + } catch (error) { + console.error("Server config operation failed:", error); + } }; const handleSaveConfig = async () => { diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 41c1d45d..15b0e812 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -65,6 +65,7 @@ interface SidebarProps { isAdmin?: boolean; username?: string | null; children?: React.ReactNode; + onLogout?: () => void; } async function handleLogout() { @@ -87,6 +88,7 @@ export function LeftSidebar({ isAdmin, username, children, + onLogout, }: SidebarProps): React.ReactElement { const { t } = useTranslation(); @@ -486,7 +488,7 @@ export function LeftSidebar({ )} {t("common.logout")} diff --git a/src/ui/desktop/navigation/SSHAuthDialog.tsx b/src/ui/desktop/navigation/SSHAuthDialog.tsx index fb254288..2afda788 100644 --- a/src/ui/desktop/navigation/SSHAuthDialog.tsx +++ b/src/ui/desktop/navigation/SSHAuthDialog.tsx @@ -137,10 +137,10 @@ export function SSHAuthDialog({ return (
- + diff --git a/src/ui/desktop/navigation/TOTPDialog.tsx b/src/ui/desktop/navigation/TOTPDialog.tsx index 1a444404..d0527b6b 100644 --- a/src/ui/desktop/navigation/TOTPDialog.tsx +++ b/src/ui/desktop/navigation/TOTPDialog.tsx @@ -25,12 +25,12 @@ export function TOTPDialog({ if (!isOpen) return null; return ( -
+
-
+

diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 3f53d511..dfc9c709 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -54,7 +54,9 @@ async function handleLogout() { }, serverOrigin, ); - } catch (err) {} + } catch (err) { + console.error("User profile operation failed:", err); + } } } } diff --git a/src/ui/hooks/useDragToSystemDesktop.ts b/src/ui/hooks/useDragToSystemDesktop.ts index 0daa5b76..3fff9239 100644 --- a/src/ui/hooks/useDragToSystemDesktop.ts +++ b/src/ui/hooks/useDragToSystemDesktop.ts @@ -48,7 +48,9 @@ export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) { store.put({ handle: dirHandle }, "lastSaveDir"); }; } - } catch {} + } catch (error) { + console.error("Drag operation failed:", error); + } }; const isFileSystemAPISupported = () => { diff --git a/src/ui/mobile/apps/terminal/Terminal.tsx b/src/ui/mobile/apps/terminal/Terminal.tsx index 461f2776..49e024fb 100644 --- a/src/ui/mobile/apps/terminal/Terminal.tsx +++ b/src/ui/mobile/apps/terminal/Terminal.tsx @@ -101,7 +101,9 @@ export const Terminal = forwardRef( terminal as { refresh?: (start: number, end: number) => void } ).refresh(0, terminal.rows - 1); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } } function performFit() { @@ -175,7 +177,9 @@ export const Terminal = forwardRef( scheduleNotify(cols, rows); hardRefresh(); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }, refresh: () => hardRefresh(), }), @@ -225,7 +229,9 @@ export const Terminal = forwardRef( `\r\n[${msg.message || t("terminal.disconnected")}]`, ); } - } catch {} + } catch (error) { + console.error("Terminal operation failed:", error); + } }); ws.addEventListener("close", (event) => { diff --git a/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx b/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx index 8869c3d7..0afc06c2 100644 --- a/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx +++ b/src/ui/mobile/apps/terminal/TerminalKeyboard.tsx @@ -110,7 +110,9 @@ export function TerminalKeyboard({ if (navigator.vibrate) { navigator.vibrate(20); } - } catch {} + } catch (error) { + console.error("Keyboard operation failed:", error); + } onSendInput(input); }, diff --git a/src/ui/mobile/authentication/Auth.tsx b/src/ui/mobile/authentication/Auth.tsx index 792f72f7..07bd4134 100644 --- a/src/ui/mobile/authentication/Auth.tsx +++ b/src/ui/mobile/authentication/Auth.tsx @@ -52,7 +52,9 @@ function postJWTToWebView() { timestamp: Date.now(), }), ); - } catch (error) {} + } catch (error) { + console.error("Auth operation failed:", error); + } } interface AuthProps extends React.ComponentProps<"div"> { diff --git a/tsconfig.node.json b/tsconfig.node.json index 4e53dcad..2edfb287 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,6 +8,7 @@ "moduleResolution": "nodenext", "verbatimModuleSyntax": true, "moduleDetection": "force", + "esModuleInterop": true, "noEmit": false, "outDir": "./dist/backend", "strict": false, -- 2.49.1 From 4399e1157432b77ffcab71ea9211f8f885f133e9 Mon Sep 17 00:00:00 2001 From: SlimGary Date: Mon, 10 Nov 2025 03:41:49 +0100 Subject: [PATCH 08/34] French translation (#434) * Adding French Language * Enhancements --- src/i18n/i18n.ts | 6 +- src/locales/fr/translation.json | 1575 ++++++++++++++++++++++ src/ui/desktop/user/LanguageSwitcher.tsx | 1 + 3 files changed, 1581 insertions(+), 1 deletion(-) create mode 100644 src/locales/fr/translation.json diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index 9fc69250..4e440866 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -7,12 +7,13 @@ import zhTranslation from "../locales/zh/translation.json"; import deTranslation from "../locales/de/translation.json"; import ptbrTranslation from "../locales/pt-BR/translation.json"; import ruTranslation from "../locales/ru/translation.json"; +import frTranslation from "../locales/fr/translation.json"; i18n .use(LanguageDetector) .use(initReactI18next) .init({ - supportedLngs: ["en", "zh", "de", "ptbr", "ru"], + supportedLngs: ["en", "zh", "de", "ptbr", "ru", "fr"], fallbackLng: "en", debug: false, @@ -40,6 +41,9 @@ i18n ru: { translation: ruTranslation, }, + fr: { + translation: frTranslation, + }, }, interpolation: { diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json new file mode 100644 index 00000000..7aecae16 --- /dev/null +++ b/src/locales/fr/translation.json @@ -0,0 +1,1575 @@ +{ + "credentials": { + "credentialsViewer": "Visionneuse d'identifiants", + "manageYourSSHCredentials": "Gérez vos identifiants SSH en toute sécurité", + "addCredential": "Ajouter un identifiant", + "createCredential": "Créer un identifiant", + "editCredential": "Modifier l'identifiant", + "viewCredential": "Afficher l'identifiant", + "duplicateCredential": "Dupliquer l'identifiant", + "deleteCredential": "Supprimer l'identifiant", + "updateCredential": "Mettre à jour l'identifiant", + "credentialName": "Nom de l'identifiant", + "credentialDescription": "Description", + "username": "Nom d'utilisateur", + "searchCredentials": "Rechercher des identifiants...", + "selectFolder": "Sélectionner un dossier", + "selectAuthType": "Sélectionner le type d'authentification", + "allFolders": "Tous les dossiers", + "allAuthTypes": "Tous les types d'authentification", + "uncategorized": "Non classé", + "totalCredentials": "Total", + "keyBased": "Basé sur une clé", + "passwordBased": "Basé sur un mot de passe", + "folders": "Dossiers", + "noCredentialsMatchFilters": "Aucun identifiant ne correspond à vos filtres", + "noCredentialsYet": "Aucun identifiant créé pour le moment", + "createFirstCredential": "Créez votre premier identifiant", + "failedToFetchCredentials": "Échec du chargement des identifiants", + "credentialDeletedSuccessfully": "Identifiant supprimé avec succès", + "failedToDeleteCredential": "Échec de la suppression de l'identifiant", + "confirmDeleteCredential": "Voulez-vous vraiment supprimer l'identifiant \"{{name}}\" ?", + "credentialCreatedSuccessfully": "Identifiant créé avec succès", + "credentialUpdatedSuccessfully": "Identifiant mis à jour avec succès", + "failedToSaveCredential": "Échec de l'enregistrement de l'identifiant", + "failedToFetchCredentialDetails": "Échec de la récupération des détails de l'identifiant", + "failedToFetchHostsUsing": "Échec de la récupération des hôtes utilisant cet identifiant", + "loadingCredentials": "Chargement des identifiants...", + "retry": "Réessayer", + "noCredentials": "Aucun identifiant", + "noCredentialsMessage": "Vous n'avez encore ajouté aucun identifiant. Cliquez sur \"Ajouter un identifiant\" pour commencer.", + "sshCredentials": "Identifiants SSH", + "credentialsCount": "{{count}} identifiants", + "refresh": "Actualiser", + "passwordRequired": "Le mot de passe est requis", + "sshKeyRequired": "La clé SSH est requise", + "credentialAddedSuccessfully": "L'identifiant \"{{name}}\" a été ajouté avec succès", + "general": "Général", + "description": "Description", + "folder": "Dossier", + "tags": "Labels", + "addTagsSpaceToAdd": "Ajouter des labels (appuyez sur espace pour valider)", + "password": "Mot de passe", + "key": "Clé", + "sshPrivateKey": "Clé privée SSH", + "upload": "Importer", + "updateKey": "Mettre à jour la clé", + "keyPassword": "Mot de passe de la clé", + "keyType": "Type de clé", + "keyTypeRSA": "RSA", + "keyTypeECDSA": "ECDSA", + "keyTypeEd25519": "Ed25519", + "basicInfo": "Informations de base", + "authentication": "Authentification", + "organization": "Organisation", + "basicInformation": "Informations de base", + "basicInformationDescription": "Indiquez les informations de base pour cet identifiant", + "authenticationMethod": "Méthode d'authentification", + "authenticationMethodDescription": "Choisissez comment vous souhaitez vous authentifier sur les serveurs SSH", + "organizationDescription": "Organisez vos identifiants avec des dossiers et des labels", + "enterCredentialName": "Saisissez le nom de l'identifiant", + "enterCredentialDescription": "Saisissez une description (facultatif)", + "enterUsername": "Saisissez le nom d'utilisateur", + "nameIsRequired": "Le nom de l'identifiant est requis", + "usernameIsRequired": "Le nom d'utilisateur est requis", + "authenticationType": "Type d'authentification", + "passwordAuthDescription": "Utiliser l'authentification par mot de passe", + "sshKeyAuthDescription": "Utiliser l'authentification par clé SSH", + "passwordIsRequired": "Le mot de passe est requis", + "sshKeyIsRequired": "La clé SSH est requise", + "sshKeyType": "Type de clé SSH", + "privateKey": "Clé privée", + "enterPassword": "Saisissez le mot de passe", + "enterPrivateKey": "Saisissez la clé privée", + "keyPassphrase": "Phrase secrète de la clé", + "enterKeyPassphrase": "Saisissez la phrase secrète (facultatif)", + "keyPassphraseOptional": "Facultatif : laissez vide si votre clé n'a pas de phrase secrète", + "leaveEmptyToKeepCurrent": "Laissez vide pour conserver la valeur actuelle", + "uploadKeyFile": "Importer un fichier de clé", + "generateKeyPairButton": "Générer une paire de clés", + "generateKeyPair": "Générer une paire de clés", + "generateKeyPairDescription": "Générez une nouvelle paire de clés SSH. Si vous souhaitez protéger la clé avec une phrase secrète, saisissez-la d'abord dans le champ Mot de passe de la clé ci-dessous.", + "deploySSHKey": "Déployer la clé SSH", + "deploySSHKeyDescription": "Déployer la clé publique sur le serveur cible", + "sourceCredential": "Identifiant source", + "targetHost": "Hôte cible", + "deploymentProcess": "Processus de déploiement", + "deploymentProcessDescription": "Cela ajoutera en toute sécurité la clé publique dans le fichier ~/.ssh/authorized_keys de l'hôte cible sans écraser les clés existantes. L'opération est réversible.", + "chooseHostToDeploy": "Choisissez un hôte sur lequel déployer...", + "deploying": "Déploiement...", + "name": "Nom", + "noHostsAvailable": "Aucun hôte disponible", + "noHostsMatchSearch": "Aucun hôte ne correspond à votre recherche", + "sshKeyGenerationNotImplemented": "La génération de clé SSH sera disponible prochainement", + "connectionTestingNotImplemented": "Le test de connexion arrive bientôt", + "testConnection": "Tester la connexion", + "selectOrCreateFolder": "Sélectionnez ou créez un dossier", + "noFolder": "Aucun dossier", + "orCreateNewFolder": "Ou créer un nouveau dossier", + "addTag": "Ajouter un label", + "saving": "Enregistrement...", + "overview": "Vue d'ensemble", + "security": "Sécurité", + "usage": "Utilisation", + "securityDetails": "Détails de sécurité", + "securityDetailsDescription": "Afficher les informations chiffrées de l'identifiant", + "credentialSecured": "Identifiant sécurisé", + "credentialSecuredDescription": "Toutes les données sensibles sont chiffrées en AES-256", + "passwordAuthentication": "Authentification par mot de passe", + "keyAuthentication": "Authentification par clé", + "securityReminder": "Rappel de sécurité", + "securityReminderText": "Ne partagez jamais vos identifiants. Toutes les données sont chiffrées au repos.", + "hostsUsingCredential": "Hôtes utilisant cet identifiant", + "noHostsUsingCredential": "Aucun hôte n'utilise actuellement cet identifiant", + "timesUsed": "Nombre d'utilisations", + "lastUsed": "Dernière utilisation", + "connectedHosts": "Hôtes connectés", + "created": "Créé", + "lastModified": "Dernière modification", + "usageStatistics": "Statistiques d'utilisation", + "copiedToClipboard": "{{field}} copié dans le presse-papiers", + "failedToCopy": "Échec de la copie dans le presse-papiers", + "sshKey": "Clé SSH", + "createCredentialDescription": "Créez un nouvel identifiant SSH pour un accès sécurisé", + "editCredentialDescription": "Mettez à jour les informations de l'identifiant", + "listView": "Liste", + "folderView": "Dossiers", + "unknownCredential": "Inconnu", + "confirmRemoveFromFolder": "Voulez-vous vraiment retirer \"{{name}}\" du dossier \"{{folder}}\" ? L'identifiant sera déplacé vers \"Non classé\".", + "removedFromFolder": "L'identifiant \"{{name}}\" a été retiré du dossier avec succès", + "failedToRemoveFromFolder": "Échec du retrait de l'identifiant du dossier", + "folderRenamed": "Le dossier \"{{oldName}}\" a été renommé en \"{{newName}}\" avec succès", + "failedToRenameFolder": "Échec du renommage du dossier", + "movedToFolder": "L'identifiant \"{{name}}\" a été déplacé vers \"{{folder}}\" avec succès", + "failedToMoveToFolder": "Échec du déplacement de l'identifiant vers le dossier", + "sshPublicKey": "Clé publique SSH", + "publicKeyNote": "La clé publique est facultative mais recommandée pour valider la clé", + "publicKeyUploaded": "Clé publique téléversée", + "uploadPublicKey": "Importer la clé publique", + "uploadPrivateKeyFile": "Importer le fichier de clé privée", + "uploadPublicKeyFile": "Importer le fichier de clé publique", + "privateKeyRequiredForGeneration": "La clé privée est nécessaire pour générer la clé publique", + "failedToGeneratePublicKey": "Échec de la génération de la clé publique", + "generatePublicKey": "Générer à partir de la clé privée", + "publicKeyGeneratedSuccessfully": "Clé publique générée avec succès", + "detectedKeyType": "Type de clé détecté", + "detectingKeyType": "détection...", + "optional": "Facultatif", + "generateKeyPairNew": "Générer une nouvelle paire de clés", + "generateEd25519": "Générer Ed25519", + "generateECDSA": "Générer ECDSA", + "generateRSA": "Générer RSA", + "keyPairGeneratedSuccessfully": "Paire de clés {{keyType}} générée avec succès", + "failedToGenerateKeyPair": "Échec de la génération de la paire de clés", + "generateKeyPairNote": "Générez une nouvelle paire de clés SSH directement. Cela remplacera toute clé existante dans le formulaire.", + "invalidKey": "Clé invalide", + "detectionError": "Erreur de détection", + "unknown": "Inconnu" + }, + "dragIndicator": { + "error": "Erreur : {{error}}", + "dragging": "Déplacement de {{fileName}}", + "preparing": "Préparation de {{fileName}}", + "readySingle": "{{fileName}} prêt à être téléchargé", + "readyMultiple": "{{count}} fichiers prêts à être téléchargés", + "batchDrag": "Faites glisser {{count}} fichiers vers le bureau", + "dragToDesktop": "Faites glisser vers le bureau", + "canDragAnywhere": "Vous pouvez faire glisser les fichiers n'importe où sur votre bureau" + }, + "sshTools": { + "title": "Outils SSH", + "closeTools": "Fermer les outils SSH", + "keyRecording": "Enregistrement des frappes", + "startKeyRecording": "Démarrer l'enregistrement des frappes", + "stopKeyRecording": "Arrêter l'enregistrement des frappes", + "selectTerminals": "Sélectionnez les terminaux :", + "typeCommands": "Saisissez des commandes (toutes les touches sont prises en charge) :", + "commandsWillBeSent": "Les commandes seront envoyées aux {{count}} terminaux sélectionnés.", + "settings": "Paramètres", + "enableRightClickCopyPaste": "Activer le copier/coller avec le clic droit", + "shareIdeas": "Des idées pour la suite des outils SSH ? Partagez-les sur" + }, + "snippets": { + "title": "Extraits", + "new": "Nouvel extrait", + "create": "Créer un extrait", + "edit": "Modifier l'extrait", + "run": "Exécuter", + "empty": "Aucun extrait pour le moment", + "emptyHint": "Créez un extrait pour enregistrer vos commandes courantes", + "name": "Nom", + "description": "Description", + "content": "Commande", + "namePlaceholder": "ex. : Redémarrer Nginx", + "descriptionPlaceholder": "Description facultative", + "contentPlaceholder": "ex. : sudo systemctl restart nginx", + "nameRequired": "Le nom est requis", + "contentRequired": "La commande est requise", + "createDescription": "Créez un nouvel extrait de commande pour l'exécuter rapidement", + "editDescription": "Modifiez cet extrait de commande", + "deleteConfirmTitle": "Supprimer l'extrait", + "deleteConfirmDescription": "Voulez-vous vraiment supprimer \"{{name}}\" ?", + "createSuccess": "Extrait créé avec succès", + "updateSuccess": "Extrait mis à jour avec succès", + "deleteSuccess": "Extrait supprimé avec succès", + "createFailed": "Échec de la création de l'extrait", + "updateFailed": "Échec de la mise à jour de l'extrait", + "deleteFailed": "Échec de la suppression de l'extrait", + "failedToFetch": "Échec du chargement des extraits", + "executeSuccess": "Exécution : {{name}}", + "copySuccess": "\"{{name}}\" copié dans le presse-papiers", + "runTooltip": "Exécuter cet extrait dans le terminal", + "copyTooltip": "Copier l'extrait dans le presse-papiers", + "editTooltip": "Modifier cet extrait", + "deleteTooltip": "Supprimer cet extrait" + }, + "homepage": { + "loggedInTitle": "Connexion réussie !", + "loggedInMessage": "Vous êtes connecté ! Utilisez la barre latérale pour accéder à tous les outils disponibles. Pour commencer, créez un hôte SSH dans l'onglet Gestionnaire SSH. Une fois créé, vous pourrez vous connecter à cet hôte avec les autres applications de la barre latérale.", + "failedToLoadAlerts": "Échec du chargement des alertes", + "failedToDismissAlert": "Échec de la fermeture de l'alerte" + }, + "serverConfig": { + "title": "Configuration du serveur", + "description": "Configurez l'URL du serveur Termix pour vous connecter à vos services backend", + "serverUrl": "URL du serveur", + "enterServerUrl": "Veuillez saisir une URL de serveur", + "testConnectionFirst": "Veuillez tester la connexion au préalable", + "connectionSuccess": "Connexion réussie !", + "connectionFailed": "Échec de la connexion", + "connectionError": "Une erreur de connexion est survenue", + "connected": "Connecté", + "disconnected": "Déconnecté", + "configSaved": "Configuration enregistrée avec succès", + "saveFailed": "Échec de l'enregistrement de la configuration", + "saveError": "Erreur lors de l'enregistrement de la configuration", + "saving": "Enregistrement...", + "saveConfig": "Enregistrer la configuration", + "helpText": "Indiquez l'URL sur laquelle votre serveur Termix est en cours d'exécution (ex. : http://localhost:30001 ou https://votre-serveur.com)", + "warning": "Avertissement", + "notValidatedWarning": "URL non validée - assurez-vous qu'elle est correcte", + "changeServer": "Changer de serveur", + "mustIncludeProtocol": "L'URL du serveur doit commencer par http:// ou https://" + }, + "versionCheck": { + "error": "Erreur de vérification de version", + "checkFailed": "Échec de la recherche de mises à jour", + "upToDate": "Application à jour", + "currentVersion": "Vous utilisez la version {{version}}", + "updateAvailable": "Mise à jour disponible", + "newVersionAvailable": "Une nouvelle version est disponible ! Vous utilisez {{current}}, mais {{latest}} est disponible.", + "releasedOn": "Publié le {{date}}", + "downloadUpdate": "Télécharger la mise à jour", + "dismiss": "Ignorer", + "checking": "Recherche de mises à jour...", + "checkUpdates": "Rechercher des mises à jour", + "checkingUpdates": "Recherche de mises à jour...", + "refresh": "Actualiser", + "updateRequired": "Mise à jour requise", + "updateDismissed": "Notification de mise à jour ignorée", + "noUpdatesFound": "Aucune mise à jour trouvée" + }, + "common": { + "close": "Fermer", + "minimize": "Réduire", + "online": "En ligne", + "offline": "Hors ligne", + "continue": "Continuer", + "maintenance": "Maintenance", + "degraded": "Dégradé", + "discord": "Discord", + "error": "Erreur", + "warning": "Avertissement", + "info": "Infos", + "success": "Succès", + "loading": "Chargement...", + "required": "Obligatoire", + "optional": "Facultatif", + "connect": "Se connecter", + "connecting": "Connexion...", + "clear": "Effacer", + "toggleSidebar": "Afficher/masquer la barre latérale", + "sidebar": "Barre latérale", + "home": "Accueil", + "expired": "Expiré", + "expiresToday": "Expire aujourd'hui", + "expiresTomorrow": "Expire demain", + "expiresInDays": "Expire dans {{days}} jours", + "updateAvailable": "Mise à jour disponible", + "sshPath": "Chemin SSH", + "localPath": "Chemin local", + "noAuthCredentials": "Aucun identifiant d'authentification disponible pour cet hôte SSH", + "noReleases": "Aucune version", + "updatesAndReleases": "Mises à jour et versions", + "newVersionAvailable": "Une nouvelle version ({{version}}) est disponible.", + "failedToFetchUpdateInfo": "Échec de la récupération des informations de mise à jour", + "preRelease": "Préversion", + "loginFailed": "Échec de la connexion", + "noReleasesFound": "Aucune version trouvée.", + "yourBackupCodes": "Vos codes de secours", + "sendResetCode": "Envoyer le code de réinitialisation", + "verifyCode": "Vérifier le code", + "resetPassword": "Réinitialiser le mot de passe", + "resetCode": "Code de réinitialisation", + "newPassword": "Nouveau mot de passe", + "folder": "Dossier", + "file": "Fichier", + "renamedSuccessfully": "renommé avec succès", + "deletedSuccessfully": "supprimé avec succès", + "noTunnelConnections": "Aucune connexion de tunnel configurée", + "sshTools": "Outils SSH", + "english": "Anglais", + "chinese": "Chinois", + "german": "Allemand", + "cancel": "Annuler", + "username": "Nom d'utilisateur", + "name": "Nom", + "login": "Connexion", + "logout": "Déconnexion", + "register": "Inscription", + "password": "Mot de passe", + "version": "Version", + "confirmPassword": "Confirmer le mot de passe", + "back": "Retour", + "email": "E-mail", + "submit": "Envoyer", + "change": "Modifier", + "save": "Enregistrer", + "delete": "Supprimer", + "edit": "Modifier", + "add": "Ajouter", + "search": "Rechercher", + "confirm": "Confirmer", + "yes": "Oui", + "no": "Non", + "ok": "OK", + "enabled": "Activé", + "disabled": "Désactivé", + "important": "Important", + "notEnabled": "Non activé", + "settingUp": "Configuration...", + "next": "Suivant", + "previous": "Précédent", + "refresh": "Actualiser", + "settings": "Paramètres", + "profile": "Profil", + "help": "Aide", + "about": "À propos", + "language": "Langue", + "autoDetect": "Détection automatique", + "changeAccountPassword": "Modifier le mot de passe de votre compte", + "passwordResetTitle": "Réinitialisation du mot de passe", + "passwordResetDescription": "Vous êtes sur le point de réinitialiser votre mot de passe. Vous serez déconnecté de toutes les sessions actives.", + "enterSixDigitCode": "Saisissez le code à 6 chiffres depuis les logs du conteneur Docker pour l'utilisateur :", + "enterNewPassword": "Saisissez votre nouveau mot de passe pour l'utilisateur :", + "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", + "passwordMinLength": "Le mot de passe doit contenir au moins 6 caractères", + "passwordResetSuccess": "Mot de passe réinitialisé avec succès ! Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.", + "failedToInitiatePasswordReset": "Échec du lancement de la réinitialisation du mot de passe", + "failedToVerifyResetCode": "Échec de la vérification du code de réinitialisation", + "failedToCompletePasswordReset": "Échec de la finalisation de la réinitialisation du mot de passe", + "documentation": "Documentation", + "retry": "Réessayer", + "checking": "Vérification...", + "checkingDatabase": "Vérification de la connexion à la base de données..." + }, + "nav": { + "home": "Accueil", + "hosts": "Hôtes", + "credentials": "Identifiants", + "terminal": "Terminal", + "tunnels": "Tunnels", + "fileManager": "Gestionnaire de fichiers", + "serverStats": "Statistiques serveur", + "admin": "Admin", + "userProfile": "Profil utilisateur", + "tools": "Outils", + "snippets": "Extraits", + "newTab": "Nouvel onglet", + "splitScreen": "Écran scindé", + "closeTab": "Fermer l'onglet", + "sshManager": "Gestionnaire SSH", + "hostManager": "Gestionnaire d'hôtes", + "cannotSplitTab": "Impossible de diviser cet onglet", + "tabNavigation": "Navigation par onglets" + }, + "admin": { + "title": "Paramètres d'administration", + "oidc": "OIDC", + "users": "Utilisateurs", + "userManagement": "Gestion des utilisateurs", + "makeAdmin": "Nommer administrateur", + "removeAdmin": "Retirer l'administrateur", + "deleteUser": "Supprimer l'utilisateur {{username}} ? Cette action est irréversible.", + "allowRegistration": "Autoriser l'inscription", + "oidcSettings": "Paramètres OIDC", + "clientId": "ID client", + "clientSecret": "Secret client", + "issuerUrl": "URL de l'émetteur", + "authorizationUrl": "URL d'autorisation", + "tokenUrl": "URL du jeton", + "updateSettings": "Mettre à jour les paramètres", + "confirmDelete": "Voulez-vous vraiment supprimer cet utilisateur ?", + "confirmMakeAdmin": "Voulez-vous vraiment donner les droits d'administration à cet utilisateur ?", + "confirmRemoveAdmin": "Voulez-vous vraiment retirer les droits d'administration de cet utilisateur ?", + "externalAuthentication": "Authentification externe (OIDC)", + "configureExternalProvider": "Configurez un fournisseur d'identité externe pour l'authentification OIDC/OAuth2.", + "userIdentifierPath": "Chemin de l'identifiant utilisateur", + "displayNamePath": "Chemin du nom d'affichage", + "scopes": "Scopes", + "saving": "Enregistrement...", + "saveConfiguration": "Enregistrer la configuration", + "reset": "Réinitialiser", + "success": "Succès", + "loading": "Chargement...", + "refresh": "Actualiser", + "loadingUsers": "Chargement des utilisateurs...", + "username": "Nom d'utilisateur", + "type": "Type", + "actions": "Actions", + "external": "Externe", + "local": "Local", + "adminManagement": "Gestion des administrateurs", + "makeUserAdmin": "Nommer l'utilisateur administrateur", + "adding": "Ajout...", + "currentAdmins": "Administrateurs actuels", + "adminBadge": "Admin", + "removeAdminButton": "Retirer l'administrateur", + "general": "Général", + "userRegistration": "Inscription utilisateur", + "allowNewAccountRegistration": "Autoriser l'inscription de nouveaux comptes", + "allowPasswordLogin": "Autoriser la connexion par nom d'utilisateur/mot de passe", + "missingRequiredFields": "Champs obligatoires manquants : {{fields}}", + "oidcConfigurationUpdated": "Configuration OIDC mise à jour avec succès !", + "failedToFetchOidcConfig": "Échec de la récupération de la configuration OIDC", + "failedToFetchRegistrationStatus": "Échec de la récupération de l'état des inscriptions", + "failedToFetchPasswordLoginStatus": "Échec de la récupération de l'état de la connexion par mot de passe", + "failedToFetchUsers": "Échec de la récupération des utilisateurs", + "oidcConfigurationDisabled": "Configuration OIDC désactivée avec succès !", + "failedToUpdateOidcConfig": "Échec de la mise à jour de la configuration OIDC", + "failedToDisableOidcConfig": "Échec de la désactivation de la configuration OIDC", + "enterUsernameToMakeAdmin": "Saisissez le nom d'utilisateur à promouvoir administrateur", + "userIsNowAdmin": "L'utilisateur {{username}} est désormais administrateur", + "failedToMakeUserAdmin": "Échec de la promotion de l'utilisateur en administrateur", + "removeAdminStatus": "Retirer le statut d'administrateur à {{username}} ?", + "adminStatusRemoved": "Statut d'administrateur retiré pour {{username}}", + "failedToRemoveAdminStatus": "Échec du retrait du statut d'administrateur", + "userDeletedSuccessfully": "Utilisateur {{username}} supprimé avec succès", + "failedToDeleteUser": "Échec de la suppression de l'utilisateur", + "overrideUserInfoUrl": "Remplacer l'URL User Info (optionnel)", + "failedToFetchSessions": "Échec de la récupération des sessions", + "sessionRevokedSuccessfully": "Session révoquée avec succès", + "failedToRevokeSession": "Échec de la révocation de la session", + "confirmRevokeSession": "Voulez-vous vraiment révoquer cette session ?", + "confirmRevokeAllSessions": "Voulez-vous vraiment révoquer toutes les sessions de cet utilisateur ?", + "failedToRevokeSessions": "Échec de la révocation des sessions", + "sessionsRevokedSuccessfully": "Sessions révoquées avec succès", + "databaseSecurity": "Sécurité de la base de données", + "encryptionStatus": "État du chiffrement", + "encryptionEnabled": "Chiffrement activé", + "enabled": "Activé", + "disabled": "Désactivé", + "keyId": "ID de clé", + "created": "Créé", + "migrationStatus": "État de la migration", + "migrationCompleted": "Migration terminée", + "migrationRequired": "Migration requise", + "deviceProtectedMasterKey": "Clé maître protégée par l'environnement", + "legacyKeyStorage": "Stockage de clés hérité", + "masterKeyEncryptedWithDeviceFingerprint": "Clé maître chiffrée avec l'empreinte de l'environnement (protection KEK active)", + "keyNotProtectedByDeviceBinding": "Clé non protégée par l'environnement (mise à niveau recommandée)", + "valid": "Valide", + "initializeDatabaseEncryption": "Initialiser le chiffrement de la base", + "enableAes256EncryptionWithDeviceBinding": "Activez le chiffrement AES-256 avec une clé maître liée à l'environnement. Cela offre une sécurité de niveau entreprise pour les clés SSH, mots de passe et jetons.", + "featuresEnabled": "Fonctionnalités activées :", + "aes256GcmAuthenticatedEncryption": "Chiffrement authentifié AES-256-GCM", + "deviceFingerprintMasterKeyProtection": "Protection de la clé maître par empreinte d'environnement (KEK)", + "pbkdf2KeyDerivation": "Dérivation de clé PBKDF2 avec 100K itérations", + "automaticKeyManagement": "Gestion et rotation automatiques des clés", + "initializing": "Initialisation...", + "initializeEnterpriseEncryption": "Initialiser le chiffrement entreprise", + "migrateExistingData": "Migrer les données existantes", + "encryptExistingUnprotectedData": "Chiffrez les données non protégées existantes dans votre base. Ce processus est sûr et crée des sauvegardes automatiques.", + "testMigrationDryRun": "Vérifier la compatibilité du chiffrement", + "migrating": "Migration...", + "migrateData": "Migrer les données", + "securityInformation": "Informations de sécurité", + "sshPrivateKeysEncryptedWithAes256": "Les clés privées SSH et mots de passe sont chiffrés en AES-256-GCM", + "userAuthTokensProtected": "Les jetons d'authentification utilisateur et secrets 2FA sont protégés", + "masterKeysProtectedByDeviceFingerprint": "Les clés maîtres sont protégées par empreinte d'environnement (KEK)", + "keysBoundToServerInstance": "Les clés sont liées à l'environnement serveur actuel (migrable via variables d'environnement)", + "pbkdf2HkdfKeyDerivation": "Dérivation de clé PBKDF2 + HKDF avec 100K itérations", + "backwardCompatibleMigration": "Toutes les données restent rétrocompatibles pendant la migration", + "enterpriseGradeSecurityActive": "Sécurité de niveau entreprise active", + "masterKeysProtectedByDeviceBinding": "Vos clés maîtres sont protégées par l'empreinte de l'environnement. Cela utilise le nom d'hôte, les chemins et autres infos pour générer les clés de protection. Pour migrer, définissez la variable d'environnement DB_ENCRYPTION_KEY sur le nouveau serveur.", + "important": "Important", + "keepEncryptionKeysSecure": "Assurez la sécurité des données : sauvegardez régulièrement vos fichiers de base de données et votre configuration serveur. Pour migrer, définissez DB_ENCRYPTION_KEY sur le nouvel environnement ou conservez le même nom d'hôte et la même arborescence.", + "loadingEncryptionStatus": "Chargement de l'état du chiffrement...", + "testMigrationDescription": "Vérifiez que les données existantes peuvent être migrées vers le format chiffré sans modification", + "serverMigrationGuide": "Guide de migration du serveur", + "migrationInstructions": "Pour migrer des données chiffrées vers un nouveau serveur : 1) Sauvegardez les fichiers de base, 2) Définissez DB_ENCRYPTION_KEY=\"votre-clé\" sur le nouveau serveur, 3) Restaurez les fichiers de base", + "environmentProtection": "Protection de l'environnement", + "environmentProtectionDesc": "Protège les clés de chiffrement à partir des informations de l'environnement serveur (nom d'hôte, chemins, etc.), migrable via variables d'environnement", + "verificationCompleted": "Vérification de compatibilité terminée - aucune donnée n'a été modifiée", + "verificationInProgress": "Vérification terminée", + "dataMigrationCompleted": "Migration des données terminée avec succès !", + "verificationFailed": "Échec de la vérification de compatibilité", + "migrationFailed": "Échec de la migration", + "runningVerification": "Exécution de la vérification de compatibilité...", + "startingMigration": "Démarrage de la migration...", + "hardwareFingerprintSecurity": "Sécurité par empreinte matérielle", + "hardwareBoundEncryption": "Chiffrement lié au matériel actif", + "masterKeysNowProtectedByHardwareFingerprint": "Les clés maîtres sont désormais protégées par une véritable empreinte matérielle plutôt que par des variables d'environnement", + "cpuSerialNumberDetection": "Détection du numéro de série CPU", + "motherboardUuidIdentification": "Identification de l'UUID de la carte mère", + "diskSerialNumberVerification": "Vérification du numéro de série du disque", + "biosSerialNumberCheck": "Contrôle du numéro de série du BIOS", + "stableMacAddressFiltering": "Filtrage des adresses MAC stables", + "databaseFileEncryption": "Chiffrement des fichiers de base de données", + "dualLayerProtection": "Protection à double couche active", + "bothFieldAndFileEncryptionActive": "Le chiffrement au niveau des champs et des fichiers est maintenant actif pour une sécurité maximale", + "fieldLevelAes256Encryption": "Chiffrement AES-256 au niveau des champs pour les données sensibles", + "fileLevelDatabaseEncryption": "Chiffrement des fichiers de base avec liaison matérielle", + "hardwareBoundFileKeys": "Clés de chiffrement des fichiers liées au matériel", + "automaticEncryptedBackups": "Création automatique de sauvegardes chiffrées", + "createEncryptedBackup": "Créer une sauvegarde chiffrée", + "creatingBackup": "Création de la sauvegarde...", + "backupCreated": "Sauvegarde créée", + "encryptedBackupCreatedSuccessfully": "Sauvegarde chiffrée créée avec succès", + "backupCreationFailed": "Échec de la création de la sauvegarde", + "databaseMigration": "Migration de la base de données", + "exportForMigration": "Exporter pour migration", + "exportDatabaseForHardwareMigration": "Exporter la base au format SQLite avec données déchiffrées pour migration vers un nouveau matériel", + "exportDatabase": "Exporter la base SQLite", + "exporting": "Exportation...", + "exportCreated": "Export SQLite créé", + "exportContainsDecryptedData": "L'export SQLite contient des données déchiffrées - conservez-le en lieu sûr !", + "databaseExportedSuccessfully": "Base SQLite exportée avec succès", + "databaseExportFailed": "Échec de l'export SQLite", + "importFromMigration": "Importer depuis une migration", + "importDatabaseFromAnotherSystem": "Importer une base SQLite depuis un autre système ou matériel", + "importDatabase": "Importer une base SQLite", + "importing": "Importation...", + "selectedFile": "Fichier SQLite sélectionné", + "importWillReplaceExistingData": "L'import SQLite remplacera les données existantes - sauvegarde recommandée !", + "pleaseSelectImportFile": "Veuillez sélectionner un fichier SQLite à importer", + "databaseImportedSuccessfully": "Base SQLite importée avec succès", + "databaseImportFailed": "Échec de l'import SQLite", + "manageEncryptionAndBackups": "Gérez les clés de chiffrement, la sécurité de la base et les sauvegardes", + "activeSecurityFeatures": "Mesures de sécurité actuellement actives", + "deviceBindingTechnology": "Technologie avancée de liaison matérielle des clés", + "backupAndRecovery": "Options de sauvegarde sécurisée et de restauration", + "crossSystemDataTransfer": "Export et import de bases entre différents systèmes", + "noMigrationNeeded": "Aucune migration nécessaire", + "encryptionKey": "Clé de chiffrement", + "keyProtection": "Protection de la clé", + "active": "Actif", + "legacy": "Hérité", + "dataStatus": "État des données", + "encrypted": "Chiffré", + "needsMigration": "Migration nécessaire", + "ready": "Prêt", + "initializeEncryption": "Initialiser le chiffrement", + "initialize": "Initialiser", + "test": "Tester", + "migrate": "Migrer", + "backup": "Sauvegarder", + "createBackup": "Créer une sauvegarde", + "exportImport": "Exporter/Importer", + "export": "Exporter", + "import": "Importer", + "passwordRequired": "Mot de passe requis", + "confirmExport": "Confirmer l'export", + "exportDescription": "Exporter les hôtes et identifiants SSH au format SQLite", + "importDescription": "Importer un fichier SQLite avec fusion incrémentale (ignore les doublons)", + "criticalWarning": "Avertissement critique", + "cannotDisablePasswordLoginWithoutOIDC": "Impossible de désactiver la connexion par mot de passe sans configurer OIDC ! Configurez d'abord l'authentification OIDC, sinon vous perdrez l'accès à Termix.", + "confirmDisablePasswordLogin": "Voulez-vous vraiment désactiver la connexion par mot de passe ? Assurez-vous qu'OIDC est correctement configuré et fonctionnel avant de continuer, sous peine de perdre l'accès à votre instance Termix.", + "passwordLoginDisabled": "Connexion par mot de passe désactivée avec succès", + "passwordLoginAndRegistrationDisabled": "Connexion par mot de passe et inscription des nouveaux comptes désactivées avec succès", + "requiresPasswordLogin": "Nécessite la connexion par mot de passe activée", + "passwordLoginDisabledWarning": "La connexion par mot de passe est désactivée. Vérifiez qu'OIDC est correctement configuré sinon vous ne pourrez plus vous connecter à Termix.", + "oidcRequiredWarning": "CRITIQUE : la connexion par mot de passe est désactivée. Si vous réinitialisez ou mal configurez OIDC, vous perdrez tout accès à Termix et bloquerez l'instance. Ne continuez que si vous en êtes absolument certain.", + "confirmDisableOIDCWarning": "AVERTISSEMENT : vous êtes sur le point de désactiver OIDC alors que la connexion par mot de passe est désactivée. Cela bloquera votre instance Termix et vous perdrez tout accès. Êtes-vous vraiment sûr de vouloir continuer ?" + }, + "hosts": { + "title": "Gestionnaire d'hôtes", + "sshHosts": "Hôtes SSH", + "noHosts": "Aucun hôte SSH", + "noHostsMessage": "Vous n'avez pas encore ajouté d'hôte SSH. Cliquez sur \"Ajouter un hôte\" pour commencer.", + "loadingHosts": "Chargement des hôtes...", + "failedToLoadHosts": "Échec du chargement des hôtes", + "retry": "Réessayer", + "refresh": "Actualiser", + "hostsCount": "{{count}} hôtes", + "importJson": "Importer JSON", + "importing": "Importation...", + "importJsonTitle": "Importer des hôtes SSH depuis un JSON", + "importJsonDesc": "Téléversez un fichier JSON pour importer en masse plusieurs hôtes SSH (100 max).", + "downloadSample": "Télécharger un exemple", + "formatGuide": "Guide de format", + "exportCredentialWarning": "Attention : l'hôte \"{{name}}\" utilise une authentification par identifiant. Le fichier exporté n'inclura pas les données d'identifiant et devra être reconfiguré manuellement après import. Voulez-vous continuer ?", + "exportSensitiveDataWarning": "Attention : l'hôte \"{{name}}\" contient des données d'authentification sensibles (mot de passe/clé SSH). Le fichier exporté inclura ces données en clair. Conservez-le en lieu sûr et supprimez-le après usage. Voulez-vous continuer ?", + "uncategorized": "Non classé", + "confirmDelete": "Voulez-vous vraiment supprimer \"{{name}}\" ?", + "failedToDeleteHost": "Échec de la suppression de l'hôte", + "failedToExportHost": "Échec de l'export de l'hôte. Vérifiez que vous êtes connecté et que vous avez accès aux données de l'hôte.", + "jsonMustContainHosts": "Le JSON doit contenir un tableau \"hosts\" ou être un tableau d'hôtes", + "noHostsInJson": "Aucun hôte trouvé dans le fichier JSON", + "maxHostsAllowed": "Maximum 100 hôtes par import", + "importCompleted": "Import terminé : {{success}} réussi(s), {{failed}} échec(s)", + "importFailed": "Échec de l'import", + "importError": "Erreur d'import", + "failedToImportJson": "Échec de l'import du fichier JSON", + "connectionDetails": "Détails de connexion", + "organization": "Organisation", + "ipAddress": "Adresse IP", + "port": "Port", + "name": "Nom", + "username": "Nom d'utilisateur", + "folder": "Dossier", + "tags": "Labels", + "pin": "Épingler", + "passwordRequired": "Le mot de passe est requis avec l'authentification par mot de passe", + "sshKeyRequired": "La clé privée SSH est requise avec l'authentification par clé", + "keyTypeRequired": "Le type de clé est requis avec l'authentification par clé", + "mustSelectValidSshConfig": "Vous devez sélectionner une configuration SSH valide dans la liste", + "addHost": "Ajouter un hôte", + "editHost": "Modifier l'hôte", + "cloneHost": "Cloner l'hôte", + "updateHost": "Mettre à jour l'hôte", + "hostUpdatedSuccessfully": "Hôte \"{{name}}\" mis à jour avec succès !", + "hostAddedSuccessfully": "Hôte \"{{name}}\" ajouté avec succès !", + "hostDeletedSuccessfully": "Hôte \"{{name}}\" supprimé avec succès !", + "failedToSaveHost": "Échec de l'enregistrement de l'hôte. Veuillez réessayer.", + "enableTerminal": "Activer le terminal", + "enableTerminalDesc": "Afficher/masquer l'hôte dans l'onglet Terminal", + "enableTunnel": "Activer le tunnel", + "enableTunnelDesc": "Afficher/masquer l'hôte dans l'onglet Tunnel", + "enableFileManager": "Activer le gestionnaire de fichiers", + "enableFileManagerDesc": "Afficher/masquer l'hôte dans l'onglet Gestionnaire de fichiers", + "defaultPath": "Chemin par défaut", + "defaultPathDesc": "Répertoire par défaut à l'ouverture du gestionnaire de fichiers pour cet hôte", + "tunnelConnections": "Connexions de tunnel", + "connection": "Connexion", + "remove": "Supprimer", + "sourcePort": "Port source", + "sourcePortDesc": " (La source correspond aux détails de connexion actuels dans l'onglet Général)", + "endpointPort": "Port de destination", + "endpointSshConfig": "Configuration SSH de destination", + "tunnelForwardDescription": "Ce tunnel redirigera le trafic du port {{sourcePort}} sur la machine source (détails de connexion dans l'onglet Général) vers le port {{endpointPort}} sur la machine de destination.", + "maxRetries": "Nombre max de tentatives", + "maxRetriesDescription": "Nombre maximal de tentatives de reconnexion du tunnel.", + "retryInterval": "Intervalle de tentative (secondes)", + "retryIntervalDescription": "Temps d'attente entre les tentatives.", + "autoStartContainer": "Démarrage auto au lancement du conteneur", + "autoStartDesc": "Démarre automatiquement ce tunnel au lancement du conteneur", + "addConnection": "Ajouter une connexion de tunnel", + "sshpassRequired": "Sshpass requis pour l'authentification par mot de passe", + "sshpassRequiredDesc": "Pour l'authentification par mot de passe dans les tunnels, sshpass doit être installé sur le système.", + "otherInstallMethods": "Autres méthodes d'installation :", + "debianUbuntuEquivalent": "(Debian/Ubuntu) ou équivalent selon votre OS.", + "or": "ou", + "centosRhelFedora": "CentOS/RHEL/Fedora", + "macos": "macOS", + "windows": "Windows", + "sshServerConfigRequired": "Configuration du serveur SSH requise", + "sshServerConfigDesc": "Pour les tunnels, le serveur SSH doit être configuré pour autoriser le transfert de ports :", + "gatewayPortsYes": "pour lier les ports distants à toutes les interfaces", + "allowTcpForwardingYes": "pour autoriser le transfert de ports", + "permitRootLoginYes": "si vous utilisez l'utilisateur root pour le tunneling", + "editSshConfig": "Modifiez /etc/ssh/sshd_config et redémarrez SSH : sudo systemctl restart sshd", + "upload": "Importer", + "authentication": "Authentification", + "password": "Mot de passe", + "key": "Clé", + "credential": "Identifiant", + "none": "Aucun", + "selectCredential": "Sélectionner un identifiant", + "selectCredentialPlaceholder": "Choisissez un identifiant...", + "credentialRequired": "Un identifiant est requis avec l'authentification par identifiant", + "credentialDescription": "La sélection d'un identifiant remplacera le nom d'utilisateur actuel et utilisera ses informations d'authentification.", + "sshPrivateKey": "Clé privée SSH", + "keyPassword": "Mot de passe de la clé", + "keyType": "Type de clé", + "autoDetect": "Détection automatique", + "rsa": "RSA", + "ed25519": "ED25519", + "ecdsaNistP256": "ECDSA NIST P-256", + "ecdsaNistP384": "ECDSA NIST P-384", + "ecdsaNistP521": "ECDSA NIST P-521", + "dsa": "DSA", + "rsaSha2256": "RSA SHA2-256", + "rsaSha2512": "RSA SHA2-512", + "uploadFile": "Importer un fichier", + "pasteKey": "Coller la clé", + "updateKey": "Mettre à jour la clé", + "existingKey": "Clé existante (cliquez pour modifier)", + "existingCredential": "Identifiant existant (cliquez pour modifier)", + "addTagsSpaceToAdd": "ajouter des labels (espace pour valider)", + "terminalBadge": "Terminal", + "tunnelBadge": "Tunnel", + "fileManagerBadge": "Gestionnaire de fichiers", + "general": "Général", + "terminal": "Terminal", + "tunnel": "Tunnel", + "fileManager": "Gestionnaire de fichiers", + "serverStats": "Statistiques serveur", + "hostViewer": "Visionneuse d'hôte", + "enableServerStats": "Activer les statistiques serveur", + "enableServerStatsDesc": "Activer/désactiver la collecte des statistiques pour cet hôte", + "displayItems": "Éléments affichés", + "displayItemsDesc": "Choisissez les métriques à montrer sur la page des statistiques", + "enableCpu": "Utilisation CPU", + "enableMemory": "Utilisation mémoire", + "enableDisk": "Utilisation disque", + "enableNetwork": "Statistiques réseau (bientôt)", + "enableProcesses": "Nombre de processus (bientôt)", + "enableUptime": "Durée de fonctionnement (bientôt)", + "enableHostname": "Nom d'hôte (bientôt)", + "enableOs": "Système d'exploitation (bientôt)", + "customCommands": "Commandes personnalisées (bientôt)", + "customCommandsDesc": "Définissez des commandes d'arrêt et de redémarrage personnalisées pour ce serveur", + "shutdownCommand": "Commande d'arrêt", + "rebootCommand": "Commande de redémarrage", + "confirmRemoveFromFolder": "Voulez-vous vraiment retirer \"{{name}}\" du dossier \"{{folder}}\" ? L'hôte sera déplacé vers \"Sans dossier\".", + "removedFromFolder": "Hôte \"{{name}}\" retiré du dossier avec succès", + "failedToRemoveFromFolder": "Échec du retrait de l'hôte du dossier", + "folderRenamed": "Dossier \"{{oldName}}\" renommé en \"{{newName}}\" avec succès", + "failedToRenameFolder": "Échec du renommage du dossier", + "movedToFolder": "Hôte \"{{name}}\" déplacé vers \"{{folder}}\" avec succès", + "failedToMoveToFolder": "Échec du déplacement de l'hôte vers le dossier", + "statistics": "Statistiques", + "enabledWidgets": "Widgets activés", + "enabledWidgetsDesc": "Sélectionnez les widgets de statistiques à afficher pour cet hôte", + "monitoringConfiguration": "Configuration de la surveillance", + "monitoringConfigurationDesc": "Configurez la fréquence des vérifications d'état et des statistiques", + "statusCheckEnabled": "Activer la surveillance d'état", + "statusCheckEnabledDesc": "Vérifie si le serveur est en ligne ou hors ligne", + "statusCheckInterval": "Intervalle de vérification d'état", + "statusCheckIntervalDesc": "Fréquence de vérification (5 s - 1 h)", + "metricsEnabled": "Activer la surveillance des métriques", + "metricsEnabledDesc": "Collecter CPU, RAM, disque et autres statistiques système", + "metricsInterval": "Intervalle de collecte des métriques", + "metricsIntervalDesc": "Fréquence de collecte des statistiques (5 s - 1 h)", + "intervalSeconds": "secondes", + "intervalMinutes": "minutes", + "intervalValidation": "Les intervalles doivent être compris entre 5 secondes et 1 heure (3600 secondes)", + "monitoringDisabled": "La surveillance du serveur est désactivée pour cet hôte", + "enableMonitoring": "Activez la surveillance dans Gestionnaire d'hôtes → onglet Statistiques", + "monitoringDisabledBadge": "Surveillance désactivée", + "statusMonitoring": "État", + "metricsMonitoring": "Métriques", + "terminalCustomizationNotice": "Remarque : les personnalisations du terminal fonctionnent uniquement sur ordinateur (site web et application Electron). Les applications mobiles utilisent les paramètres par défaut du système.", + "noneAuthTitle": "Authentification clavier-interactif", + "noneAuthDescription": "Cette méthode utilisera l'authentification clavier-interactif lors de la connexion au serveur SSH.", + "noneAuthDetails": "L'authentification clavier-interactif permet au serveur de vous demander des informations pendant la connexion. Utile pour le MFA ou si vous ne souhaitez pas stocker d'identifiants localement.", + "forceKeyboardInteractive": "Forcer le clavier-interactif", + "forceKeyboardInteractiveDesc": "Force l'utilisation de l'authentification clavier-interactif. Souvent nécessaire pour les serveurs avec 2FA (TOTP/2FA)." + }, + "terminal": { + "title": "Terminal", + "connect": "Se connecter à l'hôte", + "disconnect": "Déconnecter", + "clear": "Effacer", + "copy": "Copier", + "paste": "Coller", + "find": "Rechercher", + "fullscreen": "Plein écran", + "splitHorizontal": "Diviser horizontalement", + "splitVertical": "Diviser verticalement", + "closePanel": "Fermer le panneau", + "reconnect": "Reconnecter", + "sessionEnded": "Session terminée", + "connectionLost": "Connexion perdue", + "error": "ERREUR : {{message}}", + "disconnected": "Déconnecté", + "connectionClosed": "Connexion fermée", + "connectionError": "Erreur de connexion : {{message}}", + "connected": "Connecté", + "sshConnected": "Connexion SSH établie", + "authError": "Échec d'authentification : {{message}}", + "unknownError": "Une erreur inconnue est survenue", + "messageParseError": "Échec de l'analyse du message serveur", + "websocketError": "Erreur de connexion WebSocket", + "connecting": "Connexion...", + "reconnecting": "Reconnexion... ({{attempt}}/{{max}})", + "reconnected": "Reconnecté avec succès", + "maxReconnectAttemptsReached": "Nombre maximal de tentatives de reconnexion atteint", + "connectionTimeout": "Temps de connexion dépassé", + "terminalTitle": "Terminal - {{host}}", + "terminalWithPath": "Terminal - {{host}}:{{path}}", + "runTitle": "Exécution de {{command}} - {{host}}", + "totpRequired": "Authentification à deux facteurs requise", + "totpCodeLabel": "Code de vérification", + "totpPlaceholder": "000000", + "totpVerify": "Vérifier" + }, + "fileManager": { + "title": "Gestionnaire de fichiers", + "file": "Fichier", + "folder": "Dossier", + "connectToSsh": "Connectez-vous en SSH pour utiliser les opérations sur les fichiers", + "uploadFile": "Importer un fichier", + "downloadFile": "Télécharger", + "edit": "Modifier", + "preview": "Aperçu", + "previous": "Précédent", + "next": "Suivant", + "pageXOfY": "Page {{current}} sur {{total}}", + "zoomOut": "Zoom arrière", + "zoomIn": "Zoom avant", + "newFile": "Nouveau fichier", + "newFolder": "Nouveau dossier", + "rename": "Renommer", + "renameItem": "Renommer l'élément", + "deleteItem": "Supprimer l'élément", + "currentPath": "Chemin actuel", + "uploadFileTitle": "Importer un fichier", + "maxFileSize": "Max : 1 Go (JSON) / 5 Go (binaire) - gros fichiers pris en charge", + "removeFile": "Retirer le fichier", + "clickToSelectFile": "Cliquez pour sélectionner un fichier", + "chooseFile": "Choisir un fichier", + "uploading": "Téléversement...", + "downloading": "Téléchargement...", + "uploadingFile": "Téléversement de {{name}}...", + "uploadingLargeFile": "Téléversement du gros fichier {{name}} ({{size}})...", + "downloadingFile": "Téléchargement de {{name}}...", + "creatingFile": "Création de {{name}}...", + "creatingFolder": "Création de {{name}}...", + "deletingItem": "Suppression de {{type}} {{name}}...", + "renamingItem": "Renommage de {{type}} {{oldName}} en {{newName}}...", + "createNewFile": "Créer un nouveau fichier", + "fileName": "Nom du fichier", + "creating": "Création...", + "createFile": "Créer le fichier", + "createNewFolder": "Créer un nouveau dossier", + "folderName": "Nom du dossier", + "createFolder": "Créer le dossier", + "warningCannotUndo": "Attention : cette action est irréversible", + "itemPath": "Chemin de l'élément", + "thisIsDirectory": "Ceci est un dossier (sera supprimé récursivement)", + "deleting": "Suppression...", + "currentPathLabel": "Chemin actuel", + "newName": "Nouveau nom", + "thisIsDirectoryRename": "Ceci est un dossier", + "renaming": "Renommage...", + "fileUploadedSuccessfully": "Fichier \"{{name}}\" téléversé avec succès", + "failedToUploadFile": "Échec du téléversement", + "fileDownloadedSuccessfully": "Fichier téléchargé avec succès", + "failedToDownloadFile": "Échec du téléchargement", + "noFileContent": "Aucun contenu de fichier reçu", + "filePath": "Chemin du fichier", + "fileCreatedSuccessfully": "Fichier \"{{name}}\" créé avec succès", + "failedToCreateFile": "Échec de la création du fichier", + "folderCreatedSuccessfully": "Dossier \"{{name}}\" créé avec succès", + "failedToCreateFolder": "Échec de la création du dossier", + "failedToCreateItem": "Échec de la création de l'élément", + "operationFailed": "L'opération {{operation}} a échoué pour {{name}} : {{error}}", + "failedToResolveSymlink": "Échec de la résolution du lien symbolique", + "itemDeletedSuccessfully": "{{type}} supprimé avec succès", + "itemsDeletedSuccessfully": "{{count}} éléments supprimés avec succès", + "failedToDeleteItems": "Échec de la suppression des éléments", + "dragFilesToUpload": "Déposez des fichiers ici pour les importer", + "emptyFolder": "Ce dossier est vide", + "itemCount": "{{count}} éléments", + "selectedCount": "{{count}} sélectionné(s)", + "searchFiles": "Rechercher des fichiers...", + "upload": "Importer", + "selectHostToStart": "Sélectionnez un hôte pour démarrer la gestion des fichiers", + "failedToConnect": "Échec de la connexion SSH", + "failedToLoadDirectory": "Échec du chargement du répertoire", + "noSSHConnection": "Aucune connexion SSH disponible", + "enterFolderName": "Entrez le nom du dossier :", + "enterFileName": "Entrez le nom du fichier :", + "copy": "Copier", + "cut": "Couper", + "paste": "Coller", + "delete": "Supprimer", + "properties": "Propriétés", + "refresh": "Actualiser", + "downloadFiles": "Télécharger {{count}} fichiers dans le navigateur", + "copyFiles": "Copier {{count}} éléments", + "cutFiles": "Couper {{count}} éléments", + "deleteFiles": "Supprimer {{count}} éléments", + "filesCopiedToClipboard": "{{count}} éléments copiés dans le presse-papiers", + "filesCutToClipboard": "{{count}} éléments coupés dans le presse-papiers", + "movedItems": "{{count}} éléments déplacés", + "failedToDeleteItem": "Échec de la suppression de l'élément", + "itemRenamedSuccessfully": "{{type}} renommé avec succès", + "failedToRenameItem": "Échec du renommage de l'élément", + "download": "Télécharger", + "permissions": "Autorisations", + "size": "Taille", + "modified": "Modifié", + "path": "Chemin", + "confirmDelete": "Voulez-vous vraiment supprimer {{name}} ?", + "uploadSuccess": "Fichier téléversé avec succès", + "uploadFailed": "Échec du téléversement", + "downloadSuccess": "Fichier téléchargé avec succès", + "downloadFailed": "Échec du téléchargement", + "permissionDenied": "Permission refusée", + "checkDockerLogs": "Consultez les logs Docker pour plus de détails", + "internalServerError": "Une erreur interne du serveur est survenue", + "serverError": "Erreur serveur", + "error": "Erreur", + "requestFailed": "Réquête échouée avec le code", + "unknownFileError": "Erreur de fichier inconnue", + "cannotReadFile": "Impossible de lire le fichier", + "noSshSessionId": "Aucun ID de session SSH", + "noFilePath": "Aucun chemin de fichier spécifié", + "noCurrentHost": "Aucun hôte sélectionné", + "fileSavedSuccessfully": "Fichier enregistré avec succès", + "saveTimeout": "Expiration du délai d'enregistrement", + "failedToSaveFile": "Échec de l'enregistrement du fichier", + "deletedSuccessfully": "Supprimé avec succès", + "connectToServer": "Se connecter au serveur", + "selectServerToEdit": "Sélectionnez un serveur pour éditer", + "fileOperations": "Opérations sur les fichiers", + "confirmDeleteMessage": "Voulez-vous vraiment supprimer ces éléments ?", + "confirmDeleteSingleItem": "Supprimer {{name}} ?", + "confirmDeleteMultipleItems": "Supprimer {{count}} éléments ?", + "confirmDeleteMultipleItemsWithFolders": "Supprimer {{files}} fichiers et {{folders}} dossiers ?", + "confirmDeleteFolder": "Supprimer le dossier {{name}} ?", + "deleteDirectoryWarning": "Le dossier et son contenu seront supprimés définitivement.", + "actionCannotBeUndone": "Cette action est irréversible.", + "permanentDeleteWarning": "La suppression est permanente et ne peut pas être annulée.", + "recent": "Récents", + "pinned": "Épinglés", + "folderShortcuts": "Raccourcis de dossiers", + "noRecentFiles": "Aucun fichier récent.", + "noPinnedFiles": "Aucun fichier épinglé.", + "enterFolderPath": "Saisissez le chemin du dossier", + "noShortcuts": "Aucun raccourci.", + "searchFilesAndFolders": "Rechercher des fichiers et dossiers...", + "noFilesOrFoldersFound": "Aucun fichier ou dossier trouvé.", + "failedToConnectSSH": "Échec de la connexion SSH", + "failedToReconnectSSH": "Échec de la reconnexion SSH", + "failedToListFiles": "Échec de l'affichage des fichiers", + "fetchHomeDataTimeout": "Expiration du délai de récupération des données d'accueil", + "sshStatusCheckTimeout": "Expiration du délai de vérification du statut SSH", + "sshReconnectionTimeout": "Expiration du délai de reconnexion SSH", + "saveOperationTimeout": "Expiration du délai d'enregistrement", + "cannotSaveFile": "Impossible d'enregistrer le fichier", + "dragSystemFilesToUpload": "Faites glisser des fichiers système ici pour les importer", + "dragFilesToWindowToDownload": "Faites glisser les fichiers hors de la fenêtre pour les télécharger", + "openTerminalHere": "Ouvrir un terminal ici", + "run": "Exécuter", + "saveToSystem": "Enregistrer sous...", + "selectLocationToSave": "Sélectionnez un emplacement pour enregistrer", + "openTerminalInFolder": "Ouvrir un terminal dans ce dossier", + "openTerminalInFileLocation": "Ouvrir un terminal à l'emplacement du fichier", + "terminalWithPath": "Terminal - {{host}} : {{path}}", + "runningFile": "Exécution - {{file}}", + "onlyRunExecutableFiles": "Seuls les fichiers exécutables peuvent être lancés", + "noHostSelected": "Aucun hôte sélectionné", + "starred": "Favoris", + "shortcuts": "Raccourcis", + "directories": "Répertoires", + "removedFromRecentFiles": "\"{{name}}\" retiré des fichiers récents", + "removeFailed": "Échec de la suppression", + "unpinnedSuccessfully": "\"{{name}}\" a été désépinglé", + "unpinFailed": "Échec du désépinglage", + "removedShortcut": "Raccourci \"{{name}}\" supprimé", + "removeShortcutFailed": "Échec de la suppression du raccourci", + "clearedAllRecentFiles": "Fichiers récents effacés", + "clearFailed": "Échec du nettoyage", + "removeFromRecentFiles": "Retirer des fichiers récents", + "clearAllRecentFiles": "Effacer tous les fichiers récents", + "unpinFile": "Désépingler le fichier", + "removeShortcut": "Supprimer le raccourci", + "saveFilesToSystem": "Enregistrer {{count}} fichiers sous...", + "pinFile": "Épingler le fichier", + "addToShortcuts": "Ajouter aux raccourcis", + "downloadToDefaultLocation": "Télécharger vers l'emplacement par défaut", + "pasteFailed": "Échec du collage", + "noUndoableActions": "Aucune action à annuler", + "undoCopySuccess": "Copie annulée : {{count}} fichiers copiés supprimés", + "undoCopyFailedDelete": "Annulation impossible : suppression des fichiers copiés échouée", + "undoCopyFailedNoInfo": "Annulation impossible : informations sur les fichiers copiés introuvables", + "undoMoveSuccess": "Déplacement annulé : {{count}} fichiers remis à leur emplacement d'origine", + "undoMoveFailedMove": "Annulation impossible : impossible de remettre les fichiers", + "undoMoveFailedNoInfo": "Annulation impossible : informations sur les fichiers déplacés introuvables", + "undoDeleteNotSupported": "Suppression non annulable : les fichiers ont été supprimés définitivement du serveur", + "undoTypeNotSupported": "Type d'annulation non pris en charge", + "undoOperationFailed": "Échec de l'opération d'annulation", + "unknownError": "Erreur inconnue", + "enterPath": "Entrer un chemin...", + "editPath": "Modifier le chemin", + "confirm": "Confirmer", + "cancel": "Annuler", + "find": "Rechercher...", + "replaceWith": "Remplacer par...", + "replace": "Remplacer", + "replaceAll": "Remplacer tout", + "downloadInstead": "Télécharger à la place", + "keyboardShortcuts": "Raccourcis clavier", + "searchAndReplace": "Rechercher et remplacer", + "editing": "Édition", + "navigation": "Parcours", + "code": "Code source", + "search": "Recherche", + "findNext": "Rechercher suivant", + "findPrevious": "Rechercher précédent", + "save": "Enregistrer", + "selectAll": "Tout sélectionner", + "undo": "Annuler", + "redo": "Rétablir", + "goToLine": "Aller à la ligne", + "moveLineUp": "Monter la ligne", + "moveLineDown": "Descendre la ligne", + "toggleComment": "Basculer le commentaire", + "indent": "Augmenter l'indentation", + "outdent": "Diminuer l'indentation", + "autoComplete": "Auto-complétion", + "imageLoadError": "Échec du chargement de l'image", + "rotate": "Faire pivoter", + "originalSize": "Taille d'origine", + "startTyping": "Commencez à taper...", + "unknownSize": "Taille inconnue", + "fileIsEmpty": "Le fichier est vide", + "largeFileWarning": "Avertissement fichier volumineux", + "largeFileWarningDesc": "Ce fichier pèse {{size}}, ce qui peut provoquer des problèmes de performance lorsqu'il est ouvert en texte.", + "fileNotFoundAndRemoved": "Le fichier \"{{name}}\" est introuvable et a été retiré des fichiers récents/épinglés", + "failedToLoadFile": "Échec du chargement du fichier : {{error}}", + "serverErrorOccurred": "Une erreur serveur est survenue. Veuillez réessayer plus tard.", + "autoSaveFailed": "Échec de l'enregistrement automatique", + "fileAutoSaved": "Fichier enregistré automatiquement", + "moveFileFailed": "Échec du déplacement de {{name}}", + "moveOperationFailed": "Échec de l'opération de déplacement", + "canOnlyCompareFiles": "Seuls deux fichiers peuvent être comparés", + "comparingFiles": "Comparaison de {{file1}} et {{file2}}", + "dragFailed": "Échec du glisser-déposer", + "filePinnedSuccessfully": "Fichier \"{{name}}\" épinglé avec succès", + "pinFileFailed": "Échec de l'épinglage du fichier", + "fileUnpinnedSuccessfully": "Fichier \"{{name}}\" désépinglé avec succès", + "unpinFileFailed": "Échec du désépinglage du fichier", + "shortcutAddedSuccessfully": "Raccourci de dossier \"{{name}}\" ajouté avec succès", + "addShortcutFailed": "Échec de l'ajout du raccourci", + "operationCompletedSuccessfully": "{{operation}} {{count}} éléments avec succès", + "operationCompleted": "{{operation}} {{count}} éléments", + "downloadFileSuccess": "Fichier {{name}} téléchargé avec succès", + "downloadFileFailed": "Échec du téléchargement", + "moveTo": "Déplacer vers {{name}}", + "diffCompareWith": "Comparer (diff) avec {{name}}", + "dragOutsideToDownload": "Faites glisser hors de la fenêtre pour télécharger ({{count}} fichiers)", + "newFolderDefault": "NouveauDossier", + "newFileDefault": "NouveauFichier.txt", + "successfullyMovedItems": "{{count}} éléments déplacés vers {{target}}", + "move": "Déplacer", + "searchInFile": "Rechercher dans le fichier (Ctrl+F)", + "showKeyboardShortcuts": "Afficher les raccourcis clavier", + "startWritingMarkdown": "Commencez à écrire votre contenu markdown...", + "loadingFileComparison": "Chargement de la comparaison de fichiers...", + "reload": "Recharger", + "compare": "Comparer", + "sideBySide": "Côte à côte", + "inline": "En ligne", + "fileComparison": "Comparaison de fichiers : {{file1}} vs {{file2}}", + "fileTooLarge": "Fichier trop volumineux : {{error}}", + "sshConnectionFailed": "La connexion SSH a échoué. Vérifiez votre connexion à {{name}} ({{ip}}:{{port}})", + "loadFileFailed": "Échec du chargement du fichier : {{error}}", + "connectedSuccessfully": "Connexion réussie", + "totpVerificationFailed": "Échec de la vérification TOTP" + }, + "tunnels": { + "title": "Tunnels SSH", + "noSshTunnels": "Aucun tunnel SSH", + "createFirstTunnelMessage": "Créez votre premier tunnel SSH pour commencer. Utilisez le Gestionnaire SSH pour ajouter des hôtes avec des connexions de tunnel.", + "connected": "Connecté", + "disconnected": "Déconnecté", + "connecting": "Connexion...", + "disconnecting": "Déconnexion...", + "unknownTunnelStatus": "Inconnu", + "unknown": "Inconnu", + "error": "Erreur", + "failed": "Échec", + "retrying": "Nouvelle tentative", + "waiting": "En attente", + "waitingForRetry": "En attente d'une nouvelle tentative", + "retryingConnection": "Nouvelle tentative de connexion", + "canceling": "Annulation...", + "connect": "Connecter", + "disconnect": "Déconnecter", + "cancel": "Annuler", + "port": "Port", + "attempt": "Tentative {{current}} sur {{max}}", + "nextRetryIn": "Prochaine tentative dans {{seconds}} secondes", + "checkDockerLogs": "Consultez vos logs Docker pour connaître la cause, rejoignez le", + "noTunnelConnections": "Aucune connexion de tunnel configurée", + "tunnelConnections": "Connexions de tunnel", + "addTunnel": "Ajouter un tunnel", + "editTunnel": "Modifier le tunnel", + "deleteTunnel": "Supprimer le tunnel", + "tunnelName": "Nom du tunnel", + "localPort": "Port local", + "remoteHost": "Hôte distant", + "remotePort": "Port distant", + "autoStart": "Démarrage auto", + "status": "Statut", + "active": "Actif", + "inactive": "Inactif", + "start": "Démarrer", + "stop": "Arrêter", + "restart": "Redémarrer", + "connectionType": "Type de connexion", + "local": "Local", + "remote": "Distant", + "dynamic": "Dynamique", + "unknownConnectionStatus": "Statut inconnu", + "portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", + "endpointHostNotFound": "Hôte de destination introuvable", + "discord": "Discord", + "githubIssue": "ticket GitHub", + "forHelp": "pour obtenir de l'aide" + }, + "serverStats": { + "title": "Statistiques serveur", + "cpu": "CPU", + "memory": "Mémoire", + "disk": "Disque", + "network": "Réseau", + "uptime": "Durée de fonctionnement", + "loadAverage": "Moy. : {{avg1}}, {{avg5}}, {{avg15}}", + "processes": "Processus", + "connections": "Connexions", + "usage": "Utilisation", + "available": "Disponible", + "total": "Total", + "free": "Libre", + "used": "Utilisé", + "percentage": "Pourcentage", + "refreshStatusAndMetrics": "Actualiser le statut et les métriques", + "refreshStatus": "Actualiser le statut", + "fileManagerAlreadyOpen": "Gestionnaire de fichiers déjà ouvert pour cet hôte", + "openFileManager": "Ouvrir le gestionnaire de fichiers", + "cpuCores_one": "{{count}} CPU", + "cpuCores_other": "{{count}} CPU", + "naCpus": "N/A CPU", + "loadAverageNA": "Moy. : N/A", + "cpuUsage": "Utilisation CPU", + "memoryUsage": "Utilisation mémoire", + "diskUsage": "Utilisation disque", + "rootStorageSpace": "Espace de stockage racine", + "of": "sur", + "feedbackMessage": "Besoin d'aide ? Laissez un commentaire sur", + "failedToFetchHostConfig": "Échec de la récupération de la configuration de l'hôte", + "failedToFetchStatus": "Échec de la récupération du statut", + "failedToFetchMetrics": "Échec de la récupération des métriques", + "failedToFetchHomeData": "Échec de la récupération des données d'accueil", + "loadingMetrics": "Chargement des métriques...", + "refreshing": "Actualisation...", + "serverOffline": "Serveur hors ligne", + "cannotFetchMetrics": "Impossible de récupérer les métriques", + "totpRequired": "Code TOTP requis", + "totpUnavailable": "TOTP indisponible", + "load": "Charge", + "editLayout": "Modifier la disposition", + "cancelEdit": "Annuler la modification", + "addWidget": "Ajouter un widget", + "saveLayout": "Enregistrer la disposition", + "unsavedChanges": "Modifications non enregistrées", + "layoutSaved": "Disposition enregistrée", + "failedToSaveLayout": "Échec de l'enregistrement de la disposition", + "systemInfo": "Infos système", + "hostname": "Nom d'hôte", + "operatingSystem": "Système d'exploitation", + "kernel": "Noyau", + "totalUptime": "Temps de fonctionnement total", + "seconds": "secondes", + "networkInterfaces": "Interfaces réseau", + "noInterfacesFound": "Aucune interface trouvée", + "totalProcesses": "Processus totaux", + "running": "En cours d'exécution", + "noProcessesFound": "Aucun processus trouvé" + }, + "auth": { + "loginTitle": "Connexion à Termix", + "registerTitle": "Créer un compte", + "loginButton": "Connexion", + "registerButton": "Inscription", + "forgotPassword": "Mot de passe oublié ?", + "rememberMe": "Se souvenir de moi", + "noAccount": "Pas encore de compte ?", + "hasAccount": "Vous avez déjà un compte ?", + "loginSuccess": "Connexion réussie", + "loginFailed": "Échec de la connexion", + "registerSuccess": "Inscription réussie", + "registerFailed": "Échec de l'inscription", + "logoutSuccess": "Déconnexion réussie", + "invalidCredentials": "Nom d'utilisateur ou mot de passe invalide", + "accountCreated": "Compte créé avec succès", + "passwordReset": "Lien de réinitialisation envoyé", + "twoFactorAuth": "Authentification à deux facteurs", + "enterCode": "Saisissez le code de vérification", + "backupCode": "Ou utilisez un code de secours", + "verifyCode": "Vérifier le code", + "redirectingToApp": "Redirection vers l'application...", + "enableTwoFactor": "Activer l'authentification à deux facteurs", + "disableTwoFactor": "Désactiver l'authentification à deux facteurs", + "scanQRCode": "Scannez ce QR code avec votre application d'authentification", + "backupCodes": "Codes de secours", + "saveBackupCodes": "Enregistrez ces codes de secours en lieu sûr", + "twoFactorEnabledSuccess": "Authentification à deux facteurs activée avec succès !", + "twoFactorDisabled": "Authentification à deux facteurs désactivée", + "newBackupCodesGenerated": "Nouveaux codes de secours générés", + "backupCodesDownloaded": "Codes de secours téléchargés", + "pleaseEnterSixDigitCode": "Veuillez saisir un code à 6 chiffres", + "invalidVerificationCode": "Code de vérification invalide", + "failedToDisableTotp": "Échec de la désactivation de TOTP", + "failedToGenerateBackupCodes": "Échec de la génération des codes de secours", + "enterPassword": "Saisissez votre mot de passe", + "lockedOidcAuth": "Verrouillé (authentification OIDC)", + "twoFactorTitle": "Authentification à deux facteurs", + "twoFactorProtected": "Votre compte est protégé par l'authentification à deux facteurs", + "twoFactorActive": "L'authentification à deux facteurs est active sur votre compte", + "disable2FA": "Désactiver la 2FA", + "disableTwoFactorWarning": "Désactiver la 2FA rendra votre compte moins sécurisé", + "passwordOrTotpCode": "Mot de passe ou code TOTP", + "or": "Ou", + "generateNewBackupCodesText": "Générez de nouveaux codes de secours si vous avez perdu les précédents", + "generateNewBackupCodes": "Générer de nouveaux codes de secours", + "yourBackupCodes": "Vos codes de secours", + "download": "Télécharger", + "setupTwoFactorTitle": "Configurer l'authentification à deux facteurs", + "sshAuthenticationRequired": "Authentification SSH requise", + "sshNoKeyboardInteractive": "Authentification clavier-interactif indisponible", + "sshAuthenticationFailed": "Échec de l'authentification", + "sshAuthenticationTimeout": "Délai d'authentification dépassé", + "sshNoKeyboardInteractiveDescription": "Le serveur ne prend pas en charge l'authentification clavier-interactif. Veuillez fournir votre mot de passe ou votre clé SSH.", + "sshAuthFailedDescription": "Les identifiants fournis sont incorrects. Veuillez réessayer avec des identifiants valides.", + "sshTimeoutDescription": "La tentative d'authentification a expiré. Veuillez réessayer.", + "sshProvideCredentialsDescription": "Fournissez vos identifiants SSH pour vous connecter à ce serveur.", + "sshPasswordDescription": "Entrez le mot de passe pour cette connexion SSH.", + "sshKeyPasswordDescription": "Si votre clé SSH est chiffrée, entrez la phrase secrète ici.", + "step1ScanQR": "Étape 1 : Scannez le QR code avec votre application d'authentification", + "manualEntryCode": "Code à saisir manuellement", + "cannotScanQRText": "Si vous ne pouvez pas scanner le QR code, saisissez ce code manuellement dans votre application.", + "nextVerifyCode": "Étape suivante : vérifier le code", + "verifyAuthenticator": "Vérifiez votre application", + "step2EnterCode": "Étape 2 : Entrez le code à 6 chiffres de l'application", + "verificationCode": "Code de vérification", + "back": "Retour", + "verifyAndEnable": "Vérifier et activer", + "saveBackupCodesTitle": "Sauvegardez vos codes de secours", + "step3StoreCodesSecurely": "Étape 3 : Conservez ces codes en lieu sûr", + "importantBackupCodesText": "Conservez ces codes en lieu sûr. Ils permettent d'accéder à votre compte si vous perdez votre appareil.", + "completeSetup": "Terminer la configuration", + "notEnabledText": "La 2FA ajoute une couche de sécurité supplémentaire en demandant un code lors de la connexion.", + "enableTwoFactorButton": "Activer l'authentification à deux facteurs", + "addExtraSecurityLayer": "Ajoutez une couche de sécurité à votre compte", + "firstUser": "Premier utilisateur", + "firstUserMessage": "Vous êtes le premier utilisateur et serez défini comme administrateur. Vous pouvez consulter les paramètres d'administration dans le menu utilisateur de la barre latérale. Si c'est une erreur, vérifiez les logs Docker ou créez un ticket GitHub.", + "external": "Externe", + "loginWithExternal": "Se connecter via un fournisseur externe", + "loginWithExternalDesc": "Connectez-vous avec votre fournisseur d'identité externe configuré", + "externalNotSupportedInElectron": "L'authentification externe n'est pas encore disponible dans l'application Electron. Utilisez la version web pour OIDC.", + "resetPasswordButton": "Réinitialiser le mot de passe", + "sendResetCode": "Envoyer le code de réinitialisation", + "resetCodeDesc": "Saisissez votre nom d'utilisateur pour recevoir un code de réinitialisation. Le code sera visible dans les logs du conteneur Docker.", + "resetCode": "Code de réinitialisation", + "verifyCodeButton": "Vérifier le code", + "enterResetCode": "Entrez le code à 6 chiffres des logs Docker pour l'utilisateur :", + "goToLogin": "Aller à la connexion", + "newPassword": "Nouveau mot de passe", + "confirmNewPassword": "Confirmer le mot de passe", + "enterNewPassword": "Entrez votre nouveau mot de passe pour l'utilisateur :", + "signUp": "Créer un compte", + "mobileApp": "Application mobile", + "loggingInToMobileApp": "Connexion à l'application mobile", + "desktopApp": "Application de bureau", + "loggingInToDesktopApp": "Connexion à l'application de bureau", + "loggingInToDesktopAppViaWeb": "Connexion à l'application de bureau via l'interface web", + "loadingServer": "Chargement du serveur...", + "authenticating": "Authentification...", + "dataLossWarning": "Réinitialiser votre mot de passe de cette manière supprimera tous vos hôtes, identifiants et autres données chiffrées. Action irréversible. À utiliser uniquement si vous avez oublié votre mot de passe et n'êtes pas connecté.", + "authenticationDisabled": "Authentification désactivée", + "authenticationDisabledDesc": "Toutes les méthodes d'authentification sont actuellement désactivées. Contactez votre administrateur." + }, + "errors": { + "notFound": "Page introuvable", + "unauthorized": "Accès non autorisé", + "forbidden": "Accès interdit", + "serverError": "Erreur serveur", + "networkError": "Erreur réseau", + "databaseConnection": "Impossible de se connecter à la base de données", + "unknownError": "Erreur inconnue", + "loginFailed": "Échec de la connexion", + "failedPasswordReset": "Échec du lancement de la réinitialisation du mot de passe", + "failedVerifyCode": "Échec de la vérification du code de réinitialisation", + "failedCompleteReset": "Échec de la finalisation de la réinitialisation du mot de passe", + "invalidTotpCode": "Code TOTP invalide", + "failedOidcLogin": "Échec du démarrage de la connexion OIDC", + "failedUserInfo": "Échec de la récupération des informations utilisateur après connexion OIDC", + "oidcAuthFailed": "Échec de l'authentification OIDC", + "noTokenReceived": "Aucun jeton reçu après la connexion", + "invalidAuthUrl": "URL d'autorisation invalide reçue du backend", + "invalidInput": "Saisie invalide", + "requiredField": "Ce champ est obligatoire", + "minLength": "La longueur minimale est de {{min}}", + "maxLength": "La longueur maximale est de {{max}}", + "invalidEmail": "Adresse e-mail invalide", + "passwordMismatch": "Les mots de passe ne correspondent pas", + "passwordLoginDisabled": "La connexion par nom d'utilisateur/mot de passe est désactivée", + "weakPassword": "Mot de passe trop faible", + "usernameExists": "Ce nom d'utilisateur existe déjà", + "emailExists": "Cet e-mail existe déjà", + "loadFailed": "Échec du chargement des données", + "saveError": "Échec de l'enregistrement", + "sessionExpired": "Session expirée - veuillez vous reconnecter" + }, + "messages": { + "saveSuccess": "Enregistré avec succès", + "saveError": "Échec de l'enregistrement", + "deleteSuccess": "Supprimé avec succès", + "deleteError": "Échec de la suppression", + "updateSuccess": "Mis à jour avec succès", + "updateError": "Échec de la mise à jour", + "copySuccess": "Copié dans le presse-papiers", + "copyError": "Échec de la copie", + "copiedToClipboard": "{{item}} copié dans le presse-papiers", + "connectionEstablished": "Connexion établie", + "connectionClosed": "Connexion fermée", + "reconnecting": "Reconnexion...", + "processing": "Traitement...", + "pleaseWait": "Veuillez patienter...", + "registrationDisabled": "L'inscription de nouveaux comptes est actuellement désactivée par un administrateur. Connectez-vous ou contactez un administrateur.", + "databaseConnected": "Base de données connectée avec succès", + "databaseConnectionFailed": "Échec de la connexion au serveur de base de données", + "checkServerConnection": "Vérifiez votre connexion au serveur puis réessayez", + "resetCodeSent": "Code de réinitialisation envoyé dans les logs Docker", + "codeVerified": "Code vérifié avec succès", + "passwordResetSuccess": "Mot de passe réinitialisé avec succès", + "loginSuccess": "Connexion réussie", + "registrationSuccess": "Inscription réussie" + }, + "profile": { + "title": "Profil utilisateur", + "description": "Gérez les paramètres et la sécurité de votre compte", + "security": "Sécurité", + "changePassword": "Changer le mot de passe", + "twoFactorAuth": "Authentification à deux facteurs", + "accountInfo": "Informations du compte", + "role": "Rôle", + "admin": "Administrateur", + "user": "Utilisateur", + "authMethod": "Méthode d'authentification", + "local": "Local", + "external": "Externe (OIDC)", + "selectPreferredLanguage": "Choisissez votre langue préférée pour l'interface", + "currentPassword": "Mot de passe actuel", + "passwordChangedSuccess": "Mot de passe modifié avec succès ! Veuillez vous reconnecter.", + "failedToChangePassword": "Échec de la modification du mot de passe. Vérifiez votre mot de passe actuel et réessayez." + }, + "user": { + "failedToLoadVersionInfo": "Échec du chargement des informations de version" + }, + "placeholders": { + "enterCode": "000000", + "ipAddress": "127.0.0.1", + "port": "22", + "maxRetries": "3", + "retryInterval": "10", + "language": "Langue", + "username": "nom d'utilisateur", + "hostname": "nom d'hôte", + "folder": "dossier", + "password": "mot de passe", + "keyPassword": "mot de passe de la clé", + "pastePrivateKey": "Collez votre clé privée ici...", + "pastePublicKey": "Collez votre clé publique ici...", + "credentialName": "Mon serveur SSH", + "description": "Description de l'identifiant SSH", + "searchCredentials": "Recherchez des identifiants par nom, utilisateur ou labels...", + "sshConfig": "configuration SSH de destination", + "homePath": "/home", + "clientId": "votre-client-id", + "clientSecret": "votre-client-secret", + "authUrl": "https://votre-fournisseur.com/application/o/authorize/", + "redirectUrl": "https://votre-fournisseur.com/application/o/termix/", + "tokenUrl": "https://votre-fournisseur.com/application/o/token/", + "userIdField": "sub", + "usernameField": "name", + "scopes": "openid email profile", + "userinfoUrl": "https://votre-fournisseur.com/application/o/userinfo/", + "enterUsername": "Saisissez le nom d'utilisateur à promouvoir administrateur", + "searchHosts": "Recherchez des hôtes par nom, utilisateur, IP, dossier, labels...", + "enterPassword": "Entrez votre mot de passe", + "totpCode": "Code TOTP à 6 chiffres", + "searchHostsAny": "Recherchez des hôtes avec n'importe quelle info...", + "confirmPassword": "Entrez votre mot de passe pour confirmer", + "typeHere": "Tapez ici", + "fileName": "Saisissez un nom de fichier (ex. : exemple.txt)", + "folderName": "Saisissez un nom de dossier", + "fullPath": "Saisissez le chemin complet de l'élément", + "currentPath": "Saisissez le chemin actuel de l'élément", + "newName": "Saisissez le nouveau nom" + }, + "leftSidebar": { + "failedToLoadHosts": "Échec du chargement des hôtes", + "noFolder": "Sans dossier", + "passwordRequired": "Mot de passe requis", + "failedToDeleteAccount": "Échec de la suppression du compte", + "failedToMakeUserAdmin": "Échec de la promotion de l'utilisateur en administrateur", + "userIsNowAdmin": "L'utilisateur {{username}} est maintenant administrateur", + "removeAdminConfirm": "Voulez-vous vraiment retirer le statut d'administrateur à {{username}} ?", + "deleteUserConfirm": "Voulez-vous vraiment supprimer l'utilisateur {{username}} ? Cette action est irréversible.", + "deleteAccount": "Supprimer le compte", + "closeDeleteAccount": "Fermer la suppression de compte", + "deleteAccountWarning": "Cette action est irréversible. Elle supprimera définitivement votre compte et toutes les données associées.", + "deleteAccountWarningDetails": "Supprimer votre compte supprimera toutes vos données, y compris les hôtes SSH, configurations et paramètres. Action irréversible.", + "deleteAccountWarningShort": "Cette action est irréversible et supprimera définitivement votre compte.", + "cannotDeleteAccount": "Impossible de supprimer le compte", + "lastAdminWarning": "Vous êtes le dernier administrateur. Vous ne pouvez pas supprimer votre compte car le système n'aurait plus d'administrateur. Nommez d'abord un autre utilisateur administrateur ou contactez le support.", + "confirmPassword": "Confirmer le mot de passe", + "deleting": "Suppression...", + "cancel": "Annuler" + }, + "interface": { + "sidebar": "Barre latérale", + "toggleSidebar": "Afficher/masquer la barre latérale", + "close": "Fermer", + "online": "En ligne", + "offline": "Hors ligne", + "maintenance": "Maintenance", + "degraded": "Dégradé", + "noTunnelConnections": "Aucune connexion de tunnel configurée", + "discord": "Discord", + "connectToSshForOperations": "Connectez-vous en SSH pour utiliser les opérations sur les fichiers", + "uploadFile": "Importer un fichier", + "newFile": "Nouveau fichier", + "newFolder": "Nouveau dossier", + "rename": "Renommer", + "deleteItem": "Supprimer l'élément", + "createNewFile": "Créer un nouveau fichier", + "createNewFolder": "Créer un nouveau dossier", + "renameItem": "Renommer l'élément", + "clickToSelectFile": "Cliquez pour sélectionner un fichier", + "noSshHosts": "Aucun hôte SSH", + "sshHosts": "Hôtes SSH", + "importSshHosts": "Importer des hôtes SSH depuis un JSON", + "clientId": "ID client", + "clientSecret": "Secret client", + "error": "Erreur", + "warning": "Avertissement", + "deleteAccount": "Supprimer le compte", + "closeDeleteAccount": "Fermer la suppression de compte", + "cannotDeleteAccount": "Impossible de supprimer le compte", + "confirmPassword": "Confirmer le mot de passe", + "deleting": "Suppression...", + "externalAuth": "Authentification externe (OIDC)", + "configureExternalProvider": "Configurer un fournisseur d'identité externe pour", + "waitingForRetry": "En attente d'une nouvelle tentative", + "retryingConnection": "Nouvelle tentative de connexion", + "resetSplitSizes": "Réinitialiser les tailles de panneaux", + "sshManagerAlreadyOpen": "Gestionnaire SSH déjà ouvert", + "disabledDuringSplitScreen": "Désactivé en mode écran scindé", + "unknown": "Inconnu", + "connected": "Connecté", + "disconnected": "Déconnecté", + "maxRetriesExhausted": "Nombre maximal de tentatives atteint", + "endpointHostNotFound": "Hôte de destination introuvable", + "administrator": "Administrateur", + "user": "Utilisateur", + "external": "Externe", + "local": "Local", + "saving": "Enregistrement...", + "saveConfiguration": "Enregistrer la configuration", + "loading": "Chargement...", + "refresh": "Actualiser", + "adding": "Ajout...", + "makeAdmin": "Nommer administrateur", + "verifying": "Vérification...", + "verifyAndEnable": "Vérifier et activer", + "secretKey": "Clé secrète", + "totpQrCode": "QR code TOTP", + "passwordRequired": "Le mot de passe est requis avec l'authentification par mot de passe", + "sshKeyRequired": "La clé privée SSH est requise avec l'authentification par clé", + "keyTypeRequired": "Le type de clé est requis avec l'authentification par clé", + "validSshConfigRequired": "Vous devez sélectionner une configuration SSH valide dans la liste", + "updateHost": "Mettre à jour l'hôte", + "addHost": "Ajouter un hôte", + "editHost": "Modifier l'hôte", + "pinConnection": "Épingler la connexion", + "authentication": "Authentification", + "password": "Mot de passe", + "key": "Clé", + "sshPrivateKey": "Clé privée SSH", + "keyPassword": "Mot de passe de la clé", + "keyType": "Type de clé", + "enableTerminal": "Activer le terminal", + "enableTunnel": "Activer le tunnel", + "enableFileManager": "Activer le gestionnaire de fichiers", + "defaultPath": "Chemin par défaut", + "tunnelConnections": "Connexions de tunnel", + "maxRetries": "Nombre max de tentatives", + "upload": "Importer", + "updateKey": "Mettre à jour la clé", + "productionFolder": "Production", + "databaseServer": "Serveur de base de données", + "developmentServer": "Serveur de développement", + "developmentFolder": "Développement", + "webServerProduction": "Serveur web - Production", + "unknownError": "Erreur inconnue", + "failedToInitiatePasswordReset": "Échec du lancement de la réinitialisation du mot de passe", + "failedToVerifyResetCode": "Échec de la vérification du code de réinitialisation", + "failedToCompletePasswordReset": "Échec de la finalisation de la réinitialisation du mot de passe", + "invalidTotpCode": "Code TOTP invalide", + "failedToStartOidcLogin": "Échec du démarrage de la connexion OIDC", + "failedToGetUserInfoAfterOidc": "Échec de la récupération des infos utilisateur après connexion OIDC", + "loginWithExternalProvider": "Se connecter via un fournisseur externe", + "loginWithExternal": "Se connecter via un fournisseur externe", + "sendResetCode": "Envoyer le code de réinitialisation", + "verifyCode": "Vérifier le code", + "resetPassword": "Réinitialiser le mot de passe", + "login": "Connexion", + "signUp": "Créer un compte", + "failedToUpdateOidcConfig": "Échec de la mise à jour de la configuration OIDC", + "failedToMakeUserAdmin": "Échec de la promotion de l'utilisateur en administrateur", + "failedToStartTotpSetup": "Échec du lancement de la configuration TOTP", + "invalidVerificationCode": "Code de vérification invalide", + "failedToDisableTotp": "Échec de la désactivation de TOTP", + "failedToGenerateBackupCodes": "Échec de la génération des codes de secours" + }, + "mobile": { + "selectHostToStart": "Sélectionnez un hôte pour démarrer votre session terminal", + "limitedSupportMessage": "La version mobile du site est encore en cours d'amélioration. Utilisez l'application mobile pour une meilleure expérience.", + "mobileAppInProgress": "Application mobile en cours de développement", + "mobileAppInProgressDesc": "Nous travaillons sur une application mobile dédiée pour offrir une meilleure expérience sur mobile.", + "viewMobileAppDocs": "Installer l'application mobile", + "mobileAppDocumentation": "Documentation de l'application mobile" + }, + "dashboard": { + "title": "Tableau de bord", + "github": "GitHub officiel", + "support": "Assistance", + "discord": "Communauté Discord", + "donate": "Faire un don", + "serverOverview": "Vue d'ensemble du serveur", + "version": "Version logicielle", + "upToDate": "À jour", + "updateAvailable": "Mise à jour disponible", + "uptime": "Durée de fonctionnement", + "database": "Base de données", + "healthy": "Opérationnel", + "error": "Erreur", + "totalServers": "Serveurs", + "totalTunnels": "Tunnels", + "totalCredentials": "Identifiants", + "recentActivity": "Activité récente", + "reset": "Réinitialiser", + "loadingRecentActivity": "Chargement de l'activité récente...", + "noRecentActivity": "Aucune activité récente", + "quickActions": "Actions rapides", + "addHost": "Ajouter un hôte", + "addCredential": "Ajouter un identifiant", + "adminSettings": "Paramètres d'administration", + "userProfile": "Profil utilisateur", + "serverStats": "Statistiques serveur", + "loadingServerStats": "Chargement des statistiques serveur...", + "noServerData": "Aucune donnée serveur disponible", + "cpu": "Processeur (CPU)", + "ram": "Mémoire (RAM)", + "notAvailable": "N/D" + } +} diff --git a/src/ui/desktop/user/LanguageSwitcher.tsx b/src/ui/desktop/user/LanguageSwitcher.tsx index f36dfa0e..0adc6658 100644 --- a/src/ui/desktop/user/LanguageSwitcher.tsx +++ b/src/ui/desktop/user/LanguageSwitcher.tsx @@ -19,6 +19,7 @@ const languages = [ nativeName: "Português Brasileiro", }, { code: "ru", name: "Russian", nativeName: "Русский" }, + { code: "fr", name: "French", nativeName: "Français" }, ]; export function LanguageSwitcher() { -- 2.49.1 From 45176bc73523e4cadac001830728876e1defbc78 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 9 Nov 2025 21:47:12 -0600 Subject: [PATCH 09/34] feat: Replace the old ssh tools system with a new dedicated sidebar --- src/backend/utils/system-crypto.ts | 1 - src/types/index.ts | 2 + src/ui/desktop/DesktopApp.tsx | 140 ++- src/ui/desktop/admin/AdminSettings.tsx | 6 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 7 +- .../desktop/apps/host-manager/HostManager.tsx | 4 +- .../desktop/apps/terminal/SnippetsSidebar.tsx | 480 ---------- src/ui/desktop/apps/tools/SSHToolsSidebar.tsx | 351 ------- .../desktop/apps/tools/SSHUtilitySidebar.tsx | 901 ++++++++++++++++++ src/ui/desktop/apps/tools/ToolsMenu.tsx | 69 -- src/ui/desktop/navigation/AppView.tsx | 6 +- src/ui/desktop/navigation/TopNavbar.tsx | 61 +- src/ui/desktop/user/UserProfile.tsx | 10 +- 13 files changed, 1060 insertions(+), 978 deletions(-) delete mode 100644 src/ui/desktop/apps/terminal/SnippetsSidebar.tsx delete mode 100644 src/ui/desktop/apps/tools/SSHToolsSidebar.tsx create mode 100644 src/ui/desktop/apps/tools/SSHUtilitySidebar.tsx delete mode 100644 src/ui/desktop/apps/tools/ToolsMenu.tsx diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 41aa2ff1..d00330a7 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -111,7 +111,6 @@ class SystemCrypto { } else { } } catch (fileError) { - // OK: .env file not found or unreadable, will generate new database key databaseLogger.debug( ".env file not accessible, will generate new database key", { diff --git a/src/types/index.ts b/src/types/index.ts index c19b57e8..cf94b2f4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -371,6 +371,8 @@ export interface HostManagerProps { isTopbarOpen?: boolean; initialTab?: string; hostConfig?: SSHHost; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; } export interface SSHManagerHostEditorProps { diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index bd652536..1aba3b30 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -24,9 +24,13 @@ function AppContent() { return saved !== null ? JSON.parse(saved) : true; }); const [isTransitioning, setIsTransitioning] = useState(false); - const [transitionPhase, setTransitionPhase] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle'); + const [transitionPhase, setTransitionPhase] = useState< + "idle" | "fadeOut" | "fadeIn" + >("idle"); const { currentTab, tabs } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); + const [rightSidebarOpen, setRightSidebarOpen] = useState(false); + const [rightSidebarWidth, setRightSidebarWidth] = useState(400); const lastShiftPressTime = useRef(0); @@ -101,17 +105,17 @@ function AppContent() { userId: string | null; }) => { setIsTransitioning(true); - setTransitionPhase('fadeOut'); + setTransitionPhase("fadeOut"); setTimeout(() => { setIsAuthenticated(true); setIsAdmin(authData.isAdmin); setUsername(authData.username); - setTransitionPhase('fadeIn'); + setTransitionPhase("fadeIn"); setTimeout(() => { setIsTransitioning(false); - setTransitionPhase('idle'); + setTransitionPhase("idle"); }, 800); }, 1200); }, @@ -120,7 +124,7 @@ function AppContent() { const handleLogout = useCallback(async () => { setIsTransitioning(true); - setTransitionPhase('fadeOut'); + setTransitionPhase("fadeOut"); setTimeout(async () => { try { @@ -168,17 +172,21 @@ function AppContent() { {isAuthenticated && ( + onSelectView={handleSelectView} + disabled={!isAuthenticated || authLoading} + isAdmin={isAdmin} + username={username} + onLogout={handleLogout} + >
- +
{showHome && ( @@ -189,6 +197,8 @@ function AppContent() { authLoading={authLoading} onAuthSuccess={handleAuthSuccess} isTopbarOpen={isTopbarOpen} + rightSidebarOpen={rightSidebarOpen} + rightSidebarWidth={rightSidebarWidth} />

)} @@ -200,19 +210,29 @@ function AppContent() { isTopbarOpen={isTopbarOpen} initialTab={currentTabData?.initialTab} hostConfig={currentTabData?.hostConfig} + rightSidebarOpen={rightSidebarOpen} + rightSidebarWidth={rightSidebarWidth} />
)} {showAdmin && (
- +
)} {showProfile && (
- +
)} @@ -220,6 +240,10 @@ function AppContent() { isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen} onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} + onRightSidebarStateChange={(isOpen, width) => { + setRightSidebarOpen(isOpen); + setRightSidebarWidth(width); + }} /> )} @@ -227,51 +251,69 @@ function AppContent() { {isTransitioning && (
- {transitionPhase === 'fadeOut' && ( + {transitionPhase === "fadeOut" && ( <>
-
-
-
-
-
-
+
+
TERMIX
-
+
SSH TERMINAL MANAGER
diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 02c45dfd..a25cabc5 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -59,10 +59,14 @@ import { interface AdminSettingsProps { isTopbarOpen?: boolean; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; } export function AdminSettings({ isTopbarOpen = true, + rightSidebarOpen = false, + rightSidebarWidth = 400, }: AdminSettingsProps): React.ReactElement { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); @@ -637,7 +641,7 @@ export function AdminSettings({ const bottomMarginPx = 8; const wrapperStyle: React.CSSProperties = { marginLeft: leftMarginPx, - marginRight: 17, + marginRight: rightSidebarOpen ? rightSidebarWidth + 17 : 17, marginTop: topMarginPx, marginBottom: bottomMarginPx, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 69e2962e..249dd98c 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -50,6 +50,8 @@ interface DashboardProps { userId: string | null; }) => void; isTopbarOpen: boolean; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; } export function Dashboard({ @@ -58,6 +60,8 @@ export function Dashboard({ onAuthSuccess, isTopbarOpen, onSelectView, + rightSidebarOpen = false, + rightSidebarWidth = 400, }: DashboardProps): React.ReactElement { const { t } = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); @@ -97,6 +101,7 @@ export function Dashboard({ const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; + const rightMarginPx = rightSidebarOpen ? rightSidebarWidth + 17 : 17; const bottomMarginPx = 8; useEffect(() => { @@ -336,7 +341,7 @@ export function Dashboard({ className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex" style={{ marginLeft: leftMarginPx, - marginRight: 17, + marginRight: rightMarginPx, marginTop: topMarginPx, marginBottom: bottomMarginPx, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, diff --git a/src/ui/desktop/apps/host-manager/HostManager.tsx b/src/ui/desktop/apps/host-manager/HostManager.tsx index dff7b923..31b4af93 100644 --- a/src/ui/desktop/apps/host-manager/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/HostManager.tsx @@ -18,6 +18,8 @@ export function HostManager({ isTopbarOpen, initialTab = "host_viewer", hostConfig, + rightSidebarOpen = false, + rightSidebarWidth = 400, }: HostManagerProps): React.ReactElement { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState(initialTab); @@ -90,7 +92,7 @@ export function HostManager({ className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden" style={{ marginLeft: leftMarginPx, - marginRight: 17, + marginRight: rightSidebarOpen ? rightSidebarWidth + 17 : 17, marginTop: topMarginPx, marginBottom: bottomMarginPx, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, diff --git a/src/ui/desktop/apps/terminal/SnippetsSidebar.tsx b/src/ui/desktop/apps/terminal/SnippetsSidebar.tsx deleted file mode 100644 index 424ec142..00000000 --- a/src/ui/desktop/apps/terminal/SnippetsSidebar.tsx +++ /dev/null @@ -1,480 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Separator } from "@/components/ui/separator"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Plus, Play, Edit, Trash2, Copy, X } from "lucide-react"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { useConfirmation } from "@/hooks/use-confirmation.ts"; -import { - getSnippets, - createSnippet, - updateSnippet, - deleteSnippet, -} from "@/ui/main-axios"; -import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; -import type { Snippet, SnippetData } from "../../../../types/index.js"; - -interface TabData { - id: number; - type: string; - title: string; - terminalRef?: { - current?: { - sendInput?: (data: string) => void; - }; - }; - [key: string]: unknown; -} - -interface SnippetsSidebarProps { - isOpen: boolean; - onClose: () => void; - onExecute: (content: string) => void; -} - -export function SnippetsSidebar({ - isOpen, - onClose, - onExecute, -}: SnippetsSidebarProps) { - const { t } = useTranslation(); - const { confirmWithToast } = useConfirmation(); - const { tabs } = useTabs() as { tabs: TabData[] }; - const [snippets, setSnippets] = useState([]); - const [loading, setLoading] = useState(true); - const [showDialog, setShowDialog] = useState(false); - const [editingSnippet, setEditingSnippet] = useState(null); - const [formData, setFormData] = useState({ - name: "", - content: "", - description: "", - }); - const [formErrors, setFormErrors] = useState({ - name: false, - content: false, - }); - const [selectedTabIds, setSelectedTabIds] = useState([]); - - useEffect(() => { - if (isOpen) { - fetchSnippets(); - } - }, [isOpen]); - - const fetchSnippets = async () => { - try { - setLoading(true); - const data = await getSnippets(); - setSnippets(Array.isArray(data) ? data : []); - } catch { - toast.error(t("snippets.failedToFetch")); - setSnippets([]); - } finally { - setLoading(false); - } - }; - - const handleCreate = () => { - setEditingSnippet(null); - setFormData({ name: "", content: "", description: "" }); - setFormErrors({ name: false, content: false }); - setShowDialog(true); - }; - - const handleEdit = (snippet: Snippet) => { - setEditingSnippet(snippet); - setFormData({ - name: snippet.name, - content: snippet.content, - description: snippet.description || "", - }); - setFormErrors({ name: false, content: false }); - setShowDialog(true); - }; - - const handleDelete = (snippet: Snippet) => { - confirmWithToast( - t("snippets.deleteConfirmDescription", { name: snippet.name }), - async () => { - try { - await deleteSnippet(snippet.id); - toast.success(t("snippets.deleteSuccess")); - fetchSnippets(); - } catch { - toast.error(t("snippets.deleteFailed")); - } - }, - "destructive", - ); - }; - - const handleSubmit = async () => { - const errors = { - name: !formData.name.trim(), - content: !formData.content.trim(), - }; - - setFormErrors(errors); - - if (errors.name || errors.content) { - return; - } - - try { - if (editingSnippet) { - await updateSnippet(editingSnippet.id, formData); - toast.success(t("snippets.updateSuccess")); - } else { - await createSnippet(formData); - toast.success(t("snippets.createSuccess")); - } - setShowDialog(false); - fetchSnippets(); - } catch { - toast.error( - editingSnippet - ? t("snippets.updateFailed") - : t("snippets.createFailed"), - ); - } - }; - - const handleTabToggle = (tabId: number) => { - setSelectedTabIds((prev) => - prev.includes(tabId) - ? prev.filter((id) => id !== tabId) - : [...prev, tabId], - ); - }; - - const handleExecute = (snippet: Snippet) => { - if (selectedTabIds.length > 0) { - selectedTabIds.forEach((tabId) => { - const tab = tabs.find((t: TabData) => t.id === tabId); - if (tab?.terminalRef?.current?.sendInput) { - tab.terminalRef.current.sendInput(snippet.content + "\n"); - } - }); - toast.success( - t("snippets.executeSuccess", { - name: snippet.name, - count: selectedTabIds.length, - }), - ); - } else { - onExecute(snippet.content); - toast.success(t("snippets.executeSuccess", { name: snippet.name })); - } - }; - - const handleCopy = (snippet: Snippet) => { - navigator.clipboard.writeText(snippet.content); - toast.success(t("snippets.copySuccess", { name: snippet.name })); - }; - - const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal"); - - if (!isOpen) return null; - - return ( - <> -
-
- -
e.stopPropagation()} - > -
-

- {t("snippets.title")} -

- -
- -
-
- {terminalTabs.length > 0 && ( - <> -
- -

- {selectedTabIds.length > 0 - ? t("snippets.executeOnSelected", { - defaultValue: `Execute on ${selectedTabIds.length} selected terminal(s)`, - count: selectedTabIds.length, - }) - : t("snippets.executeOnCurrent", { - defaultValue: - "Execute on current terminal (click to select multiple)", - })} -

-
- {terminalTabs.map((tab) => ( - - ))} -
-
- - - )} - - - - {loading ? ( -
-

{t("common.loading")}

-
- ) : snippets.length === 0 ? ( -
-

{t("snippets.empty")}

-

{t("snippets.emptyHint")}

-
- ) : ( - -
- {snippets.map((snippet) => ( -
-
-

- {snippet.name} -

- {snippet.description && ( -

- {snippet.description} -

- )} -
- -
- - {snippet.content} - -
- -
- - - - - -

{t("snippets.runTooltip")}

-
-
- - - - - - -

{t("snippets.copyTooltip")}

-
-
- - - - - - -

{t("snippets.editTooltip")}

-
-
- - - - - - -

{t("snippets.deleteTooltip")}

-
-
-
-
- ))} -
-
- )} -
-
-
-
- - {showDialog && ( -
setShowDialog(false)} - > -
e.stopPropagation()} - > -
-

- {editingSnippet ? t("snippets.edit") : t("snippets.create")} -

-

- {editingSnippet - ? t("snippets.editDescription") - : t("snippets.createDescription")} -

-
- -
-
- - - setFormData({ ...formData, name: e.target.value }) - } - placeholder={t("snippets.namePlaceholder")} - className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`} - autoFocus - /> - {formErrors.name && ( -

- {t("snippets.nameRequired")} -

- )} -
- -
- - - setFormData({ ...formData, description: e.target.value }) - } - placeholder={t("snippets.descriptionPlaceholder")} - /> -
- -
- -