From 8366c99b0fea857f978b35e5557c791c3c8f0cc4 Mon Sep 17 00:00:00 2001 From: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:46:05 -0600 Subject: [PATCH] v1.9.0 (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Resolve database encryption atomicity issues and enhance debugging (#430) * 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> * fix: Merge metadata and DB into 1 file * fix: Add initial command palette * 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" * feat: Finalize command palette * fix: Several bug fixes for terminals, server stats, and general feature improvements * 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 * French translation (#434) * Adding French Language * Enhancements * feat: Replace the old ssh tools system with a new dedicated sidebar * fix: Merge zac/luke * fix: Finalize new sidebar, improve and loading animations * Added ability to close non-primary tabs involved in a split view (#435) * fix: General bug fixes/small feature improvements * feat: General UI improvements and translation updates * fix: Command history and file manager styling issues * feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc * fix: add Accept header for OIDC callback request (#436) * Delete DOWNLOADS.md * fix: add Accept header for OIDC callback request --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: More bug fixes and QOL fixes * fix: Server stats not respecting interval and fixed SSH toool type issues * fix: Remove github links * fix: Delete account spacing * fix: Increment version * fix: Unable to delete hosts and add nginx for terminal * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: OIDC/local account linking breaking both logins * chore: File cleanup * feat: Max terminal tab size and save current file manager sorting type * fix: Terminal display issue, migrate host editor to use combobox * feat: Add snippet folder/customization system * fix: Fix OIDC linking and prep release * fix: Increment version --------- Co-authored-by: ZacharyZcR Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Max Co-authored-by: SlimGary Co-authored-by: jarrah31 Co-authored-by: Kf637 --- .github/workflows/docker.yml | 3 +- .github/workflows/electron.yml | 1 - README-CN.md | 15 +- README.md | 15 +- docker/nginx-https.conf | 9 + docker/nginx.conf | 9 + electron/main.cjs | 42 + electron/preload.js | 3 + package-lock.json | 87 +- package.json | 3 +- src/backend/database/database.ts | 32 +- src/backend/database/db/index.ts | 119 +- src/backend/database/db/schema.ts | 89 +- src/backend/database/routes/credentials.ts | 21 +- src/backend/database/routes/snippets.ts | 683 ++++- src/backend/database/routes/ssh.ts | 491 +++- src/backend/database/routes/terminal.ts | 195 ++ src/backend/database/routes/users.ts | 487 +++- src/backend/ssh/file-manager.ts | 754 +++++- src/backend/ssh/server-stats.ts | 773 +++--- src/backend/ssh/terminal.ts | 318 ++- src/backend/ssh/tunnel.ts | 10 +- src/backend/ssh/widgets/common-utils.ts | 42 + src/backend/ssh/widgets/cpu-collector.ts | 83 + src/backend/ssh/widgets/disk-collector.ts | 67 + .../ssh/widgets/login-stats-collector.ts | 122 + src/backend/ssh/widgets/memory-collector.ts | 41 + src/backend/ssh/widgets/network-collector.ts | 79 + .../ssh/widgets/processes-collector.ts | 63 + src/backend/ssh/widgets/system-collector.ts | 37 + src/backend/ssh/widgets/uptime-collector.ts | 35 + src/backend/starter.ts | 2 +- src/backend/utils/auth-manager.ts | 56 +- src/backend/utils/auto-ssl-setup.ts | 2 +- src/backend/utils/database-file-encryption.ts | 391 ++- src/backend/utils/lazy-field-encryption.ts | 4 +- src/backend/utils/login-rate-limiter.ts | 146 ++ src/backend/utils/ssh-key-utils.ts | 9 +- src/backend/utils/system-crypto.ts | 36 +- src/backend/utils/user-crypto.ts | 187 +- src/components/ui/button.tsx | 2 +- src/components/ui/command.tsx | 184 ++ src/components/ui/dialog.tsx | 141 ++ src/components/ui/input.tsx | 2 +- src/components/ui/kbd.tsx | 28 + src/components/ui/tabs.tsx | 10 +- src/constants/terminal-themes.ts | 1 - src/i18n/i18n.ts | 6 +- src/locales/de/translation.json | 182 +- src/locales/en/translation.json | 212 +- src/locales/fr/translation.json | 1609 ++++++++++++ src/locales/pt-BR/translation.json | 41 +- src/locales/ru/translation.json | 138 +- src/locales/zh/translation.json | 179 +- src/types/index.ts | 65 + src/types/stats-widgets.ts | 13 +- src/ui/desktop/DesktopApp.tsx | 308 ++- src/ui/desktop/admin/AdminSettings.tsx | 448 +++- .../apps/command-palette/CommandPalette.tsx | 420 ++++ .../apps/credentials/CredentialEditor.tsx | 44 +- .../apps/credentials/CredentialsManager.tsx | 191 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 189 +- .../desktop/apps/file-manager/FileManager.tsx | 212 +- .../file-manager/FileManagerContextMenu.tsx | 125 +- .../apps/file-manager/FileManagerGrid.tsx | 113 +- .../components/CompressDialog.tsx | 158 ++ .../components/PermissionsDialog.tsx | 318 +++ .../desktop/apps/host-manager/HostManager.tsx | 38 +- .../apps/host-manager/HostManagerEditor.tsx | 1022 ++++++-- .../apps/host-manager/HostManagerViewer.tsx | 377 ++- .../components/FolderEditDialog.tsx | 191 ++ src/ui/desktop/apps/server/Server.tsx | 205 +- .../apps/server/widgets/LoginStatsWidget.tsx | 142 ++ src/ui/desktop/apps/server/widgets/index.ts | 1 + .../desktop/apps/terminal/SnippetsSidebar.tsx | 480 ---- src/ui/desktop/apps/terminal/Terminal.tsx | 506 +++- .../command-history/CommandAutocomplete.tsx | 73 + .../command-history/CommandHistoryContext.tsx | 85 + src/ui/desktop/apps/tools/SSHToolsSidebar.tsx | 2216 +++++++++++++++-- src/ui/desktop/apps/tools/ToolsMenu.tsx | 55 - src/ui/desktop/authentication/Auth.tsx | 1038 ++++---- .../authentication/ElectronLoginForm.tsx | 16 +- .../authentication/ElectronServerConfig.tsx | 4 +- src/ui/desktop/navigation/AppView.tsx | 116 +- src/ui/desktop/navigation/LeftSidebar.tsx | 112 +- src/ui/desktop/navigation/SSHAuthDialog.tsx | 4 +- src/ui/desktop/navigation/TOTPDialog.tsx | 4 +- src/ui/desktop/navigation/TopNavbar.tsx | 123 +- .../navigation/animations/SimpleLoader.tsx | 61 + .../desktop/navigation/hosts/FolderCard.tsx | 48 +- src/ui/desktop/navigation/tabs/Tab.tsx | 20 +- src/ui/desktop/navigation/tabs/TabContext.tsx | 42 +- src/ui/desktop/user/ElectronVersionCheck.tsx | 4 - src/ui/desktop/user/LanguageSwitcher.tsx | 1 + src/ui/desktop/user/UserProfile.tsx | 90 +- src/ui/hooks/useCommandHistory.ts | 126 + src/ui/hooks/useCommandTracker.ts | 111 + src/ui/hooks/useDragToSystemDesktop.ts | 4 +- src/ui/main-axios.ts | 531 +++- src/ui/mobile/MobileApp.tsx | 25 +- src/ui/mobile/apps/terminal/Terminal.tsx | 14 +- .../mobile/apps/terminal/TerminalKeyboard.tsx | 4 +- src/ui/mobile/authentication/Auth.tsx | 101 +- tsconfig.node.json | 1 + 104 files changed, 16070 insertions(+), 2821 deletions(-) create mode 100644 src/backend/database/routes/terminal.ts 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/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/kbd.tsx create mode 100644 src/locales/fr/translation.json create mode 100644 src/ui/desktop/apps/command-palette/CommandPalette.tsx create mode 100644 src/ui/desktop/apps/file-manager/components/CompressDialog.tsx create mode 100644 src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx create mode 100644 src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx create mode 100644 src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx delete mode 100644 src/ui/desktop/apps/terminal/SnippetsSidebar.tsx create mode 100644 src/ui/desktop/apps/terminal/command-history/CommandAutocomplete.tsx create mode 100644 src/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx delete mode 100644 src/ui/desktop/apps/tools/ToolsMenu.tsx create mode 100644 src/ui/desktop/navigation/animations/SimpleLoader.tsx create mode 100644 src/ui/hooks/useCommandHistory.ts create mode 100644 src/ui/hooks/useCommandTracker.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4e664c31..cc32f3d1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -84,7 +84,8 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} - outputs: type=registry,compression=zstd,compression-level=19 + org.opencontainers.image.created=${{ github.run_id }} + outputs: type=registry,compression=gzip,compression-level=9 - name: Cleanup Docker if: always() 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/README-CN.md b/README-CN.md index ea7fdde1..a882666c 100644 --- a/README-CN.md +++ b/README-CN.md @@ -54,14 +54,17 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平 - **SSH 主机管理器** - 保存、组织和管理您的 SSH 连接,支持标签和文件夹,并轻松保存可重用的登录信息,同时能够自动部署 SSH 密钥 - **服务器统计** - 在任何 SSH 服务器上查看 CPU、内存和磁盘使用情况以及网络、正常运行时间和系统信息 - **仪表板** - 在仪表板上一目了然地查看服务器信息 -- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。 -- **数据库加密** - 后端存储为加密的 SQLite 数据库文件 +- **用户认证** - 安全的用户管理,具有管理员控制以及 OIDC 和 2FA (TOTP) 支持。查看所有平台上的活动用户会话并撤销权限。将您的 OIDC/本地帐户链接在一起。 +- **数据库加密** - 后端存储为加密的 SQLite 数据库文件。查看[文档](https://docs.termix.site/security)了解更多信息。 - **数据导出/导入** - 导出和导入 SSH 主机、凭据和文件管理器数据 - **自动 SSL 设置** - 内置 SSL 证书生成和管理,支持 HTTPS 重定向 - **现代用户界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁的桌面/移动设备友好界面 - **语言** - 内置支持英语、中文、德语和葡萄牙语 - **平台支持** - 可作为 Web 应用程序、桌面应用程序(Windows、Linux 和 macOS)以及适用于 iOS 和 Android 的专用移动/平板电脑应用程序。 - **SSH 工具** - 创建可重用的命令片段,单击即可执行。在多个打开的终端上同时运行一个命令。 +- **命令历史** - 自动完成并查看以前运行的 SSH 命令 +- **命令面板** - 双击左 Shift 键可快速使用键盘访问 SSH 连接 +- **SSH 功能丰富** - 支持跳板机、warpgate、基于 TOTP 的连接等。 # 计划功能 @@ -75,16 +78,16 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平 - Windows(x64/ia32) - 便携版 - MSI 安装程序 - - Chocolatey 软件包管理器 + - Chocolatey 软件包管理器(即将推出) - Linux(x64/ia32) - 便携版 - AppImage - Deb - - Flatpak + - Flatpak(即将推出) - macOS(x64/ia32 on v12.0+) - - Apple App Store + - Apple App Store(即将推出) - DMG - - Homebrew + - Homebrew(即将推出) - iOS/iPadOS(v15.1+) - Apple App Store - ISO diff --git a/README.md b/README.md index 81af5534..0e9c10f0 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,17 @@ free and self-hosted alternative to Termius available for all platforms. - **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys - **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server - **Dashboard** - View server information at a glance on your dashboard -- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. -- **Database Encryption** - Backend stored as encrypted SQLite database files +- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. +- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more. - **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data - **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects - **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn - **Languages** - Built-in support for English, Chinese, German, and Portuguese - **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android. - **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals. +- **Command History** - Auto-complete and view previously ran SSH commands +- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard +- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc. # Planned Features @@ -77,16 +80,16 @@ Supported Devices: - Windows (x64/ia32) - Portable - MSI Installer - - Chocolatey Package Manager + - Chocolatey Package Manager (coming soon) - Linux (x64/ia32) - Portable - AppImage - Deb - - Flatpak + - Flatpak (coming soon) - macOS (x64/ia32 on v12.0+) - - Apple App Store + - Apple App Store (coming soon) - DMG - - Homebrew + - Homebrew (coming soon) - iOS/iPadOS (v15.1+) - Apple App Store - ISO diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index e27032b0..5e6126bf 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -115,6 +115,15 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location ~ ^/terminal(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location ~ ^/database(/.*)?$ { client_max_body_size 5G; client_body_timeout 300s; diff --git a/docker/nginx.conf b/docker/nginx.conf index cf078022..db5546f0 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -112,6 +112,15 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location ~ ^/terminal(/.*)?$ { + proxy_pass http://127.0.0.1:30001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location ~ ^/database(/.*)?$ { client_max_body_size 5G; client_body_timeout 300s; diff --git a/electron/main.cjs b/electron/main.cjs index 8f623912..97ced567 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -395,6 +395,48 @@ ipcMain.handle("save-server-config", (event, config) => { } }); +ipcMain.handle("get-setting", (event, key) => { + try { + const userDataPath = app.getPath("userData"); + const settingsPath = path.join(userDataPath, "settings.json"); + + if (!fs.existsSync(settingsPath)) { + return null; + } + + const settingsData = fs.readFileSync(settingsPath, "utf8"); + const settings = JSON.parse(settingsData); + return settings[key] !== undefined ? settings[key] : null; + } catch (error) { + console.error("Error reading setting:", error); + return null; + } +}); + +ipcMain.handle("set-setting", (event, key, value) => { + try { + const userDataPath = app.getPath("userData"); + const settingsPath = path.join(userDataPath, "settings.json"); + + if (!fs.existsSync(userDataPath)) { + fs.mkdirSync(userDataPath, { recursive: true }); + } + + let settings = {}; + if (fs.existsSync(settingsPath)) { + const settingsData = fs.readFileSync(settingsPath, "utf8"); + settings = JSON.parse(settingsData); + } + + settings[key] = value; + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + return { success: true }; + } catch (error) { + console.error("Error saving setting:", error); + return { success: false, error: error.message }; + } +}); + ipcMain.handle("test-server-connection", async (event, serverUrl) => { try { const https = require("https"); diff --git a/electron/preload.js b/electron/preload.js index 0fbd3dc4..1db1b356 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -22,6 +22,9 @@ contextBridge.exposeInMainWorld("electronAPI", { isElectron: true, isDev: process.env.NODE_ENV === "development", + getSetting: (key) => ipcRenderer.invoke("get-setting", key), + setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value), + invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), }); diff --git a/package-lock.json b/package-lock.json index 011b4f07..14f680ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "1.8.0", + "version": "1.8.1", "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.3.3", @@ -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", @@ -153,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", @@ -439,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", @@ -488,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", @@ -515,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", @@ -543,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", @@ -744,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", @@ -821,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" } @@ -843,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", @@ -1171,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", @@ -1558,6 +1550,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1579,6 +1572,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2535,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", @@ -2576,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" } @@ -2608,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", @@ -2631,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" } @@ -4855,7 +4845,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5013,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", @@ -5136,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" } @@ -5179,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" } @@ -5190,7 +5176,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5358,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", @@ -5735,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", @@ -5771,7 +5754,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6194,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" @@ -6329,7 +6310,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -6936,6 +6916,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", @@ -7317,7 +7313,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7394,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", @@ -7853,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", @@ -7951,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", @@ -8303,6 +8299,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8323,6 +8320,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8338,6 +8336,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8348,6 +8347,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8610,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", @@ -10209,7 +10208,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -10735,9 +10733,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -11751,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" }, @@ -13778,6 +13777,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13795,6 +13795,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14246,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" } @@ -14256,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" }, @@ -14283,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" }, @@ -14431,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" @@ -14640,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", @@ -15962,6 +15958,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -16002,6 +15999,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16016,6 +16014,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -16120,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" }, @@ -16326,7 +16324,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16739,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", @@ -16831,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/package.json b/package.json index bd71b467..a26dd5f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "termix", "private": true, - "version": "1.8.0", + "version": "1.9.0", "description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities", "author": "Karmaa", "main": "electron/main.cjs", @@ -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/backend/database/database.ts b/src/backend/database/database.ts index 661549b5..1eca73d9 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; +import terminalRoutes from "./routes/terminal.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -21,6 +22,7 @@ import { DatabaseMigration } from "../utils/database-migration.js"; import { UserDataExport } from "../utils/user-data-export.js"; import { AutoSSLSetup } from "../utils/auto-ssl-setup.js"; import { eq, and } from "drizzle-orm"; +import { parseUserAgent } from "../utils/user-agent-parser.js"; import { users, sshData, @@ -456,8 +458,12 @@ app.post("/database/export", authenticateJWT, async (req, res) => { code: "PASSWORD_REQUIRED", }); } - - const unlocked = await authManager.authenticateUser(userId, password); + const deviceInfo = parseUserAgent(req); + const unlocked = await authManager.authenticateUser( + userId, + password, + deviceInfo.type, + ); if (!unlocked) { return res.status(401).json({ error: "Invalid password" }); } @@ -904,6 +910,7 @@ app.post( const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; const mainDb = getDb(); + const deviceInfo = parseUserAgent(req); const userRecords = await mainDb .select() @@ -924,12 +931,19 @@ app.post( }); } - const unlocked = await authManager.authenticateUser(userId, password); + const unlocked = await authManager.authenticateUser( + userId, + password, + deviceInfo.type, + ); if (!unlocked) { return res.status(401).json({ error: "Invalid password" }); } } else if (!DataCrypto.getUserDataKey(userId)) { - const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + const oidcUnlocked = await authManager.authenticateOIDCUser( + userId, + deviceInfo.type, + ); if (!oidcUnlocked) { return res.status(403).json({ error: "Failed to unlock user data with SSO credentials", @@ -947,7 +961,10 @@ app.post( let userDataKey = DataCrypto.getUserDataKey(userId); if (!userDataKey && isOidcUser) { - const oidcUnlocked = await authManager.authenticateOIDCUser(userId); + const oidcUnlocked = await authManager.authenticateOIDCUser( + userId, + deviceInfo.type, + ); if (oidcUnlocked) { userDataKey = DataCrypto.getUserDataKey(userId); } @@ -1418,6 +1435,7 @@ app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); +app.use("/terminal", terminalRoutes); app.use( ( @@ -1480,13 +1498,13 @@ app.get( if (status.hasUnencryptedDb) { try { unencryptedSize = fs.statSync(dbPath).size; - } catch {} + } catch (error) {} } if (status.hasEncryptedDb) { try { encryptedSize = fs.statSync(encryptedDbPath).size; - } catch {} + } catch (error) {} } res.json({ diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 7b3b6138..f9e1017f 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -95,6 +95,26 @@ async function initializeDatabaseAsync(): Promise { databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); + try { + 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.`, ); @@ -120,6 +140,8 @@ async function initializeCompleteDatabase(): Promise { sqlite = memoryDatabase; + sqlite.exec("PRAGMA foreign_keys = ON"); + db = drizzle(sqlite, { schema }); sqlite.exec(` @@ -157,7 +179,7 @@ async function initializeCompleteDatabase(): Promise { created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, expires_at TEXT NOT NULL, last_active_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_data ( @@ -188,7 +210,7 @@ async function initializeCompleteDatabase(): Promise { terminal_config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_recent ( @@ -198,8 +220,8 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, path TEXT NOT NULL, last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_pinned ( @@ -209,8 +231,8 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, path TEXT NOT NULL, pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS file_manager_shortcuts ( @@ -220,8 +242,8 @@ async function initializeCompleteDatabase(): Promise { name TEXT NOT NULL, path TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS dismissed_alerts ( @@ -229,7 +251,7 @@ async function initializeCompleteDatabase(): Promise { user_id TEXT NOT NULL, alert_id TEXT NOT NULL, dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credentials ( @@ -249,7 +271,7 @@ async function initializeCompleteDatabase(): Promise { last_used TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS ssh_credential_usage ( @@ -258,9 +280,9 @@ async function initializeCompleteDatabase(): Promise { host_id INTEGER NOT NULL, user_id TEXT NOT NULL, used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id), - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS snippets ( @@ -271,7 +293,18 @@ async function initializeCompleteDatabase(): Promise { description TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS ssh_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS recent_activity ( @@ -279,10 +312,20 @@ async function initializeCompleteDatabase(): Promise { user_id TEXT NOT NULL, type TEXT NOT NULL, host_id INTEGER NOT NULL, - host_name TEXT NOT NULL, + host_name TEXT, timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id), - FOREIGN KEY (host_id) REFERENCES ssh_data (id) + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS command_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + command TEXT NOT NULL, + executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE ); `); @@ -343,14 +386,14 @@ const addColumnIfNotExists = ( try { sqlite .prepare( - `SELECT ${column} + `SELECT "${column}" FROM ${table} LIMIT 1`, ) .get(); } catch { try { sqlite.exec(`ALTER TABLE ${table} - ADD COLUMN ${column} ${definition};`); + ADD COLUMN "${column}" ${definition};`); } catch (alterError) { databaseLogger.warn(`Failed to add column ${column} to ${table}`, { operation: "schema_migration", @@ -405,6 +448,7 @@ const migrateSchema = () => { "INTEGER NOT NULL DEFAULT 1", ); addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT"); + addColumnIfNotExists("ssh_data", "jump_hosts", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_file_manager", @@ -428,7 +472,12 @@ const migrateSchema = () => { addColumnIfNotExists( "ssh_data", "credential_id", - "INTEGER REFERENCES ssh_credentials(id)", + "INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL", + ); + addColumnIfNotExists( + "ssh_data", + "override_credential_username", + "INTEGER", ); addColumnIfNotExists("ssh_data", "autostart_password", "TEXT"); @@ -436,6 +485,7 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT"); addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); + addColumnIfNotExists("ssh_data", "quick_actions", "TEXT"); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); @@ -445,6 +495,35 @@ const migrateSchema = () => { addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL"); addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL"); + addColumnIfNotExists("snippets", "folder", "TEXT"); + addColumnIfNotExists("snippets", "order", "INTEGER NOT NULL DEFAULT 0"); + + try { + sqlite + .prepare("SELECT id FROM snippet_folders LIMIT 1") + .get(); + } catch { + try { + sqlite.exec(` + CREATE TABLE IF NOT EXISTS snippet_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE + ); + `); + } catch (createError) { + databaseLogger.warn("Failed to create snippet_folders table", { + operation: "schema_migration", + error: createError, + }); + } + } + try { sqlite .prepare("SELECT id FROM sessions LIMIT 1") diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 86af0d02..074b4103 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -34,7 +34,7 @@ export const sessions = sqliteTable("sessions", { id: text("id").primaryKey(), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), jwtToken: text("jwt_token").notNull(), deviceType: text("device_type").notNull(), deviceInfo: text("device_info").notNull(), @@ -51,7 +51,7 @@ export const sshData = sqliteTable("ssh_data", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name"), ip: text("ip").notNull(), port: integer("port").notNull(), @@ -71,7 +71,10 @@ export const sshData = sqliteTable("ssh_data", { autostartKey: text("autostart_key", { length: 8192 }), autostartKeyPassword: text("autostart_key_password"), - credentialId: integer("credential_id").references(() => sshCredentials.id), + credentialId: integer("credential_id").references(() => sshCredentials.id, { onDelete: "set null" }), + overrideCredentialUsername: integer("override_credential_username", { + mode: "boolean", + }), enableTerminal: integer("enable_terminal", { mode: "boolean" }) .notNull() .default(true), @@ -79,12 +82,14 @@ export const sshData = sqliteTable("ssh_data", { .notNull() .default(true), tunnelConnections: text("tunnel_connections"), + jumpHosts: text("jump_hosts"), enableFileManager: integer("enable_file_manager", { mode: "boolean" }) .notNull() .default(true), defaultPath: text("default_path"), statsConfig: text("stats_config"), terminalConfig: text("terminal_config"), + quickActions: text("quick_actions"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -97,10 +102,10 @@ export const fileManagerRecent = sqliteTable("file_manager_recent", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), lastOpened: text("last_opened") @@ -112,10 +117,10 @@ export const fileManagerPinned = sqliteTable("file_manager_pinned", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), pinnedAt: text("pinned_at") @@ -127,10 +132,10 @@ export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), name: text("name").notNull(), path: text("path").notNull(), createdAt: text("created_at") @@ -142,7 +147,7 @@ export const dismissedAlerts = sqliteTable("dismissed_alerts", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), alertId: text("alert_id").notNull(), dismissedAt: text("dismissed_at") .notNull() @@ -153,7 +158,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), description: text("description"), folder: text("folder"), @@ -181,13 +186,13 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", { id: integer("id").primaryKey({ autoIncrement: true }), credentialId: integer("credential_id") .notNull() - .references(() => sshCredentials.id), + .references(() => sshCredentials.id, { onDelete: "cascade" }), hostId: integer("host_id") .notNull() - .references(() => sshData.id), + .references(() => sshData.id, { onDelete: "cascade" }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), usedAt: text("used_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -197,10 +202,44 @@ export const snippets = sqliteTable("snippets", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), name: text("name").notNull(), content: text("content").notNull(), description: text("description"), + folder: text("folder"), + order: integer("order").notNull().default(0), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const snippetFolders = sqliteTable("snippet_folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + color: text("color"), + icon: text("icon"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + +export const sshFolders = sqliteTable("ssh_folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + color: text("color"), + icon: text("icon"), createdAt: text("created_at") .notNull() .default(sql`CURRENT_TIMESTAMP`), @@ -213,13 +252,27 @@ export const recentActivity = sqliteTable("recent_activity", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") .notNull() - .references(() => users.id), + .references(() => users.id, { onDelete: "cascade" }), type: text("type").notNull(), hostId: integer("host_id") .notNull() - .references(() => sshData.id), - hostName: text("host_name").notNull(), + .references(() => sshData.id, { onDelete: "cascade" }), + hostName: text("host_name"), timestamp: text("timestamp") .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const commandHistory = sqliteTable("command_history", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id, { onDelete: "cascade" }), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 2f0c3ce4..df9ab936 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -524,6 +524,8 @@ router.delete( return res.status(404).json({ error: "Credential not found" }); } + // Update hosts using this credential to set credentialId to null + // This prevents orphaned references before deletion const hostsUsingCredential = await db .select() .from(sshData) @@ -552,14 +554,8 @@ router.delete( ); } - await db - .delete(sshCredentialUsage) - .where( - and( - eq(sshCredentialUsage.credentialId, parseInt(id)), - eq(sshCredentialUsage.userId, userId), - ), - ); + // sshCredentialUsage will be automatically deleted by ON DELETE CASCADE + // No need for manual deletion await db .delete(sshCredentials) @@ -1259,6 +1255,10 @@ async function deploySSHKeyToHost( return rejectAdd(err); } + stream.on("data", () => { + // Consume output + }); + stream.on("close", (code) => { clearTimeout(addTimeout); if (code === 0) { @@ -1519,7 +1519,8 @@ router.post( }); } - if (!credData.publicKey) { + const publicKey = credData.public_key || credData.publicKey; + if (!publicKey) { return res.status(400).json({ success: false, error: "Public key is required for deployment", @@ -1600,7 +1601,7 @@ router.post( const deployResult = await deploySSHKeyToHost( hostConfig, - credData.publicKey as string, + publicKey as string, credData, ); diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 89dc4513..51001350 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -1,8 +1,8 @@ import type { AuthenticatedRequest } from "../../../types/index.js"; import express from "express"; import { db } from "../db/index.js"; -import { snippets } from "../db/schema.js"; -import { eq, and, desc, sql } from "drizzle-orm"; +import { snippets, snippetFolders } from "../db/schema.js"; +import { eq, and, desc, asc, sql } from "drizzle-orm"; import type { Request, Response } from "express"; import { authLogger } from "../../utils/logger.js"; import { AuthManager } from "../../utils/auth-manager.js"; @@ -17,6 +17,651 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); +// Get all snippet folders +// GET /snippets/folders +router.get( + "/folders", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for snippet folders fetch"); + return res.status(400).json({ error: "Invalid userId" }); + } + + try { + const result = await db + .select() + .from(snippetFolders) + .where(eq(snippetFolders.userId, userId)) + .orderBy(asc(snippetFolders.name)); + + res.json(result); + } catch (err) { + authLogger.error("Failed to fetch snippet folders", err); + res.status(500).json({ error: "Failed to fetch snippet folders" }); + } + }, +); + +// Create a new snippet folder +// POST /snippets/folders +router.post( + "/folders", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, color, icon } = req.body; + + if (!isNonEmptyString(userId) || !isNonEmptyString(name)) { + authLogger.warn("Invalid snippet folder creation data", { + operation: "snippet_folder_create", + userId, + hasName: !!name, + }); + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + const existing = await db + .select() + .from(snippetFolders) + .where( + and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)), + ); + + if (existing.length > 0) { + return res + .status(409) + .json({ error: "Folder with this name already exists" }); + } + + const insertData = { + userId, + name: name.trim(), + color: color?.trim() || null, + icon: icon?.trim() || null, + }; + + const result = await db + .insert(snippetFolders) + .values(insertData) + .returning(); + + authLogger.success(`Snippet folder created: ${name} by user ${userId}`, { + operation: "snippet_folder_create_success", + userId, + name, + }); + + res.status(201).json(result[0]); + } catch (err) { + authLogger.error("Failed to create snippet folder", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to create snippet folder", + }); + } + }, +); + +// Update snippet folder metadata (color, icon) +// PUT /snippets/folders/:name/metadata +router.put( + "/folders/:name/metadata", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name } = req.params; + const { color, icon } = req.body; + + if (!isNonEmptyString(userId) || !name) { + authLogger.warn("Invalid request for snippet folder metadata update"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, decodeURIComponent(name)), + ), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Folder not found" }); + } + + const updateFields: Partial<{ + color: string | null; + icon: string | null; + updatedAt: ReturnType; + }> = { + updatedAt: sql`CURRENT_TIMESTAMP`, + }; + + if (color !== undefined) updateFields.color = color?.trim() || null; + if (icon !== undefined) updateFields.icon = icon?.trim() || null; + + await db + .update(snippetFolders) + .set(updateFields) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, decodeURIComponent(name)), + ), + ); + + const updated = await db + .select() + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, decodeURIComponent(name)), + ), + ); + + authLogger.success( + `Snippet folder metadata updated: ${name} by user ${userId}`, + { + operation: "snippet_folder_metadata_update_success", + userId, + name, + }, + ); + + res.json(updated[0]); + } catch (err) { + authLogger.error("Failed to update snippet folder metadata", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to update snippet folder metadata", + }); + } + }, +); + +// Rename snippet folder +// PUT /snippets/folders/rename +router.put( + "/folders/rename", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { oldName, newName } = req.body; + + if ( + !isNonEmptyString(userId) || + !isNonEmptyString(oldName) || + !isNonEmptyString(newName) + ) { + authLogger.warn("Invalid request for snippet folder rename"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const existing = await db + .select() + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, oldName), + ), + ); + + if (existing.length === 0) { + return res.status(404).json({ error: "Folder not found" }); + } + + const nameExists = await db + .select() + .from(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, newName), + ), + ); + + if (nameExists.length > 0) { + return res + .status(409) + .json({ error: "Folder with new name already exists" }); + } + + await db + .update(snippetFolders) + .set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` }) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, oldName), + ), + ); + + await db + .update(snippets) + .set({ folder: newName }) + .where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName))); + + authLogger.success( + `Snippet folder renamed: ${oldName} -> ${newName} by user ${userId}`, + { + operation: "snippet_folder_rename_success", + userId, + oldName, + newName, + }, + ); + + res.json({ success: true, oldName, newName }); + } catch (err) { + authLogger.error("Failed to rename snippet folder", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to rename snippet folder", + }); + } + }, +); + +// Delete snippet folder +// DELETE /snippets/folders/:name +router.delete( + "/folders/:name", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name } = req.params; + + if (!isNonEmptyString(userId) || !name) { + authLogger.warn("Invalid request for snippet folder delete"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + const folderName = decodeURIComponent(name); + + await db + .update(snippets) + .set({ folder: null }) + .where( + and(eq(snippets.userId, userId), eq(snippets.folder, folderName)), + ); + + await db + .delete(snippetFolders) + .where( + and( + eq(snippetFolders.userId, userId), + eq(snippetFolders.name, folderName), + ), + ); + + authLogger.success( + `Snippet folder deleted: ${folderName} by user ${userId}`, + { + operation: "snippet_folder_delete_success", + userId, + name: folderName, + }, + ); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete snippet folder", err); + res.status(500).json({ + error: + err instanceof Error + ? err.message + : "Failed to delete snippet folder", + }); + } + }, +); + +// Reorder snippets (bulk update) +// PUT /snippets/reorder +router.put( + "/reorder", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { snippets: snippetUpdates } = req.body; + + if (!isNonEmptyString(userId)) { + authLogger.warn("Invalid userId for snippet reorder"); + return res.status(400).json({ error: "Invalid userId" }); + } + + if (!Array.isArray(snippetUpdates) || snippetUpdates.length === 0) { + authLogger.warn("Invalid snippet reorder data", { + operation: "snippet_reorder", + userId, + }); + return res + .status(400) + .json({ error: "snippets array is required and must not be empty" }); + } + + try { + for (const update of snippetUpdates) { + const { id, order, folder } = update; + + if (!id || order === undefined) { + continue; + } + + const updateFields: Partial<{ + order: number; + folder: string | null; + }> = { + order, + }; + + if (folder !== undefined) { + updateFields.folder = folder?.trim() || null; + } + + await db + .update(snippets) + .set(updateFields) + .where(and(eq(snippets.id, id), eq(snippets.userId, userId))); + } + + authLogger.success(`Snippets reordered by user ${userId}`, { + operation: "snippet_reorder_success", + userId, + count: snippetUpdates.length, + }); + + res.json({ success: true, updated: snippetUpdates.length }); + } catch (err) { + authLogger.error("Failed to reorder snippets", err); + res.status(500).json({ + error: + err instanceof Error ? err.message : "Failed to reorder snippets", + }); + } + }, +); + +// Execute a snippet on a host +// POST /snippets/execute +router.post( + "/execute", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { snippetId, hostId } = req.body; + + if (!isNonEmptyString(userId) || !snippetId || !hostId) { + authLogger.warn("Invalid snippet execution request", { + userId, + snippetId, + hostId, + }); + return res + .status(400) + .json({ error: "Snippet ID and Host ID are required" }); + } + + try { + const snippetResult = await db + .select() + .from(snippets) + .where( + and( + eq(snippets.id, parseInt(snippetId)), + eq(snippets.userId, userId), + ), + ); + + if (snippetResult.length === 0) { + return res.status(404).json({ error: "Snippet not found" }); + } + + const snippet = snippetResult[0]; + + const { Client } = await import("ssh2"); + const { sshData, sshCredentials } = await import("../db/schema.js"); + + const { SimpleDBOps } = await import("../../utils/simple-db-ops.js"); + + const hostResult = await SimpleDBOps.select( + db + .select() + .from(sshData) + .where( + and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)), + ), + "ssh_data", + userId, + ); + + if (hostResult.length === 0) { + return res.status(404).json({ error: "Host not found" }); + } + + const host = hostResult[0]; + + let password = host.password; + let privateKey = host.key; + let passphrase = host.key_password; + let authType = host.authType; + + if (host.credentialId) { + const credResult = await SimpleDBOps.select( + db + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credResult.length > 0) { + const cred = credResult[0]; + authType = (cred.auth_type || cred.authType || authType) as string; + password = (cred.password || undefined) as string | undefined; + privateKey = (cred.private_key || cred.key || undefined) as + | string + | undefined; + passphrase = (cred.key_password || undefined) as string | undefined; + } + } + + const conn = new Client(); + let output = ""; + let errorOutput = ""; + + const executePromise = new Promise<{ + success: boolean; + output: string; + error?: string; + }>((resolve, reject) => { + const timeout = setTimeout(() => { + conn.end(); + reject(new Error("Command execution timeout (30s)")); + }, 30000); + + conn.on("ready", () => { + conn.exec(snippet.content, (err, stream) => { + if (err) { + clearTimeout(timeout); + conn.end(); + return reject(err); + } + + stream.on("close", () => { + clearTimeout(timeout); + conn.end(); + if (errorOutput) { + resolve({ success: false, output, error: errorOutput }); + } else { + resolve({ success: true, output }); + } + }); + + stream.on("data", (data: Buffer) => { + output += data.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + }); + }); + }); + + conn.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + const config: any = { + host: host.ip, + port: host.port, + username: host.username, + tryKeyboard: true, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 30000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + timeout: 30000, + env: { + TERM: "xterm-256color", + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + LC_CTYPE: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + LC_MONETARY: "en_US.UTF-8", + LC_NUMERIC: "en_US.UTF-8", + LC_TIME: "en_US.UTF-8", + LC_COLLATE: "en_US.UTF-8", + COLORTERM: "truecolor", + }, + algorithms: { + kex: [ + "curve25519-sha256", + "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp521", + "ecdh-sha2-nistp384", + "ecdh-sha2-nistp256", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group14-sha256", + "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group1-sha1", + ], + serverHostKey: [ + "ssh-ed25519", + "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp384", + "ecdsa-sha2-nistp256", + "rsa-sha2-512", + "rsa-sha2-256", + "ssh-rsa", + "ssh-dss", + ], + cipher: [ + "chacha20-poly1305@openssh.com", + "aes256-gcm@openssh.com", + "aes128-gcm@openssh.com", + "aes256-ctr", + "aes192-ctr", + "aes128-ctr", + "aes256-cbc", + "aes192-cbc", + "aes128-cbc", + "3des-cbc", + ], + hmac: [ + "hmac-sha2-512-etm@openssh.com", + "hmac-sha2-256-etm@openssh.com", + "hmac-sha2-512", + "hmac-sha2-256", + "hmac-sha1", + "hmac-md5", + ], + compress: ["none", "zlib@openssh.com", "zlib"], + }, + }; + + if (authType === "password" && password) { + config.password = password; + } else if (authType === "key" && privateKey) { + const cleanKey = (privateKey as string) + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (passphrase) { + config.passphrase = passphrase; + } + } else if (password) { + config.password = password; + } else if (privateKey) { + const cleanKey = (privateKey as string) + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (passphrase) { + config.passphrase = passphrase; + } + } + + conn.connect(config); + }); + + const result = await executePromise; + + authLogger.success( + `Snippet executed: ${snippet.name} on host ${hostId}`, + { + operation: "snippet_execute_success", + userId, + snippetId, + hostId, + }, + ); + + res.json(result); + } catch (err) { + authLogger.error("Failed to execute snippet", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to execute snippet", + }); + } + }, +); + // Get all snippets for the authenticated user // GET /snippets router.get( @@ -36,7 +681,12 @@ router.get( .select() .from(snippets) .where(eq(snippets.userId, userId)) - .orderBy(desc(snippets.updatedAt)); + .orderBy( + sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`, + asc(snippets.folder), + asc(snippets.order), + desc(snippets.updatedAt), + ); res.json(result); } catch (err) { @@ -93,7 +743,7 @@ router.post( requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; - const { name, content, description } = req.body; + const { name, content, description, folder, order } = req.body; if ( !isNonEmptyString(userId) || @@ -110,11 +760,31 @@ router.post( } try { + let snippetOrder = order; + if (snippetOrder === undefined || snippetOrder === null) { + const folderValue = folder?.trim() || ""; + const maxOrderResult = await db + .select({ maxOrder: sql`MAX(${snippets.order})` }) + .from(snippets) + .where( + and( + eq(snippets.userId, userId), + folderValue + ? eq(snippets.folder, folderValue) + : sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`, + ), + ); + const maxOrder = maxOrderResult[0]?.maxOrder ?? -1; + snippetOrder = maxOrder + 1; + } + const insertData = { userId, name: name.trim(), content: content.trim(), description: description?.trim() || null, + folder: folder?.trim() || null, + order: snippetOrder, }; const result = await db.insert(snippets).values(insertData).returning(); @@ -167,6 +837,8 @@ router.put( name: string; content: string; description: string | null; + folder: string | null; + order: number; }> = { updatedAt: sql`CURRENT_TIMESTAMP`, }; @@ -177,6 +849,9 @@ router.put( updateFields.content = updateData.content.trim(); if (updateData.description !== undefined) updateFields.description = updateData.description?.trim() || null; + if (updateData.folder !== undefined) + updateFields.folder = updateData.folder?.trim() || null; + if (updateData.order !== undefined) updateFields.order = updateData.order; await db .update(snippets) diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8e9cf570..955135a4 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -8,6 +8,9 @@ import { fileManagerRecent, fileManagerPinned, fileManagerShortcuts, + sshFolders, + commandHistory, + recentActivity, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -234,6 +237,8 @@ router.post( enableFileManager, defaultPath, tunnelConnections, + jumpHosts, + quickActions, statsConfig, terminalConfig, forceKeyboardInteractive, @@ -270,6 +275,10 @@ router.post( tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, + jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, + quickActions: Array.isArray(quickActions) + ? JSON.stringify(quickActions) + : null, enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, @@ -328,6 +337,9 @@ router.post( tunnelConnections: createdHost.tunnelConnections ? JSON.parse(createdHost.tunnelConnections as string) : [], + jumpHosts: createdHost.jumpHosts + ? JSON.parse(createdHost.jumpHosts as string) + : [], enableFileManager: !!createdHost.enableFileManager, statsConfig: createdHost.statsConfig ? JSON.parse(createdHost.statsConfig as string) @@ -349,6 +361,28 @@ router.post( }, ); + try { + const axios = (await import("axios")).default; + const statsPort = process.env.STATS_PORT || 30005; + await axios.post( + `http://localhost:${statsPort}/host-updated`, + { hostId: createdHost.id }, + { + headers: { + Authorization: req.headers.authorization || "", + Cookie: req.headers.cookie || "", + }, + timeout: 5000, + }, + ); + } catch (err) { + sshLogger.warn("Failed to notify stats server of new host", { + operation: "host_create", + hostId: createdHost.id as number, + error: err instanceof Error ? err.message : String(err), + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to save SSH host to database", err, { @@ -369,6 +403,7 @@ router.post( router.put( "/db/host/:id", authenticateJWT, + requireDataAccess, upload.single("key"), async (req: Request, res: Response) => { const hostId = req.params.id; @@ -424,6 +459,8 @@ router.put( enableFileManager, defaultPath, tunnelConnections, + jumpHosts, + quickActions, statsConfig, terminalConfig, forceKeyboardInteractive, @@ -461,6 +498,10 @@ router.put( tunnelConnections: Array.isArray(tunnelConnections) ? JSON.stringify(tunnelConnections) : null, + jumpHosts: Array.isArray(jumpHosts) ? JSON.stringify(jumpHosts) : null, + quickActions: Array.isArray(quickActions) + ? JSON.stringify(quickActions) + : null, enableFileManager: enableFileManager ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, @@ -537,6 +578,9 @@ router.put( tunnelConnections: updatedHost.tunnelConnections ? JSON.parse(updatedHost.tunnelConnections as string) : [], + jumpHosts: updatedHost.jumpHosts + ? JSON.parse(updatedHost.jumpHosts as string) + : [], enableFileManager: !!updatedHost.enableFileManager, statsConfig: updatedHost.statsConfig ? JSON.parse(updatedHost.statsConfig as string) @@ -558,6 +602,28 @@ router.put( }, ); + try { + const axios = (await import("axios")).default; + const statsPort = process.env.STATS_PORT || 30005; + await axios.post( + `http://localhost:${statsPort}/host-updated`, + { hostId: parseInt(hostId) }, + { + headers: { + Authorization: req.headers.authorization || "", + Cookie: req.headers.cookie || "", + }, + timeout: 5000, + }, + ); + } catch (err) { + sshLogger.warn("Failed to notify stats server of host update", { + operation: "host_update", + hostId: parseInt(hostId), + error: err instanceof Error ? err.message : String(err), + }); + } + res.json(resolvedHost); } catch (err) { sshLogger.error("Failed to update SSH host in database", err, { @@ -576,67 +642,77 @@ router.put( // Route: Get SSH data for the authenticated user (requires JWT) // GET /ssh/host -router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => { - const userId = (req as AuthenticatedRequest).userId; - if (!isNonEmptyString(userId)) { - sshLogger.warn("Invalid userId for SSH data fetch", { - operation: "host_fetch", - userId, - }); - return res.status(400).json({ error: "Invalid userId" }); - } - try { - const data = await SimpleDBOps.select( - db.select().from(sshData).where(eq(sshData.userId, userId)), - "ssh_data", - userId, - ); +router.get( + "/db/host", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + if (!isNonEmptyString(userId)) { + sshLogger.warn("Invalid userId for SSH data fetch", { + operation: "host_fetch", + userId, + }); + return res.status(400).json({ error: "Invalid userId" }); + } + try { + const data = await SimpleDBOps.select( + db.select().from(sshData).where(eq(sshData.userId, userId)), + "ssh_data", + userId, + ); - const result = await Promise.all( - data.map(async (row: Record) => { - const baseHost = { - ...row, - tags: - typeof row.tags === "string" - ? row.tags - ? row.tags.split(",").filter(Boolean) - : [] + const result = await Promise.all( + data.map(async (row: Record) => { + const baseHost = { + ...row, + tags: + typeof row.tags === "string" + ? row.tags + ? row.tags.split(",").filter(Boolean) + : [] + : [], + pin: !!row.pin, + enableTerminal: !!row.enableTerminal, + enableTunnel: !!row.enableTunnel, + tunnelConnections: row.tunnelConnections + ? JSON.parse(row.tunnelConnections as string) : [], - pin: !!row.pin, - enableTerminal: !!row.enableTerminal, - enableTunnel: !!row.enableTunnel, - tunnelConnections: row.tunnelConnections - ? JSON.parse(row.tunnelConnections as string) - : [], - enableFileManager: !!row.enableFileManager, - statsConfig: row.statsConfig - ? JSON.parse(row.statsConfig as string) - : undefined, - terminalConfig: row.terminalConfig - ? JSON.parse(row.terminalConfig as string) - : undefined, - forceKeyboardInteractive: row.forceKeyboardInteractive === "true", - }; + jumpHosts: row.jumpHosts ? JSON.parse(row.jumpHosts as string) : [], + quickActions: row.quickActions + ? JSON.parse(row.quickActions as string) + : [], + enableFileManager: !!row.enableFileManager, + statsConfig: row.statsConfig + ? JSON.parse(row.statsConfig as string) + : undefined, + terminalConfig: row.terminalConfig + ? JSON.parse(row.terminalConfig as string) + : undefined, + forceKeyboardInteractive: row.forceKeyboardInteractive === "true", + }; - return (await resolveHostCredentials(baseHost)) || baseHost; - }), - ); + return (await resolveHostCredentials(baseHost)) || baseHost; + }), + ); - res.json(result); - } catch (err) { - sshLogger.error("Failed to fetch SSH hosts from database", err, { - operation: "host_fetch", - userId, - }); - res.status(500).json({ error: "Failed to fetch SSH data" }); - } -}); + res.json(result); + } catch (err) { + sshLogger.error("Failed to fetch SSH hosts from database", err, { + operation: "host_fetch", + userId, + }); + res.status(500).json({ error: "Failed to fetch SSH data" }); + } + }, +); // Route: Get SSH host by ID (requires JWT) // GET /ssh/host/:id router.get( "/db/host/:id", authenticateJWT, + requireDataAccess, async (req: Request, res: Response) => { const hostId = req.params.id; const userId = (req as AuthenticatedRequest).userId; @@ -679,6 +755,8 @@ router.get( tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections) : [], + jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts) : [], + quickActions: host.quickActions ? JSON.parse(host.quickActions) : [], enableFileManager: !!host.enableFileManager, statsConfig: host.statsConfig ? JSON.parse(host.statsConfig) @@ -783,6 +861,7 @@ router.get( router.delete( "/db/host/:id", authenticateJWT, + requireDataAccess, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; const hostId = req.params.id; @@ -816,8 +895,8 @@ router.delete( .delete(fileManagerRecent) .where( and( - eq(fileManagerRecent.userId, userId), eq(fileManagerRecent.hostId, numericHostId), + eq(fileManagerRecent.userId, userId), ), ); @@ -825,8 +904,8 @@ router.delete( .delete(fileManagerPinned) .where( and( - eq(fileManagerPinned.userId, userId), eq(fileManagerPinned.hostId, numericHostId), + eq(fileManagerPinned.userId, userId), ), ); @@ -834,8 +913,17 @@ router.delete( .delete(fileManagerShortcuts) .where( and( - eq(fileManagerShortcuts.userId, userId), eq(fileManagerShortcuts.hostId, numericHostId), + eq(fileManagerShortcuts.userId, userId), + ), + ); + + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.hostId, numericHostId), + eq(commandHistory.userId, userId), ), ); @@ -843,8 +931,17 @@ router.delete( .delete(sshCredentialUsage) .where( and( - eq(sshCredentialUsage.userId, userId), eq(sshCredentialUsage.hostId, numericHostId), + eq(sshCredentialUsage.userId, userId), + ), + ); + + await db + .delete(recentActivity) + .where( + and( + eq(recentActivity.hostId, numericHostId), + eq(recentActivity.userId, userId), ), ); @@ -865,6 +962,28 @@ router.delete( }, ); + try { + const axios = (await import("axios")).default; + const statsPort = process.env.STATS_PORT || 30005; + await axios.post( + `http://localhost:${statsPort}/host-deleted`, + { hostId: numericHostId }, + { + headers: { + Authorization: req.headers.authorization || "", + Cookie: req.headers.cookie || "", + }, + timeout: 5000, + }, + ); + } catch (err) { + sshLogger.warn("Failed to notify stats server of host deletion", { + operation: "host_delete", + hostId: numericHostId, + error: err instanceof Error ? err.message : String(err), + }); + } + res.json({ message: "SSH host deleted" }); } catch (err) { sshLogger.error("Failed to delete SSH host from database", err, { @@ -1241,6 +1360,94 @@ router.delete( }, ); +// Route: Get command history for a host +// GET /ssh/command-history/:hostId +router.get( + "/command-history/:hostId", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const hostId = parseInt(req.params.hostId, 10); + + if (!isNonEmptyString(userId) || !hostId) { + sshLogger.warn("Invalid userId or hostId for command history fetch", { + operation: "command_history_fetch", + hostId, + userId, + }); + return res.status(400).json({ error: "Invalid userId or hostId" }); + } + + try { + const history = await db + .select({ + id: commandHistory.id, + command: commandHistory.command, + }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + ), + ) + .orderBy(desc(commandHistory.executedAt)) + .limit(200); + + res.json(history.map((h) => h.command)); + } catch (err) { + sshLogger.error("Failed to fetch command history from database", err, { + operation: "command_history_fetch", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to fetch command history" }); + } + }, +); + +// Route: Delete command from history +// DELETE /ssh/command-history +router.delete( + "/command-history", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if (!isNonEmptyString(userId) || !hostId || !command) { + sshLogger.warn("Invalid data for command history deletion", { + operation: "command_history_delete", + hostId, + userId, + }); + return res.status(400).json({ error: "Invalid data" }); + } + + try { + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostId), + eq(commandHistory.command, command), + ), + ); + + res.json({ message: "Command deleted from history" }); + } catch (err) { + sshLogger.error("Failed to delete command from history", err, { + operation: "command_history_delete", + hostId, + userId, + command, + }); + res.status(500).json({ error: "Failed to delete command" }); + } + }, +); + async function resolveHostCredentials( host: Record, ): Promise> { @@ -1341,6 +1548,16 @@ router.put( DatabaseSaveTrigger.triggerSave("folder_rename"); + await db + .update(sshFolders) + .set({ + name: newName, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)), + ); + res.json({ message: "Folder renamed successfully", updatedHosts: updatedHosts.length, @@ -1358,6 +1575,170 @@ router.put( }, ); +// Route: Get all folders with metadata (requires JWT) +// GET /ssh/db/folders +router.get("/folders", authenticateJWT, async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + return res.status(400).json({ error: "Invalid user ID" }); + } + + try { + const folders = await db + .select() + .from(sshFolders) + .where(eq(sshFolders.userId, userId)); + + res.json(folders); + } catch (err) { + sshLogger.error("Failed to fetch folders", err, { + operation: "fetch_folders", + userId, + }); + res.status(500).json({ error: "Failed to fetch folders" }); + } +}); + +// Route: Update folder metadata (requires JWT) +// PUT /ssh/db/folders/metadata +router.put( + "/folders/metadata", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, color, icon } = req.body; + + if (!isNonEmptyString(userId) || !name) { + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + const existing = await db + .select() + .from(sshFolders) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) + .limit(1); + + if (existing.length > 0) { + await db + .update(sshFolders) + .set({ + color, + icon, + updatedAt: new Date().toISOString(), + }) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))); + } else { + await db.insert(sshFolders).values({ + userId, + name, + color, + icon, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + DatabaseSaveTrigger.triggerSave("folder_metadata_update"); + + res.json({ message: "Folder metadata updated successfully" }); + } catch (err) { + sshLogger.error("Failed to update folder metadata", err, { + operation: "update_folder_metadata", + userId, + name, + }); + res.status(500).json({ error: "Failed to update folder metadata" }); + } + }, +); + +// Route: Delete all hosts in folder (requires JWT) +// DELETE /ssh/db/folders/:name/hosts +router.delete( + "/folders/:name/hosts", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const folderName = req.params.name; + + if (!isNonEmptyString(userId) || !folderName) { + return res.status(400).json({ error: "Invalid folder name" }); + } + + try { + const hostsToDelete = await db + .select() + .from(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + if (hostsToDelete.length === 0) { + return res.json({ + message: "No hosts found in folder", + deletedCount: 0, + }); + } + + await db + .delete(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + await db + .delete(sshFolders) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)), + ); + + DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); + + try { + const axios = (await import("axios")).default; + const statsPort = process.env.STATS_PORT || 30005; + for (const host of hostsToDelete) { + try { + await axios.post( + `http://localhost:${statsPort}/host-deleted`, + { hostId: host.id }, + { + headers: { + Authorization: req.headers.authorization || "", + Cookie: req.headers.cookie || "", + }, + timeout: 5000, + }, + ); + } catch (err) { + sshLogger.warn("Failed to notify stats server of host deletion", { + operation: "folder_hosts_delete", + hostId: host.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } catch (err) { + sshLogger.warn("Failed to notify stats server of folder deletion", { + operation: "folder_hosts_delete", + folderName, + error: err instanceof Error ? err.message : String(err), + }); + } + + res.json({ + message: "All hosts in folder deleted successfully", + deletedCount: hostsToDelete.length, + }); + } catch (err) { + sshLogger.error("Failed to delete hosts in folder", err, { + operation: "delete_folder_hosts", + userId, + folderName, + }); + res.status(500).json({ error: "Failed to delete hosts in folder" }); + } + }, +); + // Route: Bulk import SSH hosts (requires JWT) // POST /ssh/bulk-import router.post( diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts new file mode 100644 index 00000000..f73c7dfb --- /dev/null +++ b/src/backend/database/routes/terminal.ts @@ -0,0 +1,195 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express from "express"; +import { db } from "../db/index.js"; +import { commandHistory } from "../db/schema.js"; +import { eq, and, desc, sql } from "drizzle-orm"; +import type { Request, Response } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; + +const router = express.Router(); + +function isNonEmptyString(val: unknown): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); + +// Save command to history +// POST /terminal/command_history +router.post( + "/command_history", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) { + authLogger.warn("Invalid command history save request", { + operation: "command_history_save", + userId, + hasHostId: !!hostId, + hasCommand: !!command, + }); + return res.status(400).json({ error: "Missing required parameters" }); + } + + try { + const insertData = { + userId, + hostId: parseInt(hostId, 10), + command: command.trim(), + }; + + const result = await db + .insert(commandHistory) + .values(insertData) + .returning(); + + res.status(201).json(result[0]); + } catch (err) { + authLogger.error("Failed to save command to history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to save command", + }); + } + }, +); + +// Get command history for a specific host +// GET /terminal/command_history/:hostId +router.get( + "/command_history/:hostId", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.params; + const hostIdNum = parseInt(hostId, 10); + + if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { + authLogger.warn("Invalid command history fetch request", { + userId, + hostId: hostIdNum, + }); + return res.status(400).json({ error: "Invalid request parameters" }); + } + + try { + const result = await db + .select({ + command: commandHistory.command, + maxExecutedAt: sql`MAX(${commandHistory.executedAt})`, + }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum), + ), + ) + .groupBy(commandHistory.command) + .orderBy(desc(sql`MAX(${commandHistory.executedAt})`)) + .limit(500); + + const uniqueCommands = result.map((r) => r.command); + + res.json(uniqueCommands); + } catch (err) { + authLogger.error("Failed to fetch command history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch history", + }); + } + }, +); + +// Delete a specific command from history +// POST /terminal/command_history/delete +router.post( + "/command_history/delete", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if (!isNonEmptyString(userId) || !hostId || !isNonEmptyString(command)) { + authLogger.warn("Invalid command delete request", { + operation: "command_history_delete", + userId, + hasHostId: !!hostId, + hasCommand: !!command, + }); + return res.status(400).json({ error: "Missing required parameters" }); + } + + try { + const hostIdNum = parseInt(hostId, 10); + + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum), + eq(commandHistory.command, command.trim()), + ), + ); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete command from history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to delete command", + }); + } + }, +); + +// Clear command history for a specific host (optional feature) +// DELETE /terminal/command_history/:hostId +router.delete( + "/command_history/:hostId", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.params; + const hostIdNum = parseInt(hostId, 10); + + if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { + authLogger.warn("Invalid command history clear request"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum), + ), + ); + + authLogger.success(`Command history cleared for host ${hostId}`, { + operation: "command_history_clear_success", + userId, + hostId: hostIdNum, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to clear command history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to clear history", + }); + } + }, +); + +export default router; diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index c2b2ac03..473654d9 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(); @@ -226,6 +227,16 @@ router.post("/create", async (req, res) => { }); } + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist user to disk", saveError, { + operation: "user_create_save_failed", + userId: id, + }); + } + authLogger.success( `Traditional user created: ${username} (is_admin: ${isFirstUser})`, { @@ -587,6 +598,7 @@ router.get("/oidc/callback", async (req, res) => { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", }, body: new URLSearchParams({ grant_type: "authorization_code", @@ -736,12 +748,11 @@ router.get("/oidc/callback", async (req, res) => { }); } + const deviceInfo = parseUserAgent(req); let user = await db .select() .from(users) - .where( - and(eq(users.is_oidc, true), eq(users.oidc_identifier, identifier)), - ); + .where(eq(users.oidc_identifier, identifier)); let isFirstUser = false; if (!user || user.length === 0) { @@ -750,6 +761,43 @@ router.get("/oidc/callback", async (req, res) => { .get(); isFirstUser = ((countResult as { count?: number })?.count || 0) === 0; + if (!isFirstUser) { + try { + const regRow = db.$client + .prepare( + "SELECT value FROM settings WHERE key = 'allow_registration'", + ) + .get(); + if (regRow && (regRow as Record).value !== "true") { + authLogger.warn( + "OIDC user attempted to register when registration is disabled", + { + operation: "oidc_registration_disabled", + identifier, + name, + }, + ); + + let frontendUrl = (redirectUri as string).replace( + "/users/oidc/callback", + "", + ); + if (frontendUrl.includes("localhost")) { + frontendUrl = "http://localhost:5173"; + } + const redirectUrl = new URL(frontendUrl); + redirectUrl.searchParams.set("error", "registration_disabled"); + + return res.redirect(redirectUrl.toString()); + } + } catch (e) { + authLogger.warn("Failed to check registration status during OIDC", { + operation: "oidc_registration_check", + error: e, + }); + } + } + const id = nanoid(); await db.insert(users).values({ id, @@ -769,7 +817,11 @@ router.get("/oidc/callback", async (req, res) => { }); try { - await authManager.registerOIDCUser(id); + const sessionDurationMs = + deviceInfo.type === "desktop" || deviceInfo.type === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; + await authManager.registerOIDCUser(id, sessionDurationMs); } catch (encryptionError) { await db.delete(users).where(eq(users.id, id)); authLogger.error( @@ -785,12 +837,27 @@ router.get("/oidc/callback", async (req, res) => { }); } + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist OIDC user to disk", saveError, { + operation: "oidc_user_create_save_failed", + userId: id, + }); + } + user = await db.select().from(users).where(eq(users.id, id)); } else { - await db - .update(users) - .set({ username: name }) - .where(eq(users.id, user[0].id)); + const isDualAuth = + user[0].password_hash && user[0].password_hash.trim() !== ""; + + if (!isDualAuth) { + await db + .update(users) + .set({ username: name }) + .where(eq(users.id, user[0].id)); + } user = await db.select().from(users).where(eq(users.id, user[0].id)); } @@ -798,7 +865,7 @@ router.get("/oidc/callback", async (req, res) => { const userRecord = user[0]; try { - await authManager.authenticateOIDCUser(userRecord.id); + await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type); } catch (setupError) { authLogger.error("Failed to setup OIDC user encryption", setupError, { operation: "oidc_user_encryption_setup_failed", @@ -806,7 +873,6 @@ router.get("/oidc/callback", async (req, res) => { }); } - const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, @@ -836,6 +902,8 @@ router.get("/oidc/callback", async (req, res) => { ? 30 * 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000; + res.clearCookie("jwt", authManager.getSecureCookieOptions(req)); + return res .cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge)) .redirect(redirectUrl.toString()); @@ -862,6 +930,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", { @@ -872,6 +941,20 @@ router.post("/login", async (req, res) => { return res.status(400).json({ error: "Invalid username or password" }); } + 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'") @@ -896,17 +979,26 @@ 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]; - if (userRecord.is_oidc) { - authLogger.warn("OIDC user attempted traditional login", { + if ( + userRecord.is_oidc && + (!userRecord.password_hash || userRecord.password_hash.trim() === "") + ) { + authLogger.warn("OIDC-only user attempted traditional login", { operation: "user_login", username, userId: userRecord.id, @@ -918,12 +1010,18 @@ 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 { @@ -935,12 +1033,24 @@ router.post("/login", async (req, res) => { if (kekSalt.length === 0) { await authManager.registerUser(userRecord.id, password); } - } catch {} + } catch (error) {} + + const deviceInfo = parseUserAgent(req); + + let dataUnlocked = false; + if (userRecord.is_oidc) { + dataUnlocked = await authManager.authenticateOIDCUser( + userRecord.id, + deviceInfo.type, + ); + } else { + dataUnlocked = await authManager.authenticateUser( + userRecord.id, + password, + deviceInfo.type, + ); + } - const dataUnlocked = await authManager.authenticateUser( - userRecord.id, - password, - ); if (!dataUnlocked) { return res.status(401).json({ error: "Incorrect password" }); } @@ -957,12 +1067,13 @@ router.post("/login", async (req, res) => { }); } - const deviceInfo = parseUserAgent(req); const token = await authManager.generateJWTToken(userRecord.id, { deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, }); + loginRateLimiter.resetAttempts(clientIp, username); + authLogger.success(`User logged in successfully: ${username}`, { operation: "user_login_success", username, @@ -970,6 +1081,7 @@ router.post("/login", async (req, res) => { dataUnlocked: true, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, + ip: clientIp, }); const response: Record = { @@ -1016,7 +1128,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); @@ -1052,11 +1172,17 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { return res.status(401).json({ error: "User not found" }); } + const hasPassword = + user[0].password_hash && user[0].password_hash.trim() !== ""; + const hasOidc = user[0].is_oidc && user[0].oidc_identifier; + const isDualAuth = hasPassword && hasOidc; + res.json({ userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, is_oidc: !!user[0].is_oidc, + is_dual_auth: isDualAuth, totp_enabled: !!user[0].totp_enabled, }); } catch (err) { @@ -1610,6 +1736,7 @@ router.get("/list", authenticateJWT, async (req, res) => { username: users.username, is_admin: users.is_admin, is_oidc: users.is_oidc, + password_hash: users.password_hash, }) .from(users); @@ -1653,6 +1780,16 @@ router.post("/make-admin", authenticateJWT, async (req, res) => { .set({ is_admin: true }) .where(eq(users.username, username)); + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist admin promotion to disk", saveError, { + operation: "make_admin_save_failed", + username, + }); + } + authLogger.success( `User ${username} made admin by ${adminUser[0].username}`, ); @@ -1702,6 +1839,16 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { .set({ is_admin: false }) .where(eq(users.username, username)); + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist admin removal to disk", saveError, { + operation: "remove_admin_save_failed", + username, + }); + } + authLogger.success( `Admin status removed from ${username} by ${adminUser[0].username}`, ); @@ -2106,7 +2253,6 @@ router.delete("/delete-user", authenticateJWT, async (req, res) => { const targetUserId = targetUser[0].id; try { - // Delete all user-related data to avoid foreign key constraints await db .delete(sshCredentialUsage) .where(eq(sshCredentialUsage.userId, targetUserId)); @@ -2426,4 +2572,295 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { } }); +// Route: Link OIDC user to existing password account (merge accounts) +// POST /users/link-oidc-to-password +router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { oidcUserId, targetUsername } = req.body; + + if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) { + return res.status(400).json({ + error: "OIDC user ID and target username are required", + }); + } + + try { + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminUserId)); + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + return res.status(403).json({ error: "Admin access required" }); + } + + const oidcUserRecords = await db + .select() + .from(users) + .where(eq(users.id, oidcUserId)); + if (!oidcUserRecords || oidcUserRecords.length === 0) { + return res.status(404).json({ error: "OIDC user not found" }); + } + + const oidcUser = oidcUserRecords[0]; + + if (!oidcUser.is_oidc) { + return res.status(400).json({ + error: "Source user is not an OIDC user", + }); + } + + const targetUserRecords = await db + .select() + .from(users) + .where(eq(users.username, targetUsername)); + if (!targetUserRecords || targetUserRecords.length === 0) { + return res.status(404).json({ error: "Target password user not found" }); + } + + const targetUser = targetUserRecords[0]; + + if (targetUser.is_oidc || !targetUser.password_hash) { + return res.status(400).json({ + error: "Target user must be a password-based account", + }); + } + + if (targetUser.client_id && targetUser.oidc_identifier) { + return res.status(400).json({ + error: "Target user already has OIDC authentication configured", + }); + } + + authLogger.info("Linking OIDC user to password account", { + operation: "link_oidc_to_password", + oidcUserId, + oidcUsername: oidcUser.username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + await db + .update(users) + .set({ + is_oidc: true, + oidc_identifier: oidcUser.oidc_identifier, + client_id: oidcUser.client_id, + client_secret: oidcUser.client_secret, + issuer_url: oidcUser.issuer_url, + authorization_url: oidcUser.authorization_url, + token_url: oidcUser.token_url, + identifier_path: oidcUser.identifier_path, + name_path: oidcUser.name_path, + scopes: oidcUser.scopes || "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + try { + await authManager.convertToOIDCEncryption(targetUser.id); + } catch (encryptionError) { + authLogger.error( + "Failed to convert encryption to OIDC during linking", + encryptionError, + { + operation: "link_convert_encryption_failed", + userId: targetUser.id, + }, + ); + await db + .update(users) + .set({ + is_oidc: false, + oidc_identifier: null, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + return res.status(500).json({ + error: + "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", + details: + encryptionError instanceof Error + ? encryptionError.message + : "Unknown error", + }); + } + + await authManager.revokeAllUserSessions(oidcUserId); + authManager.logoutUser(oidcUserId); + + await db + .delete(recentActivity) + .where(eq(recentActivity.userId, oidcUserId)); + + await db.delete(users).where(eq(users.id, oidcUserId)); + + db.$client + .prepare("DELETE FROM settings WHERE key LIKE ?") + .run(`user_%_${oidcUserId}`); + + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error("Failed to persist account linking to disk", saveError, { + operation: "link_oidc_save_failed", + oidcUserId, + targetUserId: targetUser.id, + }); + } + + authLogger.success( + `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`, + { + operation: "link_oidc_to_password_success", + oidcUserId, + oidcUsername: oidcUser.username, + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }, + ); + + res.json({ + success: true, + message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`, + }); + } catch (err) { + authLogger.error("Failed to link OIDC user to password account", err, { + operation: "link_oidc_to_password_failed", + oidcUserId, + targetUsername, + adminUserId, + }); + res.status(500).json({ + error: "Failed to link accounts", + details: err instanceof Error ? err.message : "Unknown error", + }); + } +}); + +// Route: Unlink OIDC from password account (admin only) +// POST /users/unlink-oidc-from-password +router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { + const adminUserId = (req as AuthenticatedRequest).userId; + const { userId } = req.body; + + if (!userId) { + return res.status(400).json({ + error: "User ID is required", + }); + } + + try { + const adminUser = await db + .select() + .from(users) + .where(eq(users.id, adminUserId)); + + if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { + authLogger.warn("Non-admin attempted to unlink OIDC from password", { + operation: "unlink_oidc_unauthorized", + adminUserId, + targetUserId: userId, + }); + return res.status(403).json({ + error: "Admin privileges required", + }); + } + + const targetUserRecords = await db + .select() + .from(users) + .where(eq(users.id, userId)); + + if (!targetUserRecords || targetUserRecords.length === 0) { + return res.status(404).json({ + error: "User not found", + }); + } + + const targetUser = targetUserRecords[0]; + + if (!targetUser.is_oidc) { + return res.status(400).json({ + error: "User does not have OIDC authentication enabled", + }); + } + + if (!targetUser.password_hash || targetUser.password_hash === "") { + return res.status(400).json({ + error: + "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", + }); + } + + authLogger.info("Unlinking OIDC from password account", { + operation: "unlink_oidc_from_password_start", + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + await db + .update(users) + .set({ + is_oidc: false, + oidc_identifier: null, + client_id: "", + client_secret: "", + issuer_url: "", + authorization_url: "", + token_url: "", + identifier_path: "", + name_path: "", + scopes: "openid email profile", + }) + .where(eq(users.id, targetUser.id)); + + try { + const { saveMemoryDatabaseToFile } = await import("../db/index.js"); + await saveMemoryDatabaseToFile(); + } catch (saveError) { + authLogger.error( + "Failed to save database after unlinking OIDC", + saveError, + { + operation: "unlink_oidc_save_failed", + targetUserId: targetUser.id, + }, + ); + } + + authLogger.success("OIDC unlinked from password account successfully", { + operation: "unlink_oidc_from_password_success", + targetUserId: targetUser.id, + targetUsername: targetUser.username, + adminUserId, + }); + + res.json({ + success: true, + message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`, + }); + } catch (err) { + authLogger.error("Failed to unlink OIDC from password account", err, { + operation: "unlink_oidc_from_password_failed", + targetUserId: userId, + adminUserId, + }); + res.status(500).json({ + error: "Failed to unlink OIDC", + details: err instanceof Error ? err.message : "Unknown error", + }); + } +}); + export default router; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index bf30c2de..b6344b68 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"; @@ -89,11 +89,179 @@ app.use(express.raw({ limit: "5gb", type: "application/octet-stream" })); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + fileLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: SSHClient | null = null; + const clients: SSHClient[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + fileLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new SSHClient(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + fileLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + fileLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} + interface SSHSession { client: SSHClient; isConnected: boolean; lastActive: number; timeout?: NodeJS.Timeout; + activeOperations: number; } interface PendingTOTPSession { @@ -118,9 +286,22 @@ const pendingTOTPSessions: Record = {}; function cleanupSession(sessionId: string) { const session = sshSessions[sessionId]; if (session) { + if (session.activeOperations > 0) { + fileLogger.warn( + `Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`, + { + operation: "cleanup_deferred", + sessionId, + activeOperations: session.activeOperations, + }, + ); + scheduleSessionCleanup(sessionId); + return; + } + try { session.client.end(); - } catch {} + } catch (error) {} clearTimeout(session.timeout); delete sshSessions[sessionId]; } @@ -174,6 +355,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { credentialId, userProvidedPassword, forceKeyboardInteractive, + jumpHosts, } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -393,6 +575,7 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { client, isConnected: true, lastActive: Date.now(), + activeOperations: 0, }; scheduleSessionCleanup(sessionId); res.json({ status: "success", message: "SSH connection established" }); @@ -625,7 +808,52 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { }, ); - client.connect(config); + if (jumpHosts && jumpHosts.length > 0 && userId) { + try { + const jumpClient = await createJumpHostChain(jumpHosts, userId); + + if (!jumpClient) { + fileLogger.error("Failed to establish jump host chain", { + operation: "file_jump_chain", + sessionId, + hostId, + }); + return res + .status(500) + .json({ error: "Failed to connect through jump hosts" }); + } + + jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { + if (err) { + fileLogger.error("Failed to forward through jump host", err, { + operation: "file_jump_forward", + sessionId, + hostId, + ip, + port, + }); + jumpClient.end(); + return res.status(500).json({ + error: "Failed to forward through jump host: " + err.message, + }); + } + + config.sock = stream; + client.connect(config); + }); + } catch (error) { + fileLogger.error("Jump host error", error, { + operation: "file_jump_host", + sessionId, + hostId, + }); + return res + .status(500) + .json({ error: "Failed to connect through jump hosts" }); + } + } else { + client.connect(config); + } }); app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { @@ -663,7 +891,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, @@ -700,6 +930,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { client: session.client, isConnected: true, lastActive: Date.now(), + activeOperations: 0, }; scheduleSessionCleanup(sessionId); @@ -843,10 +1074,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { } sshConn.lastActive = Date.now(); + sshConn.activeOperations++; const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => { + sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => { if (err) { + sshConn.activeOperations--; fileLogger.error("SSH listFiles error:", err); return res.status(500).json({ error: err.message }); } @@ -863,6 +1096,7 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { }); stream.on("close", (code) => { + sshConn.activeOperations--; if (code !== 0) { fileLogger.error( `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, @@ -2486,6 +2720,516 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { }); }); +app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { + const { sessionId, path, permissions } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sshConn || !sshConn.isConnected) { + fileLogger.error( + "SSH connection not found or not connected for changePermissions", + { + operation: "change_permissions", + sessionId, + hasConnection: !!sshConn, + isConnected: sshConn?.isConnected, + }, + ); + return res.status(400).json({ error: "SSH connection not available" }); + } + + if (!path) { + return res.status(400).json({ error: "File path is required" }); + } + + if (!permissions || !/^\d{3,4}$/.test(permissions)) { + return res.status(400).json({ + error: "Valid permissions required (e.g., 755, 644)", + }); + } + + sshConn.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const octalPerms = permissions.slice(-3); + const escapedPath = path.replace(/'/g, "'\"'\"'"); + const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`; + + fileLogger.info("Changing file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + const commandTimeout = setTimeout(() => { + if (!res.headersSent) { + fileLogger.error("changePermissions command timeout", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + res.status(408).json({ + error: "Permission change timed out. SSH connection may be unstable.", + }); + } + }, 10000); + + sshConn.client.exec(command, (err, stream) => { + if (err) { + clearTimeout(commandTimeout); + fileLogger.error("SSH changePermissions exec error:", err, { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + if (!res.headersSent) { + return res.status(500).json({ error: "Failed to change permissions" }); + } + return; + } + + let outputData = ""; + let errorOutput = ""; + + stream.on("data", (chunk: Buffer) => { + outputData += chunk.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + }); + + stream.on("close", (code) => { + clearTimeout(commandTimeout); + + if (outputData.includes("SUCCESS")) { + fileLogger.success("File permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + if (!res.headersSent) { + res.json({ + success: true, + message: "Permissions changed successfully", + }); + } + return; + } + + if (code !== 0) { + fileLogger.error("chmod command failed", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + exitCode: code, + error: errorOutput, + }); + if (!res.headersSent) { + return res.status(500).json({ + error: errorOutput || "Failed to change permissions", + }); + } + return; + } + + fileLogger.success("File permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + if (!res.headersSent) { + res.json({ + success: true, + message: "Permissions changed successfully", + }); + } + }); + + stream.on("error", (streamErr) => { + clearTimeout(commandTimeout); + fileLogger.error("SSH changePermissions stream error:", streamErr, { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + if (!res.headersSent) { + res + .status(500) + .json({ error: "Stream error while changing permissions" }); + } + }); + }); +}); + +// Route: Extract archive file (requires JWT) +// POST /ssh/file_manager/ssh/extractArchive +app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { + const { sessionId, archivePath, extractPath } = req.body; + + if (!sessionId || !archivePath) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "SSH session not connected" }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const fileName = archivePath.split("/").pop() || ""; + const fileExt = fileName.toLowerCase(); + + let extractCommand = ""; + const targetPath = + extractPath || archivePath.substring(0, archivePath.lastIndexOf("/")); + + if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) { + extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) { + extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar.xz")) { + extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar")) { + extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".zip")) { + extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`; + } else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) { + extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`; + } else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) { + extractCommand = `bunzip2 -k "${archivePath}"`; + } else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) { + extractCommand = `unxz -k "${archivePath}"`; + } else if (fileExt.endsWith(".7z")) { + extractCommand = `7z x "${archivePath}" -o"${targetPath}"`; + } else if (fileExt.endsWith(".rar")) { + extractCommand = `unrar x "${archivePath}" "${targetPath}/"`; + } else { + return res.status(400).json({ error: "Unsupported archive format" }); + } + + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: targetPath, + command: extractCommand, + }); + + session.client.exec(extractCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH exec error during extract:", err, { + operation: "extract_archive", + sessionId, + archivePath, + }); + return res + .status(500) + .json({ error: "Failed to execute extract command" }); + } + + let errorOutput = ""; + + stream.on("data", (data: Buffer) => { + fileLogger.debug("Extract stdout", { + operation: "extract_archive", + sessionId, + output: data.toString(), + }); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + fileLogger.debug("Extract stderr", { + operation: "extract_archive", + sessionId, + error: data.toString(), + }); + }); + + stream.on("close", (code: number) => { + if (code !== 0) { + fileLogger.error("Extract command failed", { + operation: "extract_archive", + sessionId, + archivePath, + exitCode: code, + error: errorOutput, + }); + + let friendlyError = errorOutput || "Failed to extract archive"; + if ( + errorOutput.includes("command not found") || + errorOutput.includes("not found") + ) { + let missingCmd = ""; + let installHint = ""; + + if (fileExt.endsWith(".zip")) { + missingCmd = "unzip"; + installHint = + "apt install unzip / yum install unzip / brew install unzip"; + } else if ( + fileExt.endsWith(".tar.gz") || + fileExt.endsWith(".tgz") || + fileExt.endsWith(".tar.bz2") || + fileExt.endsWith(".tbz2") || + fileExt.endsWith(".tar.xz") || + fileExt.endsWith(".tar") + ) { + missingCmd = "tar"; + installHint = "Usually pre-installed on Linux/Unix systems"; + } else if (fileExt.endsWith(".gz")) { + missingCmd = "gunzip"; + installHint = + "apt install gzip / yum install gzip / Usually pre-installed"; + } else if (fileExt.endsWith(".bz2")) { + missingCmd = "bunzip2"; + installHint = + "apt install bzip2 / yum install bzip2 / brew install bzip2"; + } else if (fileExt.endsWith(".xz")) { + missingCmd = "unxz"; + installHint = + "apt install xz-utils / yum install xz / brew install xz"; + } else if (fileExt.endsWith(".7z")) { + missingCmd = "7z"; + installHint = + "apt install p7zip-full / yum install p7zip / brew install p7zip"; + } else if (fileExt.endsWith(".rar")) { + missingCmd = "unrar"; + installHint = + "apt install unrar / yum install unrar / brew install unrar"; + } + + if (missingCmd) { + friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`; + } + } + + return res.status(500).json({ error: friendlyError }); + } + + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: targetPath, + }); + + res.json({ + success: true, + message: "Archive extracted successfully", + extractPath: targetPath, + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH extractArchive stream error:", streamErr, { + operation: "extract_archive", + sessionId, + archivePath, + }); + if (!res.headersSent) { + res + .status(500) + .json({ error: "Stream error while extracting archive" }); + } + }); + }); +}); + +// Route: Compress files/folders (requires JWT) +// POST /ssh/file_manager/ssh/compressFiles +app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { + const { sessionId, paths, archiveName, format } = req.body; + + if ( + !sessionId || + !paths || + !Array.isArray(paths) || + paths.length === 0 || + !archiveName + ) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "SSH session not connected" }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const compressionFormat = format || "zip"; + let compressCommand = ""; + + const firstPath = paths[0]; + const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/"; + + const fileNames = paths + .map((p) => { + const name = p.split("/").pop(); + return `"${name}"`; + }) + .join(" "); + + let archivePath = ""; + if (archiveName.includes("/")) { + archivePath = archiveName; + } else { + archivePath = workingDir.endsWith("/") + ? `${workingDir}${archiveName}` + : `${workingDir}/${archiveName}`; + } + + if (compressionFormat === "zip") { + compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") { + compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") { + compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.xz") { + compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar") { + compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "7z") { + compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`; + } else { + return res.status(400).json({ error: "Unsupported compression format" }); + } + + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + command: compressCommand, + }); + + session.client.exec(compressCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH exec error during compress:", err, { + operation: "compress_files", + sessionId, + paths, + }); + return res + .status(500) + .json({ error: "Failed to execute compress command" }); + } + + let errorOutput = ""; + + stream.on("data", (data: Buffer) => { + fileLogger.debug("Compress stdout", { + operation: "compress_files", + sessionId, + output: data.toString(), + }); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + fileLogger.debug("Compress stderr", { + operation: "compress_files", + sessionId, + error: data.toString(), + }); + }); + + stream.on("close", (code: number) => { + if (code !== 0) { + fileLogger.error("Compress command failed", { + operation: "compress_files", + sessionId, + paths, + archivePath, + exitCode: code, + error: errorOutput, + }); + + let friendlyError = errorOutput || "Failed to compress files"; + if ( + errorOutput.includes("command not found") || + errorOutput.includes("not found") + ) { + const commandMap: Record = { + zip: { + cmd: "zip", + install: "apt install zip / yum install zip / brew install zip", + }, + "tar.gz": { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + "tar.bz2": { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + "tar.xz": { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + tar: { + cmd: "tar", + install: "Usually pre-installed on Linux/Unix systems", + }, + "7z": { + cmd: "7z", + install: + "apt install p7zip-full / yum install p7zip / brew install p7zip", + }, + }; + + const info = commandMap[compressionFormat]; + if (info) { + friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`; + } + } + + return res.status(500).json({ error: friendlyError }); + } + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + }); + + res.json({ + success: true, + message: "Files compressed successfully", + archivePath: archivePath, + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH compressFiles stream error:", streamErr, { + operation: "compress_files", + sessionId, + paths, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while compressing files" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index cc155e49..b4c958c9 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -6,10 +6,185 @@ 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"; + +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + statsLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: Client | null = null; + const clients: Client[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + statsLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new Client(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + statsLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + statsLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} interface PooledConnection { client: Client; @@ -79,7 +254,7 @@ class SSHConnectionPool { private async createConnection( host: SSHHostWithCredentials, ): Promise { - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { const client = new Client(); const timeout = setTimeout(() => { client.end(); @@ -120,7 +295,12 @@ class SSHConnectionPool { ), ); } else if (host.password) { - const responses = prompts.map(() => host.password || ""); + const responses = prompts.map((p) => { + if (/password/i.test(p.prompt)) { + return host.password || ""; + } + return ""; + }); finish(responses); } else { finish(prompts.map(() => "")); @@ -129,7 +309,44 @@ class SSHConnectionPool { ); try { - client.connect(buildSshConfig(host)); + const config = buildSshConfig(host); + + if (host.jumpHosts && host.jumpHosts.length > 0 && host.userId) { + const jumpClient = await createJumpHostChain( + host.jumpHosts, + host.userId, + ); + + if (!jumpClient) { + clearTimeout(timeout); + reject(new Error("Failed to establish jump host chain")); + return; + } + + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port, + (err, stream) => { + if (err) { + clearTimeout(timeout); + jumpClient.end(); + reject( + new Error( + "Failed to forward through jump host: " + err.message, + ), + ); + return; + } + + config.sock = stream; + client.connect(config); + }, + ); + } else { + client.connect(config); + } } catch (err) { clearTimeout(timeout); reject(err); @@ -156,7 +373,7 @@ class SSHConnectionPool { if (!conn.inUse && now - conn.lastUsed > maxAge) { try { conn.client.end(); - } catch {} + } catch (error) {} return false; } return true; @@ -176,7 +393,7 @@ class SSHConnectionPool { for (const conn of connections) { try { conn.client.end(); - } catch {} + } catch (error) {} } } this.connections.clear(); @@ -214,7 +431,7 @@ class RequestQueue { if (request) { try { await request(); - } catch {} + } catch (error) {} } } @@ -382,7 +599,8 @@ interface SSHHostWithCredentials { enableFileManager: boolean; defaultPath: string; tunnelConnections: unknown[]; - statsConfig?: string; + jumpHosts?: Array<{ hostId: number }>; + statsConfig?: string | StatsConfig; createdAt: string; updatedAt: string; userId: string; @@ -427,34 +645,64 @@ class PollingManager { } >(); - parseStatsConfig(statsConfigStr?: string): StatsConfig { + parseStatsConfig(statsConfigStr?: string | StatsConfig): StatsConfig { if (!statsConfigStr) { return DEFAULT_STATS_CONFIG; } - try { - const parsed = JSON.parse(statsConfigStr); - return { ...DEFAULT_STATS_CONFIG, ...parsed }; - } catch (error) { - statsLogger.warn( - `Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - return DEFAULT_STATS_CONFIG; + + let parsed: StatsConfig; + + if (typeof statsConfigStr === "object") { + parsed = statsConfigStr; + } else { + try { + let temp: any = JSON.parse(statsConfigStr); + + if (typeof temp === "string") { + temp = JSON.parse(temp); + } + + parsed = temp; + } catch (error) { + statsLogger.warn( + `Failed to parse statsConfig: ${error instanceof Error ? error.message : "Unknown error"}`, + { + operation: "parse_stats_config_error", + statsConfigStr, + }, + ); + return DEFAULT_STATS_CONFIG; + } } + + const result = { ...DEFAULT_STATS_CONFIG, ...parsed }; + + return result; } async startPollingForHost(host: SSHHostWithCredentials): Promise { const statsConfig = this.parseStatsConfig(host.statsConfig); + const existingConfig = this.pollingConfigs.get(host.id); if (existingConfig) { if (existingConfig.statusTimer) { clearInterval(existingConfig.statusTimer); + existingConfig.statusTimer = undefined; } if (existingConfig.metricsTimer) { clearInterval(existingConfig.metricsTimer); + existingConfig.metricsTimer = undefined; } } + if (!statsConfig.statusCheckEnabled && !statsConfig.metricsEnabled) { + this.pollingConfigs.delete(host.id); + this.statusStore.delete(host.id); + this.metricsStore.delete(host.id); + return; + } + const config: HostPollingConfig = { host, statsConfig, @@ -466,7 +714,10 @@ class PollingManager { this.pollHostStatus(host); config.statusTimer = setInterval(() => { - this.pollHostStatus(host); + const latestConfig = this.pollingConfigs.get(host.id); + if (latestConfig && latestConfig.statsConfig.statusCheckEnabled) { + this.pollHostStatus(latestConfig.host); + } }, intervalMs); } else { this.statusStore.delete(host.id); @@ -478,7 +729,10 @@ class PollingManager { this.pollHostMetrics(host); config.metricsTimer = setInterval(() => { - this.pollHostMetrics(host); + const latestConfig = this.pollingConfigs.get(host.id); + if (latestConfig && latestConfig.statsConfig.metricsEnabled) { + this.pollHostMetrics(latestConfig.host); + } }, intervalMs); } else { this.metricsStore.delete(host.id); @@ -505,27 +759,51 @@ class PollingManager { } private async pollHostMetrics(host: SSHHostWithCredentials): Promise { + const config = this.pollingConfigs.get(host.id); + if (!config || !config.statsConfig.metricsEnabled) { + return; + } + + const currentHost = config.host; + try { - const metrics = await collectMetrics(host); - this.metricsStore.set(host.id, { + const metrics = await collectMetrics(currentHost); + this.metricsStore.set(currentHost.id, { data: metrics, timestamp: Date.now(), }); - } catch (error) {} + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + const latestConfig = this.pollingConfigs.get(currentHost.id); + if (latestConfig && latestConfig.statsConfig.metricsEnabled) { + statsLogger.warn("Failed to collect metrics for host", { + operation: "metrics_poll_failed", + hostId: currentHost.id, + hostName: currentHost.name, + error: errorMessage, + }); + } + } } - stopPollingForHost(hostId: number): void { + stopPollingForHost(hostId: number, clearData = true): void { const config = this.pollingConfigs.get(hostId); if (config) { if (config.statusTimer) { clearInterval(config.statusTimer); + config.statusTimer = undefined; } if (config.metricsTimer) { clearInterval(config.metricsTimer); + config.metricsTimer = undefined; } this.pollingConfigs.delete(hostId); - this.statusStore.delete(hostId); - this.metricsStore.delete(hostId); + if (clearData) { + this.statusStore.delete(hostId); + this.metricsStore.delete(hostId); + } } } @@ -554,11 +832,23 @@ class PollingManager { } async refreshHostPolling(userId: string): Promise { + const hosts = await fetchAllHosts(userId); + const currentHostIds = new Set(hosts.map((h) => h.id)); + for (const hostId of this.pollingConfigs.keys()) { - this.stopPollingForHost(hostId); + this.stopPollingForHost(hostId, false); } - await this.initializePolling(userId); + for (const hostId of this.statusStore.keys()) { + if (!currentHostIds.has(hostId)) { + this.statusStore.delete(hostId); + this.metricsStore.delete(hostId); + } + } + + for (const host of hosts) { + await this.startPollingForHost(host); + } } destroy(): void { @@ -712,6 +1002,7 @@ async function resolveHostCredentials( tunnelConnections: host.tunnelConnections ? JSON.parse(host.tunnelConnections as string) : [], + jumpHosts: host.jumpHosts ? JSON.parse(host.jumpHosts as string) : [], statsConfig: host.statsConfig || undefined, createdAt: host.createdAt, updatedAt: host.updatedAt, @@ -911,59 +1202,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; @@ -1026,298 +1264,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); @@ -1365,7 +1343,7 @@ function tcpPing( settled = true; try { socket.destroy(); - } catch {} + } catch (error) {} resolve(result); }; @@ -1438,6 +1416,67 @@ app.post("/refresh", async (req, res) => { res.json({ message: "Polling refreshed" }); }); +app.post("/host-updated", async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.body; + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + + if (!hostId || typeof hostId !== "number") { + return res.status(400).json({ error: "Invalid hostId" }); + } + + try { + const host = await fetchHostById(hostId, userId); + if (host) { + await pollingManager.startPollingForHost(host); + res.json({ message: "Host polling started" }); + } else { + res.status(404).json({ error: "Host not found" }); + } + } catch (error) { + statsLogger.error("Failed to start polling for host", error, { + operation: "host_updated", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to start polling" }); + } +}); + +app.post("/host-deleted", async (req, res) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.body; + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + + if (!hostId || typeof hostId !== "number") { + return res.status(400).json({ error: "Invalid hostId" }); + } + + try { + pollingManager.stopPollingForHost(hostId, true); + res.json({ message: "Host polling stopped" }); + } catch (error) { + statsLogger.error("Failed to stop polling for host", error, { + operation: "host_deleted", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to stop polling" }); + } +}); + app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index a040ee91..78c181e7 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -31,6 +31,7 @@ interface ConnectToHostData { credentialId?: number; userId?: string; forceKeyboardInteractive?: boolean; + jumpHosts?: Array<{ hostId: number }>; }; initialPath?: string; executeCommand?: string; @@ -57,6 +58,173 @@ const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + sshLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: Client | null = null; + const clients: Client[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + sshLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new Client(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + sshLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + sshLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} + const wss = new WebSocketServer({ port: 30002, verifyClient: async (info) => { @@ -79,6 +247,7 @@ const wss = new WebSocketServer({ } const existingConnections = userConnections.get(payload.userId); + if (existingConnections && existingConnections.size >= 3) { return false; } @@ -152,6 +321,10 @@ wss.on("connection", async (ws: WebSocket, req) => { let totpPromptSent = false; let isKeyboardInteractive = false; let keyboardInteractiveResponded = false; + let isConnecting = false; + let isConnected = false; + let isCleaningUp = false; + let isShellInitializing = false; ws.on("close", () => { const userWs = userConnections.get(userId); @@ -417,10 +590,21 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } + if (isConnecting || isConnected) { + sshLogger.warn("Connection already in progress or established", { + operation: "ssh_connect", + hostId: id, + isConnecting, + isConnected, + }); + return; + } + + isConnecting = true; sshConn = new Client(); const connectionTimeout = setTimeout(() => { - if (sshConn) { + if (sshConn && isConnecting && !isConnected) { sshLogger.error("SSH connection timeout", undefined, { operation: "ssh_connect", hostId: id, @@ -433,7 +617,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ); cleanupSSH(connectionTimeout); } - }, 60000); + }, 120000); let resolvedCredentials = { password, key, keyPassword, keyType, authType }; let authMethodNotAvailable = false; @@ -498,7 +682,9 @@ wss.on("connection", async (ws: WebSocket, req) => { sshConn.on("ready", () => { clearTimeout(connectionTimeout); - if (!sshConn) { + const conn = sshConn; + + if (!conn || isCleaningUp || !sshConn) { sshLogger.warn( "SSH connection was cleaned up before shell could be created", { @@ -507,6 +693,9 @@ wss.on("connection", async (ws: WebSocket, req) => { ip, port, username, + isCleaningUp, + connNull: !conn, + sshConnNull: !sshConn, }, ); ws.send( @@ -519,13 +708,37 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - sshConn.shell( + isShellInitializing = true; + isConnecting = false; + isConnected = true; + + if (!sshConn) { + sshLogger.error( + "SSH connection became null right before shell creation", + { + operation: "ssh_shell", + hostId: id, + }, + ); + ws.send( + JSON.stringify({ + type: "error", + message: "SSH connection lost during setup", + }), + ); + isShellInitializing = false; + return; + } + + conn.shell( { rows: data.rows, cols: data.cols, term: "xterm-256color", } as PseudoTtyOptions, (err, stream) => { + isShellInitializing = false; + if (err) { sshLogger.error("Shell error", err, { operation: "ssh_shell", @@ -836,9 +1049,10 @@ wss.on("connection", async (ws: WebSocket, req) => { tryKeyboard: true, keepaliveInterval: 30000, keepaliveCountMax: 3, - readyTimeout: 60000, + readyTimeout: 120000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, + timeout: 120000, env: { TERM: "xterm-256color", LANG: "en_US.UTF-8", @@ -969,7 +1183,68 @@ wss.on("connection", async (ws: WebSocket, req) => { return; } - sshConn.connect(connectConfig); + if ( + hostConfig.jumpHosts && + hostConfig.jumpHosts.length > 0 && + hostConfig.userId + ) { + try { + const jumpClient = await createJumpHostChain( + hostConfig.jumpHosts, + hostConfig.userId, + ); + + if (!jumpClient) { + sshLogger.error("Failed to establish jump host chain"); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to connect through jump hosts", + }), + ); + cleanupSSH(connectionTimeout); + return; + } + + jumpClient.forwardOut("127.0.0.1", 0, ip, port, (err, stream) => { + if (err) { + sshLogger.error("Failed to forward through jump host", err, { + operation: "ssh_jump_forward", + hostId: id, + ip, + port, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to forward through jump host: " + err.message, + }), + ); + jumpClient.end(); + cleanupSSH(connectionTimeout); + return; + } + + connectConfig.sock = stream; + sshConn.connect(connectConfig); + }); + } catch (error) { + sshLogger.error("Jump host error", error, { + operation: "ssh_jump_host", + hostId: id, + }); + ws.send( + JSON.stringify({ + type: "error", + message: "Failed to connect through jump hosts", + }), + ); + cleanupSSH(connectionTimeout); + return; + } + } else { + sshConn.connect(connectConfig); + } } function handleResize(data: ResizeData) { @@ -982,6 +1257,24 @@ wss.on("connection", async (ws: WebSocket, req) => { } function cleanupSSH(timeoutId?: NodeJS.Timeout) { + if (isCleaningUp) { + return; + } + + if (isShellInitializing) { + sshLogger.warn( + "Cleanup attempted during shell initialization, deferring", + { + operation: "cleanup_deferred", + userId, + }, + ); + setTimeout(() => cleanupSSH(timeoutId), 100); + return; + } + + isCleaningUp = true; + if (timeoutId) { clearTimeout(timeoutId); } @@ -1019,6 +1312,12 @@ wss.on("connection", async (ws: WebSocket, req) => { isKeyboardInteractive = false; keyboardInteractiveResponded = false; keyboardInteractiveFinish = null; + isConnecting = false; + isConnected = false; + + setTimeout(() => { + isCleaningUp = false; + }, 100); } function setupPingInterval() { @@ -1033,7 +1332,12 @@ wss.on("connection", async (ws: WebSocket, req) => { ); cleanupSSH(); } + } else if (!sshConn || !sshStream) { + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } } - }, 60000); + }, 30000); } }); diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 6262af86..4365abb8 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,7 @@ function cleanupTunnelResources( if (verification?.timeout) clearTimeout(verification.timeout); try { verification?.conn.end(); - } catch {} + } catch (error) {} tunnelVerifications.delete(tunnelName); } @@ -282,7 +282,7 @@ function handleDisconnect( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch {} + } catch (error) {} tunnelVerifications.delete(tunnelName); } @@ -638,7 +638,7 @@ async function connectSSHTunnel( try { conn.end(); - } catch {} + } catch (error) {} activeTunnels.delete(tunnelName); @@ -778,7 +778,7 @@ async function connectSSHTunnel( const verification = tunnelVerifications.get(tunnelName); if (verification?.timeout) clearTimeout(verification.timeout); verification?.conn.end(); - } catch {} + } catch (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..bf8bf6aa --- /dev/null +++ b/src/backend/ssh/widgets/common-utils.ts @@ -0,0 +1,42 @@ +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..b34f3d80 --- /dev/null +++ b/src/backend/ssh/widgets/login-stats-collector.ts @@ -0,0 +1,122 @@ +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..1ee0f51c --- /dev/null +++ b/src/backend/ssh/widgets/processes-collector.ts @@ -0,0 +1,63 @@ +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..b74c9b11 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -21,7 +21,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; if (persistentConfig.parsed) { Object.assign(process.env, persistentConfig.parsed); } - } catch {} + } catch (error) {} let version = "unknown"; diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 4c936110..fd706176 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -85,24 +85,25 @@ class AuthManager { await this.userCrypto.setupUserEncryption(userId, password); } - async registerOIDCUser(userId: string): Promise { - await this.userCrypto.setupOIDCUserEncryption(userId); + async registerOIDCUser( + userId: string, + sessionDurationMs: number, + ): Promise { + await this.userCrypto.setupOIDCUserEncryption(userId, sessionDurationMs); } - async authenticateOIDCUser(userId: string): Promise { - const authenticated = await this.userCrypto.authenticateOIDCUser(userId); + async authenticateOIDCUser( + userId: string, + deviceType?: DeviceType, + ): Promise { + const sessionDurationMs = + deviceType === "desktop" || deviceType === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; - if (authenticated) { - await this.performLazyEncryptionMigration(userId); - } - - return authenticated; - } - - async authenticateUser(userId: string, password: string): Promise { - const authenticated = await this.userCrypto.authenticateUser( + const authenticated = await this.userCrypto.authenticateOIDCUser( userId, - password, + sessionDurationMs, ); if (authenticated) { @@ -112,6 +113,33 @@ class AuthManager { return authenticated; } + async authenticateUser( + userId: string, + password: string, + deviceType?: DeviceType, + ): Promise { + const sessionDurationMs = + deviceType === "desktop" || deviceType === "mobile" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000; + + const authenticated = await this.userCrypto.authenticateUser( + userId, + password, + sessionDurationMs, + ); + + if (authenticated) { + await this.performLazyEncryptionMigration(userId); + } + + return authenticated; + } + + async convertToOIDCEncryption(userId: string): Promise { + await this.userCrypto.convertToOIDCEncryption(userId); + } + private async performLazyEncryptionMigration(userId: string): Promise { try { const userDataKey = this.getUserDataKey(userId); diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/utils/auto-ssl-setup.ts index e45ce2ec..acb85d21 100644 --- a/src/backend/utils/auto-ssl-setup.ts +++ b/src/backend/utils/auto-ssl-setup.ts @@ -233,7 +233,7 @@ IP.3 = 0.0.0.0 let envContent = ""; try { envContent = await fs.readFile(this.ENV_FILE, "utf8"); - } catch {} + } catch (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 002464b0..f0adc96a 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,11 +26,12 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { + const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; + 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, @@ -45,14 +47,55 @@ 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)); + 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); + + 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) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + } 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 +117,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 +138,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 +151,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 +166,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, @@ -134,16 +208,69 @@ 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 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, + }, + ); + 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") { @@ -181,13 +308,67 @@ 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 (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", + error, + { + operation: "database_buffer_decryption_auth_failed", + encryptedPath, + 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 +396,26 @@ 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(); @@ -274,18 +475,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; @@ -322,6 +548,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 (error) {} + } + + 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/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index 6be7b44d..ae7ee615 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -82,7 +82,7 @@ export class LazyFieldEncryption { legacyFieldName, ); return decrypted; - } catch {} + } catch (error) {} } const sensitiveFields = [ @@ -174,7 +174,7 @@ export class LazyFieldEncryption { wasPlaintext: false, wasLegacyEncryption: true, }; - } catch {} + } catch (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..105995f3 --- /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 = 10 * 60 * 1000; + private readonly LOCKOUT_MS = 10 * 60 * 1000; + + constructor() { + setInterval(() => this.cleanup(), 5 * 60 * 1000); + } + + private cleanup(): void { + const now = Date.now(); + + 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); + } + } + + 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(); + + const ipAttempt = this.ipAttempts.get(ip); + if (!ipAttempt) { + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else if (now - ipAttempt.firstAttempt > this.WINDOW_MS) { + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else { + ipAttempt.count++; + if (ipAttempt.count >= this.MAX_ATTEMPTS) { + ipAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + + 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) { + 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(); + + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000), + }; + } + + 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; + + 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); + } + + 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 const loginRateLimiter = new LoginRateLimiter(); diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 8cd3d3d3..8685ec99 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,7 @@ function detectKeyTypeFromContent(keyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch {} + } catch (error) {} if (content.length < 800) { return "ssh-ed25519"; @@ -140,7 +141,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string { } else if (decodedString.includes("1.3.101.112")) { return "ssh-ed25519"; } - } catch {} + } catch (error) {} if (content.length < 400) { return "ssh-ed25519"; @@ -242,7 +243,7 @@ export function parseSSHKey( useSSH2 = true; } - } catch {} + } catch (error) {} } if (!useSSH2) { @@ -268,7 +269,7 @@ export function parseSSHKey( success: true, }; } - } catch {} + } catch (error) {} return { privateKey: privateKeyData, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index bd6aa727..fdff0263 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"); } @@ -134,7 +140,7 @@ class SystemCrypto { process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1]; return; } - } catch {} + } catch (error) {} await this.generateAndGuideInternalAuthToken(); } catch (error) { diff --git a/src/backend/utils/user-crypto.ts b/src/backend/utils/user-crypto.ts index 607066b6..593fd08b 100644 --- a/src/backend/utils/user-crypto.ts +++ b/src/backend/utils/user-crypto.ts @@ -21,8 +21,8 @@ interface EncryptedDEK { interface UserSession { dataKey: Buffer; - lastActivity: number; expiresAt: number; + lastActivity?: number; } class UserCrypto { @@ -33,8 +33,6 @@ class UserCrypto { private static readonly PBKDF2_ITERATIONS = 100000; private static readonly KEK_LENGTH = 32; private static readonly DEK_LENGTH = 32; - private static readonly SESSION_DURATION = 24 * 60 * 60 * 1000; - private static readonly MAX_INACTIVITY = 6 * 60 * 60 * 1000; private constructor() { setInterval( @@ -69,7 +67,10 @@ class UserCrypto { DEK.fill(0); } - async setupOIDCUserEncryption(userId: string): Promise { + async setupOIDCUserEncryption( + userId: string, + sessionDurationMs: number, + ): Promise { const existingEncryptedDEK = await this.getEncryptedDEK(userId); let DEK: Buffer; @@ -104,14 +105,17 @@ class UserCrypto { const now = Date.now(); this.userSessions.set(userId, { dataKey: Buffer.from(DEK), - lastActivity: now, - expiresAt: now + UserCrypto.SESSION_DURATION, + expiresAt: now + sessionDurationMs, }); DEK.fill(0); } - async authenticateUser(userId: string, password: string): Promise { + async authenticateUser( + userId: string, + password: string, + sessionDurationMs: number, + ): Promise { try { const kekSalt = await this.getKEKSalt(userId); if (!kekSalt) return false; @@ -144,8 +148,7 @@ class UserCrypto { this.userSessions.set(userId, { dataKey: Buffer.from(DEK), - lastActivity: now, - expiresAt: now + UserCrypto.SESSION_DURATION, + expiresAt: now + sessionDurationMs, }); DEK.fill(0); @@ -161,13 +164,49 @@ class UserCrypto { } } - async authenticateOIDCUser(userId: string): Promise { + async authenticateOIDCUser( + userId: string, + sessionDurationMs: number, + ): Promise { try { + const oidcEncryptedDEK = await this.getOIDCEncryptedDEK(userId); + + if (oidcEncryptedDEK) { + const systemKey = this.deriveOIDCSystemKey(userId); + const DEK = this.decryptDEK(oidcEncryptedDEK, systemKey); + systemKey.fill(0); + + if (!DEK || DEK.length === 0) { + databaseLogger.error( + "Failed to decrypt OIDC DEK for dual-auth user", + { + operation: "oidc_auth_dual_decrypt_failed", + userId, + }, + ); + return false; + } + + const now = Date.now(); + const oldSession = this.userSessions.get(userId); + if (oldSession) { + oldSession.dataKey.fill(0); + } + + this.userSessions.set(userId, { + dataKey: Buffer.from(DEK), + expiresAt: now + sessionDurationMs, + }); + + DEK.fill(0); + return true; + } + const kekSalt = await this.getKEKSalt(userId); const encryptedDEK = await this.getEncryptedDEK(userId); if (!kekSalt || !encryptedDEK) { - await this.setupOIDCUserEncryption(userId); + await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } @@ -176,7 +215,7 @@ class UserCrypto { systemKey.fill(0); if (!DEK || DEK.length === 0) { - await this.setupOIDCUserEncryption(userId); + await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } @@ -189,15 +228,19 @@ class UserCrypto { this.userSessions.set(userId, { dataKey: Buffer.from(DEK), - lastActivity: now, - expiresAt: now + UserCrypto.SESSION_DURATION, + expiresAt: now + sessionDurationMs, }); DEK.fill(0); return true; - } catch { - await this.setupOIDCUserEncryption(userId); + } catch (error) { + databaseLogger.error("OIDC authentication failed", error, { + operation: "oidc_auth_error", + userId, + error: error instanceof Error ? error.message : "Unknown", + }); + await this.setupOIDCUserEncryption(userId, sessionDurationMs); return true; } } @@ -219,16 +262,6 @@ class UserCrypto { return null; } - if (now - session.lastActivity > UserCrypto.MAX_INACTIVITY) { - this.userSessions.delete(userId); - session.dataKey.fill(0); - if (this.sessionExpiredCallback) { - this.sessionExpiredCallback(userId); - } - return null; - } - - session.lastActivity = now; return session.dataKey; } @@ -331,6 +364,83 @@ class UserCrypto { } } + /** + * Convert a password-based user's encryption to DUAL-AUTH encryption. + * This is used when linking an OIDC account to a password account for dual-auth. + * + * IMPORTANT: This does NOT delete the password-based KEK salt! + * The user needs to maintain BOTH password and OIDC authentication methods. + * We keep the password KEK salt so password login still works. + * We also store the DEK encrypted with OIDC system key for OIDC login. + */ + async convertToOIDCEncryption(userId: string): Promise { + try { + const existingEncryptedDEK = await this.getEncryptedDEK(userId); + const existingKEKSalt = await this.getKEKSalt(userId); + + if (!existingEncryptedDEK && !existingKEKSalt) { + databaseLogger.warn("No existing encryption to convert for user", { + operation: "convert_to_oidc_encryption_skip", + userId, + }); + return; + } + + const existingDEK = this.getUserDataKey(userId); + + if (!existingDEK) { + throw new Error( + "Cannot convert to OIDC encryption - user session not active. Please log in with password first.", + ); + } + + const systemKey = this.deriveOIDCSystemKey(userId); + const oidcEncryptedDEK = this.encryptDEK(existingDEK, systemKey); + systemKey.fill(0); + + const key = `user_encrypted_dek_oidc_${userId}`; + const value = JSON.stringify(oidcEncryptedDEK); + + const { getDb } = await import("../database/db/index.js"); + const { settings } = await import("../database/db/schema.js"); + const { eq } = await import("drizzle-orm"); + + const existing = await getDb() + .select() + .from(settings) + .where(eq(settings.key, key)); + + if (existing.length > 0) { + await getDb() + .update(settings) + .set({ value }) + .where(eq(settings.key, key)); + } else { + await getDb().insert(settings).values({ key, value }); + } + + databaseLogger.info( + "Converted user encryption to dual-auth (password + OIDC)", + { + operation: "convert_to_oidc_encryption_preserved", + userId, + }, + ); + + const { saveMemoryDatabaseToFile } = await import( + "../database/db/index.js" + ); + await saveMemoryDatabaseToFile(); + } catch (error) { + databaseLogger.error("Failed to convert to OIDC encryption", error, { + operation: "convert_to_oidc_encryption_error", + userId, + error: error instanceof Error ? error.message : "Unknown error", + }); + throw error; + } + } + private async validatePassword( userId: string, password: string, @@ -359,10 +469,7 @@ class UserCrypto { const expiredUsers: string[] = []; for (const [userId, session] of this.userSessions.entries()) { - if ( - now > session.expiresAt || - now - session.lastActivity > UserCrypto.MAX_INACTIVITY - ) { + if (now > session.expiresAt) { session.dataKey.fill(0); expiredUsers.push(userId); } @@ -512,6 +619,26 @@ class UserCrypto { return null; } } + + private async getOIDCEncryptedDEK( + userId: string, + ): Promise { + try { + const key = `user_encrypted_dek_oidc_${userId}`; + const result = await getDb() + .select() + .from(settings) + .where(eq(settings.key, key)); + + if (result.length === 0) { + return null; + } + + return JSON.parse(result[0].value); + } catch { + return null; + } + } } export { UserCrypto, type KEKSalt, type EncryptedDEK }; 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/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/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..44fefd02 --- /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..8e30362e 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -40,7 +40,7 @@ function TabsTrigger({ ); diff --git a/src/constants/terminal-themes.ts b/src/constants/terminal-themes.ts index 0786a952..63f55055 100644 --- a/src/constants/terminal-themes.ts +++ b/src/constants/terminal-themes.ts @@ -617,7 +617,6 @@ export const TERMINAL_THEMES: Record = { }, }; -// Font families available for terminal export const TERMINAL_FONTS = [ { value: "Caskaydia Cove Nerd Font Mono", 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/de/translation.json b/src/locales/de/translation.json index c407191b..c6cdb3fa 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -187,11 +187,25 @@ "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" + }, + "commandHistory": { + "title": "Verlauf", + "searchPlaceholder": "Befehle suchen...", + "noTerminal": "Kein aktives Terminal", + "noTerminalHint": "Öffnen Sie ein Terminal, um dessen Befehlsverlauf anzuzeigen.", + "empty": "Noch kein Befehlsverlauf", + "emptyHint": "Führen Sie Befehle im aktiven Terminal aus, um einen Verlauf zu erstellen.", + "noResults": "Keine Befehle gefunden", + "noResultsHint": "Keine Befehle mit \"{{query}}\" gefunden", + "deleteSuccess": "Befehl aus Verlauf gelöscht", + "deleteFailed": "Befehl konnte nicht gelöscht werden.", + "deleteTooltip": "Befehl löschen", + "tabHint": "Verwenden Sie Tab im Terminal, um aus dem Befehlsverlauf zu vervollständigen" }, "homepage": { "loggedInTitle": "Eingeloggt!", - "loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab „SSH-Manager“. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.", + "loggedInMessage": "Sie sind angemeldet! Über die Seitenleiste haben Sie Zugriff auf alle verfügbaren Tools. Erstellen Sie zunächst einen SSH-Host im Tab SSH-Manager. Anschließend können Sie sich über die anderen Apps in der Seitenleiste mit diesem Host verbinden.", "failedToLoadAlerts": "Warnmeldungen konnten nicht geladen werden", "failedToDismissAlert": "Benachrichtigung konnte nicht geschlossen werden" }, @@ -295,6 +309,7 @@ "submit": "Senden", "change": "Ändern", "save": "Speichern", + "saving": "Speichern...", "delete": "Löschen", "edit": "Bearbeiten", "add": "Hinzufügen", @@ -355,7 +370,7 @@ "tabNavigation": "Registerkarte Navigation" }, "admin": { - "title": "Administratoreinstellungen", + "title": "Admin-Einstellungen", "oidc": "OIDC", "users": "Benutzer", "userManagement": "Benutzerverwaltung", @@ -390,11 +405,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 +431,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 +642,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.", @@ -699,11 +721,85 @@ "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.", "forceKeyboardInteractive": "Tastatur-Interaktiv erzwingen", - "forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden." + "forceKeyboardInteractiveDesc": "Erzwingt die Verwendung der tastatur-interaktiven Authentifizierung. Dies ist oft für Server erforderlich, die eine Zwei-Faktor-Authentifizierung (TOTP/2FA) verwenden.", + "overrideCredentialUsername": "Benutzernamen der Anmeldedaten überschreiben", + "overrideCredentialUsernameDesc": "Verwenden Sie einen anderen Benutzernamen als den, der in den Anmeldedaten gespeichert ist. Dadurch können Sie dieselben Anmeldedaten mit unterschiedlichen Benutzernamen verwenden.", + "jumpHosts": "Jump-Hosts", + "jumpHostsDescription": "Jump-Hosts (auch bekannt als Bastion-Hosts) ermöglichen es Ihnen, sich über einen oder mehrere Zwischen-Server mit einem Ziel-Server zu verbinden. Dies ist nützlich für den Zugriff auf Server hinter Firewalls oder in privaten Netzwerken.", + "jumpHostChain": "Jump-Host-Kette", + "addJumpHost": "Jump-Host hinzufügen", + "selectServer": "Server auswählen", + "searchServers": "Server durchsuchen...", + "noServerFound": "Kein Server gefunden", + "jumpHostsOrder": "Verbindungen werden in dieser Reihenfolge hergestellt: Jump-Host 1 → Jump-Host 2 → ... → Ziel-Server", + "advancedAuthSettings": "Erweiterte Authentifizierungseinstellungen" }, "terminal": { "title": "Terminal", @@ -819,6 +915,8 @@ "copy": "Kopieren", "cut": "Ausschneiden", "paste": "Einfügen", + "copyPath": "Pfad kopieren", + "copyPaths": "Pfade kopieren", "delete": "Löschen", "properties": "Eigenschaften", "refresh": "Aktualisieren", @@ -828,6 +926,9 @@ "deleteFiles": "{{count}} Element(e) löschen", "filesCopiedToClipboard": "{{count}} Element(e) in die Zwischenablage kopiert", "filesCutToClipboard": "{{count}} Element(e) in die Zwischenablage ausschneiden", + "pathCopiedToClipboard": "Pfad in Zwischenablage kopiert", + "pathsCopiedToClipboard": "{{count}} Pfade in Zwischenablage kopiert", + "failedToCopyPath": "Fehler beim Kopieren des Pfades in die Zwischenablage", "movedItems": "{{count}} Element(e) verschoben", "failedToDeleteItem": "Das Löschen des Elements ist fehlgeschlagen.", "itemRenamedSuccessfully": "{{type}} erfolgreich umbenannt", @@ -1072,7 +1173,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 +1182,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,9 +1194,40 @@ "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", + "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 SERVER 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", @@ -1260,6 +1393,10 @@ "local": "Lokal", "external": "Extern (OIDC)", "selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche", + "fileColorCoding": "Dateifarb-Codierung", + "fileColorCodingDesc": "Farbcodierung von Dateien nach Typ: Ordner (rot), Dateien (blau), Symlinks (grün)", + "commandAutocomplete": "Befehlsautovervollständigung", + "commandAutocompleteDesc": "Tab-Taste Autovervollständigung für Terminal-Befehle basierend auf Ihrem Befehlsverlauf aktivieren", "currentPassword": "Aktuelles Passwort", "passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.", "failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut." @@ -1372,7 +1509,7 @@ "disconnected": "Getrennt", "maxRetriesExhausted": "Maximale Wiederholungsversuche ausgeschöpft", "endpointHostNotFound": "Endpunkthost nicht gefunden", - "administrator": "Administrator", + "administrator": "Admin", "user": "Benutzer", "external": "Extern", "local": "Lokal", @@ -1474,5 +1611,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 3de6628c..60e2ec3a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -109,6 +109,7 @@ "orCreateNewFolder": "Or create new folder", "addTag": "Add tag", "saving": "Saving...", + "credentialId": "Credential ID", "overview": "Overview", "security": "Security", "usage": "Usage", @@ -225,6 +226,20 @@ "editTooltip": "Edit this snippet", "deleteTooltip": "Delete this snippet" }, + "commandHistory": { + "title": "History", + "searchPlaceholder": "Search commands...", + "noTerminal": "No active terminal", + "noTerminalHint": "Open a terminal to see its command history.", + "empty": "No command history yet", + "emptyHint": "Execute commands in the active terminal to build its history.", + "noResults": "No commands found", + "noResultsHint": "No commands matching \"{{query}}\"", + "deleteSuccess": "Command deleted from history", + "deleteFailed": "Failed to delete command.", + "deleteTooltip": "Delete command", + "tabHint": "Use Tab in Terminal to autocomplete from command history" + }, "homepage": { "loggedInTitle": "Logged in!", "loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.", @@ -343,6 +358,7 @@ "cancel": "Cancel", "change": "Change", "save": "Save", + "saving": "Saving...", "delete": "Delete", "edit": "Edit", "add": "Add", @@ -480,6 +496,24 @@ "confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?", "failedToRevokeSessions": "Failed to revoke sessions", "sessionsRevokedSuccessfully": "Sessions revoked successfully", + "linkToPasswordAccount": "Link to Password Account", + "linkOIDCDialogTitle": "Link OIDC Account to Password Account", + "linkOIDCDialogDescription": "Link {{username}} (OIDC user) to an existing password account. This will enable dual authentication for the password account.", + "linkOIDCWarningTitle": "Warning: OIDC User Data Will Be Deleted", + "linkOIDCActionDeleteUser": "Delete the OIDC user account and all their data", + "linkOIDCActionAddCapability": "Add OIDC login capability to the target password account", + "linkOIDCActionDualAuth": "Allow the password account to login with both password and OIDC", + "linkTargetUsernameLabel": "Target Password Account Username", + "linkTargetUsernamePlaceholder": "Enter username of password account", + "linkAccountsButton": "Link Accounts", + "linkingAccounts": "Linking...", + "accountsLinkedSuccessfully": "OIDC user {{oidcUsername}} has been linked to {{targetUsername}}", + "failedToLinkAccounts": "Failed to link accounts", + "linkTargetUsernameRequired": "Target username is required", + "unlinkOIDCTitle": "Unlink OIDC Authentication", + "unlinkOIDCDescription": "Remove OIDC authentication from {{username}}? The user will only be able to login with username/password after this.", + "unlinkOIDCSuccess": "OIDC unlinked from {{username}}", + "failedToUnlinkOIDC": "Failed to unlink OIDC", "databaseSecurity": "Database Security", "encryptionStatus": "Encryption Status", "encryptionEnabled": "Encryption Enabled", @@ -754,6 +788,17 @@ "failedToRemoveFromFolder": "Failed to remove host from folder", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", + "editFolderAppearance": "Edit Folder Appearance", + "editFolderAppearanceDesc": "Customize the color and icon for folder", + "folderColor": "Folder Color", + "folderIcon": "Folder Icon", + "preview": "Preview", + "folderAppearanceUpdated": "Folder appearance updated successfully", + "failedToUpdateFolderAppearance": "Failed to update folder appearance", + "deleteAllHostsInFolder": "Delete All Hosts in Folder", + "confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.", + "allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully", + "failedToDeleteHostsInFolder": "Failed to delete hosts in folder", "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully", "failedToMoveToFolder": "Failed to move host to folder", "statistics": "Statistics", @@ -778,11 +823,92 @@ "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.", "forceKeyboardInteractive": "Force Keyboard-Interactive", - "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA)." + "forceKeyboardInteractiveDesc": "Forces the use of keyboard-interactive authentication. This is often required for servers that use Two-Factor Authentication (TOTP/2FA).", + "overrideCredentialUsername": "Override Credential Username", + "overrideCredentialUsernameDesc": "Use a different username than the one stored in the credential. This allows you to use the same credential with different usernames.", + "jumpHosts": "Jump Hosts", + "jumpHostsDescription": "Jump hosts (also known as bastion hosts) allow you to connect to a target server through one or more intermediate servers. This is useful for accessing servers behind firewalls or in private networks.", + "jumpHostChain": "Jump Host Chain", + "addJumpHost": "Add Jump Host", + "selectServer": "Select Server", + "searchServers": "Search servers...", + "noServerFound": "No server found", + "jumpHostsOrder": "Connections will be made in order: Jump Host 1 → Jump Host 2 → ... → Target Server", + "quickActions": "Quick Actions", + "quickActionsDescription": "Quick actions allow you to create custom buttons that execute SSH snippets on this server. These buttons will appear at the top of the Server Stats page for quick access.", + "quickActionsList": "Quick Actions List", + "addQuickAction": "Add Quick Action", + "quickActionName": "Action name", + "noSnippetFound": "No snippet found", + "quickActionsOrder": "Quick action buttons will appear in the order listed above on the Server Stats page", + "advancedAuthSettings": "Advanced Authentication Settings" }, "terminal": { "title": "Terminal", @@ -829,6 +955,22 @@ "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", "downloadFile": "Download", + "extractArchive": "Extract Archive", + "extractingArchive": "Extracting {{name}}...", + "archiveExtractedSuccessfully": "{{name}} extracted successfully", + "extractFailed": "Extract failed", + "compressFile": "Compress File", + "compressFiles": "Compress Files", + "compressFilesDesc": "Compress {{count}} items into an archive", + "archiveName": "Archive Name", + "enterArchiveName": "Enter archive name...", + "compressionFormat": "Compression Format", + "selectedFiles": "Selected files", + "andMoreFiles": "and {{count}} more...", + "compress": "Compress", + "compressingFiles": "Compressing {{count}} items into {{name}}...", + "filesCompressedSuccessfully": "{{name}} created successfully", + "compressFailed": "Compression failed", "edit": "Edit", "preview": "Preview", "previous": "Previous", @@ -902,6 +1044,8 @@ "copy": "Copy", "cut": "Cut", "paste": "Paste", + "copyPath": "Copy Path", + "copyPaths": "Copy Paths", "delete": "Delete", "properties": "Properties", "preview": "Preview", @@ -912,6 +1056,9 @@ "deleteFiles": "Delete {{count}} items", "filesCopiedToClipboard": "{{count}} items copied to clipboard", "filesCutToClipboard": "{{count}} items cut to clipboard", + "pathCopiedToClipboard": "Path copied to clipboard", + "pathsCopiedToClipboard": "{{count}} paths copied to clipboard", + "failedToCopyPath": "Failed to copy path to clipboard", "movedItems": "Moved {{count}} items", "failedToDeleteItem": "Failed to delete item", "itemRenamedSuccessfully": "{{type}} renamed successfully", @@ -1106,7 +1253,19 @@ "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", "loadFileFailed": "Failed to load file: {{error}}", "connectedSuccessfully": "Connected successfully", - "totpVerificationFailed": "TOTP verification failed" + "totpVerificationFailed": "TOTP verification failed", + "changePermissions": "Change Permissions", + "changePermissionsDesc": "Modify file permissions for", + "currentPermissions": "Current Permissions", + "newPermissions": "New Permissions", + "owner": "Owner", + "group": "Group", + "others": "Others", + "read": "Read", + "write": "Write", + "execute": "Execute", + "permissionsChangedSuccessfully": "Permissions changed successfully", + "failedToChangePermissions": "Failed to change permissions" }, "tunnels": { "title": "SSH Tunnels", @@ -1210,7 +1369,6 @@ "totpRequired": "TOTP Authentication Required", "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers", "load": "Load", - "free": "Free", "available": "Available", "editLayout": "Edit Layout", "cancelEdit": "Cancel", @@ -1229,9 +1387,27 @@ "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", + "quickActions": "Quick Actions", + "executeQuickAction": "Execute {{name}}", + "executingQuickAction": "Executing {{name}}...", + "quickActionSuccess": "{{name}} completed successfully", + "quickActionFailed": "{{name}} failed", + "quickActionError": "Failed to execute {{name}}" }, "auth": { + "tagline": "SSH SERVER 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", @@ -1404,7 +1580,12 @@ "authMethod": "Authentication Method", "local": "Local", "external": "External (OIDC)", + "externalAndLocal": "Dual Auth", "selectPreferredLanguage": "Select your preferred language for the interface", + "fileColorCoding": "File Color Coding", + "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", + "commandAutocomplete": "Command Autocomplete", + "commandAutocompleteDesc": "Enable Tab key autocomplete suggestions for terminal commands based on your command history", "currentPassword": "Current Password", "passwordChangedSuccess": "Password changed successfully! Please log in again.", "failedToChangePassword": "Failed to change password. Please check your current password and try again." @@ -1620,5 +1801,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/fr/translation.json b/src/locales/fr/translation.json new file mode 100644 index 00000000..7f111fb8 --- /dev/null +++ b/src/locales/fr/translation.json @@ -0,0 +1,1609 @@ +{ + "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" + }, + "commandHistory": { + "title": "Historique", + "searchPlaceholder": "Rechercher des commandes...", + "noTerminal": "Aucun terminal actif", + "noTerminalHint": "Ouvrez un terminal pour voir son historique de commandes.", + "empty": "Aucun historique de commandes", + "emptyHint": "Exécutez des commandes dans le terminal actif pour créer un historique.", + "noResults": "Aucune commande trouvée", + "noResultsHint": "Aucune commande correspondant à \"{{query}}\"", + "deleteSuccess": "Commande supprimée de l'historique", + "deleteFailed": "Échec de la suppression de la commande.", + "deleteTooltip": "Supprimer la commande", + "tabHint": "Utilisez Tab dans le terminal pour compléter automatiquement depuis l'historique des commandes" + }, + "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).", + "overrideCredentialUsername": "Remplacer le nom d'utilisateur des identifiants", + "overrideCredentialUsernameDesc": "Utilisez un nom d'utilisateur différent de celui stocké dans les identifiants. Cela vous permet d'utiliser les mêmes identifiants avec différents noms d'utilisateur.", + "jumpHosts": "Serveurs de rebond", + "jumpHostsDescription": "Les serveurs de rebond (également appelés bastions) vous permettent de vous connecter à un serveur cible via un ou plusieurs serveurs intermédiaires. Utile pour accéder à des serveurs derrière des pare-feu ou dans des réseaux privés.", + "jumpHostChain": "Chaîne de serveurs de rebond", + "addJumpHost": "Ajouter un serveur de rebond", + "selectServer": "Sélectionner un serveur", + "searchServers": "Rechercher des serveurs...", + "noServerFound": "Aucun serveur trouvé", + "jumpHostsOrder": "Les connexions seront établies dans l'ordre : Serveur de rebond 1 → Serveur de rebond 2 → ... → Serveur cible", + "advancedAuthSettings": "Paramètres d'authentification avancés" + }, + "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", + "copyPath": "Copier le chemin", + "copyPaths": "Copier les chemins", + "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", + "pathCopiedToClipboard": "Chemin copié dans le presse-papiers", + "pathsCopiedToClipboard": "{{count}} chemins copiés dans le presse-papiers", + "failedToCopyPath": "Échec de la copie du chemin 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", + "fileColorCoding": "Codage couleur des fichiers", + "fileColorCodingDesc": "Codage couleur des fichiers par type : dossiers (rouge), fichiers (bleu), liens symboliques (vert)", + "commandAutocomplete": "Autocomplétion des commandes", + "commandAutocompleteDesc": "Activer les suggestions d'autocomplétion avec la touche Tab pour les commandes du terminal basées sur votre historique", + "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/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index c656e9cb..9e9350e3 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -191,6 +191,20 @@ "enableRightClickCopyPaste": "Habilitar copiar/colar com botão direito", "shareIdeas": "Tem ideias sobre o que deve vir a seguir nas ferramentas SSH? Compartilhe em" }, + "commandHistory": { + "title": "Histórico", + "searchPlaceholder": "Pesquisar comandos...", + "noTerminal": "Nenhum terminal ativo", + "noTerminalHint": "Abra um terminal para ver seu histórico de comandos.", + "empty": "Ainda não há histórico de comandos", + "emptyHint": "Execute comandos no terminal ativo para criar um histórico.", + "noResults": "Nenhum comando encontrado", + "noResultsHint": "Nenhum comando correspondente a \"{{query}}\"", + "deleteSuccess": "Comando removido do histórico", + "deleteFailed": "Falha ao excluir comando.", + "deleteTooltip": "Excluir comando", + "tabHint": "Use Tab no Terminal para autocompletar do histórico de comandos" + }, "homepage": { "loggedInTitle": "Conectado!", "loggedInMessage": "Você está conectado! Use a barra lateral para acessar todas as ferramentas disponíveis. Para começar, crie um Host SSH na aba Gerenciador SSH. Depois de criado, você pode se conectar a esse host usando os outros apps na barra lateral.", @@ -720,7 +734,18 @@ "noneAuthDescription": "Este método de autenticação usará autenticação interativa por teclado ao conectar ao servidor SSH.", "noneAuthDetails": "A autenticação interativa por teclado permite que o servidor solicite credenciais durante a conexão. Isso é útil para servidores que requerem autenticação multifator ou entrada de senha dinâmica.", "forceKeyboardInteractive": "Forçar Interativo com Teclado", - "forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA)." + "forceKeyboardInteractiveDesc": "Força o uso da autenticação interativa com teclado. Isso é frequentemente necessário para servidores que usam Autenticação de Dois Fatores (TOTP/2FA).", + "overrideCredentialUsername": "Substituir Nome de Usuário da Credencial", + "overrideCredentialUsernameDesc": "Use um nome de usuário diferente daquele armazenado na credencial. Isso permite que você use a mesma credencial com diferentes nomes de usuário.", + "jumpHosts": "Hosts de Salto", + "jumpHostsDescription": "Hosts de salto (também conhecidos como bastions) permitem que você se conecte a um servidor de destino através de um ou mais servidores intermediários. Isso é útil para acessar servidores atrás de firewalls ou em redes privadas.", + "jumpHostChain": "Cadeia de Hosts de Salto", + "addJumpHost": "Adicionar Host de Salto", + "selectServer": "Selecionar Servidor", + "searchServers": "Pesquisar servidores...", + "noServerFound": "Nenhum servidor encontrado", + "jumpHostsOrder": "As conexões serão feitas na ordem: Host de Salto 1 → Host de Salto 2 → ... → Servidor de Destino", + "advancedAuthSettings": "Configurações Avançadas de Autenticação" }, "terminal": { "title": "Terminal", @@ -836,6 +861,8 @@ "copy": "Copiar", "cut": "Recortar", "paste": "Colar", + "copyPath": "Copiar caminho", + "copyPaths": "Copiar caminhos", "delete": "Excluir", "properties": "Propriedades", "preview": "Visualizar", @@ -846,6 +873,9 @@ "deleteFiles": "Excluir {{count}} itens", "filesCopiedToClipboard": "{{count}} itens copiados para a área de transferência", "filesCutToClipboard": "{{count}} itens recortados para a área de transferência", + "pathCopiedToClipboard": "Caminho copiado para a área de transferência", + "pathsCopiedToClipboard": "{{count}} caminhos copiados para a área de transferência", + "failedToCopyPath": "Falha ao copiar caminho para a área de transferência", "movedItems": "{{count}} itens movidos", "failedToDeleteItem": "Falha ao excluir item", "itemRenamedSuccessfully": "{{type}} renomeado com sucesso", @@ -1143,6 +1173,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", @@ -1306,6 +1341,10 @@ "local": "Local", "external": "Externo (OIDC)", "selectPreferredLanguage": "Selecione seu idioma preferido para a interface", + "fileColorCoding": "Codificação de Cores de Arquivos", + "fileColorCodingDesc": "Codificar arquivos por cores por tipo: pastas (vermelho), arquivos (azul), links simbólicos (verde)", + "commandAutocomplete": "Autocompletar Comandos", + "commandAutocompleteDesc": "Ativar sugestões de autocompletar com a tecla Tab para comandos do terminal baseado no seu histórico", "currentPassword": "Senha Atual", "passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.", "failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente." diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 4a757ac7..0f523deb 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -225,6 +225,20 @@ "editTooltip": "Редактировать этот сниппет", "deleteTooltip": "Удалить этот сниппет" }, + "commandHistory": { + "title": "История", + "searchPlaceholder": "Поиск команд...", + "noTerminal": "Нет активного терминала", + "noTerminalHint": "Откройте терминал, чтобы увидеть историю команд.", + "empty": "История команд пока пуста", + "emptyHint": "Выполните команды в активном терминале, чтобы создать историю.", + "noResults": "Команды не найдены", + "noResultsHint": "Нет команд, соответствующих \"{{query}}\"", + "deleteSuccess": "Команда удалена из истории", + "deleteFailed": "Не удалось удалить команду.", + "deleteTooltip": "Удалить команду", + "tabHint": "Используйте Tab в Терминале для автозаполнения из истории команд" + }, "homepage": { "loggedInTitle": "Вы вошли в систему!", "loggedInMessage": "Вы вошли в систему! Используйте боковую панель для доступа ко всем доступным инструментам. Чтобы начать, создайте SSH-хост в разделе SSH-менеджера. После создания вы можете подключиться к этому хосту, используя другие приложения на боковой панели.", @@ -766,9 +780,85 @@ "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": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля." + "noneAuthDetails": "Интерактивная аутентификация по клавиатуре позволяет серверу запрашивать у вас учетные данные во время подключения. Это полезно для серверов, которые требуют многофакторную аутентификацию или динамический ввод пароля.", + "forceKeyboardInteractive": "Принудительная клавиатурная аутентификация", + "forceKeyboardInteractiveDesc": "Принудительно использует интерактивную аутентификацию по клавиатуре. Часто требуется для серверов с двухфакторной аутентификацией (TOTP/2FA).", + "overrideCredentialUsername": "Переопределить имя пользователя учетных данных", + "overrideCredentialUsernameDesc": "Используйте другое имя пользователя, отличное от того, что хранится в учетных данных. Это позволяет использовать одни и те же учетные данные с разными именами пользователей.", + "jumpHosts": "Промежуточные хосты", + "jumpHostsDescription": "Промежуточные хосты (также известные как бастионы) позволяют подключаться к целевому серверу через один или несколько промежуточных серверов. Это полезно для доступа к серверам за брандмауэрами или в частных сетях.", + "jumpHostChain": "Цепочка промежуточных хостов", + "addJumpHost": "Добавить промежуточный хост", + "selectServer": "Выбрать сервер", + "searchServers": "Поиск серверов...", + "noServerFound": "Сервер не найден", + "jumpHostsOrder": "Подключения будут выполнены в порядке: Промежуточный хост 1 → Промежуточный хост 2 → ... → Целевой сервер", + "advancedAuthSettings": "Расширенные настройки аутентификации" }, "terminal": { "title": "Терминал", @@ -888,6 +978,8 @@ "copy": "Копировать", "cut": "Вырезать", "paste": "Вставить", + "copyPath": "Копировать путь", + "copyPaths": "Копировать пути", "delete": "Удалить", "properties": "Свойства", "preview": "Просмотр", @@ -898,6 +990,9 @@ "deleteFiles": "Удалить {{count}} элементов", "filesCopiedToClipboard": "{{count}} элементов скопировано в буфер обмена", "filesCutToClipboard": "{{count}} элементов вырезано в буфер обмена", + "pathCopiedToClipboard": "Путь скопирован в буфер обмена", + "pathsCopiedToClipboard": "{{count}} путей скопировано в буфер обмена", + "failedToCopyPath": "Не удалось скопировать путь в буфер обмена", "movedItems": "Перемещено {{count}} элементов", "failedToDeleteItem": "Не удалось удалить элемент", "itemRenamedSuccessfully": "{{type}} успешно переименован", @@ -1215,9 +1310,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": "Войти", @@ -1373,6 +1480,10 @@ "local": "Локальный", "external": "Внешний (OIDC)", "selectPreferredLanguage": "Выберите предпочитаемый язык интерфейса", + "fileColorCoding": "Цветовое кодирование файлов", + "fileColorCodingDesc": "Цветовая кодировка файлов по типу: папки (красный), файлы (синий), символические ссылки (зелёный)", + "commandAutocomplete": "Автодополнение команд", + "commandAutocompleteDesc": "Включить автодополнение команд терминала клавишей Tab на основе вашей истории команд", "currentPassword": "Текущий пароль", "passwordChangedSuccess": "Пароль успешно изменен! Пожалуйста, войдите снова.", "failedToChangePassword": "Не удалось изменить пароль. Пожалуйста, проверьте ваш текущий пароль и попробуйте снова." @@ -1587,5 +1698,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..3b15347d 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -223,6 +223,20 @@ "editTooltip": "编辑此片段", "deleteTooltip": "删除此片段" }, + "commandHistory": { + "title": "历史记录", + "searchPlaceholder": "搜索命令...", + "noTerminal": "无活动终端", + "noTerminalHint": "打开终端以查看其命令历史记录。", + "empty": "暂无命令历史记录", + "emptyHint": "在活动终端中执行命令以建立历史记录。", + "noResults": "未找到命令", + "noResultsHint": "没有匹配 \"{{query}}\" 的命令", + "deleteSuccess": "命令已从历史记录中删除", + "deleteFailed": "删除命令失败。", + "deleteTooltip": "删除命令", + "tabHint": "在终端中使用 Tab 键从命令历史记录自动完成" + }, "homepage": { "loggedInTitle": "登录成功!", "loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。", @@ -766,6 +780,17 @@ "failedToRemoveFromFolder": "从文件夹中移除主机失败", "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", "failedToRenameFolder": "重命名文件夹失败", + "editFolderAppearance": "编辑文件夹外观", + "editFolderAppearanceDesc": "自定义文件夹的颜色和图标", + "folderColor": "文件夹颜色", + "folderIcon": "文件夹图标", + "preview": "预览", + "folderAppearanceUpdated": "文件夹外观更新成功", + "failedToUpdateFolderAppearance": "更新文件夹外观失败", + "deleteAllHostsInFolder": "删除文件夹内所有主机", + "confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。", + "allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机", + "failedToDeleteHostsInFolder": "删除文件夹中的主机失败", "movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"", "failedToMoveToFolder": "移动主机到文件夹失败", "statistics": "统计", @@ -790,11 +815,85 @@ "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": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。", "forceKeyboardInteractive": "强制键盘交互式认证", - "forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。" + "forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。", + "overrideCredentialUsername": "覆盖凭据用户名", + "overrideCredentialUsernameDesc": "使用与凭据中存储的用户名不同的用户名。这允许您对不同的用户名使用相同的凭据。", + "jumpHosts": "跳板主机", + "jumpHostsDescription": "跳板主机(也称为堡垒主机)允许您通过一个或多个中间服务器连接到目标服务器。这对于访问防火墙后或私有网络中的服务器很有用。", + "jumpHostChain": "跳板主机链", + "addJumpHost": "添加跳板主机", + "selectServer": "选择服务器", + "searchServers": "搜索服务器...", + "noServerFound": "未找到服务器", + "jumpHostsOrder": "连接将按顺序进行:跳板主机 1 → 跳板主机 2 → ... → 目标服务器", + "advancedAuthSettings": "高级身份验证设置" }, "terminal": { "title": "终端", @@ -841,6 +940,22 @@ "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", "downloadFile": "下载", + "extractArchive": "解压文件", + "extractingArchive": "正在解压 {{name}}...", + "archiveExtractedSuccessfully": "{{name}} 解压成功", + "extractFailed": "解压失败", + "compressFile": "压缩文件", + "compressFiles": "压缩文件", + "compressFilesDesc": "将 {{count}} 个项目压缩为归档文件", + "archiveName": "归档文件名", + "enterArchiveName": "输入归档文件名...", + "compressionFormat": "压缩格式", + "selectedFiles": "已选文件", + "andMoreFiles": "以及其他 {{count}} 个...", + "compress": "压缩", + "compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...", + "filesCompressedSuccessfully": "{{name}} 创建成功", + "compressFailed": "压缩失败", "edit": "编辑", "preview": "预览", "previous": "上一页", @@ -910,7 +1025,11 @@ "noSSHConnection": "无SSH连接可用", "enterFolderName": "输入文件夹名称:", "enterFileName": "输入文件名称:", + "copy": "复制", "cut": "剪切", + "paste": "粘贴", + "copyPath": "复制路径", + "copyPaths": "复制路径", "properties": "属性", "refresh": "刷新", "downloadFiles": "下载 {{count}} 个文件", @@ -919,6 +1038,9 @@ "deleteFiles": "删除 {{count}} 个项目", "filesCopiedToClipboard": "{{count}} 个项目已复制到剪贴板", "filesCutToClipboard": "{{count}} 个项目已剪切到剪贴板", + "pathCopiedToClipboard": "路径已复制到剪贴板", + "pathsCopiedToClipboard": "{{count}} 个路径已复制到剪贴板", + "failedToCopyPath": "复制路径到剪贴板失败", "movedItems": "已移动 {{count}} 个项目", "unknownSize": "未知大小", "fileIsEmpty": "文件为空", @@ -1088,7 +1210,19 @@ "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", "loadFileFailed": "加载文件失败:{{error}}", "connectedSuccessfully": "连接成功", - "totpVerificationFailed": "TOTP 验证失败" + "totpVerificationFailed": "TOTP 验证失败", + "changePermissions": "修改权限", + "changePermissionsDesc": "修改文件权限", + "currentPermissions": "当前权限", + "newPermissions": "新权限", + "owner": "所有者", + "group": "组", + "others": "其他", + "read": "读取", + "write": "写入", + "execute": "执行", + "permissionsChangedSuccessfully": "权限修改成功", + "failedToChangePermissions": "权限修改失败" }, "tunnels": { "title": "SSH 隧道", @@ -1199,9 +1333,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": "登录", @@ -1367,6 +1513,10 @@ "local": "本地", "external": "外部 (OIDC)", "selectPreferredLanguage": "选择您的界面首选语言", + "fileColorCoding": "文件颜色编码", + "fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)", + "commandAutocomplete": "命令自动补全", + "commandAutocompleteDesc": "启用基于命令历史记录的 Tab 键终端命令自动补全建议", "currentPassword": "当前密码", "passwordChangedSuccess": "密码修改成功!请重新登录。", "failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。" @@ -1512,5 +1662,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/index.ts b/src/types/index.ts index 027de232..7adc1ab6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,15 @@ import type { Request } from "express"; // SSH HOST TYPES // ============================================================================ +export interface JumpHost { + hostId: number; +} + +export interface QuickAction { + name: string; + snippetId: number; +} + export interface SSHHost { id: number; name: string; @@ -26,18 +35,30 @@ export interface SSHHost { autostartKeyPassword?: string; credentialId?: number; + overrideCredentialUsername?: boolean; userId?: string; enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; defaultPath: string; tunnelConnections: TunnelConnection[]; + jumpHosts?: JumpHost[]; + quickActions?: QuickAction[]; statsConfig?: string; terminalConfig?: TerminalConfig; createdAt: string; updatedAt: string; } +export interface JumpHostData { + hostId: number; +} + +export interface QuickActionData { + name: string; + snippetId: number; +} + export interface SSHHostData { name?: string; ip: string; @@ -52,16 +73,29 @@ export interface SSHHostData { keyPassword?: string; keyType?: string; credentialId?: number | null; + overrideCredentialUsername?: boolean; enableTerminal?: boolean; enableTunnel?: boolean; enableFileManager?: boolean; defaultPath?: string; forceKeyboardInteractive?: boolean; tunnelConnections?: TunnelConnection[]; + jumpHosts?: JumpHostData[]; + quickActions?: QuickActionData[]; statsConfig?: string | Record; terminalConfig?: TerminalConfig; } +export interface SSHFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; +} + // ============================================================================ // CREDENTIAL TYPES // ============================================================================ @@ -295,6 +329,21 @@ export interface TabContextTab { initialTab?: string; } +export type SplitLayout = "2h" | "2v" | "3l" | "3r" | "3t" | "4grid"; + +export interface SplitConfiguration { + layout: SplitLayout; + positions: Map; +} + +export interface SplitLayoutOption { + id: SplitLayout; + name: string; + description: string; + cellCount: number; + icon: string; // lucide icon name +} + // ============================================================================ // CONNECTION STATES // ============================================================================ @@ -369,6 +418,8 @@ export interface HostManagerProps { isTopbarOpen?: boolean; initialTab?: string; hostConfig?: SSHHost; + rightSidebarOpen?: boolean; + rightSidebarWidth?: number; } export interface SSHManagerHostEditorProps { @@ -455,6 +506,8 @@ export interface Snippet { name: string; content: string; description?: string; + folder?: string; + order?: number; createdAt: string; updatedAt: string; } @@ -463,6 +516,18 @@ export interface SnippetData { name: string; content: string; description?: string; + folder?: string; + order?: number; +} + +export interface SnippetFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; } // ============================================================================ 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 1bedd2c1..fb015997 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"; @@ -8,9 +8,11 @@ import { useTabs, } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; +import { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.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() { @@ -22,7 +24,41 @@ 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); + const [rightSidebarOpen, setRightSidebarOpen] = useState(false); + const [rightSidebarWidth, setRightSidebarWidth] = useState(400); + + const lastShiftPressTime = useRef(0); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === "ShiftLeft") { + if (event.repeat) { + return; + } + const now = Date.now(); + if (now - lastShiftPressTime.current < 300) { + setIsCommandPaletteOpen((isOpen) => !isOpen); + lastShiftPressTime.current = 0; + } else { + lastShiftPressTime.current = now; + } + } + if (event.key === "Escape") { + setIsCommandPaletteOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); useEffect(() => { const checkAuth = () => { @@ -33,6 +69,7 @@ function AppContent() { setIsAuthenticated(false); setIsAdmin(false); setUsername(null); + localStorage.removeItem("jwt"); } else { setIsAuthenticated(true); setIsAdmin(!!meRes.is_admin); @@ -44,6 +81,8 @@ function AppContent() { setIsAdmin(false); setUsername(null); + localStorage.removeItem("jwt"); + const errorCode = err?.response?.data?.code; if (errorCode === "SESSION_EXPIRED") { console.warn("Session expired - please log in again"); @@ -74,13 +113,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" || @@ -91,10 +161,35 @@ function AppContent() { const showAdmin = currentTabData?.type === "admin"; const showProfile = currentTabData?.type === "user_profile"; + if (authLoading) { + return ( +
+
+
+
+
+ ); + } + return ( -
- {!isAuthenticated && !authLoading && ( -
+
+ + {!isAuthenticated && ( +
- +
{showHome && ( @@ -127,6 +227,8 @@ function AppContent() { authLoading={authLoading} onAuthSuccess={handleAuthSuccess} isTopbarOpen={isTopbarOpen} + rightSidebarOpen={rightSidebarOpen} + rightSidebarWidth={rightSidebarWidth} />
)} @@ -138,28 +240,210 @@ function AppContent() { isTopbarOpen={isTopbarOpen} initialTab={currentTabData?.initialTab} hostConfig={currentTabData?.hostConfig} + rightSidebarOpen={rightSidebarOpen} + rightSidebarWidth={rightSidebarWidth} />
)} {showAdmin && (
- +
)} {showProfile && (
- +
)} setIsCommandPaletteOpen(true)} + onRightSidebarStateChange={(isOpen, width) => { + setRightSidebarOpen(isOpen); + setRightSidebarWidth(width); + }} /> )} + + {isTransitioning && ( +
+ {transitionPhase === "fadeOut" && ( + <> +
+
+
+
+
+
+
+ TERMIX +
+
+ SSH SERVER MANAGER +
+
+
+ + + )} +
+ )} + - + + + ); } diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index 02c45dfd..9febd08a 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -13,6 +13,14 @@ import { TabsList, TabsTrigger, } from "@/components/ui/tabs.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; import { Table, TableBody, @@ -26,7 +34,8 @@ import { Trash2, Users, Database, - Lock, + Link2, + Unlink, Download, Upload, Monitor, @@ -55,14 +64,20 @@ import { getSessions, revokeSession, revokeAllUserSessions, + linkOIDCToPasswordAccount, + unlinkOIDCFromPasswordAccount, } from "@/ui/main-axios.ts"; 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(); @@ -94,6 +109,7 @@ export function AdminSettings({ username: string; is_admin: boolean; is_oidc: boolean; + password_hash?: string; }> >([]); const [usersLoading, setUsersLoading] = React.useState(false); @@ -134,6 +150,14 @@ export function AdminSettings({ >([]); const [sessionsLoading, setSessionsLoading] = React.useState(false); + const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false); + const [linkOidcUser, setLinkOidcUser] = React.useState<{ + id: string; + username: string; + } | null>(null); + const [linkTargetUsername, setLinkTargetUsername] = React.useState(""); + const [linkLoading, setLinkLoading] = React.useState(false); + const requiresImportPassword = React.useMemo( () => !currentUser?.is_oidc, [currentUser?.is_oidc], @@ -632,15 +656,82 @@ export function AdminSettings({ ); }; + const handleLinkOIDCUser = (user: { id: string; username: string }) => { + setLinkOidcUser(user); + setLinkTargetUsername(""); + setLinkAccountAlertOpen(true); + }; + + const handleLinkSubmit = async () => { + if (!linkOidcUser || !linkTargetUsername.trim()) { + toast.error("Target username is required"); + return; + } + + setLinkLoading(true); + try { + const result = await linkOIDCToPasswordAccount( + linkOidcUser.id, + linkTargetUsername.trim(), + ); + + toast.success( + result.message || + `OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`, + ); + setLinkAccountAlertOpen(false); + setLinkTargetUsername(""); + setLinkOidcUser(null); + fetchUsers(); + fetchSessions(); + } catch (error: unknown) { + const err = error as { + response?: { data?: { error?: string; code?: string } }; + }; + toast.error(err.response?.data?.error || "Failed to link accounts"); + } finally { + setLinkLoading(false); + } + }; + + const handleUnlinkOIDC = async (userId: string, username: string) => { + confirmWithToast( + t("admin.unlinkOIDCDescription", { username }), + async () => { + try { + const result = await unlinkOIDCFromPasswordAccount(userId); + + toast.success( + result.message || t("admin.unlinkOIDCSuccess", { username }), + ); + fetchUsers(); + fetchSessions(); + } catch (error: unknown) { + const err = error as { + response?: { data?: { error?: string; code?: string } }; + }; + toast.error( + err.response?.data?.error || t("admin.failedToUnlinkOIDC"), + ); + } + }, + "destructive", + ); + }; + const topMarginPx = isTopbarOpen ? 74 : 26; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const bottomMarginPx = 8; const wrapperStyle: React.CSSProperties = { marginLeft: leftMarginPx, - marginRight: 17, + marginRight: rightSidebarOpen + ? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)` + : 17, marginTop: topMarginPx, marginBottom: bottomMarginPx, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, + transition: + "margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear", }; return ( @@ -1017,20 +1108,55 @@ export function AdminSettings({ )} - {user.is_oidc - ? t("admin.external") - : t("admin.local")} + {user.is_oidc && user.password_hash + ? "Dual Auth" + : user.is_oidc + ? t("admin.external") + : t("admin.local")} - +
+ {user.is_oidc && !user.password_hash && ( + + )} + {user.is_oidc && user.password_hash && ( + + )} + +
))} @@ -1064,109 +1190,113 @@ export function AdminSettings({
) : (
- - - - Device - User - Created - Last Active - Expires - - {t("admin.actions")} - - - - - {sessions.map((session) => { - const DeviceIcon = - session.deviceType === "desktop" - ? Monitor - : session.deviceType === "mobile" - ? Smartphone - : Globe; +
+
+ + + Device + User + Created + Last Active + Expires + + {t("admin.actions")} + + + + + {sessions.map((session) => { + const DeviceIcon = + session.deviceType === "desktop" + ? Monitor + : session.deviceType === "mobile" + ? Smartphone + : Globe; - const createdDate = new Date(session.createdAt); - const lastActiveDate = new Date(session.lastActiveAt); - const expiresDate = new Date(session.expiresAt); + const createdDate = new Date(session.createdAt); + const lastActiveDate = new Date( + session.lastActiveAt, + ); + const expiresDate = new Date(session.expiresAt); - const formatDate = (date: Date) => - date.toLocaleDateString() + - " " + - date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); + const formatDate = (date: Date) => + date.toLocaleDateString() + + " " + + date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); - return ( - - -
- -
- - {session.deviceInfo} - - {session.isRevoked && ( - - Revoked + return ( + + +
+ +
+ + {session.deviceInfo} - )} + {session.isRevoked && ( + + Revoked + + )} +
-
- - - {session.username || session.userId} - - - {formatDate(createdDate)} - - - {formatDate(lastActiveDate)} - - - {formatDate(expiresDate)} - - -
- - {session.username && ( + + + {session.username || session.userId} + + + {formatDate(createdDate)} + + + {formatDate(lastActiveDate)} + + + {formatDate(expiresDate)} + + +
- )} -
-
- - ); - })} - -
+ {session.username && ( + + )} +
+ + + ); + })} + + +
)}
@@ -1177,8 +1307,8 @@ export function AdminSettings({

{t("admin.adminManagement")}

-
-

{t("admin.makeUserAdmin")}

+
+

{t("admin.makeUserAdmin")}