v1.8.0 (#429)
* Dev 1.8.0 (#399) * Feature request: Add delete confirmation dialog to file manager (#344) * Feature request: Add delete confirmation dialog to file manager - Added confirmation dialog before deleting files/folders - Users must confirm deletion with a warning message - Works for both Delete key and right-click delete - Shows different messages for single file, folder, or multiple items - Includes permanent deletion warning - Follows existing design patterns using confirmWithToast * Adds confirmation for deletion of items including folders Updates the file deletion confirmation logic to distinguish between deleting multiple items with or without folders. Introduces a new translation string for a clearer user prompt when folders and their contents are included in the deletion. Improves clarity and reduces user error when performing bulk deletions. * feat: Add Chinese translations for delete confirmation messages * Adds camelCase support for encrypted field mappings (#342) Extends encrypted field mappings to include camelCase variants to support consistency and compatibility with different naming conventions. Updates reverse mappings for Drizzle ORM to allow conversion between camelCase and snake_case field names. Improves integration with systems using mixed naming styles. * Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password. * Fix snake case mismatching * Add real client IP * Fix OIDC credential persistence issue The issue was that OIDC users were getting a new random Data Encryption Key (DEK) on every login, which made previously encrypted credentials inaccessible. Changes: - Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key - Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK - Ensured OIDC users now have the same encryption persistence as password-based users This fix ensures that credentials created by OIDC users remain accessible across multiple login sessions. * Fix race condition and remove redundant kekSalt for OIDC users Critical fixes: 1. Race Condition Mitigation: - Added read-after-write verification in setupOIDCUserEncryption() - Ensures session uses the DEK that's actually in the database - Prevents data loss when concurrent logins occur for new OIDC users - If race is detected, discards generated DEK and uses stored one 2. Remove Redundant kekSalt Logic: - Removed unnecessary kekSalt generation and checks for OIDC users - kekSalt is not used in OIDC key derivation (uses userId as salt) - Reduces database operations from 4 to 2 per authentication - Simplifies code and removes potential confusion 3. Improved Error Handling: - systemKey cleanup moved to finally block - Ensures sensitive key material is always cleared from memory These changes ensure data consistency and prevent potential data loss in high-concurrency scenarios. * Cleanup OIDC pr and run prettier * Replace jetbrains mono with caskaydia cove * Fix alert issues * Finalize font update * Feature/german language support (#374) * v1.7.2 (#364) * Feature request: Add delete confirmation dialog to file manager (#344) * Feature request: Add delete confirmation dialog to file manager - Added confirmation dialog before deleting files/folders - Users must confirm deletion with a warning message - Works for both Delete key and right-click delete - Shows different messages for single file, folder, or multiple items - Includes permanent deletion warning - Follows existing design patterns using confirmWithToast * Adds confirmation for deletion of items including folders Updates the file deletion confirmation logic to distinguish between deleting multiple items with or without folders. Introduces a new translation string for a clearer user prompt when folders and their contents are included in the deletion. Improves clarity and reduces user error when performing bulk deletions. * feat: Add Chinese translations for delete confirmation messages * Adds camelCase support for encrypted field mappings (#342) Extends encrypted field mappings to include camelCase variants to support consistency and compatibility with different naming conventions. Updates reverse mappings for Drizzle ORM to allow conversion between camelCase and snake_case field names. Improves integration with systems using mixed naming styles. * Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password. * Fix snake case mismatching * Add real client IP * Fix OIDC credential persistence issue The issue was that OIDC users were getting a new random Data Encryption Key (DEK) on every login, which made previously encrypted credentials inaccessible. Changes: - Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key - Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK - Ensured OIDC users now have the same encryption persistence as password-based users This fix ensures that credentials created by OIDC users remain accessible across multiple login sessions. * Fix race condition and remove redundant kekSalt for OIDC users Critical fixes: 1. Race Condition Mitigation: - Added read-after-write verification in setupOIDCUserEncryption() - Ensures session uses the DEK that's actually in the database - Prevents data loss when concurrent logins occur for new OIDC users - If race is detected, discards generated DEK and uses stored one 2. Remove Redundant kekSalt Logic: - Removed unnecessary kekSalt generation and checks for OIDC users - kekSalt is not used in OIDC key derivation (uses userId as salt) - Reduces database operations from 4 to 2 per authentication - Simplifies code and removes potential confusion 3. Improved Error Handling: - systemKey cleanup moved to finally block - Ensures sensitive key material is always cleared from memory These changes ensure data consistency and prevent potential data loss in high-concurrency scenarios. * Cleanup OIDC pr and run prettier --------- Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com> * Fix typos and improve wording in README.md Corrected grammar and punctuation in README. * Image 7.png * Rename 3gi3b3os5psf1.png to Image 7.png * Add video demonstration to README Added a video demonstration to the README. * Delete repo-images/Image 7.png * Add files via upload * Delete repo-images/Image 7.png * Add files via upload * Initial German translation * German translation (#281) * German translation (#281) * Implementation of German language support (#281) * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Add germanm support * Fix SSH Key Password (keyPassword) Field Naming Mismatch Between Frontend and Backend (#375) * Refactor key_password to keyPassword for consistency across SSH routes * Standardizes keyPassword field handling and simplifies auth field logic Standardizes the handling of the `keyPassword` field by converting `key_password` to camelCase and ensuring consistent output while preserving resolved credentials. Removes redundant snake_case fields to avoid duplication. Simplifies UI handling of authentication fields by allowing non-relevant fields to persist, delegating filtering logic to the backend for cleaner and more maintainable code. Improves code clarity and aligns with consistent data handling practices. * Cleanup code + resolve conversion logic --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Feature disable password login (#378) * Add admin toggle to disable password login * Update src/backend/database/routes/users.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/ui/main-axios.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/ui/Desktop/Admin/AdminSettings.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/database/routes/users.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/database/routes/users.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Add SSH TOTP authentication support (#350) * Add SSH TOTP authentication support - Implement keyboard-interactive authentication for SSH connections - Add TOTP dialog component for Terminal and File Manager - Handle TOTP prompts in WebSocket and HTTP connections - Disable Server Stats for TOTP-enabled servers - Add i18n support for TOTP-related messages * Update src/backend/ssh/server-stats.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/ssh/file-manager.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Add terminal snippets feature with sidebar UI (#377) * Add terminal snippets feature with sidebar UI - Add snippets CRUD API endpoints and database schema - Implement snippets sidebar accessible from TopNavbar - Add copy to clipboard functionality - Include tooltips and optimized styling - Add English and Chinese translations * Update src/backend/database/routes/snippets.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Feature engineering improvements (#376) * chore: add engineering improvements - Configure Prettier with unified code style rules - Add husky + lint-staged for automated pre-commit checks - Add commitlint to enforce conventional commit messages - Add PR check workflow for CI automation - Auto-format all files with Prettier - Fix TypeScript any types in field-crypto.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: enhance development environment - Add .editorconfig for unified editor settings - Add .nvmrc to specify Node.js version (20) - Add useful npm scripts: format, format:check, lint, lint:fix, type-check * chore: add IDE and Git configuration - Add VS Code workspace settings for consistent development experience - Add VS Code extension recommendations (ESLint, Prettier, EditorConfig) - Add .gitattributes to enforce LF line endings * refactor: clean up unused variables and empty blocks - database.ts: Remove unused variables (authManager, format, HTTPS_PORT, etc.) - database.ts: Fix empty catch blocks with descriptive comments - database.ts: Add eslint-disable for required middleware parameter - db/index.ts: Remove unused variables and fix empty catch blocks - Temporarily remove ESLint from pre-commit to allow incremental fixes Reduced total errors from 947 to 913 (34 fixes) * refactor: clean up unused variables and empty blocks in routes Routes updated: - credentials.ts: Remove 12 unused variables/imports - alerts.ts: Remove 1 unused variable - users.ts: Remove 9 unused variables/imports Changes: - Remove unused imports (NextFunction, jwt, UserCrypto, detectKeyType) - Fix empty catch blocks with descriptive comments - Prefix reserved parameters with underscore - Clean up unused error variables in catch blocks Reduced errors from 913 to 886 (27 fixes) * refactor: clean up unused variables in routes/ssh.ts - Remove unused imports (NextFunction, jwt) - Remove 6 unused variables (result, updateResult, name x3) - All 8 no-unused-vars errors fixed * refactor: clean up unused variables and empty blocks in file-manager.ts - Remove 22 unused variables (linkCount, hostId, userId, content, escapedTempFile, index, code) - Fix 1 empty catch block - Simplify multiple route handlers by removing unused destructured parameters Reduced errors from 878 to 855 (23 fixes) * refactor: clean up unused variables and empty blocks in utils database-migration.ts: - Remove 3 unused variables (encryptedSize, totalOriginalRows, totalMemoryRows) lazy-field-encryption.ts: - Fix 6 empty catch blocks with descriptive comments - Keep error variables where they are used in logging tunnel.ts: - Fix multiple empty catch blocks - Remove empty else blocks - Partially fixed (10/21 issues resolved) Reduced errors from 855 to 833 (22 fixes) * fix: restore error variable in catch block for logging Fix TypeScript error where error variable was removed from catch block but still used in logging statements. The error variable is needed for proper error logging and re-throwing. * fix: clean up tunnel.ts empty blocks and unused variables 移除了 tunnel.ts 中的空块和未使用的变量: - 移除 2 个空 else 块 - 修复 2 个空 if 块并添加注释 - 修复空错误处理器并添加注释 - 将未使用的 err 参数重命名为 _err 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty blocks and unused variables in backend utils 修复了后端工具文件中的空块和未使用的变量: - auth-manager.ts: 移除空 else 块 - system-crypto.ts: 修复空 catch 块并添加注释 - starter.ts: 修复空 catch 块并添加注释 - server-stats.ts: 将未使用的 reject 参数重命名为 _reject - credentials.ts: 将 connectionTimeout 从 let 改为 const 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in frontend components 修复了前端组件中的空 catch 块: - Tunnel.tsx: 修复空 catch 块并添加注释 - ServerConfig.tsx: 修复空 catch 块并添加注释 - TerminalKeyboard.tsx: 修复空 catch 块并添加注释 - system-crypto.ts: 修复遗漏的空 catch 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in backend utilities 修复了后端工具文件中的 10 个空 catch 块: - system-crypto.ts: 修复 1 个空 catch 块 - server-stats.ts: 修复 4 个空 catch 块 - auto-ssl-setup.ts: 修复 1 个空 catch 块 - ssh-key-utils.ts: 修复 4 个空 catch 块 所有空块都添加了描述性注释说明为何忽略错误。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in UI hooks and components 修复了 5 个 UI 组件和 hooks 中的空 catch 块: - useDragToSystemDesktop.ts: 修复 2 个空 catch 块 - HomepageAuth.tsx: 修复 1 个空 catch 块 - HostManagerEditor.tsx: 修复 2 个空 catch 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty blocks in file manager and credential editor 修复了 5 个空块: - FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块 - CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up all empty catch blocks in Terminal components 修复了 Terminal 组件中的所有 8 个空 catch 块: - Desktop/Apps/Terminal/Terminal.tsx: 修复 5 个空 catch 块 - Mobile/Apps/Terminal/Terminal.tsx: 修复 3 个空 catch 块 所有空块都添加了描述性注释。这是空块修复的最后一批。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove useless try/catch wrappers 移除了 3 个无用的 try/catch 包装器: - users.ts: 移除只重新抛出错误的外层 try/catch - FileManager.tsx: 移除只重新抛出错误的内层 try/catch - DiffViewer.tsx: 移除只重新抛出错误的内层 try/catch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove unused imports and mark unused parameters 移除了未使用的导入和标记未使用的参数: - auto-ssl-setup.ts: 移除未使用的 crypto 导入 - user-crypto.ts: 移除未使用的 users 导入 - user-data-import.ts: 移除未使用的 nanoid 导入 - simple-db-ops.ts: 标记未使用的 userId 和 tableName 参数 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unnecessary escape characters in regex patterns 移除了正则表达式中不必要的转义字符: - users.ts: 修复 5 个 \/ 不必要的转义 - TabContext.tsx: 修复 1 个 \/ 不必要的转义 在字符串形式的正则表达式中,/ 不需要转义。 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> * feat: enhance server stats widgets and fix TypeScript/ESLint errors (#394) * feat: add draggable server stats dashboard with customizable widgets * fix: widget deletion and layout persistence issues * fix: improve widget deletion UX and add debug logs for persistence * fix: resolve widget deletion and layout persistence issues - Add drag handles to widget title bars for precise drag control - Prevent delete button from triggering drag via event stopPropagation - Include statsConfig field in all GET/PUT API responses - Remove debug console logs from production code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete statsConfig field support across all API routes - Add statsConfig to POST /db/host (create) route - Add statsConfig to all GET routes for consistent API responses - Remove incorrect statsConfig schema from HostManagerEditor - statsConfig is now only managed by Server page layout editor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add statsConfig to metrics API response - Add statsConfig field to SSHHostWithCredentials interface - Include statsConfig in resolveHostCredentials baseHost object - Ensures /metrics/:id API returns complete host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: include statsConfig in SSH host create/update requests The statsConfig field was being dropped by createSSHHost and updateSSHHost functions in main-axios.ts, preventing layout customization from persisting. Fixed by adding statsConfig to the submitData object in both functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: refactor server stats widgets into modular structure Created dedicated widgets directory with individual components: - CpuWidget, MemoryWidget, DiskWidget as separate components - Widget registry for centralized widget configuration - AddWidgetDialog for user-friendly widget selection - Updated Server.tsx to use modular widget system Benefits: - Better code organization and maintainability - Easier to add new widget types in the future - Centralized widget metadata and configuration - User can now add widgets via dialog interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exit edit mode after saving layout * feat: add customizable widget sizes with chart visualizations Add three-tier size system (small/medium/large) for server stats widgets. Integrate recharts library for visualizing trends in large widgets with line charts (CPU), area charts (Memory), and radial bar charts (Disk). Fix layout overflow issues with proper flexbox patterns. * refactor: simplify server stats widget system Replaced complex drag-and-drop grid layout with simple checkbox-based configuration and static responsive grid display. - Removed react-grid-layout dependency and 6 related packages - Simplified StatsConfig from complex Widget objects to simple array - Added Statistics tab in HostManagerEditor for checkbox selection - Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout - Simplified widget components by removing edit mode and size selection - Deleted unused AddWidgetDialog and registry files - Fixed statsConfig serialization in backend routes Net result: -787 lines of code, cleaner architecture. * feat: add system, uptime, network and processes widgets Add four new server statistics widgets: - SystemWidget: displays hostname, OS, and kernel information - UptimeWidget: shows server total uptime with formatted display - NetworkWidget: lists network interfaces with IP and status - ProcessesWidget: displays top processes by CPU usage Backend changes: - Extended SSH metrics collection to gather network, uptime, process, and system data - Added commands to parse /proc/uptime, ip addr, ps aux output Frontend changes: - Created 4 new widget components with consistent styling - Updated widget type definitions and HostManagerEditor - Unified all widget heights to 280px for consistent layout - Added translations for all new widgets (EN/ZH) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve widget styling and UX consistency Enhance all server stats widgets with improved styling and user experience: Widget improvements: - Fix hardcoded titles, now use i18n translations for all widgets - Improve data formatting with consistent translation keys - Enhance empty state displays with better visual hierarchy - Add smooth hover transitions and visual feedback - Standardize spacing and layout patterns across widgets Specific optimizations: - CPU: Use translated load average display - Memory: Translate "Free" label - Disk: Translate "Available" label - System: Improve icon colors and spacing consistency - Network: Better empty state, enhanced card styling - Processes: Improved card borders and spacing Visual polish: - Unified icon sizing and opacity for empty states - Consistent border radius (rounded-lg) - Better hover states with subtle transitions - Enhanced font weights for improved readability * fix: replace explicit any types with proper TypeScript types - Replace 'any' with 'unknown' in catch blocks and add type assertions - Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle) - Fix window/document object type extensions - Update Electron API type definitions - Improve type safety in database routes and utilities - Add proper types to Terminal components (Desktop & Mobile) - Fix navigation component types (TopNavbar, LeftSidebar, AppView) Reduces TypeScript lint errors from 394 to 358 (-36 errors) Fixes 45 @typescript-eslint/no-explicit-any violations * fix: replace explicit any types with proper TypeScript types - Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders) - Add type definitions for WebSocket messages and SSH connection data - Use generic types in DataCrypto methods instead of any return types - Define proper interfaces for file manager data structures - Replace catch block any types with unknown and proper type assertions - Add HostConfig and TabData interfaces for Server component Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files * fix: resolve 6 TypeScript compilation errors Fixed field name mismatches and generic type issues: - database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields - simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in backend utils Fixed @typescript-eslint/no-unused-vars errors in: - starter.ts: removed unused error variables (2 fixes) - auto-ssl-setup.ts: removed unused error variable (1 fix) - ssh-key-utils.ts: removed unused error variables (3 fixes) - user-crypto.ts: removed unused error variables (5 fixes) - data-crypto.ts: removed unused plaintextFields and error variables (2 fixes) - simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes) Total: 15 unused variable errors fixed Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variable in terminal.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused userPayload variable (line 123) - Removed unused cols and rows from destructuring (line 348) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in server-stats.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused _reject parameter in Promise (line 64) - Removed shadowed now variable in pollStatusesOnce (line 1130) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in tunnel.ts Removed 5 unused variables: - Removed unused data parameter from stdout event handler - Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in main-axios.ts Removed 8 unused variables: - Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse) - Removed unused apiPort variable - Removed unused error variables in 3 catch blocks * fix: remove unused variables in terminal.ts and starter.ts Removed 2 unused variables: - Removed unused JWTPayload type import from terminal.ts - Removed unused _promise parameter from starter.ts * fix: remove unused variables in sidebar.tsx Removed 9 unused variables: - Removed 5 unused Sheet component imports - Removed unused SIDEBAR_WIDTH_MOBILE constant - Removed 3 unused variables from useSidebar destructuring * fix: remove 13 unused variables in frontend files - version-check-modal.tsx: removed 4 unused imports and functions - main.tsx: removed unused isMobile state - AdminSettings.tsx: removed 8 unused imports and error variables * fix: remove 28 unused variables across frontend components Cleaned up unused imports, state variables, and function parameters: - CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports) - FileManager.tsx: removed 10 unused variables (icons, state, functions) - Terminal.tsx (Desktop): removed 5 unused variables (state, handlers) - Terminal.tsx (Mobile): removed 5 unused variables (imports, state) Reduced lint errors from 271 to 236 (35 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in File Manager and config files Cleaned up more unused imports, parameters, and variables: - FileManagerGrid.tsx: removed 4 unused variables (params, function) - FileManagerContextMenu.tsx: removed Share import - FileManagerSidebar.tsx: removed onLoadDirectory parameter - DraggableWindow.tsx: removed Square import - FileWindow.tsx: removed updateWindow variable - ServerConfig.tsx: removed 2 unused error parameters Reduced lint errors from 236 to 222 (14 errors fixed total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 7 unused variables in widgets and Homepage components Cleaned up unused imports, parameters, and variables: - DiskWidget.tsx: removed metricsHistory parameter - FileManagerContextMenu.tsx: removed ExternalLink import - Homepage.tsx: removed useTranslation import - HomepageAlertManager.tsx: removed loading variable - HomepageAuth.tsx: removed setCookie import (Desktop & Mobile) - HompageUpdateLog.tsx: removed err parameter Reduced lint errors from 222 to 216 (6 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 8 unused variables in File Manager and Host Manager components Cleaned up unused imports, state variables, and function parameters: - DiffViewer.tsx: removed unused error parameter in catch block - FileViewer.tsx: removed ReactPlayer import, unused originalContent state, node parameters from markdown code components, audio variable - HostManager.tsx: removed onSelectView and updatedHost parameters - TunnelViewer.tsx: removed TunnelConnection import Reduced lint errors from 271 to 208 (63 errors fixed total) * fix: remove 7 unused variables in UI hooks and components Cleaned up unused parameters and functions: - status/index.tsx: removed unused className parameter from StatusIndicator - useDragToDesktop.ts: removed unused sshHost parameter and from dependency arrays (4 occurrences) - useDragToSystemDesktop.ts: removed unused sshHost parameter and getLastSaveDirectory function (29 lines removed) Continued reducing frontend lint errors * fix: remove 2 unused variables in hooks and TabContext - useDragToDesktop.ts: removed unused onSuccess in dragFolderToDesktop - TabContext.tsx: removed unused useTranslation import and t variable Continued reducing frontend lint errors * fix: remove 2 unused variables in Homepage component - Removed unused isAdmin state variable (changed to setter only) - Removed unused jwt variable by inlining getCookie check Continued reducing frontend lint errors * fix: remove 3 unused variables in Mobile navigation components - Host.tsx: removed unused Server icon import - LeftSidebar.tsx: removed unused setHostsLoading setter and err parameter Continued reducing frontend lint errors * fix: remove 9 unused variables across multiple files Fixed unused variables in: - database-file-encryption.ts: removed currentFingerprint (backend) - FileManagerContextMenu.tsx: removed ExternalLink import, hasDirectories - frontend-logger.ts: removed 5 unused shortUrl variables Continued reducing lint errors * fix: remove 18 unused variables across 4 files - HostManagerViewer.tsx: remove 9 unused error variables and parameters - HostManagerEditor.tsx: remove WidgetType import, hosts/loading states, error variable - CredentialViewer.tsx: remove 3 unused error variables - Server.tsx: remove 2 unused error variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across 4 files - SnippetsSidebar.tsx: remove 3 unused err variables in catch blocks - TunnelViewer.tsx: remove 2 unused parameters from callback - DesktopApp.tsx: remove getCookie import and unused state variables - HomepageAlertManager.tsx: remove 2 unused err variables in catch blocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables and imports across 4 navigation files - Homepage.tsx: remove unused username state variable - AppView.tsx: remove 3 unused Lucide icon imports - TopNavbar.tsx: remove 4 unused Accordion component imports - LeftSidebar.tsx: remove 2 unused variables (err, jwt) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 5 unused variables across 4 user/credentials files - PasswordReset.tsx: remove unused result variable - UserProfile.tsx: remove unused Key import and err variable - version-check-modal.tsx: remove unused setVersionDismissed setter - CredentialsManager.tsx: remove unused e parameter from handleDragLeave 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 2 unused variables in FileViewer and TerminalWindow - FileViewer.tsx: remove unused node parameter from code component - TerminalWindow.tsx: remove unused handleMinimize function 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in HomepageAuth.tsx Removed unused variables: - getCookie import - dbError prop - visibility state and toggleVisibility - error state variable - result variable in handleInitiatePasswordReset - token URL parameter - err parameters in catch blocks - retryDatabaseConnection function - Multiple setError(null) calls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 9 unused variables across multiple files Files fixed: - DesktopApp.tsx: Removed _nextView parameter - TerminalWindow.tsx: Removed minimizeWindow - Mobile Host.tsx: Removed Server import - Mobile LeftSidebar.tsx: Removed setHostsLoading, err in catch - Desktop LeftSidebar.tsx: Removed getCookie, setCookie, onSelectView, getView, setHostsLoading 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in Mobile files Files fixed: - MobileApp.tsx: Removed getCookie, removeTab, isAdmin, id, err parameters - Mobile/HomepageAuth.tsx: Removed getCookie, error state, result, token, err parameters All @typescript-eslint/no-unused-vars errors in frontend now resolved! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused t variable in TabContext Removed useTranslation import and unused t variable in Mobile TabContext.tsx All @typescript-eslint/no-unused-vars errors now resolved! Total fixed: 154 unused variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve TypeScript and ESLint errors across the codebase - Fixed @typescript-eslint/no-unused-vars errors (31 instances) - Fixed @typescript-eslint/no-explicit-any errors in backend (~22 instances) - Fixed @typescript-eslint/no-explicit-any errors in frontend (~60 instances) - Fixed prefer-const errors (5 instances) - Fixed no-empty-object-type and rules-of-hooks errors - Added proper type assertions for database operations - Improved type safety in authentication and encryption modules - Enhanced type definitions for API routes and SSH operations All TypeScript compilation errors resolved. Application builds and runs successfully. * fix: disable react-refresh/only-export-components rule for component files Disable the react-refresh/only-export-components ESLint rule in files that export both components and related utilities (hooks, types, constants). This is a pragmatic solution to maintain code organization without splitting files unnecessarily. * style: fix prettier formatting issues Fix code style issues in translation file and TOTP dialog component to pass CI prettier check. * chore: fix rollup optional dependencies installation in CI Add step to force reinstall rollup after npm ci to fix the known npm bug with optional dependencies on Linux x64 platform. * chore: fix lightningcss optional dependencies in CI Add lightningcss to the force reinstall step to fix npm optional dependencies bug for both rollup and lightningcss on Linux x64. * chore: fix npm optional dependencies bug in CI Remove package-lock.json and node_modules before install to properly handle optional dependencies for rollup, lightningcss, and tailwindcss native bindings on Linux x64 platform as recommended by npm. * Update src/types/index.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Set terminal environment variables for SSH Added environment variables for terminal configuration. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * feat: begin macOS support * Delete .github/ISSUE_TEMPLATE/bug_report.yml * Delete .github/ISSUE_TEMPLATE/feature_request.yml * Add issue template configuration for support links * Revise support instructions in README.md Updated support section with new issue reporting instructions and clarified Discord support response times. * Update repository links and badge URLs in README * Update links to new orgnanization * Migrate workflows to Blacksmith (#421) Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com> * Feature request: Add delete confirmation dialog to file manager (#344) * Feature request: Add delete confirmation dialog to file manager - Added confirmation dialog before deleting files/folders - Users must confirm deletion with a warning message - Works for both Delete key and right-click delete - Shows different messages for single file, folder, or multiple items - Includes permanent deletion warning - Follows existing design patterns using confirmWithToast * Adds confirmation for deletion of items including folders Updates the file deletion confirmation logic to distinguish between deleting multiple items with or without folders. Introduces a new translation string for a clearer user prompt when folders and their contents are included in the deletion. Improves clarity and reduces user error when performing bulk deletions. * feat: Add Chinese translations for delete confirmation messages * Adds camelCase support for encrypted field mappings (#342) Extends encrypted field mappings to include camelCase variants to support consistency and compatibility with different naming conventions. Updates reverse mappings for Drizzle ORM to allow conversion between camelCase and snake_case field names. Improves integration with systems using mixed naming styles. * Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password. * Fix snake case mismatching * Fix race condition and remove redundant kekSalt for OIDC users Critical fixes: 1. Race Condition Mitigation: - Added read-after-write verification in setupOIDCUserEncryption() - Ensures session uses the DEK that's actually in the database - Prevents data loss when concurrent logins occur for new OIDC users - If race is detected, discards generated DEK and uses stored one 2. Remove Redundant kekSalt Logic: - Removed unnecessary kekSalt generation and checks for OIDC users - kekSalt is not used in OIDC key derivation (uses userId as salt) - Reduces database operations from 4 to 2 per authentication - Simplifies code and removes potential confusion 3. Improved Error Handling: - systemKey cleanup moved to finally block - Ensures sensitive key material is always cleared from memory These changes ensure data consistency and prevent potential data loss in high-concurrency scenarios. * Cleanup OIDC pr and run prettier * Feature/german language support (#374) * v1.7.2 (#364) * Feature request: Add delete confirmation dialog to file manager (#344) * Feature request: Add delete confirmation dialog to file manager - Added confirmation dialog before deleting files/folders - Users must confirm deletion with a warning message - Works for both Delete key and right-click delete - Shows different messages for single file, folder, or multiple items - Includes permanent deletion warning - Follows existing design patterns using confirmWithToast * Adds confirmation for deletion of items including folders Updates the file deletion confirmation logic to distinguish between deleting multiple items with or without folders. Introduces a new translation string for a clearer user prompt when folders and their contents are included in the deletion. Improves clarity and reduces user error when performing bulk deletions. * feat: Add Chinese translations for delete confirmation messages * Adds camelCase support for encrypted field mappings (#342) Extends encrypted field mappings to include camelCase variants to support consistency and compatibility with different naming conventions. Updates reverse mappings for Drizzle ORM to allow conversion between camelCase and snake_case field names. Improves integration with systems using mixed naming styles. * Run code cleanup, add sidebar persistence, fix OIDC credentials, force SSH password. * Fix snake case mismatching * Add real client IP * Fix OIDC credential persistence issue The issue was that OIDC users were getting a new random Data Encryption Key (DEK) on every login, which made previously encrypted credentials inaccessible. Changes: - Modified setupOIDCUserEncryption() to persist the DEK encrypted with a system-derived key - Updated authenticateOIDCUser() to properly retrieve and use the persisted DEK - Ensured OIDC users now have the same encryption persistence as password-based users This fix ensures that credentials created by OIDC users remain accessible across multiple login sessions. * Fix race condition and remove redundant kekSalt for OIDC users Critical fixes: 1. Race Condition Mitigation: - Added read-after-write verification in setupOIDCUserEncryption() - Ensures session uses the DEK that's actually in the database - Prevents data loss when concurrent logins occur for new OIDC users - If race is detected, discards generated DEK and uses stored one 2. Remove Redundant kekSalt Logic: - Removed unnecessary kekSalt generation and checks for OIDC users - kekSalt is not used in OIDC key derivation (uses userId as salt) - Reduces database operations from 4 to 2 per authentication - Simplifies code and removes potential confusion 3. Improved Error Handling: - systemKey cleanup moved to finally block - Ensures sensitive key material is always cleared from memory These changes ensure data consistency and prevent potential data loss in high-concurrency scenarios. * Cleanup OIDC pr and run prettier --------- Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com> * Fix typos and improve wording in README.md Corrected grammar and punctuation in README. * Image 7.png * Rename 3gi3b3os5psf1.png to Image 7.png * Add video demonstration to README Added a video demonstration to the README. * Delete repo-images/Image 7.png * Add files via upload * Delete repo-images/Image 7.png * Add files via upload * Initial German translation * German translation (#281) * German translation (#281) * Implementation of German language support (#281) * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/locales/de/translation.json Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Feature disable password login (#378) * Add admin toggle to disable password login * Update src/backend/database/routes/users.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/ui/main-axios.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/ui/Desktop/Admin/AdminSettings.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/database/routes/users.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/database/routes/users.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Add SSH TOTP authentication support (#350) * Add SSH TOTP authentication support - Implement keyboard-interactive authentication for SSH connections - Add TOTP dialog component for Terminal and File Manager - Handle TOTP prompts in WebSocket and HTTP connections - Disable Server Stats for TOTP-enabled servers - Add i18n support for TOTP-related messages * Update src/backend/ssh/server-stats.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Update src/backend/ssh/file-manager.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Add terminal snippets feature with sidebar UI (#377) * Add terminal snippets feature with sidebar UI - Add snippets CRUD API endpoints and database schema - Implement snippets sidebar accessible from TopNavbar - Add copy to clipboard functionality - Include tooltips and optimized styling - Add English and Chinese translations * Update src/backend/database/routes/snippets.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * Feature engineering improvements (#376) * chore: add engineering improvements - Configure Prettier with unified code style rules - Add husky + lint-staged for automated pre-commit checks - Add commitlint to enforce conventional commit messages - Add PR check workflow for CI automation - Auto-format all files with Prettier - Fix TypeScript any types in field-crypto.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: enhance development environment - Add .editorconfig for unified editor settings - Add .nvmrc to specify Node.js version (20) - Add useful npm scripts: format, format:check, lint, lint:fix, type-check * chore: add IDE and Git configuration - Add VS Code workspace settings for consistent development experience - Add VS Code extension recommendations (ESLint, Prettier, EditorConfig) - Add .gitattributes to enforce LF line endings * refactor: clean up unused variables and empty blocks - database.ts: Remove unused variables (authManager, format, HTTPS_PORT, etc.) - database.ts: Fix empty catch blocks with descriptive comments - database.ts: Add eslint-disable for required middleware parameter - db/index.ts: Remove unused variables and fix empty catch blocks - Temporarily remove ESLint from pre-commit to allow incremental fixes Reduced total errors from 947 to 913 (34 fixes) * refactor: clean up unused variables and empty blocks in routes Routes updated: - credentials.ts: Remove 12 unused variables/imports - alerts.ts: Remove 1 unused variable - users.ts: Remove 9 unused variables/imports Changes: - Remove unused imports (NextFunction, jwt, UserCrypto, detectKeyType) - Fix empty catch blocks with descriptive comments - Prefix reserved parameters with underscore - Clean up unused error variables in catch blocks Reduced errors from 913 to 886 (27 fixes) * refactor: clean up unused variables in routes/ssh.ts - Remove unused imports (NextFunction, jwt) - Remove 6 unused variables (result, updateResult, name x3) - All 8 no-unused-vars errors fixed * refactor: clean up unused variables and empty blocks in file-manager.ts - Remove 22 unused variables (linkCount, hostId, userId, content, escapedTempFile, index, code) - Fix 1 empty catch block - Simplify multiple route handlers by removing unused destructured parameters Reduced errors from 878 to 855 (23 fixes) * refactor: clean up unused variables and empty blocks in utils database-migration.ts: - Remove 3 unused variables (encryptedSize, totalOriginalRows, totalMemoryRows) lazy-field-encryption.ts: - Fix 6 empty catch blocks with descriptive comments - Keep error variables where they are used in logging tunnel.ts: - Fix multiple empty catch blocks - Remove empty else blocks - Partially fixed (10/21 issues resolved) Reduced errors from 855 to 833 (22 fixes) * fix: restore error variable in catch block for logging Fix TypeScript error where error variable was removed from catch block but still used in logging statements. The error variable is needed for proper error logging and re-throwing. * fix: clean up tunnel.ts empty blocks and unused variables 移除了 tunnel.ts 中的空块和未使用的变量: - 移除 2 个空 else 块 - 修复 2 个空 if 块并添加注释 - 修复空错误处理器并添加注释 - 将未使用的 err 参数重命名为 _err 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty blocks and unused variables in backend utils 修复了后端工具文件中的空块和未使用的变量: - auth-manager.ts: 移除空 else 块 - system-crypto.ts: 修复空 catch 块并添加注释 - starter.ts: 修复空 catch 块并添加注释 - server-stats.ts: 将未使用的 reject 参数重命名为 _reject - credentials.ts: 将 connectionTimeout 从 let 改为 const 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in frontend components 修复了前端组件中的空 catch 块: - Tunnel.tsx: 修复空 catch 块并添加注释 - ServerConfig.tsx: 修复空 catch 块并添加注释 - TerminalKeyboard.tsx: 修复空 catch 块并添加注释 - system-crypto.ts: 修复遗漏的空 catch 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in backend utilities 修复了后端工具文件中的 10 个空 catch 块: - system-crypto.ts: 修复 1 个空 catch 块 - server-stats.ts: 修复 4 个空 catch 块 - auto-ssl-setup.ts: 修复 1 个空 catch 块 - ssh-key-utils.ts: 修复 4 个空 catch 块 所有空块都添加了描述性注释说明为何忽略错误。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty catch blocks in UI hooks and components 修复了 5 个 UI 组件和 hooks 中的空 catch 块: - useDragToSystemDesktop.ts: 修复 2 个空 catch 块 - HomepageAuth.tsx: 修复 1 个空 catch 块 - HostManagerEditor.tsx: 修复 2 个空 catch 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up empty blocks in file manager and credential editor 修复了 5 个空块: - FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块 - CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: clean up all empty catch blocks in Terminal components 修复了 Terminal 组件中的所有 8 个空 catch 块: - Desktop/Apps/Terminal/Terminal.tsx: 修复 5 个空 catch 块 - Mobile/Apps/Terminal/Terminal.tsx: 修复 3 个空 catch 块 所有空块都添加了描述性注释。这是空块修复的最后一批。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove useless try/catch wrappers 移除了 3 个无用的 try/catch 包装器: - users.ts: 移除只重新抛出错误的外层 try/catch - FileManager.tsx: 移除只重新抛出错误的内层 try/catch - DiffViewer.tsx: 移除只重新抛出错误的内层 try/catch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: remove unused imports and mark unused parameters 移除了未使用的导入和标记未使用的参数: - auto-ssl-setup.ts: 移除未使用的 crypto 导入 - user-crypto.ts: 移除未使用的 users 导入 - user-data-import.ts: 移除未使用的 nanoid 导入 - simple-db-ops.ts: 标记未使用的 userId 和 tableName 参数 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unnecessary escape characters in regex patterns 移除了正则表达式中不必要的转义字符: - users.ts: 修复 5 个 \/ 不必要的转义 - TabContext.tsx: 修复 1 个 \/ 不必要的转义 在字符串形式的正则表达式中,/ 不需要转义。 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> * feat: enhance server stats widgets and fix TypeScript/ESLint errors (#394) * feat: add draggable server stats dashboard with customizable widgets * fix: widget deletion and layout persistence issues * fix: improve widget deletion UX and add debug logs for persistence * fix: resolve widget deletion and layout persistence issues - Add drag handles to widget title bars for precise drag control - Prevent delete button from triggering drag via event stopPropagation - Include statsConfig field in all GET/PUT API responses - Remove debug console logs from production code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: complete statsConfig field support across all API routes - Add statsConfig to POST /db/host (create) route - Add statsConfig to all GET routes for consistent API responses - Remove incorrect statsConfig schema from HostManagerEditor - statsConfig is now only managed by Server page layout editor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add statsConfig to metrics API response - Add statsConfig field to SSHHostWithCredentials interface - Include statsConfig in resolveHostCredentials baseHost object - Ensures /metrics/:id API returns complete host configuration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: include statsConfig in SSH host create/update requests The statsConfig field was being dropped by createSSHHost and updateSSHHost functions in main-axios.ts, preventing layout customization from persisting. Fixed by adding statsConfig to the submitData object in both functions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: refactor server stats widgets into modular structure Created dedicated widgets directory with individual components: - CpuWidget, MemoryWidget, DiskWidget as separate components - Widget registry for centralized widget configuration - AddWidgetDialog for user-friendly widget selection - Updated Server.tsx to use modular widget system Benefits: - Better code organization and maintainability - Easier to add new widget types in the future - Centralized widget metadata and configuration - User can now add widgets via dialog interface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: exit edit mode after saving layout * feat: add customizable widget sizes with chart visualizations Add three-tier size system (small/medium/large) for server stats widgets. Integrate recharts library for visualizing trends in large widgets with line charts (CPU), area charts (Memory), and radial bar charts (Disk). Fix layout overflow issues with proper flexbox patterns. * refactor: simplify server stats widget system Replaced complex drag-and-drop grid layout with simple checkbox-based configuration and static responsive grid display. - Removed react-grid-layout dependency and 6 related packages - Simplified StatsConfig from complex Widget objects to simple array - Added Statistics tab in HostManagerEditor for checkbox selection - Refactored Server.tsx to use CSS Grid instead of ResponsiveGridLayout - Simplified widget components by removing edit mode and size selection - Deleted unused AddWidgetDialog and registry files - Fixed statsConfig serialization in backend routes Net result: -787 lines of code, cleaner architecture. * feat: add system, uptime, network and processes widgets Add four new server statistics widgets: - SystemWidget: displays hostname, OS, and kernel information - UptimeWidget: shows server total uptime with formatted display - NetworkWidget: lists network interfaces with IP and status - ProcessesWidget: displays top processes by CPU usage Backend changes: - Extended SSH metrics collection to gather network, uptime, process, and system data - Added commands to parse /proc/uptime, ip addr, ps aux output Frontend changes: - Created 4 new widget components with consistent styling - Updated widget type definitions and HostManagerEditor - Unified all widget heights to 280px for consistent layout - Added translations for all new widgets (EN/ZH) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: improve widget styling and UX consistency Enhance all server stats widgets with improved styling and user experience: Widget improvements: - Fix hardcoded titles, now use i18n translations for all widgets - Improve data formatting with consistent translation keys - Enhance empty state displays with better visual hierarchy - Add smooth hover transitions and visual feedback - Standardize spacing and layout patterns across widgets Specific optimizations: - CPU: Use translated load average display - Memory: Translate "Free" label - Disk: Translate "Available" label - System: Improve icon colors and spacing consistency - Network: Better empty state, enhanced card styling - Processes: Improved card borders and spacing Visual polish: - Unified icon sizing and opacity for empty states - Consistent border radius (rounded-lg) - Better hover states with subtle transitions - Enhanced font weights for improved readability * fix: replace explicit any types with proper TypeScript types - Replace 'any' with 'unknown' in catch blocks and add type assertions - Create explicit interfaces for complex objects (HostConfig, TabData, TerminalHandle) - Fix window/document object type extensions - Update Electron API type definitions - Improve type safety in database routes and utilities - Add proper types to Terminal components (Desktop & Mobile) - Fix navigation component types (TopNavbar, LeftSidebar, AppView) Reduces TypeScript lint errors from 394 to 358 (-36 errors) Fixes 45 @typescript-eslint/no-explicit-any violations * fix: replace explicit any types with proper TypeScript types - Create explicit interfaces for Request extensions (AuthenticatedRequest, RequestWithHeaders) - Add type definitions for WebSocket messages and SSH connection data - Use generic types in DataCrypto methods instead of any return types - Define proper interfaces for file manager data structures - Replace catch block any types with unknown and proper type assertions - Add HostConfig and TabData interfaces for Server component Fixes 32 @typescript-eslint/no-explicit-any violations across 5 files * fix: resolve 6 TypeScript compilation errors Fixed field name mismatches and generic type issues: - database.ts: Changed camelCase to snake_case for key_password, private_key, public_key fields - simple-db-ops.ts: Added explicit generic type parameters to DataCrypto method calls Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in backend utils Fixed @typescript-eslint/no-unused-vars errors in: - starter.ts: removed unused error variables (2 fixes) - auto-ssl-setup.ts: removed unused error variable (1 fix) - ssh-key-utils.ts: removed unused error variables (3 fixes) - user-crypto.ts: removed unused error variables (5 fixes) - data-crypto.ts: removed unused plaintextFields and error variables (2 fixes) - simple-db-ops.ts: removed unused parameters _userId and _tableName (2 fixes) Total: 15 unused variable errors fixed Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variable in terminal.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused userPayload variable (line 123) - Removed unused cols and rows from destructuring (line 348) Co-Authored-By: Claude <noreply@anthropic.com> * fix: resolve unused variables in server-stats.ts Fixed @typescript-eslint/no-unused-vars errors: - Removed unused _reject parameter in Promise (line 64) - Removed shadowed now variable in pollStatusesOnce (line 1130) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in tunnel.ts Removed 5 unused variables: - Removed unused data parameter from stdout event handler - Removed hasSourcePassword, hasSourceKey, hasEndpointPassword, hasEndpointKey variables 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove unused variables in main-axios.ts Removed 8 unused variables: - Removed unused type imports (Credential, CredentialData, HostInfo, ApiResponse) - Removed unused apiPort variable - Removed unused error variables in 3 catch blocks * fix: remove unused variables in terminal.ts and starter.ts Removed 2 unused variables: - Removed unused JWTPayload type import from terminal.ts - Removed unused _promise parameter from starter.ts * fix: remove unused variables in sidebar.tsx Removed 9 unused variables: - Removed 5 unused Sheet component imports - Removed unused SIDEBAR_WIDTH_MOBILE constant - Removed 3 unused variables from useSidebar destructuring * fix: remove 13 unused variables in frontend files - version-check-modal.tsx: removed 4 unused imports and functions - main.tsx: removed unused isMobile state - AdminSettings.tsx: removed 8 unused imports and error variables * fix: remove 28 unused variables across frontend components Cleaned up unused imports, state variables, and function parameters: - CredentialsManager.tsx: removed 8 unused variables (Sheet/Select imports) - FileManager.tsx: removed 10 unused variables (icons, state, functions) - Terminal.tsx (Desktop): removed 5 unused variables (state, handlers) - Terminal.tsx (Mobile): removed 5 unused variables (imports, state) Reduced lint errors from 271 to 236 (35 errors fixed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 10 unused variables in File Manager and config files Cleaned up more unused imports, parameters, and variables: - FileManagerGrid.tsx: removed 4 unused variables (params, function) - FileManagerContextMenu.tsx: removed Share import - FileManagerSidebar.tsx: removed onLoadDirectory parameter - DraggableWindow.tsx: removed Square import - FileWindow.tsx: removed updateWindow variable - ServerConfig.tsx: removed 2 unused error parameters Reduced lint errors from 236 to 222 (14 errors fixed total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove 7 unused variables in widgets and Homepage components Cleaned up unused imports, p… * fix: Improve TOTP reliability, move components around, turn homepage update log into a sheet * fix: Work more on TOTP, renamed homepage to dashboard and began improvements * fix: test commit * fix: Fix server stats login * feat: Complete layout of Termix dashboard * feat: Update font for reacent activity * feat: Connect dashboard to backend and update tab system to be similar to a browser (neither are fully finished) * feat: Improve dashboard API, improve tab system, various other fixes * fix: Resize dashboard boxes and reduce server stats size to add scrolling * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix: Improve macOS support * fix(auth): Fix admin user authentication for /users/db-health endpoint by adding cookie JWT support (#422) Fixed authentication issue for admin users accessing the /users/db-health endpoint: - Added JWT token extraction from cookies (req.cookies?.jwt) - Added support for Bearer token from Authorization header - Improved error handling for missing and invalid tokens - Ensured consistent authentication flow for admin users Changes made: - Check for JWT token in req.cookies?.jwt - Support Bearer token from Authorization header - Return 401 error when token is missing - Return 401 error when token validation fails Fixes: https://github.com/Termix-SSH/Support/issues/12 * Update Docker login credentials and image names * Update docker-image.yml * Refactor Docker image workflow for registry options Updated workflow to allow selection of Docker registry and simplified tag handling. * Update Docker login conditions and tag handling * Enhance Docker image workflow with better tagging Updated Docker image workflow to improve tag handling and descriptions. * Update Docker workflow for tag handling and cleanup * Update docker-image.yml * Update Docker workflow inputs and tag logic Refactor Docker workflow to include version and production inputs, and streamline tag determination. * Update Docker image workflow for multi-platform builds * Refactor Docker image tags for clarity Updated Docker image tags to use multi-line syntax for better readability and added latest tag conditionally. * Fix typo in exposed ports in Dockerfile * Update docker-image.yml * Refactor Docker image workflow for registry handling Removed registry input and adjusted Docker Hub login condition. * Handle OIDC users during database import (#424) * Update Docker image name for GitHub registry * Fix image name casing in Docker workflow * Remove untagged image cleanup step from workflow Removed the step to delete untagged image versions from the workflow. * Change Docker login to use GHCR credentials Updated Docker login credentials for GitHub Container Registry. * Remove cache moving step from Docker workflow Removed the step to move the build cache in the Docker workflow. * Refactor Docker image workflow for versioning and builds * Update docker-image.yml * Allow OIDC users to import database without password * Skip import password prompt for OIDC users * docs: clarify OIDC import unlocking flow * docs: explain admin import password logic --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nikola Novoselec <nikolanovoselec@users.noreply.github.com> * fix: Fixed various issues with the dashboard, tab bar, and database issues * feat: Added none password option and fixed some navbar issues (still present) * fix: Fix tab reload/state loss whenever moving them to the rigbht * feat: Make tabs auto expand and contract and scroll * fix: Remove vertical scrolling in the tab bar and dashboard and reduce scrollbar height in tab bar * feat: Add many terminal customizations * feat: Add many terminal customizations * fix: incorrect macOS logo, termix hangs on macOS, and macOS reporting incorrect version * fix: fix macOS verison build error * fix: fix macOS build error * fix: replaced macOS icon * feat: Added more output types for electron and streamlined the workflow * fix: Rollup package issue * fix: Rollup package issue for macOS * feat: fix macOS/Linux build error * fix: fix macOS build error * fix: fix macOS build error and double folder issues * fix: fix macOS build error * fix: fix macOS build error * fix: fix macOS build error * fix: fix macOS build error * fix: files uploading as folders instead of raw executable * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing * fix: macOS build failing and update workflow options * fix: macOS build failing * fix: Upload to release not finding a release * fix: ChaNge platform for upload to release * fix: Standardize file naming * fix: Build error with custom tar.gz naming * fix: Allow .dmg signing * fix: Fix .dmg signing * fix: Fix notarize build error * fix: Fix notarize build error * fix: Add app specific password * fix: add developer ID certificate * fix: macOS app not closing * fix: cache error * Add Brazilian Portuguese translation (#425) * Update Docker image name for GitHub registry * Fix image name casing in Docker workflow * Remove untagged image cleanup step from workflow Removed the step to delete untagged image versions from the workflow. * Change Docker login to use GHCR credentials Updated Docker login credentials for GitHub Container Registry. * Remove cache moving step from Docker workflow Removed the step to move the build cache in the Docker workflow. * Refactor Docker image workflow for versioning and builds * Update docker-image.yml * Add Brazilian Portuguese translation --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add chocolatey support * feat: add initial flatpak/homebrew support * fix: incorrect choco URL * fix: rename choco package * fix: updated package lock * fix: move totp dialog * feat: centralize SSH tools and allow multi terminal snippets * fix: Squash commit of several fixes and features for many different elements * fix: Fix some translations * fix: pt-BR build error * fix: npm build error * fix: npm build error * feat: rename gh actions * fix: None auth and Host.tsx edit button issues * fix: macOS dmg fail * fix: linux not building x64 * fix: linux not uploading x64 * fix: Password reset issues, ODIC admin auth not filling, and electron x64 build issues * feat: Squashed commit of fixing "none" authentication and adding a sessions system for mobile, electron, and web * fix: Replace checkbox in docker build with dropdown * fix: Issue with electron not displaying site * fix: Issue with electron not displaying se * fix: Issue with electron not displaying * fix: Mobile reporting wrong user-agent * fix: Nginx runtime error * fix: JWT not persisting after reboot * feat: add null to gitnore * feat: remove sessions after reboot * fix: File cleanup * fix: Uncapitalize folder titles and finalize file cleanup * fix: Build errors after cleanup * fix: GITHUB_TOKEN issue in electron build * fix: Random macOS build error * fix: macOS GH token error * fix: Incorrect desktop user agent and build issues * fix: Notarize cleanup * fix: None auth issues and macOS build failure and rename files for consistency * fix: Run prettier * feat: Update readme for iPadOS * fix: Electron desktop not logging in * fix: Electron desktop not logging in * fix: Duplicated CORS headers * fix: Electron login issues * fix: Sqlite package fix * fix: Desktop app login issues and rename version check and host manager folder * fix: Electron HTTP fix + stripped background fix * fix: Electron security issues and TOTP/None auth issues * fix: Server config showing in web view * fix: Update readme * fix: Update readme * [FEATURE] Adjustable Left Menu Width in Web Interface (#427) #234 Added to LeftSidebar.tsx functionality Update TopNavbar.tsx to use sidebar dynamic width Co-authored-by: Robert Coroianu <robert.coroianu@easydo.co> * fix: Sidebar resize issues and issues with TOTP interfering with password auth * chore: Run prettier * fix: Tunnels being same name * fix: Electron build problems * fix: Type error * fix: Linux app image and server conifg issue * fix: Run linter * fix: Incorrect android user agent * fix: No x64 appimage and server config displaying in electron webview * fix: Electron API and terminal websocket issues * fix: Android user agent edgecase and electron using web view incorrectly * feat: Added mobile and electron UI redirecting system * fix: Fix electron login and mobile redirect * feat: add Russian translation and readme (#428) * Update Docker image name for GitHub registry * Fix image name casing in Docker workflow * Remove untagged image cleanup step from workflow Removed the step to delete untagged image versions from the workflow. * Change Docker login to use GHCR credentials Updated Docker login credentials for GitHub Container Registry. * Remove cache moving step from Docker workflow Removed the step to move the build cache in the Docker workflow. * Refactor Docker image workflow for versioning and builds * Update docker-image.yml * Update print statement from 'Hello' to 'Goodbye' * Update docker build * Rename docker-image.yml to docker.yml * Rename electron-build.yml to electron.yml * feat: add Russian translation and readme * feat: Added mobile and electron UI redirecting system * fix: Fix electron login and mobile redirect * Update Docker image name for GitHub registry * Fix image name casing in Docker workflow * Remove untagged image cleanup step from workflow Removed the step to delete untagged image versions from the workflow. * Change Docker login to use GHCR credentials Updated Docker login credentials for GitHub Container Registry. * Remove cache moving step from Docker workflow Removed the step to move the build cache in the Docker workflow. * Refactor Docker image workflow for versioning and builds * Update docker-image.yml * Update print statement from 'Hello' to 'Goodbye' * Update docker build * Rename docker-image.yml to docker.yml * Rename electron-build.yml to electron.yml * feat: add Russian translation and readme * fix: Add russian --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: root <root@codeserver.192.168.0.5> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * fix: remove russian readme * fix: Revert workflows back to normal * fix: Session invoking all sessions and mobile success redirect not displaying * fix: Logging out on one device logs out all on same user * fix: Improve session clearing (possible RC) * fix: Linux portable naming incorrect * fix: Linux desktop not opening * fix: Linux build failure * fix: Linux build failure * fix: Linux build failure * fix: Linux build failure * fix: Linux sandbox issue * fix: Linux sandbox issue * fix: Linux sandbox issue * fix: Finalize electron * fix: Database check failure (release cantidate) * fix: Run cleanup and final fix for electron --------- Co-authored-by: Ved Prakash <54140516+thorved@users.noreply.github.com> Co-authored-by: P3RF3CTION <herzmaximilian@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: ZacharyZcR <2903735704@qq.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com> Co-authored-by: suraimu-team <team@suraimu.com> Co-authored-by: Nikola Novoselec <12149536+nikolanovoselec@users.noreply.github.com> Co-authored-by: Nikola Novoselec <nikolanovoselec@users.noreply.github.com> Co-authored-by: xhemp <13650956+xhemp@users.noreply.github.com> Co-authored-by: Robert Coroianu <robert.coroianu@gmail.com> Co-authored-by: Robert Coroianu <robert.coroianu@easydo.co> Co-authored-by: shizaterrorblade <shizaterrorblayde@gmail.com> Co-authored-by: root <root@codeserver.192.168.0.5>
This commit was merged in pull request #429.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { getDb } from "./database/db/index.js";
|
||||
import { recentActivity, sshData } from "./database/db/schema.js";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { dashboardLogger } 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";
|
||||
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin) return callback(null, true);
|
||||
|
||||
const allowedOrigins = [
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("http://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
],
|
||||
}),
|
||||
);
|
||||
app.use(cookieParser());
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
const uptimeSeconds = Math.floor(uptimeMs / 1000);
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
res.json({
|
||||
uptimeMs,
|
||||
uptimeSeconds,
|
||||
formatted: `${days}d ${hours}h ${minutes}m`,
|
||||
});
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get uptime", err);
|
||||
res.status(500).json({ error: "Failed to get uptime" });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(req.query.limit) || 20;
|
||||
|
||||
const activities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp))
|
||||
.limit(limit),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
res.json(activities);
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to get recent activity", err);
|
||||
res.status(500).json({ error: "Failed to get recent activity" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const { type, hostId, hostName } = req.body;
|
||||
|
||||
if (!type || !hostId || !hostName) {
|
||||
return res.status(400).json({
|
||||
error: "Missing required fields: type, hostId, hostName",
|
||||
});
|
||||
}
|
||||
|
||||
if (type !== "terminal" && type !== "file_manager") {
|
||||
return res.status(400).json({
|
||||
error: "Invalid activity type. Must be 'terminal' or 'file_manager'",
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||
const now = Date.now();
|
||||
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||
|
||||
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
|
||||
activityRateLimiter.set(rateLimitKey, now);
|
||||
|
||||
if (activityRateLimiter.size > 10000) {
|
||||
const entriesToDelete: string[] = [];
|
||||
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||
if (now - timestamp > RATE_LIMIT_MS * 2) {
|
||||
entriesToDelete.push(key);
|
||||
}
|
||||
}
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
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 res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
{
|
||||
userId,
|
||||
type,
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
userId,
|
||||
)) as unknown as { id: number };
|
||||
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(recentActivity)
|
||||
.where(eq(recentActivity.userId, userId))
|
||||
.orderBy(desc(recentActivity.timestamp)),
|
||||
"recent_activity",
|
||||
userId,
|
||||
);
|
||||
|
||||
if (allActivities.length > 100) {
|
||||
const toDelete = allActivities.slice(100);
|
||||
for (const activity of toDelete) {
|
||||
await SimpleDBOps.delete(recentActivity, "recent_activity", userId);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message: "Activity logged", id: result.id });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to log activity", err);
|
||||
res.status(500).json({ error: "Failed to log activity" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!SimpleDBOps.isUserDataUnlocked(userId)) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
eq(recentActivity.userId, userId),
|
||||
);
|
||||
|
||||
dashboardLogger.success("Recent activity cleared", {
|
||||
operation: "reset_recent_activity",
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ message: "Recent activity cleared" });
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to reset activity", err);
|
||||
res.status(500).json({ error: "Failed to reset activity" });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = 30006;
|
||||
app.listen(PORT, async () => {
|
||||
try {
|
||||
await authManager.initialize();
|
||||
} catch (err) {
|
||||
dashboardLogger.error("Failed to initialize AuthManager", err, {
|
||||
operation: "auth_init_error",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import userRoutes from "./routes/users.js";
|
||||
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 cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
@@ -31,6 +32,12 @@ import {
|
||||
sshCredentialUsage,
|
||||
settings,
|
||||
} from "./db/schema.js";
|
||||
import type {
|
||||
CacheEntry,
|
||||
GitHubRelease,
|
||||
GitHubAPIResponse,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import { getDb } from "./db/index.js";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
@@ -53,6 +60,10 @@ app.use(
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
@@ -61,10 +72,6 @@ app.use(
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
@@ -74,6 +81,8 @@ app.use(
|
||||
"Authorization",
|
||||
"User-Agent",
|
||||
"X-Electron-App",
|
||||
"Accept",
|
||||
"Origin",
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -105,17 +114,11 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class GitHubCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
set<T>(key: string, data: T): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
@@ -124,7 +127,7 @@ class GitHubCache {
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
@@ -135,7 +138,7 @@ class GitHubCache {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
return entry.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,34 +148,16 @@ const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
interface GitHubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
assets: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
}>;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
async function fetchGitHubAPI(
|
||||
async function fetchGitHubAPI<T>(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): Promise<any> {
|
||||
const cachedData = githubCache.get(cacheKey);
|
||||
if (cachedData) {
|
||||
): Promise<GitHubAPIResponse<T>> {
|
||||
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
|
||||
if (cachedEntry) {
|
||||
return {
|
||||
data: cachedData,
|
||||
data: cachedEntry.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedData.timestamp,
|
||||
cache_age: Date.now() - cachedEntry.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,8 +176,13 @@ async function fetchGitHubAPI(
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
githubCache.set(cacheKey, data);
|
||||
const data = (await response.json()) as T;
|
||||
const cacheData: CacheEntry<T> = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||
};
|
||||
githubCache.set(cacheKey, cacheData);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
@@ -257,7 +247,7 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
localVersion = foundVersion;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -272,7 +262,7 @@ app.get("/version", authenticateJWT, async (req, res) => {
|
||||
|
||||
try {
|
||||
const cacheKey = "latest_release";
|
||||
const releaseData = await fetchGitHubAPI(
|
||||
const releaseData = await fetchGitHubAPI<GitHubRelease>(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
||||
cacheKey,
|
||||
);
|
||||
@@ -323,12 +313,12 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
||||
);
|
||||
const cacheKey = `releases_rss_${page}_${per_page}`;
|
||||
|
||||
const releasesData = await fetchGitHubAPI(
|
||||
const releasesData = await fetchGitHubAPI<GitHubRelease[]>(
|
||||
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
|
||||
cacheKey,
|
||||
);
|
||||
|
||||
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
|
||||
const rssItems = releasesData.data.map((release) => ({
|
||||
id: release.id,
|
||||
title: release.name || release.tag_name,
|
||||
description: release.body,
|
||||
@@ -372,7 +362,6 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => {
|
||||
|
||||
app.get("/encryption/status", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const authManager = AuthManager.getInstance();
|
||||
const securityStatus = {
|
||||
initialized: true,
|
||||
system: { hasSecret: true, isValid: true },
|
||||
@@ -417,8 +406,6 @@ app.post("/encryption/initialize", requireAdmin, async (req, res) => {
|
||||
|
||||
app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
apiLogger.warn("System JWT secret regenerated via API", {
|
||||
operation: "jwt_regenerate_api",
|
||||
});
|
||||
@@ -440,8 +427,6 @@ app.post("/encryption/regenerate", requireAdmin, async (req, res) => {
|
||||
|
||||
app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
apiLogger.warn("JWT secret regenerated via API", {
|
||||
operation: "jwt_secret_regenerate_api",
|
||||
});
|
||||
@@ -462,7 +447,7 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => {
|
||||
|
||||
app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
@@ -695,7 +680,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.authType,
|
||||
decrypted.password || null,
|
||||
decrypted.key || null,
|
||||
decrypted.keyPassword || null,
|
||||
decrypted.key_password || null,
|
||||
decrypted.keyType || null,
|
||||
decrypted.autostartPassword || null,
|
||||
decrypted.autostartKey || null,
|
||||
@@ -738,9 +723,9 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
decrypted.username,
|
||||
decrypted.password || null,
|
||||
decrypted.key || null,
|
||||
decrypted.privateKey || null,
|
||||
decrypted.publicKey || null,
|
||||
decrypted.keyPassword || null,
|
||||
decrypted.private_key || null,
|
||||
decrypted.public_key || null,
|
||||
decrypted.key_password || null,
|
||||
decrypted.keyType || null,
|
||||
decrypted.detectedKeyType || null,
|
||||
decrypted.usageCount || 0,
|
||||
@@ -916,19 +901,40 @@ app.post(
|
||||
return res.status(400).json({ error: "No file uploaded" });
|
||||
}
|
||||
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { password } = req.body;
|
||||
const mainDb = getDb();
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for import",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
const userRecords = await mainDb
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
if (!userRecords || userRecords.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
const isOidcUser = !!userRecords[0].is_oidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for import",
|
||||
code: "PASSWORD_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
const unlocked = await authManager.authenticateUser(userId, password);
|
||||
if (!unlocked) {
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
error: "Failed to unlock user data with SSO credentials",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
apiLogger.info("Importing SQLite data", {
|
||||
@@ -939,7 +945,13 @@ app.post(
|
||||
mimetype: req.file.mimetype,
|
||||
});
|
||||
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey && isOidcUser) {
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||
if (oidcUnlocked) {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
}
|
||||
}
|
||||
if (!userDataKey) {
|
||||
throw new Error("User data not unlocked");
|
||||
}
|
||||
@@ -968,7 +980,7 @@ app.post(
|
||||
try {
|
||||
importDb = new Database(req.file.path, { readonly: true });
|
||||
|
||||
const tables = importDb
|
||||
importDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all();
|
||||
} catch (sqliteError) {
|
||||
@@ -993,8 +1005,6 @@ app.post(
|
||||
};
|
||||
|
||||
try {
|
||||
const mainDb = getDb();
|
||||
|
||||
try {
|
||||
const importedHosts = importDb
|
||||
.prepare("SELECT * FROM ssh_data")
|
||||
@@ -1059,7 +1069,7 @@ app.post(
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
} catch {
|
||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||
}
|
||||
|
||||
@@ -1120,7 +1130,7 @@ app.post(
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"ssh_credentials table not found in import file, skipping",
|
||||
);
|
||||
@@ -1191,7 +1201,7 @@ app.post(
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
} catch {
|
||||
apiLogger.info(`${table} table not found in import file, skipping`);
|
||||
}
|
||||
}
|
||||
@@ -1229,7 +1239,7 @@ app.post(
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
} catch {
|
||||
apiLogger.info(
|
||||
"dismissed_alerts table not found in import file, skipping",
|
||||
);
|
||||
@@ -1270,7 +1280,7 @@ app.post(
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (tableError) {
|
||||
} catch {
|
||||
apiLogger.info("settings table not found in import file, skipping");
|
||||
}
|
||||
} else {
|
||||
@@ -1288,7 +1298,7 @@ app.post(
|
||||
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupError) {
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file", {
|
||||
operation: "file_cleanup_warning",
|
||||
filePath: req.file.path,
|
||||
@@ -1314,7 +1324,7 @@ app.post(
|
||||
if (req.file?.path && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupError) {
|
||||
} catch {
|
||||
apiLogger.warn("Failed to clean up uploaded file after error", {
|
||||
operation: "file_cleanup_error",
|
||||
filePath: req.file.path,
|
||||
@@ -1324,7 +1334,7 @@ app.post(
|
||||
|
||||
apiLogger.error("SQLite import failed", error, {
|
||||
operation: "sqlite_import_api_failed",
|
||||
userId: (req as any).userId,
|
||||
userId: (req as AuthenticatedRequest).userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
error: "Failed to import SQLite data",
|
||||
@@ -1336,12 +1346,8 @@ app.post(
|
||||
|
||||
app.post("/database/export/preview", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
const {
|
||||
format = "encrypted",
|
||||
scope = "user_data",
|
||||
includeCredentials = true,
|
||||
} = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { scope = "user_data", includeCredentials = true } = req.body;
|
||||
|
||||
const exportData = await UserDataExport.exportUserData(userId, {
|
||||
format: "encrypted",
|
||||
@@ -1411,13 +1417,14 @@ app.use("/users", userRoutes);
|
||||
app.use("/ssh", sshRoutes);
|
||||
app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
|
||||
app.use(
|
||||
(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
apiLogger.error("Unhandled error in request", err, {
|
||||
operation: "error_handler",
|
||||
@@ -1430,7 +1437,6 @@ app.use(
|
||||
);
|
||||
|
||||
const HTTP_PORT = 30001;
|
||||
const HTTPS_PORT = process.env.SSL_PORT || 8443;
|
||||
|
||||
async function initializeSecurity() {
|
||||
try {
|
||||
@@ -1443,13 +1449,6 @@ async function initializeSecurity() {
|
||||
if (!isValid) {
|
||||
throw new Error("Security system validation failed");
|
||||
}
|
||||
|
||||
const securityStatus = {
|
||||
initialized: true,
|
||||
system: { hasSecret: true, isValid: true },
|
||||
activeSessions: {},
|
||||
activeSessionCount: 0,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize security system", error, {
|
||||
operation: "security_init_error",
|
||||
@@ -1481,13 +1480,13 @@ app.get(
|
||||
if (status.hasUnencryptedDb) {
|
||||
try {
|
||||
unencryptedSize = fs.statSync(dbPath).size;
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (status.hasEncryptedDb) {
|
||||
try {
|
||||
encryptedSize = fs.statSync(encryptedDbPath).size;
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -12,10 +12,6 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
databaseLogger.info(`Creating database directory`, {
|
||||
operation: "db_init",
|
||||
path: dbDir,
|
||||
});
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -23,7 +19,7 @@ const enableFileEncryption = process.env.DB_FILE_ENCRYPTION !== "false";
|
||||
const dbPath = path.join(dataDir, "db.sqlite");
|
||||
const encryptedDbPath = `${dbPath}.encrypted`;
|
||||
|
||||
let actualDbPath = ":memory:";
|
||||
const actualDbPath = ":memory:";
|
||||
let memoryDatabase: Database.Database;
|
||||
let isNewDatabase = false;
|
||||
let sqlite: Database.Database;
|
||||
@@ -31,7 +27,7 @@ let sqlite: Database.Database;
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
const dbKey = await systemCrypto.getDatabaseKey();
|
||||
await systemCrypto.getDatabaseKey();
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
if (DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)) {
|
||||
@@ -39,6 +35,13 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
await DatabaseFileEncryption.decryptDatabaseToBuffer(encryptedDbPath);
|
||||
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
} catch (countError) {
|
||||
}
|
||||
} else {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
const migrationStatus = migration.checkMigrationStatus();
|
||||
@@ -145,6 +148,18 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
jwt_token TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_data (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -165,6 +180,12 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
tunnel_connections TEXT,
|
||||
enable_file_manager INTEGER NOT NULL DEFAULT 1,
|
||||
default_path TEXT,
|
||||
autostart_password TEXT,
|
||||
autostart_key TEXT,
|
||||
autostart_key_password TEXT,
|
||||
force_keyboard_interactive TEXT,
|
||||
stats_config TEXT,
|
||||
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)
|
||||
@@ -242,8 +263,39 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recent_activity (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
host_id INTEGER NOT NULL,
|
||||
host_name TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id),
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
migrateSchema();
|
||||
|
||||
try {
|
||||
@@ -263,6 +315,24 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const row = sqlite
|
||||
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
|
||||
.get();
|
||||
if (!row) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('allow_password_login', 'true')",
|
||||
)
|
||||
.run();
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not initialize allow_password_login setting", {
|
||||
operation: "db_init",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addColumnIfNotExists = (
|
||||
@@ -277,7 +347,7 @@ const addColumnIfNotExists = (
|
||||
FROM ${table} LIMIT 1`,
|
||||
)
|
||||
.get();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`ALTER TABLE ${table}
|
||||
ADD COLUMN ${column} ${definition};`);
|
||||
@@ -351,7 +421,10 @@ const migrateSchema = () => {
|
||||
"updated_at",
|
||||
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||
);
|
||||
|
||||
addColumnIfNotExists("ssh_data", "force_keyboard_interactive", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"credential_id",
|
||||
@@ -361,6 +434,8 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
|
||||
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
|
||||
|
||||
addColumnIfNotExists("ssh_credentials", "private_key", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");
|
||||
@@ -370,6 +445,33 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
.get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
jwt_token TEXT NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
device_info TEXT NOT NULL,
|
||||
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)
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create sessions table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
@@ -385,6 +487,13 @@ async function saveMemoryDatabaseToFile() {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
} catch (countError) {
|
||||
}
|
||||
|
||||
if (enableFileEncryption) {
|
||||
await DatabaseFileEncryption.encryptDatabaseFromBuffer(
|
||||
buffer,
|
||||
@@ -476,21 +585,25 @@ async function cleanupDatabase() {
|
||||
for (const file of files) {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
process.on("exit", () => {
|
||||
if (sqlite) {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,23 @@ export const settings = sqliteTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
jwtToken: text("jwt_token").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
deviceInfo: text("device_info").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
lastActiveAt: text("last_active_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sshData = sqliteTable("ssh_data", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
@@ -43,6 +60,7 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
tags: text("tags"),
|
||||
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
|
||||
authType: text("auth_type").notNull(),
|
||||
forceKeyboardInteractive: text("force_keyboard_interactive"),
|
||||
|
||||
password: text("password"),
|
||||
key: text("key", { length: 8192 }),
|
||||
@@ -65,6 +83,8 @@ export const sshData = sqliteTable("ssh_data", {
|
||||
.notNull()
|
||||
.default(true),
|
||||
defaultPath: text("default_path"),
|
||||
statsConfig: text("stats_config"),
|
||||
terminalConfig: text("terminal_config"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -172,3 +192,34 @@ export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const snippets = sqliteTable("snippets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
name: text("name").notNull(),
|
||||
content: text("content").notNull(),
|
||||
description: text("description"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const recentActivity = sqliteTable("recent_activity", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: text("type").notNull(),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id),
|
||||
hostName: text("host_name").notNull(),
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
CacheEntry,
|
||||
TermixAlert,
|
||||
} from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { dismissedAlerts } from "../db/schema.js";
|
||||
@@ -6,17 +11,11 @@ import fetch from "node-fetch";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
|
||||
interface CacheEntry {
|
||||
data: any;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class AlertCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000;
|
||||
|
||||
set(key: string, data: any): void {
|
||||
set<T>(key: string, data: T): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
@@ -25,7 +24,7 @@ class AlertCache {
|
||||
});
|
||||
}
|
||||
|
||||
get(key: string): any | null {
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
@@ -36,7 +35,7 @@ class AlertCache {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
return entry.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,20 +46,9 @@ const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Docs";
|
||||
const ALERTS_FILE = "main/termix-alerts.json";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
expiresAt: string;
|
||||
priority?: "low" | "medium" | "high" | "critical";
|
||||
type?: "info" | "warning" | "error" | "success";
|
||||
actionUrl?: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
async function fetchAlertsFromGitHub(): Promise<TermixAlert[]> {
|
||||
const cacheKey = "termix_alerts";
|
||||
const cachedData = alertCache.get(cacheKey);
|
||||
const cachedData = alertCache.get<TermixAlert[]>(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
@@ -115,7 +103,7 @@ const authenticateJWT = authManager.createAuthMiddleware();
|
||||
// GET /alerts
|
||||
router.get("/", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const allAlerts = await fetchAlertsFromGitHub();
|
||||
|
||||
@@ -148,7 +136,7 @@ router.get("/", authenticateJWT, async (req, res) => {
|
||||
router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!alertId) {
|
||||
authLogger.warn("Missing alertId in dismiss request", { userId });
|
||||
@@ -170,7 +158,7 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
return res.status(409).json({ error: "Alert already dismissed" });
|
||||
}
|
||||
|
||||
const result = await db.insert(dismissedAlerts).values({
|
||||
await db.insert(dismissedAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
});
|
||||
@@ -186,7 +174,7 @@ router.post("/dismiss", authenticateJWT, async (req, res) => {
|
||||
// GET /alerts/dismissed/:userId
|
||||
router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
const dismissedAlertRecords = await db
|
||||
.select({
|
||||
@@ -211,7 +199,7 @@ router.get("/dismissed", authenticateJWT, async (req, res) => {
|
||||
router.delete("/dismiss", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const { alertId } = req.body;
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!alertId) {
|
||||
return res.status(400).json({ error: "Alert ID is required" });
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import { sshCredentials, sshCredentialUsage, sshData } from "../db/schema.js";
|
||||
import { eq, and, desc, sql } from "drizzle-orm";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Request, Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import {
|
||||
parseSSHKey,
|
||||
parsePublicKey,
|
||||
detectKeyType,
|
||||
validateKeyPair,
|
||||
} from "../../utils/ssh-key-utils.js";
|
||||
import crypto from "crypto";
|
||||
@@ -29,7 +28,11 @@ function generateSSHKeyPair(
|
||||
} {
|
||||
try {
|
||||
let ssh2Type = keyType;
|
||||
const options: any = {};
|
||||
const options: {
|
||||
bits?: number;
|
||||
passphrase?: string;
|
||||
cipher?: string;
|
||||
} = {};
|
||||
|
||||
if (keyType === "ssh-rsa") {
|
||||
ssh2Type = "rsa";
|
||||
@@ -46,6 +49,7 @@ function generateSSHKeyPair(
|
||||
options.cipher = "aes128-cbc";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options);
|
||||
|
||||
return {
|
||||
@@ -64,7 +68,7 @@ function generateSSHKeyPair(
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function isNonEmptyString(val: any): val is string {
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
}
|
||||
|
||||
@@ -79,7 +83,7 @@ router.post(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
@@ -226,7 +230,7 @@ router.get(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for credential fetch");
|
||||
@@ -259,7 +263,7 @@ router.get(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for credential folder fetch");
|
||||
@@ -297,7 +301,7 @@ router.get(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
@@ -328,19 +332,19 @@ router.get(
|
||||
const output = formatCredentialOutput(credential);
|
||||
|
||||
if (credential.password) {
|
||||
(output as any).password = credential.password;
|
||||
output.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
(output as any).key = credential.key;
|
||||
output.key = credential.key;
|
||||
}
|
||||
if (credential.private_key) {
|
||||
(output as any).privateKey = credential.private_key;
|
||||
output.privateKey = credential.private_key;
|
||||
}
|
||||
if (credential.public_key) {
|
||||
(output as any).publicKey = credential.public_key;
|
||||
output.publicKey = credential.public_key;
|
||||
}
|
||||
if (credential.key_password) {
|
||||
(output as any).keyPassword = credential.key_password;
|
||||
output.keyPassword = credential.key_password;
|
||||
}
|
||||
|
||||
res.json(output);
|
||||
@@ -361,7 +365,7 @@ router.put(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
@@ -385,7 +389,7 @@ router.put(
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const updateFields: any = {};
|
||||
const updateFields: Record<string, string | null | undefined> = {};
|
||||
|
||||
if (updateData.name !== undefined)
|
||||
updateFields.name = updateData.name.trim();
|
||||
@@ -497,7 +501,7 @@ router.delete(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
@@ -596,7 +600,7 @@ router.post(
|
||||
"/:id/apply-to-host/:hostId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id: credentialId, hostId } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
|
||||
@@ -629,8 +633,8 @@ router.post(
|
||||
.update(sshData)
|
||||
.set({
|
||||
credentialId: parseInt(credentialId),
|
||||
username: credential.username,
|
||||
authType: credential.auth_type || credential.authType,
|
||||
username: credential.username as string,
|
||||
authType: (credential.auth_type || credential.authType) as string,
|
||||
password: null,
|
||||
key: null,
|
||||
key_password: null,
|
||||
@@ -675,7 +679,7 @@ router.get(
|
||||
"/:id/hosts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id: credentialId } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !credentialId) {
|
||||
@@ -707,7 +711,9 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
function formatCredentialOutput(credential: any): any {
|
||||
function formatCredentialOutput(
|
||||
credential: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: credential.id,
|
||||
name: credential.name,
|
||||
@@ -731,7 +737,9 @@ function formatCredentialOutput(credential: any): any {
|
||||
};
|
||||
}
|
||||
|
||||
function formatSSHHostOutput(host: any): any {
|
||||
function formatSSHHostOutput(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
@@ -751,7 +759,7 @@ function formatSSHHostOutput(host: any): any {
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
enableTunnel: !!host.enableTunnel,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
defaultPath: host.defaultPath,
|
||||
@@ -766,7 +774,7 @@ router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { oldName, newName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
|
||||
@@ -970,7 +978,7 @@ router.post(
|
||||
|
||||
try {
|
||||
let privateKeyObj;
|
||||
let parseAttempts = [];
|
||||
const parseAttempts = [];
|
||||
|
||||
try {
|
||||
privateKeyObj = crypto.createPrivateKey({
|
||||
@@ -1093,7 +1101,9 @@ router.post(
|
||||
finalPublicKey = `${keyType} ${base64Data}`;
|
||||
formatType = "ssh";
|
||||
}
|
||||
} catch (sshError) {}
|
||||
} catch {
|
||||
// Ignore validation errors
|
||||
}
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
@@ -1117,15 +1127,15 @@ router.post(
|
||||
);
|
||||
|
||||
async function deploySSHKeyToHost(
|
||||
hostConfig: any,
|
||||
hostConfig: Record<string, unknown>,
|
||||
publicKey: string,
|
||||
credentialData: any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_credentialData: Record<string, unknown>,
|
||||
): Promise<{ success: boolean; message?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const conn = new Client();
|
||||
let connectionTimeout: NodeJS.Timeout;
|
||||
|
||||
connectionTimeout = setTimeout(() => {
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
conn.destroy();
|
||||
resolve({ success: false, error: "Connection timeout" });
|
||||
}, 120000);
|
||||
@@ -1158,7 +1168,9 @@ async function deploySSHKeyToHost(
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data) => {});
|
||||
stream.on("data", () => {
|
||||
// Ignore output
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1175,7 +1187,9 @@ async function deploySSHKeyToHost(
|
||||
if (parsed.data) {
|
||||
actualPublicKey = parsed.data;
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
const keyParts = actualPublicKey.trim().split(" ");
|
||||
if (keyParts.length < 2) {
|
||||
@@ -1202,7 +1216,7 @@ async function deploySSHKeyToHost(
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
stream.on("close", () => {
|
||||
clearTimeout(checkTimeout);
|
||||
const exists = output.trim() === "0";
|
||||
resolveCheck(exists);
|
||||
@@ -1229,7 +1243,9 @@ async function deploySSHKeyToHost(
|
||||
if (parsed.data) {
|
||||
actualPublicKey = parsed.data;
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
const escapedKey = actualPublicKey
|
||||
.replace(/\\/g, "\\\\")
|
||||
@@ -1269,7 +1285,9 @@ async function deploySSHKeyToHost(
|
||||
if (parsed.data) {
|
||||
actualPublicKey = parsed.data;
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
const keyParts = actualPublicKey.trim().split(" ");
|
||||
if (keyParts.length < 2) {
|
||||
@@ -1295,7 +1313,7 @@ async function deploySSHKeyToHost(
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code) => {
|
||||
stream.on("close", () => {
|
||||
clearTimeout(verifyTimeout);
|
||||
const verified = output.trim() === "0";
|
||||
resolveVerify(verified);
|
||||
@@ -1356,7 +1374,7 @@ async function deploySSHKeyToHost(
|
||||
});
|
||||
|
||||
try {
|
||||
const connectionConfig: any = {
|
||||
const connectionConfig: Record<string, unknown> = {
|
||||
host: hostConfig.ip,
|
||||
port: hostConfig.port || 22,
|
||||
username: hostConfig.username,
|
||||
@@ -1403,14 +1421,15 @@ async function deploySSHKeyToHost(
|
||||
connectionConfig.password = hostConfig.password;
|
||||
} else if (hostConfig.authType === "key" && hostConfig.privateKey) {
|
||||
try {
|
||||
const privateKey = hostConfig.privateKey as string;
|
||||
if (
|
||||
!hostConfig.privateKey.includes("-----BEGIN") ||
|
||||
!hostConfig.privateKey.includes("-----END")
|
||||
!privateKey.includes("-----BEGIN") ||
|
||||
!privateKey.includes("-----END")
|
||||
) {
|
||||
throw new Error("Invalid private key format");
|
||||
}
|
||||
|
||||
const cleanKey = hostConfig.privateKey
|
||||
const cleanKey = privateKey
|
||||
.trim()
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n");
|
||||
@@ -1465,7 +1484,7 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
@@ -1521,7 +1540,7 @@ router.post(
|
||||
|
||||
const hostData = targetHost[0];
|
||||
|
||||
let hostConfig = {
|
||||
const hostConfig = {
|
||||
ip: hostData.ip,
|
||||
port: hostData.port,
|
||||
username: hostData.username,
|
||||
@@ -1532,7 +1551,7 @@ router.post(
|
||||
};
|
||||
|
||||
if (hostData.authType === "credential" && hostData.credentialId) {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -1546,7 +1565,7 @@ router.post(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(eq(sshCredentials.id, hostData.credentialId))
|
||||
.where(eq(sshCredentials.id, hostData.credentialId as number))
|
||||
.limit(1),
|
||||
"ssh_credentials",
|
||||
userId,
|
||||
@@ -1571,7 +1590,7 @@ router.post(
|
||||
error: "Host credential not found",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Failed to resolve host credentials",
|
||||
@@ -1581,7 +1600,7 @@ router.post(
|
||||
|
||||
const deployResult = await deploySSHKeyToHost(
|
||||
hostConfig,
|
||||
credData.publicKey,
|
||||
credData.publicKey as string,
|
||||
credData,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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 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();
|
||||
|
||||
// Get all snippets for the authenticated user
|
||||
// GET /snippets
|
||||
router.get(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId)) {
|
||||
authLogger.warn("Invalid userId for snippets fetch");
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.orderBy(desc(snippets.updatedAt));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch snippets", err);
|
||||
res.status(500).json({ error: "Failed to fetch snippets" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Get a specific snippet by ID
|
||||
// GET /snippets/:id
|
||||
router.get(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const snippetId = parseInt(id, 10);
|
||||
|
||||
if (!isNonEmptyString(userId) || isNaN(snippetId)) {
|
||||
authLogger.warn("Invalid request for snippet fetch: invalid ID", {
|
||||
userId,
|
||||
id,
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid request parameters" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
res.json(result[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to fetch snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to fetch snippet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Create a new snippet
|
||||
// POST /snippets
|
||||
router.post(
|
||||
"/",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, content, description } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
!isNonEmptyString(name) ||
|
||||
!isNonEmptyString(content)
|
||||
) {
|
||||
authLogger.warn("Invalid snippet creation data validation failed", {
|
||||
operation: "snippet_create",
|
||||
userId,
|
||||
hasName: !!name,
|
||||
hasContent: !!content,
|
||||
});
|
||||
return res.status(400).json({ error: "Name and content are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const insertData = {
|
||||
userId,
|
||||
name: name.trim(),
|
||||
content: content.trim(),
|
||||
description: description?.trim() || null,
|
||||
};
|
||||
|
||||
const result = await db.insert(snippets).values(insertData).returning();
|
||||
|
||||
authLogger.success(`Snippet created: ${name} by user ${userId}`, {
|
||||
operation: "snippet_create_success",
|
||||
userId,
|
||||
snippetId: result[0].id,
|
||||
name,
|
||||
});
|
||||
|
||||
res.status(201).json(result[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to create snippet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Update a snippet
|
||||
// PUT /snippets/:id
|
||||
router.put(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for snippet update");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
const updateFields: Partial<{
|
||||
updatedAt: ReturnType<typeof sql.raw>;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
|
||||
if (updateData.name !== undefined)
|
||||
updateFields.name = updateData.name.trim();
|
||||
if (updateData.content !== undefined)
|
||||
updateFields.content = updateData.content.trim();
|
||||
if (updateData.description !== undefined)
|
||||
updateFields.description = updateData.description?.trim() || null;
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
|
||||
const updated = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.id, parseInt(id)));
|
||||
|
||||
authLogger.success(
|
||||
`Snippet updated: ${updated[0].name} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_update_success",
|
||||
userId,
|
||||
snippetId: parseInt(id),
|
||||
name: updated[0].name,
|
||||
},
|
||||
);
|
||||
|
||||
res.json(updated[0]);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to update snippet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a snippet
|
||||
// DELETE /snippets/:id
|
||||
router.delete(
|
||||
"/:id",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!isNonEmptyString(userId) || !id) {
|
||||
authLogger.warn("Invalid request for snippet delete");
|
||||
return res.status(400).json({ error: "Invalid request" });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
|
||||
if (existing.length === 0) {
|
||||
return res.status(404).json({ error: "Snippet not found" });
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(snippets)
|
||||
.where(and(eq(snippets.id, parseInt(id)), eq(snippets.userId, userId)));
|
||||
|
||||
authLogger.success(
|
||||
`Snippet deleted: ${existing[0].name} by user ${userId}`,
|
||||
{
|
||||
operation: "snippet_delete_success",
|
||||
userId,
|
||||
snippetId: parseInt(id),
|
||||
name: existing[0].name,
|
||||
},
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete snippet", err);
|
||||
res.status(500).json({
|
||||
error: err instanceof Error ? err.message : "Failed to delete snippet",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
@@ -9,8 +10,7 @@ import {
|
||||
fileManagerShortcuts,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import { SimpleDBOps } from "../../utils/simple-db-ops.js";
|
||||
@@ -23,11 +23,11 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
function isNonEmptyString(value: any): value is string {
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isValidPort(port: any): port is number {
|
||||
function isValidPort(port: unknown): port is number {
|
||||
return typeof port === "number" && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
: [];
|
||||
|
||||
const hasAutoStartTunnels = tunnelConnections.some(
|
||||
(tunnel: any) => tunnel.autoStart,
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
);
|
||||
|
||||
if (!hasAutoStartTunnels) {
|
||||
@@ -100,7 +100,7 @@ router.get("/db/host/internal", async (req: Request, res: Response) => {
|
||||
credentialId: host.credentialId,
|
||||
enableTunnel: true,
|
||||
tunnelConnections: tunnelConnections.filter(
|
||||
(tunnel: any) => tunnel.autoStart,
|
||||
(tunnel: Record<string, unknown>) => tunnel.autoStart,
|
||||
),
|
||||
pin: !!host.pin,
|
||||
enableTerminal: !!host.enableTerminal,
|
||||
@@ -184,8 +184,8 @@ router.post(
|
||||
requireDataAccess,
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
let hostData: any;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
let hostData: Record<string, unknown>;
|
||||
|
||||
if (req.headers["content-type"]?.includes("multipart/form-data")) {
|
||||
if (req.body.data) {
|
||||
@@ -234,6 +234,9 @@ router.post(
|
||||
enableFileManager,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
statsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
} = hostData;
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -251,7 +254,7 @@ router.post(
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const sshDataObj: any = {
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
name,
|
||||
folder: folder || null,
|
||||
@@ -269,6 +272,9 @@ router.post(
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -320,9 +326,12 @@ router.post(
|
||||
enableTerminal: !!createdHost.enableTerminal,
|
||||
enableTunnel: !!createdHost.enableTunnel,
|
||||
tunnelConnections: createdHost.tunnelConnections
|
||||
? JSON.parse(createdHost.tunnelConnections)
|
||||
? JSON.parse(createdHost.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: !!createdHost.enableFileManager,
|
||||
statsConfig: createdHost.statsConfig
|
||||
? JSON.parse(createdHost.statsConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -332,7 +341,7 @@ router.post(
|
||||
{
|
||||
operation: "host_create_success",
|
||||
userId,
|
||||
hostId: createdHost.id,
|
||||
hostId: createdHost.id as number,
|
||||
name,
|
||||
ip,
|
||||
port,
|
||||
@@ -363,8 +372,8 @@ router.put(
|
||||
upload.single("key"),
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = req.params.id;
|
||||
const userId = (req as any).userId;
|
||||
let hostData: any;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
let hostData: Record<string, unknown>;
|
||||
|
||||
if (req.headers["content-type"]?.includes("multipart/form-data")) {
|
||||
if (req.body.data) {
|
||||
@@ -415,6 +424,9 @@ router.put(
|
||||
enableFileManager,
|
||||
defaultPath,
|
||||
tunnelConnections,
|
||||
statsConfig,
|
||||
terminalConfig,
|
||||
forceKeyboardInteractive,
|
||||
} = hostData;
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -434,7 +446,7 @@ router.put(
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const sshDataObj: any = {
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
name,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
@@ -451,6 +463,9 @@ router.put(
|
||||
: null,
|
||||
enableFileManager: enableFileManager ? 1 : 0,
|
||||
defaultPath: defaultPath || null,
|
||||
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
|
||||
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
|
||||
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
|
||||
};
|
||||
|
||||
if (effectiveAuthType === "password") {
|
||||
@@ -520,9 +535,12 @@ router.put(
|
||||
enableTerminal: !!updatedHost.enableTerminal,
|
||||
enableTunnel: !!updatedHost.enableTunnel,
|
||||
tunnelConnections: updatedHost.tunnelConnections
|
||||
? JSON.parse(updatedHost.tunnelConnections)
|
||||
? JSON.parse(updatedHost.tunnelConnections as string)
|
||||
: [],
|
||||
enableFileManager: !!updatedHost.enableFileManager,
|
||||
statsConfig: updatedHost.statsConfig
|
||||
? JSON.parse(updatedHost.statsConfig as string)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -559,7 +577,7 @@ 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 any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
if (!isNonEmptyString(userId)) {
|
||||
sshLogger.warn("Invalid userId for SSH data fetch", {
|
||||
operation: "host_fetch",
|
||||
@@ -575,7 +593,7 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
);
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: any) => {
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
const baseHost = {
|
||||
...row,
|
||||
tags:
|
||||
@@ -588,9 +606,16 @@ router.get("/db/host", authenticateJWT, async (req: Request, res: Response) => {
|
||||
enableTerminal: !!row.enableTerminal,
|
||||
enableTunnel: !!row.enableTunnel,
|
||||
tunnelConnections: row.tunnelConnections
|
||||
? JSON.parse(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",
|
||||
};
|
||||
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -614,7 +639,7 @@ router.get(
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = req.params.id;
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
sshLogger.warn("Invalid userId or hostId for SSH host fetch by ID", {
|
||||
@@ -655,6 +680,13 @@ router.get(
|
||||
? JSON.parse(host.tunnelConnections)
|
||||
: [],
|
||||
enableFileManager: !!host.enableFileManager,
|
||||
statsConfig: host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: undefined,
|
||||
terminalConfig: host.terminalConfig
|
||||
? JSON.parse(host.terminalConfig)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: host.forceKeyboardInteractive === "true",
|
||||
};
|
||||
|
||||
res.json((await resolveHostCredentials(result)) || result);
|
||||
@@ -677,7 +709,7 @@ router.get(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const hostId = req.params.id;
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
return res.status(400).json({ error: "Invalid userId or hostId" });
|
||||
@@ -711,7 +743,7 @@ router.get(
|
||||
authType: resolvedHost.authType,
|
||||
password: resolvedHost.password || null,
|
||||
key: resolvedHost.key || null,
|
||||
keyPassword: resolvedHost.keyPassword || null,
|
||||
keyPassword: resolvedHost.key_password || null,
|
||||
keyType: resolvedHost.keyType || null,
|
||||
folder: resolvedHost.folder,
|
||||
tags:
|
||||
@@ -724,7 +756,7 @@ router.get(
|
||||
enableFileManager: !!resolvedHost.enableFileManager,
|
||||
defaultPath: resolvedHost.defaultPath,
|
||||
tunnelConnections: resolvedHost.tunnelConnections
|
||||
? JSON.parse(resolvedHost.tunnelConnections)
|
||||
? JSON.parse(resolvedHost.tunnelConnections as string)
|
||||
: [],
|
||||
};
|
||||
|
||||
@@ -752,7 +784,7 @@ router.delete(
|
||||
"/db/host/:id",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = req.params.id;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId) {
|
||||
@@ -816,7 +848,7 @@ router.delete(
|
||||
),
|
||||
);
|
||||
|
||||
const result = await db
|
||||
await db
|
||||
.delete(sshData)
|
||||
.where(and(eq(sshData.id, numericHostId), eq(sshData.userId, userId)));
|
||||
|
||||
@@ -851,7 +883,7 @@ router.get(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = req.query.hostId
|
||||
? parseInt(req.query.hostId as string)
|
||||
: null;
|
||||
@@ -893,7 +925,7 @@ router.post(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
@@ -942,8 +974,8 @@ router.delete(
|
||||
"/file_manager/recent",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for recent file deletion");
|
||||
@@ -975,7 +1007,7 @@ router.get(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = req.query.hostId
|
||||
? parseInt(req.query.hostId as string)
|
||||
: null;
|
||||
@@ -1016,7 +1048,7 @@ router.post(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
@@ -1062,8 +1094,8 @@ router.delete(
|
||||
"/file_manager/pinned",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for pinned file deletion");
|
||||
@@ -1095,7 +1127,7 @@ router.get(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = req.query.hostId
|
||||
? parseInt(req.query.hostId as string)
|
||||
: null;
|
||||
@@ -1136,7 +1168,7 @@ router.post(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
@@ -1182,8 +1214,8 @@ router.delete(
|
||||
"/file_manager/shortcuts",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const { hostId, path, name } = req.body;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hostId, path } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !hostId || !path) {
|
||||
sshLogger.warn("Invalid data for shortcut deletion");
|
||||
@@ -1209,21 +1241,26 @@ router.delete(
|
||||
},
|
||||
);
|
||||
|
||||
async function resolveHostCredentials(host: any): Promise<any> {
|
||||
async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
if (host.credentialId && host.userId) {
|
||||
const credentialId = host.credentialId as number;
|
||||
const userId = host.userId as string;
|
||||
|
||||
const credentials = await SimpleDBOps.select(
|
||||
db
|
||||
.select()
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, host.credentialId),
|
||||
eq(sshCredentials.userId, host.userId),
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
),
|
||||
"ssh_credentials",
|
||||
host.userId,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (credentials.length > 0) {
|
||||
@@ -1239,6 +1276,7 @@ async function resolveHostCredentials(host: any): Promise<any> {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = { ...host };
|
||||
if (host.key_password !== undefined) {
|
||||
if (result.keyPassword === undefined) {
|
||||
@@ -1261,7 +1299,7 @@ router.put(
|
||||
"/folders/rename",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { oldName, newName } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !oldName || !newName) {
|
||||
@@ -1326,7 +1364,7 @@ router.post(
|
||||
"/bulk-import",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { hosts } = req.body;
|
||||
|
||||
if (!Array.isArray(hosts) || hosts.length === 0) {
|
||||
@@ -1398,7 +1436,7 @@ router.post(
|
||||
continue;
|
||||
}
|
||||
|
||||
const sshDataObj: any = {
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
name: hostData.name || `${hostData.username}@${hostData.ip}`,
|
||||
folder: hostData.folder || "Default",
|
||||
@@ -1411,7 +1449,7 @@ router.post(
|
||||
credentialId:
|
||||
hostData.authType === "credential" ? hostData.credentialId : null,
|
||||
key: hostData.authType === "key" ? hostData.key : null,
|
||||
key_password:
|
||||
keyPassword:
|
||||
hostData.authType === "key"
|
||||
? hostData.keyPassword || hostData.key_password || null
|
||||
: null,
|
||||
@@ -1425,6 +1463,9 @@ router.post(
|
||||
tunnelConnections: hostData.tunnelConnections
|
||||
? JSON.stringify(hostData.tunnelConnections)
|
||||
: "[]",
|
||||
statsConfig: hostData.statsConfig
|
||||
? JSON.stringify(hostData.statsConfig)
|
||||
: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -1455,7 +1496,7 @@ router.post(
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
@@ -1519,7 +1560,7 @@ router.post(
|
||||
const tunnelConnections = JSON.parse(config.tunnelConnections);
|
||||
|
||||
const resolvedConnections = await Promise.all(
|
||||
tunnelConnections.map(async (tunnel: any) => {
|
||||
tunnelConnections.map(async (tunnel: Record<string, unknown>) => {
|
||||
if (
|
||||
tunnel.autoStart &&
|
||||
tunnel.endpointHost &&
|
||||
@@ -1567,7 +1608,7 @@ router.post(
|
||||
}
|
||||
}
|
||||
|
||||
const updateResult = await db
|
||||
await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
autostartPassword: decryptedConfig.password || null,
|
||||
@@ -1608,7 +1649,7 @@ router.delete(
|
||||
"/autostart/disable",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { sshConfigId } = req.body;
|
||||
|
||||
if (!sshConfigId || typeof sshConfigId !== "number") {
|
||||
@@ -1624,7 +1665,7 @@ router.delete(
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db
|
||||
await db
|
||||
.update(sshData)
|
||||
.set({
|
||||
autostartPassword: null,
|
||||
@@ -1654,7 +1695,7 @@ router.get(
|
||||
"/autostart/status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
const autostartConfigs = await db
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+459
-55
@@ -1,13 +1,15 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import axios from "axios";
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fileLogger } 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";
|
||||
|
||||
function isExecutableFile(permissions: string, fileName: string): boolean {
|
||||
const hasExecutePermission =
|
||||
@@ -94,7 +96,24 @@ interface SSHSession {
|
||||
timeout?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: import("ssh2").ConnectConfig;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
}
|
||||
|
||||
const sshSessions: Record<string, SSHSession> = {};
|
||||
const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
@@ -153,9 +172,11 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
keyPassword,
|
||||
authType,
|
||||
credentialId,
|
||||
userProvidedPassword,
|
||||
forceKeyboardInteractive,
|
||||
} = req.body;
|
||||
|
||||
const userId = (req as any).userId;
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!userId) {
|
||||
fileLogger.error("SSH connection rejected: no authenticated user", {
|
||||
@@ -235,40 +256,68 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
const config: any = {
|
||||
const config: Record<string, unknown> = {
|
||||
host: ip,
|
||||
port: port || 22,
|
||||
port,
|
||||
username,
|
||||
readyTimeout: 60000,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 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-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"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: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
@@ -277,12 +326,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.password.trim()
|
||||
) {
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.sshKey &&
|
||||
resolvedCredentials.sshKey.trim()
|
||||
@@ -313,6 +356,17 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
});
|
||||
return res.status(400).json({ error: "Invalid SSH key format" });
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "password") {
|
||||
if (!resolvedCredentials.password || !resolvedCredentials.password.trim()) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Password required for password authentication" });
|
||||
}
|
||||
|
||||
if (!forceKeyboardInteractive) {
|
||||
config.password = resolvedCredentials.password;
|
||||
}
|
||||
} else if (resolvedCredentials.authType === "none") {
|
||||
} else {
|
||||
fileLogger.warn(
|
||||
"No valid authentication method provided for file manager",
|
||||
@@ -342,6 +396,48 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
|
||||
if (hostId && userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "file_manager",
|
||||
hostId,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(userId)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity", {
|
||||
operation: "activity_log_error",
|
||||
userId,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
@@ -356,7 +452,19 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
username,
|
||||
error: err.message,
|
||||
});
|
||||
res.status(500).json({ status: "error", message: err.message });
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" &&
|
||||
(err.message.includes("authentication") ||
|
||||
err.message.includes("All configured authentication methods failed"))
|
||||
) {
|
||||
res.json({
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ status: "error", message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
client.on("close", () => {
|
||||
@@ -364,9 +472,324 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
cleanupSession(sessionId);
|
||||
});
|
||||
|
||||
let keyboardInteractiveResponded = false;
|
||||
|
||||
client.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
if (responseSent) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
responseSent = true;
|
||||
|
||||
if (pendingTOTPSessions[sessionId]) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardInteractiveResponded = true;
|
||||
|
||||
pendingTOTPSessions[sessionId] = {
|
||||
client,
|
||||
finish,
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
userId,
|
||||
prompts,
|
||||
totpPromptIndex,
|
||||
resolvedPassword: resolvedCredentials.password,
|
||||
};
|
||||
|
||||
res.json({
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
});
|
||||
} else {
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.authType !== "none";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (
|
||||
resolvedCredentials.authType === "none" &&
|
||||
passwordPromptIndex !== -1
|
||||
) {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
|
||||
client.end();
|
||||
|
||||
res.json({
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||
if (responseSent) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
responseSent = true;
|
||||
|
||||
if (pendingTOTPSessions[sessionId]) {
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
finish(responses);
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardInteractiveResponded = true;
|
||||
|
||||
pendingTOTPSessions[sessionId] = {
|
||||
client,
|
||||
finish,
|
||||
config,
|
||||
createdAt: Date.now(),
|
||||
sessionId,
|
||||
hostId,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
userId,
|
||||
prompts,
|
||||
totpPromptIndex: passwordPromptIndex,
|
||||
resolvedPassword: resolvedCredentials.password,
|
||||
};
|
||||
|
||||
res.json({
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
isPassword: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
client.connect(config);
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
const { sessionId, totpCode } = req.body;
|
||||
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
if (!userId) {
|
||||
fileLogger.error("TOTP verification rejected: no authenticated user", {
|
||||
operation: "file_totp_auth",
|
||||
sessionId,
|
||||
});
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
if (!sessionId || !totpCode) {
|
||||
return res.status(400).json({ error: "Session ID and TOTP code required" });
|
||||
}
|
||||
|
||||
const session = pendingTOTPSessions[sessionId];
|
||||
|
||||
if (!session) {
|
||||
fileLogger.warn("TOTP session not found or expired", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
availableSessions: Object.keys(pendingTOTPSessions),
|
||||
});
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "TOTP session expired. Please reconnect." });
|
||||
}
|
||||
|
||||
if (Date.now() - session.createdAt > 180000) {
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {}
|
||||
fileLogger.warn("TOTP session timeout before code submission", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
age: Date.now() - session.createdAt,
|
||||
});
|
||||
return res
|
||||
.status(408)
|
||||
.json({ error: "TOTP session timeout. Please reconnect." });
|
||||
}
|
||||
|
||||
const responses = (session.prompts || []).map((p, index) => {
|
||||
if (index === session.totpPromptIndex) {
|
||||
return totpCode;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && session.resolvedPassword) {
|
||||
return session.resolvedPassword;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
let responseSent = false;
|
||||
let responseTimeout: NodeJS.Timeout;
|
||||
|
||||
session.client.once("ready", () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
clearTimeout(responseTimeout);
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
setTimeout(() => {
|
||||
sshSessions[sessionId] = {
|
||||
client: session.client,
|
||||
isConnected: true,
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
scheduleSessionCleanup(sessionId);
|
||||
|
||||
res.json({
|
||||
status: "success",
|
||||
message: "TOTP verified, SSH connection established",
|
||||
});
|
||||
|
||||
if (session.hostId && session.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, session.hostId!),
|
||||
eq(sshData.userId, session.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
session.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${session.username}@${session.ip}:${session.port}`;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "file_manager",
|
||||
hostId: session.hostId,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(session.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
fileLogger.warn("Failed to log file manager activity (TOTP)", {
|
||||
operation: "activity_log_error",
|
||||
userId: session.userId,
|
||||
hostId: session.hostId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
|
||||
session.client.once("error", (err) => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
clearTimeout(responseTimeout);
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
fileLogger.error("TOTP verification failed", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
res.status(401).json({ status: "error", message: "Invalid TOTP code" });
|
||||
});
|
||||
|
||||
responseTimeout = setTimeout(() => {
|
||||
if (!responseSent) {
|
||||
responseSent = true;
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
fileLogger.warn("TOTP verification timeout", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
userId,
|
||||
});
|
||||
res.status(408).json({ error: "TOTP verification timeout" });
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
session.finish(responses);
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/disconnect", (req, res) => {
|
||||
const { sessionId } = req.body;
|
||||
cleanupSession(sessionId);
|
||||
@@ -455,13 +878,12 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 9) {
|
||||
const permissions = parts[0];
|
||||
const linkCount = parts[1];
|
||||
const owner = parts[2];
|
||||
const group = parts[3];
|
||||
const size = parseInt(parts[4], 10);
|
||||
|
||||
let dateStr = "";
|
||||
let nameStartIndex = 8;
|
||||
const nameStartIndex = 8;
|
||||
|
||||
if (parts[5] && parts[6] && parts[7]) {
|
||||
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
|
||||
@@ -694,7 +1116,7 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
const { sessionId, path: filePath, content, hostId, userId } = req.body;
|
||||
const { sessionId, path: filePath, content } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -881,14 +1303,7 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
const {
|
||||
sessionId,
|
||||
path: filePath,
|
||||
content,
|
||||
fileName,
|
||||
hostId,
|
||||
userId,
|
||||
} = req.body;
|
||||
const { sessionId, path: filePath, content, fileName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -1022,8 +1437,6 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
}
|
||||
|
||||
if (chunks.length === 1) {
|
||||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
const writeCommand = `echo '${chunks[0]}' | base64 -d > '${escapedPath}' && echo "SUCCESS"`;
|
||||
@@ -1088,13 +1501,11 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const tempFile = `/tmp/upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const escapedTempFile = tempFile.replace(/'/g, "'\"'\"'");
|
||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||
|
||||
let writeCommand = `> '${escapedPath}'`;
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
chunks.forEach((chunk) => {
|
||||
writeCommand += ` && echo '${chunk}' | base64 -d >> '${escapedPath}'`;
|
||||
});
|
||||
|
||||
@@ -1177,14 +1588,7 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
const {
|
||||
sessionId,
|
||||
path: filePath,
|
||||
fileName,
|
||||
content = "",
|
||||
hostId,
|
||||
userId,
|
||||
} = req.body;
|
||||
const { sessionId, path: filePath, fileName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -1285,7 +1689,7 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
const { sessionId, path: folderPath, folderName, hostId, userId } = req.body;
|
||||
const { sessionId, path: folderPath, folderName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -1386,7 +1790,7 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => {
|
||||
});
|
||||
|
||||
app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
const { sessionId, path: itemPath, isDirectory, hostId, userId } = req.body;
|
||||
const { sessionId, path: itemPath, isDirectory } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -1488,7 +1892,7 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => {
|
||||
});
|
||||
|
||||
app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newName, hostId, userId } = req.body;
|
||||
const { sessionId, oldPath, newName } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -1596,7 +2000,7 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => {
|
||||
});
|
||||
|
||||
app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => {
|
||||
const { sessionId, oldPath, newPath, hostId, userId } = req.body;
|
||||
const { sessionId, oldPath, newPath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -1985,7 +2389,7 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
const { sessionId, filePath, hostId, userId } = req.body;
|
||||
const { sessionId, filePath } = req.body;
|
||||
const sshConn = sshSessions[sessionId];
|
||||
|
||||
if (!sshConn || !sshConn.isConnected) {
|
||||
@@ -2022,7 +2426,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
checkResult += data.toString();
|
||||
});
|
||||
|
||||
checkStream.on("close", (code) => {
|
||||
checkStream.on("close", () => {
|
||||
if (!checkResult.includes("EXECUTABLE")) {
|
||||
return res.status(400).json({ error: "File is not executable" });
|
||||
}
|
||||
|
||||
+784
-304
File diff suppressed because it is too large
Load Diff
+441
-135
@@ -1,14 +1,57 @@
|
||||
import { WebSocketServer, WebSocket, type RawData } from "ws";
|
||||
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
|
||||
import {
|
||||
Client,
|
||||
type ClientChannel,
|
||||
type PseudoTtyOptions,
|
||||
type ConnectConfig,
|
||||
} from "ssh2";
|
||||
import { parse as parseUrl } from "url";
|
||||
import axios from "axios";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { sshCredentials } from "../database/db/schema.js";
|
||||
import { sshCredentials, sshData } from "../database/db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
import { SimpleDBOps } from "../utils/simple-db-ops.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { UserCrypto } from "../utils/user-crypto.js";
|
||||
|
||||
interface ConnectToHostData {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
}
|
||||
|
||||
interface ResizeData {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface TOTPResponseData {
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface WebSocketMessage {
|
||||
type: string;
|
||||
data?: ConnectToHostData | ResizeData | TOTPResponseData | string | unknown;
|
||||
code?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const userCrypto = UserCrypto.getInstance();
|
||||
|
||||
@@ -22,47 +65,21 @@ const wss = new WebSocketServer({
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn("WebSocket connection rejected: missing token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "missing_token",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
sshLogger.warn("WebSocket connection rejected: invalid token", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "invalid_token",
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (payload.pendingTOTP) {
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: TOTP verification pending",
|
||||
{
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "totp_pending",
|
||||
userId: payload.userId,
|
||||
ip: info.req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingConnections = userConnections.get(payload.userId);
|
||||
if (existingConnections && existingConnections.size >= 3) {
|
||||
sshLogger.warn("WebSocket connection rejected: too many connections", {
|
||||
operation: "websocket_auth_reject",
|
||||
reason: "connection_limit",
|
||||
userId: payload.userId,
|
||||
currentConnections: existingConnections.size,
|
||||
ip: info.req.socket.remoteAddress,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -79,41 +96,23 @@ const wss = new WebSocketServer({
|
||||
|
||||
wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let userId: string | undefined;
|
||||
let userPayload: any;
|
||||
|
||||
try {
|
||||
const url = parseUrl(req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: missing token in connection",
|
||||
{
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "missing_token",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
if (!payload) {
|
||||
sshLogger.warn(
|
||||
"WebSocket connection rejected: invalid token in connection",
|
||||
{
|
||||
operation: "websocket_connection_reject",
|
||||
reason: "invalid_token",
|
||||
ip: req.socket.remoteAddress,
|
||||
},
|
||||
);
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
userId = payload.userId;
|
||||
userPayload = payload;
|
||||
} catch (error) {
|
||||
sshLogger.error(
|
||||
"WebSocket JWT verification failed during connection",
|
||||
@@ -129,11 +128,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
|
||||
const dataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
sshLogger.warn("WebSocket connection rejected: data locked", {
|
||||
operation: "websocket_data_locked",
|
||||
userId,
|
||||
ip: req.socket.remoteAddress,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
@@ -154,6 +148,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
let sshConn: Client | null = null;
|
||||
let sshStream: ClientChannel | null = null;
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
|
||||
let totpPromptSent = false;
|
||||
let isKeyboardInteractive = false;
|
||||
let keyboardInteractiveResponded = false;
|
||||
|
||||
ws.on("close", () => {
|
||||
const userWs = userConnections.get(userId);
|
||||
@@ -170,11 +168,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.on("message", (msg: RawData) => {
|
||||
const currentDataKey = userCrypto.getUserDataKey(userId);
|
||||
if (!currentDataKey) {
|
||||
sshLogger.warn("WebSocket message rejected: data access expired", {
|
||||
operation: "websocket_message_rejected",
|
||||
userId,
|
||||
reason: "data_access_expired",
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
@@ -186,9 +179,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
let parsed: WebSocketMessage;
|
||||
try {
|
||||
parsed = JSON.parse(msg.toString());
|
||||
parsed = JSON.parse(msg.toString()) as WebSocketMessage;
|
||||
} catch (e) {
|
||||
sshLogger.error("Invalid JSON received", e, {
|
||||
operation: "websocket_message_invalid_json",
|
||||
@@ -202,16 +195,17 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
const { type, data } = parsed;
|
||||
|
||||
switch (type) {
|
||||
case "connectToHost":
|
||||
if (data.hostConfig) {
|
||||
data.hostConfig.userId = userId;
|
||||
case "connectToHost": {
|
||||
const connectData = data as ConnectToHostData;
|
||||
if (connectData.hostConfig) {
|
||||
connectData.hostConfig.userId = userId;
|
||||
}
|
||||
handleConnectToHost(data).catch((error) => {
|
||||
handleConnectToHost(connectData).catch((error) => {
|
||||
sshLogger.error("Failed to connect to host", error, {
|
||||
operation: "ssh_connect",
|
||||
userId,
|
||||
hostId: data.hostConfig?.id,
|
||||
ip: data.hostConfig?.ip,
|
||||
hostId: connectData.hostConfig?.id,
|
||||
ip: connectData.hostConfig?.ip,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -223,40 +217,144 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "resize":
|
||||
handleResize(data);
|
||||
case "resize": {
|
||||
const resizeData = data as ResizeData;
|
||||
handleResize(resizeData);
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect":
|
||||
cleanupSSH();
|
||||
break;
|
||||
|
||||
case "input":
|
||||
case "input": {
|
||||
const inputData = data as string;
|
||||
if (sshStream) {
|
||||
if (data === "\t") {
|
||||
sshStream.write(data);
|
||||
} else if (data.startsWith("\x1b")) {
|
||||
sshStream.write(data);
|
||||
if (inputData === "\t") {
|
||||
sshStream.write(inputData);
|
||||
} else if (
|
||||
typeof inputData === "string" &&
|
||||
inputData.startsWith("\x1b")
|
||||
) {
|
||||
sshStream.write(inputData);
|
||||
} else {
|
||||
try {
|
||||
sshStream.write(Buffer.from(data, "utf8"));
|
||||
sshStream.write(Buffer.from(inputData, "utf8"));
|
||||
} catch (error) {
|
||||
sshLogger.error("Error writing input to SSH stream", error, {
|
||||
operation: "ssh_input_encoding",
|
||||
userId,
|
||||
dataLength: data.length,
|
||||
dataLength: inputData.length,
|
||||
});
|
||||
sshStream.write(Buffer.from(data, "latin1"));
|
||||
sshStream.write(Buffer.from(inputData, "latin1"));
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ping":
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
break;
|
||||
|
||||
case "totp_response": {
|
||||
const totpData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && totpData?.code) {
|
||||
const totpCode = totpData.code;
|
||||
keyboardInteractiveFinish([totpCode]);
|
||||
keyboardInteractiveFinish = null;
|
||||
} else {
|
||||
sshLogger.warn("TOTP response received but no callback available", {
|
||||
operation: "totp_response_error",
|
||||
userId,
|
||||
hasCallback: !!keyboardInteractiveFinish,
|
||||
hasCode: !!totpData?.code,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "TOTP authentication state lost. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "password_response": {
|
||||
const passwordData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && passwordData?.code) {
|
||||
const password = passwordData.code;
|
||||
keyboardInteractiveFinish([password]);
|
||||
keyboardInteractiveFinish = null;
|
||||
} else {
|
||||
sshLogger.warn(
|
||||
"Password response received but no callback available",
|
||||
{
|
||||
operation: "password_response_error",
|
||||
userId,
|
||||
hasCallback: !!keyboardInteractiveFinish,
|
||||
hasCode: !!passwordData?.code,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Password authentication state lost. Please reconnect.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "reconnect_with_credentials": {
|
||||
const credentialsData = data as {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: ConnectToHostData["hostConfig"];
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
};
|
||||
|
||||
if (credentialsData.password) {
|
||||
credentialsData.hostConfig.password = credentialsData.password;
|
||||
credentialsData.hostConfig.authType = "password";
|
||||
(credentialsData.hostConfig as any).userProvidedPassword = true;
|
||||
} else if (credentialsData.sshKey) {
|
||||
credentialsData.hostConfig.key = credentialsData.sshKey;
|
||||
credentialsData.hostConfig.keyPassword = credentialsData.keyPassword;
|
||||
credentialsData.hostConfig.authType = "key";
|
||||
}
|
||||
|
||||
cleanupSSH();
|
||||
|
||||
const reconnectData: ConnectToHostData = {
|
||||
cols: credentialsData.cols,
|
||||
rows: credentialsData.rows,
|
||||
hostConfig: credentialsData.hostConfig,
|
||||
};
|
||||
|
||||
handleConnectToHost(reconnectData).catch((error) => {
|
||||
sshLogger.error("Failed to reconnect with credentials", error, {
|
||||
operation: "ssh_reconnect_with_credentials",
|
||||
userId,
|
||||
hostId: credentialsData.hostConfig?.id,
|
||||
ip: credentialsData.hostConfig?.ip,
|
||||
});
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Failed to connect with provided credentials: " +
|
||||
(error instanceof Error ? error.message : "Unknown error"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type received", {
|
||||
operation: "websocket_message_unknown_type",
|
||||
@@ -266,26 +364,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function handleConnectToHost(data: {
|
||||
cols: number;
|
||||
rows: number;
|
||||
hostConfig: {
|
||||
id: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
userId?: string;
|
||||
};
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
}) {
|
||||
const { cols, rows, hostConfig, initialPath, executeCommand } = data;
|
||||
async function handleConnectToHost(data: ConnectToHostData) {
|
||||
const { hostConfig, initialPath, executeCommand } = data;
|
||||
const {
|
||||
id,
|
||||
ip,
|
||||
@@ -356,6 +436,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}, 60000);
|
||||
|
||||
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
|
||||
let authMethodNotAvailable = false;
|
||||
if (credentialId && id && hostConfig.userId) {
|
||||
try {
|
||||
const credentials = await SimpleDBOps.select(
|
||||
@@ -375,12 +456,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedCredentials = {
|
||||
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,
|
||||
password: credential.password as string | undefined,
|
||||
key: (credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key) as string | undefined,
|
||||
keyPassword: (credential.key_password || credential.keyPassword) as
|
||||
| string
|
||||
| undefined,
|
||||
keyType: (credential.key_type || credential.keyType) as
|
||||
| string
|
||||
| undefined,
|
||||
authType: (credential.auth_type || credential.authType) as
|
||||
| string
|
||||
| undefined,
|
||||
};
|
||||
} else {
|
||||
sshLogger.warn(`No credentials found for host ${id}`, {
|
||||
@@ -410,7 +498,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
sshConn!.shell(
|
||||
if (!sshConn) {
|
||||
sshLogger.warn(
|
||||
"SSH connection was cleaned up before shell could be created",
|
||||
{
|
||||
operation: "ssh_shell",
|
||||
hostId: id,
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"SSH connection was closed before terminal could be created",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshConn.shell(
|
||||
{
|
||||
rows: data.rows,
|
||||
cols: data.cols,
|
||||
@@ -497,12 +606,76 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
|
||||
if (id && hostConfig.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(
|
||||
and(
|
||||
eq(sshData.id, id),
|
||||
eq(sshData.userId, hostConfig.userId!),
|
||||
),
|
||||
),
|
||||
"ssh_data",
|
||||
hostConfig.userId!,
|
||||
);
|
||||
|
||||
const hostName =
|
||||
hosts.length > 0 && hosts[0].name
|
||||
? hosts[0].name
|
||||
: `${username}@${ip}:${port}`;
|
||||
|
||||
await axios.post(
|
||||
"http://localhost:30006/activity/log",
|
||||
{
|
||||
type: "terminal",
|
||||
hostId: id,
|
||||
hostName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${await authManager.generateJWTToken(hostConfig.userId!)}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Failed to log terminal activity", {
|
||||
operation: "activity_log_error",
|
||||
userId: hostConfig.userId,
|
||||
hostId: id,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
sshConn.on("error", (err: Error) => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
if (
|
||||
(authMethodNotAvailable && resolvedCredentials.authType === "none") ||
|
||||
(resolvedCredentials.authType === "none" &&
|
||||
err.message.includes("All configured authentication methods failed"))
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth_method_not_available",
|
||||
message:
|
||||
"The server does not support keyboard-interactive authentication. Please provide credentials.",
|
||||
}),
|
||||
);
|
||||
cleanupSSH(connectionTimeout);
|
||||
return;
|
||||
}
|
||||
|
||||
sshLogger.error("SSH connection error", err, {
|
||||
operation: "ssh_connect",
|
||||
hostId: id,
|
||||
@@ -557,16 +730,115 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
cleanupSSH(connectionTimeout);
|
||||
});
|
||||
|
||||
sshConn.on(
|
||||
"keyboard-interactive",
|
||||
(
|
||||
name: string,
|
||||
instructions: string,
|
||||
instructionsLang: string,
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
isKeyboardInteractive = true;
|
||||
const promptTexts = prompts.map((p) => p.prompt);
|
||||
const totpPromptIndex = prompts.findIndex((p) =>
|
||||
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
|
||||
p.prompt,
|
||||
),
|
||||
);
|
||||
|
||||
if (totpPromptIndex !== -1) {
|
||||
if (totpPromptSent) {
|
||||
sshLogger.warn("TOTP prompt asked again - ignoring duplicate", {
|
||||
operation: "ssh_keyboard_interactive_totp_duplicate",
|
||||
hostId: id,
|
||||
prompts: promptTexts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
totpPromptSent = true;
|
||||
keyboardInteractiveResponded = true;
|
||||
|
||||
keyboardInteractiveFinish = (totpResponses: string[]) => {
|
||||
const totpCode = (totpResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === totpPromptIndex) {
|
||||
return totpCode;
|
||||
}
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "totp_required",
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.authType !== "none";
|
||||
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||
if (keyboardInteractiveResponded) {
|
||||
return;
|
||||
}
|
||||
keyboardInteractiveResponded = true;
|
||||
|
||||
keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === passwordPromptIndex) {
|
||||
return userInput;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "password_required",
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
finish(responses);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const connectConfig: any = {
|
||||
host: ip,
|
||||
port,
|
||||
username,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 30000,
|
||||
|
||||
env: {
|
||||
TERM: "xterm-256color",
|
||||
LANG: "en_US.UTF-8",
|
||||
@@ -579,45 +851,72 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
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-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"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: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
compress: ["none", "zlib@openssh.com", "zlib"],
|
||||
},
|
||||
};
|
||||
if (
|
||||
resolvedCredentials.authType === "password" &&
|
||||
resolvedCredentials.password
|
||||
) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
|
||||
if (resolvedCredentials.authType === "none") {
|
||||
} else if (resolvedCredentials.authType === "password") {
|
||||
if (!resolvedCredentials.password) {
|
||||
sshLogger.error(
|
||||
"Password authentication requested but no password provided",
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Password authentication requested but no password provided",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hostConfig.forceKeyboardInteractive) {
|
||||
connectConfig.password = resolvedCredentials.password;
|
||||
}
|
||||
} else if (
|
||||
resolvedCredentials.authType === "key" &&
|
||||
resolvedCredentials.key
|
||||
@@ -640,13 +939,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (resolvedCredentials.keyPassword) {
|
||||
connectConfig.passphrase = resolvedCredentials.keyPassword;
|
||||
}
|
||||
|
||||
if (
|
||||
resolvedCredentials.keyType &&
|
||||
resolvedCredentials.keyType !== "auto"
|
||||
) {
|
||||
connectConfig.privateKeyType = resolvedCredentials.keyType;
|
||||
}
|
||||
} catch (keyError) {
|
||||
sshLogger.error("SSH key format error: " + keyError.message);
|
||||
ws.send(
|
||||
@@ -680,7 +972,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.connect(connectConfig);
|
||||
}
|
||||
|
||||
function handleResize(data: { cols: number; rows: number }) {
|
||||
function handleResize(data: ResizeData) {
|
||||
if (sshStream && sshStream.setWindow) {
|
||||
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
|
||||
ws.send(
|
||||
@@ -702,8 +994,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (sshStream) {
|
||||
try {
|
||||
sshStream.end();
|
||||
} catch (e: any) {
|
||||
sshLogger.error("Error closing stream: " + e.message);
|
||||
} catch (e: unknown) {
|
||||
sshLogger.error(
|
||||
"Error closing stream: " +
|
||||
(e instanceof Error ? e.message : "Unknown error"),
|
||||
);
|
||||
}
|
||||
sshStream = null;
|
||||
}
|
||||
@@ -711,11 +1006,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (sshConn) {
|
||||
try {
|
||||
sshConn.end();
|
||||
} catch (e: any) {
|
||||
sshLogger.error("Error closing connection: " + e.message);
|
||||
} catch (e: unknown) {
|
||||
sshLogger.error(
|
||||
"Error closing connection: " +
|
||||
(e instanceof Error ? e.message : "Unknown error"),
|
||||
);
|
||||
}
|
||||
sshConn = null;
|
||||
}
|
||||
|
||||
totpPromptSent = false;
|
||||
isKeyboardInteractive = false;
|
||||
keyboardInteractiveResponded = false;
|
||||
keyboardInteractiveFinish = null;
|
||||
}
|
||||
|
||||
function setupPingInterval() {
|
||||
@@ -723,8 +1026,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
if (sshConn && sshStream) {
|
||||
try {
|
||||
sshStream.write("\x00");
|
||||
} catch (e: any) {
|
||||
sshLogger.error("SSH keepalive failed: " + e.message);
|
||||
} catch (e: unknown) {
|
||||
sshLogger.error(
|
||||
"SSH keepalive failed: " +
|
||||
(e instanceof Error ? e.message : "Unknown error"),
|
||||
);
|
||||
cleanupSSH();
|
||||
}
|
||||
}
|
||||
|
||||
+96
-72
@@ -33,6 +33,10 @@ app.use(
|
||||
"http://127.0.0.1:3000",
|
||||
];
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (origin.startsWith("https://")) {
|
||||
return callback(null, true);
|
||||
}
|
||||
@@ -41,10 +45,6 @@ app.use(
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
callback(new Error("Not allowed by CORS"));
|
||||
},
|
||||
credentials: true,
|
||||
@@ -217,7 +217,7 @@ function cleanupTunnelResources(
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
try {
|
||||
verification?.conn.end();
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -282,7 +282,7 @@ function handleDisconnect(
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
verification?.conn.end();
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -511,16 +511,19 @@ async function connectSSHTunnel(
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authMethod: credential.auth_type || credential.authType,
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key) as string | undefined,
|
||||
keyPassword: (credential.key_password || credential.keyPassword) as
|
||||
| string
|
||||
| undefined,
|
||||
keyType: (credential.key_type || credential.keyType) as
|
||||
| string
|
||||
| undefined,
|
||||
authMethod: (credential.auth_type || credential.authType) as string,
|
||||
};
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source credentials from database", {
|
||||
@@ -591,12 +594,17 @@ async function connectSSHTunnel(
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedEndpointCredentials = {
|
||||
password: credential.password,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authMethod: credential.auth_type || credential.authType,
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key) as string | undefined,
|
||||
keyPassword: (credential.key_password || credential.keyPassword) as
|
||||
| string
|
||||
| undefined,
|
||||
keyType: (credential.key_type || credential.keyType) as
|
||||
| string
|
||||
| undefined,
|
||||
authMethod: (credential.auth_type || credential.authType) as string,
|
||||
};
|
||||
} else {
|
||||
tunnelLogger.warn("No endpoint credentials found in database", {
|
||||
@@ -605,7 +613,6 @@ async function connectSSHTunnel(
|
||||
credentialId: tunnelConfig.endpointCredentialId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn(
|
||||
@@ -631,7 +638,7 @@ async function connectSSHTunnel(
|
||||
|
||||
try {
|
||||
conn.end();
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
@@ -771,7 +778,7 @@ async function connectSSHTunnel(
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
verification?.conn.end();
|
||||
} catch (e) {}
|
||||
} catch {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -822,13 +829,9 @@ async function connectSSHTunnel(
|
||||
}
|
||||
});
|
||||
|
||||
stream.stdout?.on("data", (data: Buffer) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
}
|
||||
});
|
||||
stream.stdout?.on("data", () => {});
|
||||
|
||||
stream.on("error", (err: Error) => {});
|
||||
stream.on("error", () => {});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const errorMsg = data.toString().trim();
|
||||
@@ -888,42 +891,68 @@ async function connectSSHTunnel(
|
||||
});
|
||||
});
|
||||
|
||||
const connOptions: any = {
|
||||
const connOptions: Record<string, unknown> = {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
tryKeyboard: true,
|
||||
keepaliveInterval: 30000,
|
||||
keepaliveCountMax: 3,
|
||||
readyTimeout: 60000,
|
||||
tcpKeepAlive: true,
|
||||
tcpKeepAliveInitialDelay: 15000,
|
||||
tcpKeepAliveInitialDelay: 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-group1-sha1",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group-exchange-sha1",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"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: [
|
||||
"aes128-ctr",
|
||||
"aes192-ctr",
|
||||
"aes256-ctr",
|
||||
"aes128-gcm@openssh.com",
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes192-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
],
|
||||
hmac: [
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-md5",
|
||||
],
|
||||
@@ -1026,15 +1055,19 @@ async function killRemoteTunnelByMarker(
|
||||
if (credentials.length > 0) {
|
||||
const credential = credentials[0];
|
||||
resolvedSourceCredentials = {
|
||||
password: credential.password,
|
||||
sshKey:
|
||||
credential.private_key || credential.privateKey || credential.key,
|
||||
keyPassword: credential.key_password || credential.keyPassword,
|
||||
keyType: credential.key_type || credential.keyType,
|
||||
authMethod: credential.auth_type || credential.authType,
|
||||
password: credential.password as string | undefined,
|
||||
sshKey: (credential.private_key ||
|
||||
credential.privateKey ||
|
||||
credential.key) as string | undefined,
|
||||
keyPassword: (credential.key_password || credential.keyPassword) as
|
||||
| string
|
||||
| undefined,
|
||||
keyType: (credential.key_type || credential.keyType) as
|
||||
| string
|
||||
| undefined,
|
||||
authMethod: (credential.auth_type || credential.authType) as string,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
tunnelLogger.warn("Failed to resolve source credentials for cleanup", {
|
||||
@@ -1046,7 +1079,7 @@ async function killRemoteTunnelByMarker(
|
||||
}
|
||||
|
||||
const conn = new Client();
|
||||
const connOptions: any = {
|
||||
const connOptions: Record<string, unknown> = {
|
||||
host: tunnelConfig.sourceIP,
|
||||
port: tunnelConfig.sourceSSHPort,
|
||||
username: tunnelConfig.sourceUsername,
|
||||
@@ -1122,7 +1155,7 @@ async function killRemoteTunnelByMarker(
|
||||
conn.on("ready", () => {
|
||||
const checkCmd = `ps aux | grep -E '(${tunnelMarker}|ssh.*-R.*${tunnelConfig.endpointPort}:localhost:${tunnelConfig.sourcePort}.*${tunnelConfig.endpointUsername}@${tunnelConfig.endpointIP}|sshpass.*ssh.*-R.*${tunnelConfig.endpointPort})' | grep -v grep`;
|
||||
|
||||
conn.exec(checkCmd, (err, stream) => {
|
||||
conn.exec(checkCmd, (_err, stream) => {
|
||||
let foundProcesses = false;
|
||||
|
||||
stream.on("data", (data) => {
|
||||
@@ -1150,7 +1183,7 @@ async function killRemoteTunnelByMarker(
|
||||
|
||||
function executeNextKillCommand() {
|
||||
if (commandIndex >= killCmds.length) {
|
||||
conn.exec(checkCmd, (err, verifyStream) => {
|
||||
conn.exec(checkCmd, (_err, verifyStream) => {
|
||||
let stillRunning = false;
|
||||
|
||||
verifyStream.on("data", (data) => {
|
||||
@@ -1183,19 +1216,14 @@ async function killRemoteTunnelByMarker(
|
||||
tunnelLogger.warn(
|
||||
`Kill command ${commandIndex + 1} failed for '${tunnelName}': ${err.message}`,
|
||||
);
|
||||
} else {
|
||||
}
|
||||
|
||||
stream.on("close", (code) => {
|
||||
stream.on("close", () => {
|
||||
commandIndex++;
|
||||
executeNextKillCommand();
|
||||
});
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
if (output) {
|
||||
}
|
||||
});
|
||||
stream.on("data", () => {});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
@@ -1381,7 +1409,11 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
|
||||
if (endpointHost) {
|
||||
const tunnelConfig: TunnelConfig = {
|
||||
name: `${host.name || `${host.username}@${host.ip}`}_${tunnelConnection.sourcePort}_${tunnelConnection.endpointPort}`,
|
||||
name: `${host.name || `${host.username}@${host.ip}`}_${
|
||||
tunnelConnection.sourcePort
|
||||
}_${tunnelConnection.endpointHost}_${
|
||||
tunnelConnection.endpointPort
|
||||
}`,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
@@ -1423,14 +1455,6 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
isPinned: host.pin,
|
||||
};
|
||||
|
||||
const hasSourcePassword = host.autostartPassword;
|
||||
const hasSourceKey = host.autostartKey;
|
||||
const hasEndpointPassword =
|
||||
tunnelConnection.endpointPassword ||
|
||||
endpointHost.autostartPassword;
|
||||
const hasEndpointKey =
|
||||
tunnelConnection.endpointKey || endpointHost.autostartKey;
|
||||
|
||||
autoStartTunnels.push(tunnelConfig);
|
||||
} else {
|
||||
tunnelLogger.error(
|
||||
@@ -1453,10 +1477,10 @@ async function initializeAutoStartTunnels(): Promise<void> {
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
tunnelLogger.error(
|
||||
"Failed to initialize auto-start tunnels:",
|
||||
error.message,
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
version = foundVersion;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
await import("./ssh/tunnel.js");
|
||||
await import("./ssh/file-manager.js");
|
||||
await import("./ssh/server-stats.js");
|
||||
await import("./dashboard.js");
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
systemLogger.info(
|
||||
@@ -126,7 +127,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
systemLogger.error("Unhandled promise rejection", reason, {
|
||||
operation: "error_handling",
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ import { SystemCrypto } from "./system-crypto.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { db } from "../database/db/index.js";
|
||||
import { sessions } from "../database/db/schema.js";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { DeviceType } from "./user-agent-parser.js";
|
||||
|
||||
interface AuthenticationResult {
|
||||
success: boolean;
|
||||
@@ -18,16 +23,28 @@ interface AuthenticationResult {
|
||||
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
sessionId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
pendingTOTP?: boolean;
|
||||
dataKey?: Buffer;
|
||||
}
|
||||
|
||||
interface RequestWithHeaders extends Request {
|
||||
headers: Request["headers"] & {
|
||||
"x-forwarded-proto"?: string;
|
||||
};
|
||||
}
|
||||
|
||||
class AuthManager {
|
||||
private static instance: AuthManager;
|
||||
private systemCrypto: SystemCrypto;
|
||||
private userCrypto: UserCrypto;
|
||||
private invalidatedTokens: Set<string> = new Set();
|
||||
|
||||
private constructor() {
|
||||
this.systemCrypto = SystemCrypto.getInstance();
|
||||
@@ -36,6 +53,21 @@ class AuthManager {
|
||||
this.userCrypto.setSessionExpiredCallback((userId: string) => {
|
||||
this.invalidateUserTokens(userId);
|
||||
});
|
||||
|
||||
setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredSessions().catch((error) => {
|
||||
databaseLogger.error(
|
||||
"Failed to run periodic session cleanup",
|
||||
error,
|
||||
{
|
||||
operation: "session_cleanup_periodic",
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
static getInstance(): AuthManager {
|
||||
@@ -108,7 +140,6 @@ class AuthManager {
|
||||
|
||||
if (migrationResult.migrated) {
|
||||
await saveMemoryDatabaseToFile();
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Lazy encryption migration failed", error, {
|
||||
@@ -121,50 +152,323 @@ class AuthManager {
|
||||
|
||||
async generateJWTToken(
|
||||
userId: string,
|
||||
options: { expiresIn?: string; pendingTOTP?: boolean } = {},
|
||||
options: {
|
||||
expiresIn?: string;
|
||||
pendingTOTP?: boolean;
|
||||
deviceType?: DeviceType;
|
||||
deviceInfo?: string;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
let expiresIn = options.expiresIn;
|
||||
if (!expiresIn && !options.pendingTOTP) {
|
||||
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
|
||||
expiresIn = "30d";
|
||||
} else {
|
||||
expiresIn = "7d";
|
||||
}
|
||||
} else if (!expiresIn) {
|
||||
expiresIn = "7d";
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
if (options.pendingTOTP) {
|
||||
payload.pendingTOTP = true;
|
||||
}
|
||||
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: options.expiresIn || "24h",
|
||||
} as jwt.SignOptions);
|
||||
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
||||
const sessionId = nanoid();
|
||||
payload.sessionId = sessionId;
|
||||
|
||||
const token = jwt.sign(payload, jwtSecret, {
|
||||
expiresIn,
|
||||
} as jwt.SignOptions);
|
||||
|
||||
const expirationMs = this.parseExpiresIn(expiresIn);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
|
||||
const createdAt = now.toISOString();
|
||||
|
||||
try {
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
userId,
|
||||
jwtToken: token,
|
||||
deviceType: options.deviceType,
|
||||
deviceInfo: options.deviceInfo,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
lastActiveAt: createdAt,
|
||||
});
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after session creation",
|
||||
saveError,
|
||||
{
|
||||
operation: "session_create_db_save_failed",
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create session", error, {
|
||||
operation: "session_create_failed",
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
return jwt.sign(payload, jwtSecret, { expiresIn } as jwt.SignOptions);
|
||||
}
|
||||
|
||||
private parseExpiresIn(expiresIn: string): number {
|
||||
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case "s":
|
||||
return value * 1000;
|
||||
case "m":
|
||||
return value * 60 * 1000;
|
||||
case "h":
|
||||
return value * 60 * 60 * 1000;
|
||||
case "d":
|
||||
return value * 24 * 60 * 60 * 1000;
|
||||
default:
|
||||
return 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
|
||||
try {
|
||||
if (this.invalidatedTokens.has(token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRecords = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (sessionRecords.length === 0) {
|
||||
databaseLogger.warn("Session not found during JWT verification", {
|
||||
operation: "jwt_verify_session_not_found",
|
||||
sessionId: payload.sessionId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
} catch (dbError) {
|
||||
databaseLogger.error(
|
||||
"Failed to check session in database during JWT verification",
|
||||
dbError,
|
||||
{
|
||||
operation: "jwt_verify_session_check_failed",
|
||||
sessionId: payload.sessionId,
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("JWT verification failed", {
|
||||
operation: "jwt_verify_failed",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
errorName: error instanceof Error ? error.name : "Unknown",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
invalidateJWTToken(token: string): void {
|
||||
this.invalidatedTokens.add(token);
|
||||
invalidateJWTToken(token: string): void {}
|
||||
|
||||
invalidateUserTokens(userId: string): void {}
|
||||
|
||||
async revokeSession(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after session revocation",
|
||||
saveError,
|
||||
{
|
||||
operation: "session_revoke_db_save_failed",
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete session", error, {
|
||||
operation: "session_delete_failed",
|
||||
sessionId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
invalidateUserTokens(userId: string): void {
|
||||
databaseLogger.info("User tokens invalidated due to data lock", {
|
||||
operation: "user_tokens_invalidate",
|
||||
userId,
|
||||
});
|
||||
async revokeAllUserSessions(
|
||||
userId: string,
|
||||
exceptSessionId?: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const userSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
const deletedCount = userSessions.filter(
|
||||
(s) => !exceptSessionId || s.id !== exceptSessionId,
|
||||
).length;
|
||||
|
||||
if (exceptSessionId) {
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
sql`${sessions.id} != ${exceptSessionId}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
}
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after revoking all user sessions",
|
||||
saveError,
|
||||
{
|
||||
operation: "user_sessions_revoke_db_save_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete user sessions", error, {
|
||||
operation: "user_sessions_delete_failed",
|
||||
userId,
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
getSecureCookieOptions(req: any, maxAge: number = 24 * 60 * 60 * 1000) {
|
||||
async cleanupExpiredSessions(): Promise<number> {
|
||||
try {
|
||||
const expiredSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||
|
||||
const expiredCount = expiredSessions.length;
|
||||
|
||||
if (expiredCount === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after cleaning up expired sessions",
|
||||
saveError,
|
||||
{
|
||||
operation: "sessions_cleanup_db_save_failed",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const affectedUsers = new Set(expiredSessions.map((s) => s.userId));
|
||||
for (const userId of affectedUsers) {
|
||||
const remainingSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
if (remainingSessions.length === 0) {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to cleanup expired sessions", error, {
|
||||
operation: "sessions_cleanup_failed",
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllSessions(): Promise<any[]> {
|
||||
try {
|
||||
const allSessions = await db.select().from(sessions);
|
||||
return allSessions;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get all sessions", error, {
|
||||
operation: "sessions_get_all_failed",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getUserSessions(userId: string): Promise<any[]> {
|
||||
try {
|
||||
const userSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
return userSessions;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get user sessions", error, {
|
||||
operation: "sessions_get_user_failed",
|
||||
userId,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getSecureCookieOptions(
|
||||
req: RequestWithHeaders,
|
||||
maxAge: number = 7 * 24 * 60 * 60 * 1000,
|
||||
) {
|
||||
return {
|
||||
httpOnly: false,
|
||||
secure: req.secure || req.headers["x-forwarded-proto"] === "https",
|
||||
@@ -176,10 +480,11 @@ class AuthManager {
|
||||
|
||||
createAuthMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let token = req.cookies?.jwt;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
let token = authReq.cookies?.jwt;
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
const authHeader = authReq.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.split(" ")[1];
|
||||
}
|
||||
@@ -195,40 +500,142 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).pendingTOTP = payload.pendingTOTP;
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRecords = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.limit(1);
|
||||
|
||||
if (sessionRecords.length === 0) {
|
||||
databaseLogger.warn("Session not found in middleware", {
|
||||
operation: "middleware_session_not_found",
|
||||
sessionId: payload.sessionId,
|
||||
userId: payload.userId,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: "Session not found",
|
||||
code: "SESSION_NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
const session = sessionRecords[0];
|
||||
|
||||
const sessionExpiryTime = new Date(session.expiresAt).getTime();
|
||||
const currentTime = Date.now();
|
||||
const isExpired = sessionExpiryTime < currentTime;
|
||||
|
||||
if (isExpired) {
|
||||
databaseLogger.warn("Session has expired", {
|
||||
operation: "session_expired",
|
||||
sessionId: payload.sessionId,
|
||||
expiresAt: session.expiresAt,
|
||||
expiryTime: sessionExpiryTime,
|
||||
currentTime: currentTime,
|
||||
difference: currentTime - sessionExpiryTime,
|
||||
});
|
||||
|
||||
db.delete(sessions)
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.then(async () => {
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
|
||||
const remainingSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, payload.userId));
|
||||
|
||||
if (remainingSessions.length === 0) {
|
||||
this.userCrypto.logoutUser(payload.userId);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.error(
|
||||
"Failed to cleanup after expired session",
|
||||
cleanupError,
|
||||
{
|
||||
operation: "expired_session_cleanup_failed",
|
||||
sessionId: payload.sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
databaseLogger.error(
|
||||
"Failed to delete expired session",
|
||||
error,
|
||||
{
|
||||
operation: "expired_session_delete_failed",
|
||||
sessionId: payload.sessionId,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
error: "Session has expired",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
db.update(sessions)
|
||||
.set({ lastActiveAt: new Date().toISOString() })
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
.then(() => {})
|
||||
.catch((error) => {
|
||||
databaseLogger.warn("Failed to update session lastActiveAt", {
|
||||
operation: "session_update_last_active",
|
||||
sessionId: payload.sessionId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Session check failed in middleware", error, {
|
||||
operation: "middleware_session_check_failed",
|
||||
sessionId: payload.sessionId,
|
||||
});
|
||||
return res.status(500).json({ error: "Session check failed" });
|
||||
}
|
||||
}
|
||||
|
||||
authReq.userId = payload.userId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
createDataAccessMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const userId = (req as any).userId;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
const userId = authReq.userId;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
const dataKey = this.userCrypto.getUserDataKey(userId);
|
||||
if (!dataKey) {
|
||||
return res.status(401).json({
|
||||
error: "Session expired - please log in again",
|
||||
code: "SESSION_EXPIRED",
|
||||
});
|
||||
}
|
||||
|
||||
(req as any).dataKey = dataKey;
|
||||
authReq.dataKey = dataKey || undefined;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
createAdminMiddleware() {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
return res.status(401).json({ error: "Missing Authorization header" });
|
||||
let token = req.cookies?.jwt;
|
||||
|
||||
if (!token) {
|
||||
const authHeader = req.headers["authorization"];
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
token = authHeader.split(" ")[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Missing authentication token" });
|
||||
}
|
||||
|
||||
const token = authHeader.split(" ")[1];
|
||||
const payload = await this.verifyJWTToken(token);
|
||||
|
||||
if (!payload) {
|
||||
@@ -257,8 +664,9 @@ class AuthManager {
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).pendingTOTP = payload.pendingTOTP;
|
||||
const authReq = req as AuthenticatedRequest;
|
||||
authReq.userId = payload.userId;
|
||||
authReq.pendingTOTP = payload.pendingTOTP;
|
||||
next();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to verify admin privileges", error, {
|
||||
@@ -272,8 +680,47 @@ class AuthManager {
|
||||
};
|
||||
}
|
||||
|
||||
logoutUser(userId: string): void {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
||||
if (sessionId) {
|
||||
try {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after logout",
|
||||
saveError,
|
||||
{
|
||||
operation: "logout_db_save_failed",
|
||||
userId,
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const remainingSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
|
||||
if (remainingSessions.length === 0) {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete session on logout", error, {
|
||||
operation: "session_delete_logout_failed",
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
}
|
||||
}
|
||||
|
||||
getUserDataKey(userId: string): Buffer | null {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { execSync } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { systemLogger } from "./logger.js";
|
||||
|
||||
export class AutoSSLSetup {
|
||||
@@ -102,7 +101,7 @@ export class AutoSSLSetup {
|
||||
try {
|
||||
try {
|
||||
execSync("openssl version", { stdio: "pipe" });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new Error(
|
||||
"OpenSSL is not installed or not available in PATH. Please install OpenSSL to enable SSL certificate generation.",
|
||||
);
|
||||
|
||||
@@ -3,6 +3,19 @@ import { LazyFieldEncryption } from "./lazy-field-encryption.js";
|
||||
import { UserCrypto } from "./user-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface DatabaseInstance {
|
||||
prepare: (sql: string) => {
|
||||
all: (param?: unknown) => DatabaseRecord[];
|
||||
get: (param?: unknown) => DatabaseRecord;
|
||||
run: (...params: unknown[]) => unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface DatabaseRecord {
|
||||
id: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class DataCrypto {
|
||||
private static userCrypto: UserCrypto;
|
||||
|
||||
@@ -10,13 +23,13 @@ class DataCrypto {
|
||||
this.userCrypto = UserCrypto.getInstance();
|
||||
}
|
||||
|
||||
static encryptRecord(
|
||||
static encryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: any,
|
||||
record: T,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any {
|
||||
const encryptedRecord = { ...record };
|
||||
): T {
|
||||
const encryptedRecord: Record<string, unknown> = { ...record };
|
||||
const recordId = record.id || "temp-" + Date.now();
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
@@ -24,24 +37,24 @@ class DataCrypto {
|
||||
encryptedRecord[fieldName] = FieldCrypto.encryptField(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
recordId as string,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return encryptedRecord;
|
||||
return encryptedRecord as T;
|
||||
}
|
||||
|
||||
static decryptRecord(
|
||||
static decryptRecord<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: any,
|
||||
record: T,
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any {
|
||||
): T {
|
||||
if (!record) return record;
|
||||
|
||||
const decryptedRecord = { ...record };
|
||||
const decryptedRecord: Record<string, unknown> = { ...record };
|
||||
const recordId = record.id;
|
||||
|
||||
for (const [fieldName, value] of Object.entries(record)) {
|
||||
@@ -49,21 +62,21 @@ class DataCrypto {
|
||||
decryptedRecord[fieldName] = LazyFieldEncryption.safeGetFieldValue(
|
||||
value as string,
|
||||
userDataKey,
|
||||
recordId,
|
||||
recordId as string,
|
||||
fieldName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return decryptedRecord;
|
||||
return decryptedRecord as T;
|
||||
}
|
||||
|
||||
static decryptRecords(
|
||||
static decryptRecords<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
records: any[],
|
||||
records: T[],
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
): any[] {
|
||||
): T[] {
|
||||
if (!Array.isArray(records)) return records;
|
||||
return records.map((record) =>
|
||||
this.decryptRecord(tableName, record, userId, userDataKey),
|
||||
@@ -73,7 +86,7 @@ class DataCrypto {
|
||||
static async migrateUserSensitiveFields(
|
||||
userId: string,
|
||||
userDataKey: Buffer,
|
||||
db: any,
|
||||
db: DatabaseInstance,
|
||||
): Promise<{
|
||||
migrated: boolean;
|
||||
migratedTables: string[];
|
||||
@@ -84,7 +97,7 @@ class DataCrypto {
|
||||
let migratedFieldsCount = 0;
|
||||
|
||||
try {
|
||||
const { needsMigration, plaintextFields } =
|
||||
const { needsMigration } =
|
||||
await LazyFieldEncryption.checkUserNeedsMigration(
|
||||
userId,
|
||||
userDataKey,
|
||||
@@ -97,7 +110,7 @@ class DataCrypto {
|
||||
|
||||
const sshDataRecords = db
|
||||
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all(userId);
|
||||
.all(userId) as DatabaseRecord[];
|
||||
for (const record of sshDataRecords) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_data");
|
||||
@@ -112,13 +125,17 @@ class DataCrypto {
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE ssh_data
|
||||
SET password = ?, key = ?, key_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET password = ?, key = ?, key_password = ?, key_type = ?, autostart_password = ?, autostart_key = ?, autostart_key_password = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
updatedRecord.key_password || updatedRecord.keyPassword || null,
|
||||
updatedRecord.keyType || null,
|
||||
updatedRecord.autostartPassword || null,
|
||||
updatedRecord.autostartKey || null,
|
||||
updatedRecord.autostartKeyPassword || null,
|
||||
record.id,
|
||||
);
|
||||
|
||||
@@ -132,7 +149,7 @@ class DataCrypto {
|
||||
|
||||
const sshCredentialsRecords = db
|
||||
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||
.all(userId);
|
||||
.all(userId) as DatabaseRecord[];
|
||||
for (const record of sshCredentialsRecords) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("ssh_credentials");
|
||||
@@ -147,15 +164,16 @@ class DataCrypto {
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE ssh_credentials
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET password = ?, key = ?, key_password = ?, private_key = ?, public_key = ?, key_type = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.password || null,
|
||||
updatedRecord.key || null,
|
||||
updatedRecord.key_password || null,
|
||||
updatedRecord.private_key || null,
|
||||
updatedRecord.public_key || null,
|
||||
updatedRecord.key_password || updatedRecord.keyPassword || null,
|
||||
updatedRecord.private_key || updatedRecord.privateKey || null,
|
||||
updatedRecord.public_key || updatedRecord.publicKey || null,
|
||||
updatedRecord.keyType || null,
|
||||
record.id,
|
||||
);
|
||||
|
||||
@@ -169,7 +187,7 @@ class DataCrypto {
|
||||
|
||||
const userRecord = db
|
||||
.prepare("SELECT * FROM users WHERE id = ?")
|
||||
.get(userId);
|
||||
.get(userId) as DatabaseRecord | undefined;
|
||||
if (userRecord) {
|
||||
const sensitiveFields =
|
||||
LazyFieldEncryption.getSensitiveFieldsForTable("users");
|
||||
@@ -184,12 +202,18 @@ class DataCrypto {
|
||||
if (needsUpdate) {
|
||||
const updateQuery = `
|
||||
UPDATE users
|
||||
SET totp_secret = ?, totp_backup_codes = ?
|
||||
SET totp_secret = ?, totp_backup_codes = ?, client_secret = ?, oidc_identifier = ?
|
||||
WHERE id = ?
|
||||
`;
|
||||
db.prepare(updateQuery).run(
|
||||
updatedRecord.totp_secret || null,
|
||||
updatedRecord.totp_backup_codes || null,
|
||||
updatedRecord.totp_secret || updatedRecord.totpSecret || null,
|
||||
updatedRecord.totp_backup_codes ||
|
||||
updatedRecord.totpBackupCodes ||
|
||||
null,
|
||||
updatedRecord.client_secret || updatedRecord.clientSecret || null,
|
||||
updatedRecord.oidc_identifier ||
|
||||
updatedRecord.oidcIdentifier ||
|
||||
null,
|
||||
userId,
|
||||
);
|
||||
|
||||
@@ -220,7 +244,7 @@ class DataCrypto {
|
||||
static async reencryptUserDataAfterPasswordReset(
|
||||
userId: string,
|
||||
newUserDataKey: Buffer,
|
||||
db: any,
|
||||
db: DatabaseInstance,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
reencryptedTables: string[];
|
||||
@@ -236,24 +260,44 @@ class DataCrypto {
|
||||
|
||||
try {
|
||||
const tablesToReencrypt = [
|
||||
{ table: "ssh_data", fields: ["password", "key", "key_password"] },
|
||||
{
|
||||
table: "ssh_data",
|
||||
fields: [
|
||||
"password",
|
||||
"key",
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
"keyType",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
],
|
||||
},
|
||||
{
|
||||
table: "ssh_credentials",
|
||||
fields: [
|
||||
"password",
|
||||
"private_key",
|
||||
"privateKey",
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
"key",
|
||||
"public_key",
|
||||
"publicKey",
|
||||
"keyType",
|
||||
],
|
||||
},
|
||||
{
|
||||
table: "users",
|
||||
fields: [
|
||||
"client_secret",
|
||||
"clientSecret",
|
||||
"totp_secret",
|
||||
"totpSecret",
|
||||
"totp_backup_codes",
|
||||
"totpBackupCodes",
|
||||
"oidc_identifier",
|
||||
"oidcIdentifier",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -262,17 +306,21 @@ class DataCrypto {
|
||||
try {
|
||||
const records = db
|
||||
.prepare(`SELECT * FROM ${table} WHERE user_id = ?`)
|
||||
.all(userId);
|
||||
.all(userId) as DatabaseRecord[];
|
||||
|
||||
for (const record of records) {
|
||||
const recordId = record.id.toString();
|
||||
const updatedRecord: DatabaseRecord = { ...record };
|
||||
let needsUpdate = false;
|
||||
const updatedRecord = { ...record };
|
||||
|
||||
for (const fieldName of fields) {
|
||||
const fieldValue = record[fieldName];
|
||||
|
||||
if (fieldValue && fieldValue.trim() !== "") {
|
||||
if (
|
||||
fieldValue &&
|
||||
typeof fieldValue === "string" &&
|
||||
fieldValue.trim() !== ""
|
||||
) {
|
||||
try {
|
||||
const reencryptedValue = FieldCrypto.encryptField(
|
||||
fieldValue,
|
||||
@@ -345,18 +393,6 @@ class DataCrypto {
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
databaseLogger.info(
|
||||
"User data re-encryption completed after password reset",
|
||||
{
|
||||
operation: "password_reset_reencrypt_completed",
|
||||
userId,
|
||||
success: result.success,
|
||||
reencryptedTables: result.reencryptedTables,
|
||||
reencryptedFieldsCount: result.reencryptedFieldsCount,
|
||||
errorsCount: result.errors.length,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
@@ -384,29 +420,29 @@ class DataCrypto {
|
||||
return userDataKey;
|
||||
}
|
||||
|
||||
static encryptRecordForUser(
|
||||
static encryptRecordForUser<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: any,
|
||||
record: T,
|
||||
userId: string,
|
||||
): any {
|
||||
): T {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.encryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
static decryptRecordForUser(
|
||||
static decryptRecordForUser<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
record: any,
|
||||
record: T,
|
||||
userId: string,
|
||||
): any {
|
||||
): T {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecord(tableName, record, userId, userDataKey);
|
||||
}
|
||||
|
||||
static decryptRecordsForUser(
|
||||
static decryptRecordsForUser<T extends Record<string, unknown>>(
|
||||
tableName: string,
|
||||
records: any[],
|
||||
records: T[],
|
||||
userId: string,
|
||||
): any[] {
|
||||
): T[] {
|
||||
const userDataKey = this.validateUserAccess(userId);
|
||||
return this.decryptRecords(tableName, records, userId, userDataKey);
|
||||
}
|
||||
@@ -435,7 +471,7 @@ class DataCrypto {
|
||||
);
|
||||
|
||||
return decrypted === testData;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ class DatabaseFileEncryption {
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
const cipher = crypto.createCipheriv(
|
||||
this.ALGORITHM,
|
||||
key,
|
||||
iv,
|
||||
) as crypto.CipherGCM;
|
||||
const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
@@ -78,7 +82,11 @@ class DatabaseFileEncryption {
|
||||
|
||||
const iv = crypto.randomBytes(16);
|
||||
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv) as any;
|
||||
const cipher = crypto.createCipheriv(
|
||||
this.ALGORITHM,
|
||||
key,
|
||||
iv,
|
||||
) as crypto.CipherGCM;
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(sourceData),
|
||||
cipher.final(),
|
||||
@@ -163,7 +171,7 @@ class DatabaseFileEncryption {
|
||||
metadata.algorithm,
|
||||
key,
|
||||
Buffer.from(metadata.iv, "hex"),
|
||||
) as any;
|
||||
) as crypto.DecipherGCM;
|
||||
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
|
||||
|
||||
const decryptedBuffer = Buffer.concat([
|
||||
@@ -233,7 +241,7 @@ class DatabaseFileEncryption {
|
||||
metadata.algorithm,
|
||||
key,
|
||||
Buffer.from(metadata.iv, "hex"),
|
||||
) as any;
|
||||
) as crypto.DecipherGCM;
|
||||
decipher.setAuthTag(Buffer.from(metadata.tag, "hex"));
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
@@ -301,7 +309,6 @@ class DatabaseFileEncryption {
|
||||
const metadata: EncryptedFileMetadata = JSON.parse(metadataContent);
|
||||
|
||||
const fileStats = fs.statSync(encryptedPath);
|
||||
const currentFingerprint = "termix-v1-file";
|
||||
|
||||
return {
|
||||
version: metadata.version,
|
||||
|
||||
@@ -55,7 +55,6 @@ export class DatabaseMigration {
|
||||
|
||||
if (hasEncryptedDb && hasUnencryptedDb) {
|
||||
const unencryptedSize = fs.statSync(this.unencryptedDbPath).size;
|
||||
const encryptedSize = fs.statSync(this.encryptedDbPath).size;
|
||||
|
||||
if (unencryptedSize === 0) {
|
||||
needsMigration = false;
|
||||
@@ -63,10 +62,6 @@ export class DatabaseMigration {
|
||||
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
|
||||
try {
|
||||
fs.unlinkSync(this.unencryptedDbPath);
|
||||
databaseLogger.info("Removed empty unencrypted database file", {
|
||||
operation: "migration_cleanup_empty",
|
||||
path: this.unencryptedDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to remove empty unencrypted database", {
|
||||
operation: "migration_cleanup_empty_failed",
|
||||
@@ -168,9 +163,6 @@ export class DatabaseMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
let totalOriginalRows = 0;
|
||||
let totalMemoryRows = 0;
|
||||
|
||||
for (const table of originalTables) {
|
||||
const originalCount = originalDb
|
||||
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
|
||||
@@ -179,9 +171,6 @@ export class DatabaseMigration {
|
||||
.prepare(`SELECT COUNT(*) as count FROM ${table.name}`)
|
||||
.get() as { count: number };
|
||||
|
||||
totalOriginalRows += originalCount.count;
|
||||
totalMemoryRows += memoryCount.count;
|
||||
|
||||
if (originalCount.count !== memoryCount.count) {
|
||||
databaseLogger.error(
|
||||
"Row count mismatch for table during migration verification",
|
||||
@@ -241,7 +230,9 @@ export class DatabaseMigration {
|
||||
memoryDb.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
for (const table of tables) {
|
||||
const rows = originalDb.prepare(`SELECT * FROM ${table.name}`).all();
|
||||
const rows = originalDb
|
||||
.prepare(`SELECT * FROM ${table.name}`)
|
||||
.all() as Record<string, unknown>[];
|
||||
|
||||
if (rows.length > 0) {
|
||||
const columns = Object.keys(rows[0]);
|
||||
@@ -251,7 +242,7 @@ export class DatabaseMigration {
|
||||
);
|
||||
|
||||
const insertTransaction = memoryDb.transaction(
|
||||
(dataRows: any[]) => {
|
||||
(dataRows: Record<string, unknown>[]) => {
|
||||
for (const row of dataRows) {
|
||||
const values = columns.map((col) => row[col]);
|
||||
insertStmt.run(values);
|
||||
|
||||
@@ -71,11 +71,6 @@ export class DatabaseSaveTrigger {
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Force saving database", {
|
||||
operation: "db_save_trigger_force_start",
|
||||
reason,
|
||||
});
|
||||
|
||||
await this.saveFunction();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
@@ -110,9 +105,5 @@ export class DatabaseSaveTrigger {
|
||||
this.pendingSave = false;
|
||||
this.isInitialized = false;
|
||||
this.saveFunction = null;
|
||||
|
||||
databaseLogger.info("Database save trigger cleaned up", {
|
||||
operation: "db_save_trigger_cleanup",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,18 +17,36 @@ class FieldCrypto {
|
||||
private static readonly ENCRYPTED_FIELDS = {
|
||||
users: new Set([
|
||||
"password_hash",
|
||||
"passwordHash",
|
||||
"client_secret",
|
||||
"clientSecret",
|
||||
"totp_secret",
|
||||
"totpSecret",
|
||||
"totp_backup_codes",
|
||||
"totpBackupCodes",
|
||||
"oidc_identifier",
|
||||
"oidcIdentifier",
|
||||
]),
|
||||
ssh_data: new Set([
|
||||
"password",
|
||||
"key",
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
"keyType",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
]),
|
||||
ssh_data: new Set(["password", "key", "key_password"]),
|
||||
ssh_credentials: new Set([
|
||||
"password",
|
||||
"private_key",
|
||||
"privateKey",
|
||||
"key_password",
|
||||
"keyPassword",
|
||||
"key",
|
||||
"public_key",
|
||||
"publicKey",
|
||||
"keyType",
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -47,7 +65,11 @@ class FieldCrypto {
|
||||
);
|
||||
|
||||
const iv = crypto.randomBytes(this.IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(this.ALGORITHM, fieldKey, iv) as any;
|
||||
const cipher = crypto.createCipheriv(
|
||||
this.ALGORITHM,
|
||||
fieldKey,
|
||||
iv,
|
||||
) as crypto.CipherGCM;
|
||||
|
||||
let encrypted = cipher.update(plaintext, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
@@ -89,7 +111,7 @@ class FieldCrypto {
|
||||
this.ALGORITHM,
|
||||
fieldKey,
|
||||
Buffer.from(encrypted.iv, "hex"),
|
||||
) as any;
|
||||
) as crypto.DecipherGCM;
|
||||
decipher.setAuthTag(Buffer.from(encrypted.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(encrypted.data, "hex", "utf8");
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { FieldCrypto } from "./field-crypto.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface DatabaseInstance {
|
||||
prepare: (sql: string) => {
|
||||
all: (param?: unknown) => unknown[];
|
||||
get: (param?: unknown) => unknown;
|
||||
run: (...params: unknown[]) => unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export class LazyFieldEncryption {
|
||||
private static readonly LEGACY_FIELD_NAME_MAP: Record<string, string> = {
|
||||
key_password: "keyPassword",
|
||||
@@ -39,7 +47,7 @@ export class LazyFieldEncryption {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (jsonError) {
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +82,7 @@ export class LazyFieldEncryption {
|
||||
legacyFieldName,
|
||||
);
|
||||
return decrypted;
|
||||
} catch (legacyError) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sensitiveFields = [
|
||||
@@ -145,7 +153,7 @@ export class LazyFieldEncryption {
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: false,
|
||||
};
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
|
||||
if (legacyFieldName) {
|
||||
try {
|
||||
@@ -166,7 +174,7 @@ export class LazyFieldEncryption {
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: true,
|
||||
};
|
||||
} catch (legacyError) {}
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
encrypted: fieldValue,
|
||||
@@ -178,12 +186,12 @@ export class LazyFieldEncryption {
|
||||
}
|
||||
|
||||
static migrateRecordSensitiveFields(
|
||||
record: any,
|
||||
record: Record<string, unknown>,
|
||||
sensitiveFields: string[],
|
||||
userKEK: Buffer,
|
||||
recordId: string,
|
||||
): {
|
||||
updatedRecord: any;
|
||||
updatedRecord: Record<string, unknown>;
|
||||
migratedFields: string[];
|
||||
needsUpdate: boolean;
|
||||
} {
|
||||
@@ -198,7 +206,7 @@ export class LazyFieldEncryption {
|
||||
try {
|
||||
const { encrypted, wasPlaintext, wasLegacyEncryption } =
|
||||
this.migrateFieldToEncrypted(
|
||||
fieldValue,
|
||||
fieldValue as string,
|
||||
userKEK,
|
||||
recordId,
|
||||
fieldName,
|
||||
@@ -253,7 +261,7 @@ export class LazyFieldEncryption {
|
||||
try {
|
||||
FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName);
|
||||
return false;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const legacyFieldName = this.LEGACY_FIELD_NAME_MAP[fieldName];
|
||||
if (legacyFieldName) {
|
||||
try {
|
||||
@@ -264,7 +272,7 @@ export class LazyFieldEncryption {
|
||||
legacyFieldName,
|
||||
);
|
||||
return true;
|
||||
} catch (legacyError) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -275,7 +283,7 @@ export class LazyFieldEncryption {
|
||||
static async checkUserNeedsMigration(
|
||||
userId: string,
|
||||
userKEK: Buffer,
|
||||
db: any,
|
||||
db: DatabaseInstance,
|
||||
): Promise<{
|
||||
needsMigration: boolean;
|
||||
plaintextFields: Array<{
|
||||
@@ -294,7 +302,9 @@ export class LazyFieldEncryption {
|
||||
try {
|
||||
const sshHosts = db
|
||||
.prepare("SELECT * FROM ssh_data WHERE user_id = ?")
|
||||
.all(userId);
|
||||
.all(userId) as Array<
|
||||
Record<string, unknown> & { id: string | number }
|
||||
>;
|
||||
for (const host of sshHosts) {
|
||||
const sensitiveFields = this.getSensitiveFieldsForTable("ssh_data");
|
||||
const hostPlaintextFields: string[] = [];
|
||||
@@ -303,7 +313,7 @@ export class LazyFieldEncryption {
|
||||
if (
|
||||
host[field] &&
|
||||
this.fieldNeedsMigration(
|
||||
host[field],
|
||||
host[field] as string,
|
||||
userKEK,
|
||||
host.id.toString(),
|
||||
field,
|
||||
@@ -325,7 +335,9 @@ export class LazyFieldEncryption {
|
||||
|
||||
const sshCredentials = db
|
||||
.prepare("SELECT * FROM ssh_credentials WHERE user_id = ?")
|
||||
.all(userId);
|
||||
.all(userId) as Array<
|
||||
Record<string, unknown> & { id: string | number }
|
||||
>;
|
||||
for (const credential of sshCredentials) {
|
||||
const sensitiveFields =
|
||||
this.getSensitiveFieldsForTable("ssh_credentials");
|
||||
@@ -335,7 +347,7 @@ export class LazyFieldEncryption {
|
||||
if (
|
||||
credential[field] &&
|
||||
this.fieldNeedsMigration(
|
||||
credential[field],
|
||||
credential[field] as string,
|
||||
userKEK,
|
||||
credential.id.toString(),
|
||||
field,
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface LogContext {
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
@@ -253,5 +253,6 @@ export const apiLogger = new Logger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new Logger("AUTH", "🔐", "#ef4444");
|
||||
export const systemLogger = new Logger("SYSTEM", "🚀", "#14b8a6");
|
||||
export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6");
|
||||
export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -2,10 +2,10 @@ import { getDb, DatabaseSaveTrigger } from "../database/db/index.js";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import type { SQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials";
|
||||
type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, any>>(
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
@@ -44,8 +44,8 @@ class SimpleDBOps {
|
||||
return decryptedResult as T;
|
||||
}
|
||||
|
||||
static async select<T extends Record<string, any>>(
|
||||
query: any,
|
||||
static async select<T extends Record<string, unknown>>(
|
||||
query: unknown,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
@@ -56,9 +56,9 @@ class SimpleDBOps {
|
||||
|
||||
const results = await query;
|
||||
|
||||
const decryptedResults = DataCrypto.decryptRecords(
|
||||
const decryptedResults = DataCrypto.decryptRecords<T>(
|
||||
tableName,
|
||||
results,
|
||||
results as T[],
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
@@ -66,8 +66,8 @@ class SimpleDBOps {
|
||||
return decryptedResults;
|
||||
}
|
||||
|
||||
static async selectOne<T extends Record<string, any>>(
|
||||
query: any,
|
||||
static async selectOne<T extends Record<string, unknown>>(
|
||||
query: unknown,
|
||||
tableName: TableName,
|
||||
userId: string,
|
||||
): Promise<T | undefined> {
|
||||
@@ -79,9 +79,9 @@ class SimpleDBOps {
|
||||
const result = await query;
|
||||
if (!result) return undefined;
|
||||
|
||||
const decryptedResult = DataCrypto.decryptRecord(
|
||||
const decryptedResult = DataCrypto.decryptRecord<T>(
|
||||
tableName,
|
||||
result,
|
||||
result as T,
|
||||
userId,
|
||||
userDataKey,
|
||||
);
|
||||
@@ -89,10 +89,10 @@ class SimpleDBOps {
|
||||
return decryptedResult;
|
||||
}
|
||||
|
||||
static async update<T extends Record<string, any>>(
|
||||
static async update<T extends Record<string, unknown>>(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
where: unknown,
|
||||
data: Partial<T>,
|
||||
userId: string,
|
||||
): Promise<T[]> {
|
||||
@@ -108,7 +108,7 @@ class SimpleDBOps {
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
.where(where)
|
||||
.where(where as any)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave(`update_${tableName}`);
|
||||
@@ -126,10 +126,12 @@ class SimpleDBOps {
|
||||
static async delete(
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: any,
|
||||
userId: string,
|
||||
): Promise<any[]> {
|
||||
const result = await getDb().delete(table).where(where).returning();
|
||||
where: unknown,
|
||||
): Promise<unknown[]> {
|
||||
const result = await getDb()
|
||||
.delete(table)
|
||||
.where(where as any)
|
||||
.returning();
|
||||
|
||||
DatabaseSaveTrigger.triggerSave(`delete_${tableName}`);
|
||||
|
||||
@@ -144,13 +146,10 @@ class SimpleDBOps {
|
||||
return DataCrypto.getUserDataKey(userId) !== null;
|
||||
}
|
||||
|
||||
static async selectEncrypted(
|
||||
query: any,
|
||||
tableName: TableName,
|
||||
): Promise<any[]> {
|
||||
static async selectEncrypted(query: unknown): Promise<unknown[]> {
|
||||
const results = await query;
|
||||
|
||||
return results;
|
||||
return results as unknown[];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
}
|
||||
|
||||
return "ssh-rsa";
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return "ssh-rsa";
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
|
||||
if (content.length < 800) {
|
||||
return "ssh-ed25519";
|
||||
@@ -140,7 +140,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
|
||||
if (content.length < 400) {
|
||||
return "ssh-ed25519";
|
||||
@@ -236,13 +236,13 @@ export function parseSSHKey(
|
||||
} else {
|
||||
publicKey = "";
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
publicKey = "";
|
||||
}
|
||||
|
||||
useSSH2 = true;
|
||||
}
|
||||
} catch (error) {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!useSSH2) {
|
||||
@@ -268,7 +268,7 @@ export function parseSSHKey(
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
} catch (fallbackError) {}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
@@ -310,7 +310,7 @@ export function detectKeyType(privateKeyData: string): string {
|
||||
return "unknown";
|
||||
}
|
||||
return parsedKey.type || "unknown";
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,33 @@ class SystemCrypto {
|
||||
if (jwtMatch && jwtMatch[1] && jwtMatch[1].length >= 64) {
|
||||
this.jwtSecret = jwtMatch[1];
|
||||
process.env.JWT_SECRET = jwtMatch[1];
|
||||
databaseLogger.success("JWT secret loaded from .env file", {
|
||||
operation: "jwt_init_from_file_success",
|
||||
secretLength: jwtMatch[1].length,
|
||||
secretPrefix: jwtMatch[1].substring(0, 8) + "...",
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
databaseLogger.warn(
|
||||
"JWT_SECRET in .env file is invalid or too short",
|
||||
{
|
||||
operation: "jwt_init_invalid_secret",
|
||||
hasMatch: !!jwtMatch,
|
||||
secretLength: jwtMatch?.[1]?.length || 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {}
|
||||
} 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",
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.warn("Generating new JWT secret", {
|
||||
operation: "jwt_generating_new_secret",
|
||||
});
|
||||
await this.generateAndGuideUser();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to initialize JWT secret", error, {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { Request } from "express";
|
||||
|
||||
export type DeviceType = "web" | "desktop" | "mobile";
|
||||
|
||||
export interface DeviceInfo {
|
||||
type: DeviceType;
|
||||
browser: string;
|
||||
version: string;
|
||||
os: string;
|
||||
deviceInfo: string;
|
||||
}
|
||||
|
||||
export function detectPlatform(req: Request): DeviceType {
|
||||
const userAgent = req.headers["user-agent"] || "";
|
||||
const electronHeader = req.headers["x-electron-app"];
|
||||
|
||||
if (electronHeader === "true" || userAgent.includes("Termix-Desktop")) {
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
if (userAgent.includes("Termix-Mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
if (userAgent.includes("Android")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
return "web";
|
||||
}
|
||||
|
||||
export function parseUserAgent(req: Request): DeviceInfo {
|
||||
const userAgent = req.headers["user-agent"] || "Unknown";
|
||||
const platform = detectPlatform(req);
|
||||
|
||||
if (platform === "desktop") {
|
||||
return parseElectronUserAgent(userAgent);
|
||||
}
|
||||
|
||||
if (platform === "mobile") {
|
||||
return parseMobileUserAgent(userAgent);
|
||||
}
|
||||
|
||||
return parseWebUserAgent(userAgent);
|
||||
}
|
||||
|
||||
function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
||||
let os = "Unknown OS";
|
||||
let version = "Unknown";
|
||||
|
||||
const termixMatch = userAgent.match(/Termix-Desktop\/([\d.]+)\s*\(([^;)]+)/);
|
||||
if (termixMatch) {
|
||||
version = termixMatch[1];
|
||||
os = termixMatch[2].trim();
|
||||
} else {
|
||||
if (userAgent.includes("Windows")) {
|
||||
os = parseWindowsVersion(userAgent);
|
||||
} else if (userAgent.includes("Mac OS X")) {
|
||||
os = parseMacVersion(userAgent);
|
||||
} else if (userAgent.includes("macOS")) {
|
||||
os = "macOS";
|
||||
} else if (userAgent.includes("Linux")) {
|
||||
os = "Linux";
|
||||
}
|
||||
|
||||
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
|
||||
if (electronMatch) {
|
||||
version = electronMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "desktop",
|
||||
browser: "Termix Desktop",
|
||||
version,
|
||||
os,
|
||||
deviceInfo: `Termix Desktop on ${os}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
||||
let os = "Unknown OS";
|
||||
let version = "Unknown";
|
||||
|
||||
const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i);
|
||||
if (termixPlatformMatch) {
|
||||
const platform = termixPlatformMatch[1];
|
||||
if (platform.toLowerCase() === "android") {
|
||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||
} else if (platform.toLowerCase() === "ios") {
|
||||
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
||||
if (iosMatch) {
|
||||
const iosVersion = iosMatch[1].replace(/_/g, ".");
|
||||
os = `iOS ${iosVersion}`;
|
||||
} else {
|
||||
os = "iOS";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (userAgent.includes("Android")) {
|
||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||
} else if (
|
||||
userAgent.includes("iOS") ||
|
||||
userAgent.includes("iPhone") ||
|
||||
userAgent.includes("iPad")
|
||||
) {
|
||||
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
||||
if (iosMatch) {
|
||||
const iosVersion = iosMatch[1].replace(/_/g, ".");
|
||||
os = `iOS ${iosVersion}`;
|
||||
} else {
|
||||
os = "iOS";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const versionMatch = userAgent.match(
|
||||
/Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i,
|
||||
);
|
||||
if (versionMatch) {
|
||||
version = versionMatch[1];
|
||||
}
|
||||
|
||||
return {
|
||||
type: "mobile",
|
||||
browser: "Termix Mobile",
|
||||
version,
|
||||
os,
|
||||
deviceInfo: `Termix Mobile on ${os}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebUserAgent(userAgent: string): DeviceInfo {
|
||||
let browser = "Unknown Browser";
|
||||
let version = "Unknown";
|
||||
let os = "Unknown OS";
|
||||
|
||||
if (userAgent.includes("Edg/")) {
|
||||
const match = userAgent.match(/Edg\/([\d.]+)/);
|
||||
browser = "Edge";
|
||||
version = match ? match[1] : "Unknown";
|
||||
} else if (userAgent.includes("Chrome/") && !userAgent.includes("Edg")) {
|
||||
const match = userAgent.match(/Chrome\/([\d.]+)/);
|
||||
browser = "Chrome";
|
||||
version = match ? match[1] : "Unknown";
|
||||
} else if (userAgent.includes("Firefox/")) {
|
||||
const match = userAgent.match(/Firefox\/([\d.]+)/);
|
||||
browser = "Firefox";
|
||||
version = match ? match[1] : "Unknown";
|
||||
} else if (userAgent.includes("Safari/") && !userAgent.includes("Chrome")) {
|
||||
const match = userAgent.match(/Version\/([\d.]+)/);
|
||||
browser = "Safari";
|
||||
version = match ? match[1] : "Unknown";
|
||||
} else if (userAgent.includes("Opera/") || userAgent.includes("OPR/")) {
|
||||
const match = userAgent.match(/(?:Opera|OPR)\/([\d.]+)/);
|
||||
browser = "Opera";
|
||||
version = match ? match[1] : "Unknown";
|
||||
}
|
||||
|
||||
if (userAgent.includes("Windows")) {
|
||||
os = parseWindowsVersion(userAgent);
|
||||
} else if (userAgent.includes("Android")) {
|
||||
const match = userAgent.match(/Android ([\d.]+)/);
|
||||
os = match ? `Android ${match[1]}` : "Android";
|
||||
} else if (
|
||||
userAgent.includes("iOS") ||
|
||||
userAgent.includes("iPhone") ||
|
||||
userAgent.includes("iPad")
|
||||
) {
|
||||
const match = userAgent.match(/OS ([\d_]+)/);
|
||||
if (match) {
|
||||
const iosVersion = match[1].replace(/_/g, ".");
|
||||
os = `iOS ${iosVersion}`;
|
||||
} else {
|
||||
os = "iOS";
|
||||
}
|
||||
} else if (userAgent.includes("Mac OS X")) {
|
||||
os = parseMacVersion(userAgent);
|
||||
} else if (userAgent.includes("Linux")) {
|
||||
os = "Linux";
|
||||
}
|
||||
|
||||
if (version !== "Unknown") {
|
||||
const versionParts = version.split(".");
|
||||
version = versionParts.slice(0, 2).join(".");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "web",
|
||||
browser,
|
||||
version,
|
||||
os,
|
||||
deviceInfo: `${browser} ${version} on ${os}`,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWindowsVersion(userAgent: string): string {
|
||||
if (userAgent.includes("Windows NT 10.0")) {
|
||||
return "Windows 10/11";
|
||||
} else if (userAgent.includes("Windows NT 6.3")) {
|
||||
return "Windows 8.1";
|
||||
} else if (userAgent.includes("Windows NT 6.2")) {
|
||||
return "Windows 8";
|
||||
} else if (userAgent.includes("Windows NT 6.1")) {
|
||||
return "Windows 7";
|
||||
} else if (userAgent.includes("Windows NT 6.0")) {
|
||||
return "Windows Vista";
|
||||
} else if (
|
||||
userAgent.includes("Windows NT 5.1") ||
|
||||
userAgent.includes("Windows NT 5.2")
|
||||
) {
|
||||
return "Windows XP";
|
||||
}
|
||||
return "Windows";
|
||||
}
|
||||
|
||||
function parseMacVersion(userAgent: string): string {
|
||||
const match = userAgent.match(/Mac OS X ([\d_]+)/);
|
||||
if (match) {
|
||||
const version = match[1].replace(/_/g, ".");
|
||||
const parts = version.split(".");
|
||||
const major = parseInt(parts[0]);
|
||||
const minor = parseInt(parts[1]);
|
||||
|
||||
if (major === 10) {
|
||||
if (minor >= 15) return `macOS ${major}.${minor}`;
|
||||
if (minor === 14) return "macOS Mojave";
|
||||
if (minor === 13) return "macOS High Sierra";
|
||||
if (minor === 12) return "macOS Sierra";
|
||||
} else if (major >= 11) {
|
||||
return `macOS ${major}`;
|
||||
}
|
||||
|
||||
return `macOS ${version}`;
|
||||
}
|
||||
return "macOS";
|
||||
}
|
||||
@@ -163,9 +163,10 @@ class UserCrypto {
|
||||
|
||||
async authenticateOIDCUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const kekSalt = await this.getKEKSalt(userId);
|
||||
const encryptedDEK = await this.getEncryptedDEK(userId);
|
||||
|
||||
if (!encryptedDEK) {
|
||||
if (!kekSalt || !encryptedDEK) {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
return true;
|
||||
}
|
||||
@@ -195,7 +196,7 @@ class UserCrypto {
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
await this.setupOIDCUserEncryption(userId);
|
||||
return true;
|
||||
}
|
||||
@@ -276,21 +277,6 @@ class UserCrypto {
|
||||
|
||||
oldKEK.fill(0);
|
||||
newKEK.fill(0);
|
||||
|
||||
const dekCopy = Buffer.from(DEK);
|
||||
|
||||
const now = Date.now();
|
||||
const oldSession = this.userSessions.get(userId);
|
||||
if (oldSession) {
|
||||
oldSession.dataKey.fill(0);
|
||||
}
|
||||
|
||||
this.userSessions.set(userId, {
|
||||
dataKey: dekCopy,
|
||||
lastActivity: now,
|
||||
expiresAt: now + UserCrypto.SESSION_DURATION,
|
||||
});
|
||||
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
@@ -363,7 +349,7 @@ class UserCrypto {
|
||||
DEK.fill(0);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -482,7 +468,7 @@ class UserCrypto {
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -522,7 +508,7 @@ class UserCrypto {
|
||||
}
|
||||
|
||||
return JSON.parse(result[0].value);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,14 @@ interface UserExportData {
|
||||
userId: string;
|
||||
username: string;
|
||||
userData: {
|
||||
sshHosts: any[];
|
||||
sshCredentials: any[];
|
||||
sshHosts: unknown[];
|
||||
sshCredentials: unknown[];
|
||||
fileManagerData: {
|
||||
recent: any[];
|
||||
pinned: any[];
|
||||
shortcuts: any[];
|
||||
recent: unknown[];
|
||||
pinned: unknown[];
|
||||
shortcuts: unknown[];
|
||||
};
|
||||
dismissedAlerts: any[];
|
||||
dismissedAlerts: unknown[];
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
@@ -83,7 +83,7 @@ class UserDataExport {
|
||||
)
|
||||
: sshHosts;
|
||||
|
||||
let sshCredentialsData: any[] = [];
|
||||
let sshCredentialsData: unknown[] = [];
|
||||
if (includeCredentials) {
|
||||
const credentials = await getDb()
|
||||
.select()
|
||||
@@ -185,7 +185,10 @@ class UserDataExport {
|
||||
return JSON.stringify(exportData, null, pretty ? 2 : 0);
|
||||
}
|
||||
|
||||
static validateExportData(data: any): { valid: boolean; errors: string[] } {
|
||||
static validateExportData(data: unknown): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
@@ -193,23 +196,26 @@ class UserDataExport {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
if (!data.version) {
|
||||
const dataObj = data as Record<string, unknown>;
|
||||
|
||||
if (!dataObj.version) {
|
||||
errors.push("Missing version field");
|
||||
}
|
||||
|
||||
if (!data.userId) {
|
||||
if (!dataObj.userId) {
|
||||
errors.push("Missing userId field");
|
||||
}
|
||||
|
||||
if (!data.userData || typeof data.userData !== "object") {
|
||||
if (!dataObj.userData || typeof dataObj.userData !== "object") {
|
||||
errors.push("Missing or invalid userData field");
|
||||
}
|
||||
|
||||
if (!data.metadata || typeof data.metadata !== "object") {
|
||||
if (!dataObj.metadata || typeof dataObj.metadata !== "object") {
|
||||
errors.push("Missing or invalid metadata field");
|
||||
}
|
||||
|
||||
if (data.userData) {
|
||||
if (dataObj.userData) {
|
||||
const userData = dataObj.userData as Record<string, unknown>;
|
||||
const requiredFields = [
|
||||
"sshHosts",
|
||||
"sshCredentials",
|
||||
@@ -218,23 +224,24 @@ class UserDataExport {
|
||||
];
|
||||
for (const field of requiredFields) {
|
||||
if (
|
||||
!Array.isArray(data.userData[field]) &&
|
||||
!(
|
||||
field === "fileManagerData" &&
|
||||
typeof data.userData[field] === "object"
|
||||
)
|
||||
!Array.isArray(userData[field]) &&
|
||||
!(field === "fileManagerData" && typeof userData[field] === "object")
|
||||
) {
|
||||
errors.push(`Missing or invalid userData.${field} field`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
data.userData.fileManagerData &&
|
||||
typeof data.userData.fileManagerData === "object"
|
||||
userData.fileManagerData &&
|
||||
typeof userData.fileManagerData === "object"
|
||||
) {
|
||||
const fileManagerData = userData.fileManagerData as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const fmFields = ["recent", "pinned", "shortcuts"];
|
||||
for (const field of fmFields) {
|
||||
if (!Array.isArray(data.userData.fileManagerData[field])) {
|
||||
if (!Array.isArray(fileManagerData[field])) {
|
||||
errors.push(
|
||||
`Missing or invalid userData.fileManagerData.${field} field`,
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import { eq, and } from "drizzle-orm";
|
||||
import { DataCrypto } from "./data-crypto.js";
|
||||
import { UserDataExport, type UserExportData } from "./user-data-export.js";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
interface ImportOptions {
|
||||
replaceExisting?: boolean;
|
||||
@@ -90,7 +89,7 @@ class UserDataImport {
|
||||
) {
|
||||
const importStats = await this.importSshHosts(
|
||||
targetUserId,
|
||||
exportData.userData.sshHosts,
|
||||
exportData.userData.sshHosts as Record<string, unknown>[],
|
||||
{ replaceExisting, dryRun, userDataKey },
|
||||
);
|
||||
result.summary.sshHostsImported = importStats.imported;
|
||||
@@ -105,7 +104,7 @@ class UserDataImport {
|
||||
) {
|
||||
const importStats = await this.importSshCredentials(
|
||||
targetUserId,
|
||||
exportData.userData.sshCredentials,
|
||||
exportData.userData.sshCredentials as Record<string, unknown>[],
|
||||
{ replaceExisting, dryRun, userDataKey },
|
||||
);
|
||||
result.summary.sshCredentialsImported = importStats.imported;
|
||||
@@ -130,7 +129,7 @@ class UserDataImport {
|
||||
) {
|
||||
const importStats = await this.importDismissedAlerts(
|
||||
targetUserId,
|
||||
exportData.userData.dismissedAlerts,
|
||||
exportData.userData.dismissedAlerts as Record<string, unknown>[],
|
||||
{ replaceExisting, dryRun },
|
||||
);
|
||||
result.summary.dismissedAlertsImported = importStats.imported;
|
||||
@@ -160,7 +159,7 @@ class UserDataImport {
|
||||
|
||||
private static async importSshHosts(
|
||||
targetUserId: string,
|
||||
sshHosts: any[],
|
||||
sshHosts: Record<string, unknown>[],
|
||||
options: {
|
||||
replaceExisting: boolean;
|
||||
dryRun: boolean;
|
||||
@@ -199,7 +198,9 @@ class UserDataImport {
|
||||
|
||||
delete processedHostData.id;
|
||||
|
||||
await getDb().insert(sshData).values(processedHostData);
|
||||
await getDb()
|
||||
.insert(sshData)
|
||||
.values(processedHostData as unknown as typeof sshData.$inferInsert);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
@@ -214,7 +215,7 @@ class UserDataImport {
|
||||
|
||||
private static async importSshCredentials(
|
||||
targetUserId: string,
|
||||
credentials: any[],
|
||||
credentials: Record<string, unknown>[],
|
||||
options: {
|
||||
replaceExisting: boolean;
|
||||
dryRun: boolean;
|
||||
@@ -255,7 +256,11 @@ class UserDataImport {
|
||||
|
||||
delete processedCredentialData.id;
|
||||
|
||||
await getDb().insert(sshCredentials).values(processedCredentialData);
|
||||
await getDb()
|
||||
.insert(sshCredentials)
|
||||
.values(
|
||||
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
|
||||
);
|
||||
imported++;
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
@@ -270,7 +275,7 @@ class UserDataImport {
|
||||
|
||||
private static async importFileManagerData(
|
||||
targetUserId: string,
|
||||
fileManagerData: any,
|
||||
fileManagerData: Record<string, unknown>,
|
||||
options: { replaceExisting: boolean; dryRun: boolean },
|
||||
) {
|
||||
let imported = 0;
|
||||
@@ -357,7 +362,7 @@ class UserDataImport {
|
||||
|
||||
private static async importDismissedAlerts(
|
||||
targetUserId: string,
|
||||
alerts: any[],
|
||||
alerts: Record<string, unknown>[],
|
||||
options: { replaceExisting: boolean; dryRun: boolean },
|
||||
) {
|
||||
let imported = 0;
|
||||
@@ -377,7 +382,7 @@ class UserDataImport {
|
||||
.where(
|
||||
and(
|
||||
eq(dismissedAlerts.userId, targetUserId),
|
||||
eq(dismissedAlerts.alertId, alert.alertId),
|
||||
eq(dismissedAlerts.alertId, alert.alertId as string),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -396,10 +401,12 @@ class UserDataImport {
|
||||
if (existing.length > 0 && options.replaceExisting) {
|
||||
await getDb()
|
||||
.update(dismissedAlerts)
|
||||
.set(newAlert)
|
||||
.set(newAlert as typeof dismissedAlerts.$inferInsert)
|
||||
.where(eq(dismissedAlerts.id, existing[0].id));
|
||||
} else {
|
||||
await getDb().insert(dismissedAlerts).values(newAlert);
|
||||
await getDb()
|
||||
.insert(dismissedAlerts)
|
||||
.values(newAlert as typeof dismissedAlerts.$inferInsert);
|
||||
}
|
||||
|
||||
imported++;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Chart Container
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "ChartContainer";
|
||||
|
||||
export { ChartContainer, RechartsPrimitive };
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
@@ -5,8 +5,7 @@ import { Eye, EyeOff } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PasswordInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
type PasswordInputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const PasswordInput = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
|
||||
@@ -17,10 +17,7 @@ export const Status = ({ className, status, ...props }: StatusProps) => (
|
||||
|
||||
export type StatusIndicatorProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const StatusIndicator = ({
|
||||
className,
|
||||
...props
|
||||
}: StatusIndicatorProps) => (
|
||||
export const StatusIndicator = ({ ...props }: StatusIndicatorProps) => (
|
||||
<span className="relative flex h-2 w-2" {...props}>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
@@ -8,13 +9,6 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -26,7 +20,6 @@ import {
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
@@ -161,7 +154,7 @@ function Sidebar({
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
const { state } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -213,7 +206,6 @@ function Sidebar({
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -8,7 +8,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
|
||||
const originalToast = toast;
|
||||
|
||||
const rateLimitedToast = (message: string, options?: any) => {
|
||||
const rateLimitedToast = (
|
||||
message: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => {
|
||||
const now = Date.now();
|
||||
const lastToast = lastToastRef.current;
|
||||
|
||||
@@ -25,13 +28,13 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
};
|
||||
|
||||
Object.assign(toast, {
|
||||
success: (message: string, options?: any) =>
|
||||
success: (message: string, options?: Record<string, unknown>) =>
|
||||
rateLimitedToast(message, { ...options, type: "success" }),
|
||||
error: (message: string, options?: any) =>
|
||||
error: (message: string, options?: Record<string, unknown>) =>
|
||||
rateLimitedToast(message, { ...options, type: "error" }),
|
||||
warning: (message: string, options?: any) =>
|
||||
warning: (message: string, options?: Record<string, unknown>) =>
|
||||
rateLimitedToast(message, { ...options, type: "warning" }),
|
||||
info: (message: string, options?: any) =>
|
||||
info: (message: string, options?: Record<string, unknown>) =>
|
||||
rateLimitedToast(message, { ...options, type: "info" }),
|
||||
message: rateLimitedToast,
|
||||
});
|
||||
|
||||
@@ -2,8 +2,7 @@ import * as React from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
|
||||
@@ -0,0 +1,711 @@
|
||||
export interface TerminalTheme {
|
||||
name: string;
|
||||
category: "dark" | "light" | "colorful";
|
||||
colors: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor?: string;
|
||||
cursorAccent?: string;
|
||||
selectionBackground?: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
termix: {
|
||||
name: "Termix Default",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#18181b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#18181b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
name: "Dracula",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
monokai: {
|
||||
name: "Monokai",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
},
|
||||
|
||||
nord: {
|
||||
name: "Nord",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxDark: {
|
||||
name: "Gruvbox Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxLight: {
|
||||
name: "Gruvbox Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fbf1c7",
|
||||
foreground: "#3c3836",
|
||||
cursor: "#3c3836",
|
||||
cursorAccent: "#fbf1c7",
|
||||
selectionBackground: "#d5c4a1",
|
||||
black: "#fbf1c7",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#7c6f64",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#9d0006",
|
||||
brightGreen: "#79740e",
|
||||
brightYellow: "#b57614",
|
||||
brightBlue: "#076678",
|
||||
brightMagenta: "#8f3f71",
|
||||
brightCyan: "#427b58",
|
||||
brightWhite: "#3c3836",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedDark: {
|
||||
name: "Solarized Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedLight: {
|
||||
name: "Solarized Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
cursor: "#657b83",
|
||||
cursorAccent: "#fdf6e3",
|
||||
selectionBackground: "#eee8d5",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
oneDark: {
|
||||
name: "One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
tokyoNight: {
|
||||
name: "Tokyo Night",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#a9b1d6",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#283457",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
|
||||
ayuDark: {
|
||||
name: "Ayu Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0a0e14",
|
||||
foreground: "#b3b1ad",
|
||||
cursor: "#e6b450",
|
||||
cursorAccent: "#0a0e14",
|
||||
selectionBackground: "#253340",
|
||||
black: "#01060e",
|
||||
red: "#ea6c73",
|
||||
green: "#91b362",
|
||||
yellow: "#f9af4f",
|
||||
blue: "#53bdfa",
|
||||
magenta: "#fae994",
|
||||
cyan: "#90e1c6",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#f07178",
|
||||
brightGreen: "#c2d94c",
|
||||
brightYellow: "#ffb454",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#ffee99",
|
||||
brightCyan: "#95e6cb",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
ayuLight: {
|
||||
name: "Ayu Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fafafa",
|
||||
foreground: "#5c6166",
|
||||
cursor: "#ff9940",
|
||||
cursorAccent: "#fafafa",
|
||||
selectionBackground: "#d1e4f4",
|
||||
black: "#000000",
|
||||
red: "#f51818",
|
||||
green: "#86b300",
|
||||
yellow: "#f2ae49",
|
||||
blue: "#399ee6",
|
||||
magenta: "#a37acc",
|
||||
cyan: "#4cbf99",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff3333",
|
||||
brightGreen: "#b8e532",
|
||||
brightYellow: "#ffc849",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#bf7ce0",
|
||||
brightCyan: "#5cf7a0",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
materialTheme: {
|
||||
name: "Material Theme",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#263238",
|
||||
foreground: "#eeffff",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#263238",
|
||||
selectionBackground: "#546e7a",
|
||||
black: "#000000",
|
||||
red: "#e53935",
|
||||
green: "#91b859",
|
||||
yellow: "#ffb62c",
|
||||
blue: "#6182b8",
|
||||
magenta: "#7c4dff",
|
||||
cyan: "#39adb5",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#546e7a",
|
||||
brightRed: "#ff5370",
|
||||
brightGreen: "#c3e88d",
|
||||
brightYellow: "#ffcb6b",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#89ddff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
palenight: {
|
||||
name: "Palenight",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#292d3e",
|
||||
foreground: "#a6accd",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#292d3e",
|
||||
selectionBackground: "#676e95",
|
||||
black: "#292d3e",
|
||||
red: "#f07178",
|
||||
green: "#c3e88d",
|
||||
yellow: "#ffcb6b",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#89ddff",
|
||||
white: "#d0d0d0",
|
||||
brightBlack: "#434758",
|
||||
brightRed: "#ff8b92",
|
||||
brightGreen: "#ddffa7",
|
||||
brightYellow: "#ffe585",
|
||||
brightBlue: "#9cc4ff",
|
||||
brightMagenta: "#e1acff",
|
||||
brightCyan: "#a3f7ff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
oceanicNext: {
|
||||
name: "Oceanic Next",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1b2b34",
|
||||
foreground: "#cdd3de",
|
||||
cursor: "#c0c5ce",
|
||||
cursorAccent: "#1b2b34",
|
||||
selectionBackground: "#343d46",
|
||||
black: "#343d46",
|
||||
red: "#ec5f67",
|
||||
green: "#99c794",
|
||||
yellow: "#fac863",
|
||||
blue: "#6699cc",
|
||||
magenta: "#c594c5",
|
||||
cyan: "#5fb3b3",
|
||||
white: "#cdd3de",
|
||||
brightBlack: "#65737e",
|
||||
brightRed: "#ec5f67",
|
||||
brightGreen: "#99c794",
|
||||
brightYellow: "#fac863",
|
||||
brightBlue: "#6699cc",
|
||||
brightMagenta: "#c594c5",
|
||||
brightCyan: "#5fb3b3",
|
||||
brightWhite: "#d8dee9",
|
||||
},
|
||||
},
|
||||
|
||||
nightOwl: {
|
||||
name: "Night Owl",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#011627",
|
||||
foreground: "#d6deeb",
|
||||
cursor: "#80a4c2",
|
||||
cursorAccent: "#011627",
|
||||
selectionBackground: "#1d3b53",
|
||||
black: "#011627",
|
||||
red: "#ef5350",
|
||||
green: "#22da6e",
|
||||
yellow: "#c5e478",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#21c7a8",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#575656",
|
||||
brightRed: "#ef5350",
|
||||
brightGreen: "#22da6e",
|
||||
brightYellow: "#ffeb95",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#7fdbca",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
synthwave84: {
|
||||
name: "Synthwave '84",
|
||||
category: "colorful",
|
||||
colors: {
|
||||
background: "#241b2f",
|
||||
foreground: "#f92aad",
|
||||
cursor: "#f92aad",
|
||||
cursorAccent: "#241b2f",
|
||||
selectionBackground: "#495495",
|
||||
black: "#000000",
|
||||
red: "#f6188f",
|
||||
green: "#1eff8e",
|
||||
yellow: "#ffe261",
|
||||
blue: "#03edf9",
|
||||
magenta: "#f10596",
|
||||
cyan: "#03edf9",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#5a5a5a",
|
||||
brightRed: "#ff1a8e",
|
||||
brightGreen: "#1eff8e",
|
||||
brightYellow: "#ffff00",
|
||||
brightBlue: "#00d8ff",
|
||||
brightMagenta: "#ff00d4",
|
||||
brightCyan: "#00ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
cobalt2: {
|
||||
name: "Cobalt2",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#193549",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#f0cc09",
|
||||
cursorAccent: "#193549",
|
||||
selectionBackground: "#0050a4",
|
||||
black: "#000000",
|
||||
red: "#ff0000",
|
||||
green: "#38de21",
|
||||
yellow: "#ffe50a",
|
||||
blue: "#1460d2",
|
||||
magenta: "#ff005d",
|
||||
cyan: "#00bbbb",
|
||||
white: "#bbbbbb",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#f40e17",
|
||||
brightGreen: "#3bd01d",
|
||||
brightYellow: "#edc809",
|
||||
brightBlue: "#5555ff",
|
||||
brightMagenta: "#ff55ff",
|
||||
brightCyan: "#6ae3fa",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
snazzy: {
|
||||
name: "Snazzy",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#eff0eb",
|
||||
cursor: "#97979b",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#97979b",
|
||||
black: "#282a36",
|
||||
red: "#ff5c57",
|
||||
green: "#5af78e",
|
||||
yellow: "#f3f99d",
|
||||
blue: "#57c7ff",
|
||||
magenta: "#ff6ac1",
|
||||
cyan: "#9aedfe",
|
||||
white: "#f1f1f0",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff5c57",
|
||||
brightGreen: "#5af78e",
|
||||
brightYellow: "#f3f99d",
|
||||
brightBlue: "#57c7ff",
|
||||
brightMagenta: "#ff6ac1",
|
||||
brightCyan: "#9aedfe",
|
||||
brightWhite: "#eff0eb",
|
||||
},
|
||||
},
|
||||
|
||||
atomOneDark: {
|
||||
name: "Atom One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e2127",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#1e2127",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#000000",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#d19a66",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#d19a66",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
catppuccinMocha: {
|
||||
name: "Catppuccin Mocha",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#585b70",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Font families available for terminal
|
||||
export const TERMINAL_FONTS = [
|
||||
{
|
||||
value: "Caskaydia Cove Nerd Font Mono",
|
||||
label: "Caskaydia Cove Nerd Font Mono",
|
||||
fallback:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "JetBrains Mono",
|
||||
label: "JetBrains Mono",
|
||||
fallback:
|
||||
'"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Fira Code",
|
||||
label: "Fira Code",
|
||||
fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Cascadia Code",
|
||||
label: "Cascadia Code",
|
||||
fallback:
|
||||
'"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Source Code Pro",
|
||||
label: "Source Code Pro",
|
||||
fallback:
|
||||
'"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "SF Mono",
|
||||
label: "SF Mono",
|
||||
fallback: '"SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Consolas",
|
||||
label: "Consolas",
|
||||
fallback: 'Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Monaco",
|
||||
label: "Monaco",
|
||||
fallback: 'Monaco, "Liberation Mono", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
export const CURSOR_STYLES = [
|
||||
{ value: "block", label: "Block" },
|
||||
{ value: "underline", label: "Underline" },
|
||||
{ value: "bar", label: "Bar" },
|
||||
] as const;
|
||||
|
||||
export const BELL_STYLES = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "sound", label: "Sound" },
|
||||
{ value: "visual", label: "Visual" },
|
||||
{ value: "both", label: "Both" },
|
||||
] as const;
|
||||
|
||||
export const FAST_SCROLL_MODIFIERS = [
|
||||
{ value: "alt", label: "Alt" },
|
||||
{ value: "ctrl", label: "Ctrl" },
|
||||
{ value: "shift", label: "Shift" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TERMINAL_CONFIG = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar" as const,
|
||||
fontSize: 14,
|
||||
fontFamily: "Caskaydia Cove Nerd Font Mono",
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
theme: "termix",
|
||||
|
||||
scrollback: 10000,
|
||||
bellStyle: "none" as const,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: "alt" as const,
|
||||
fastScrollSensitivity: 5,
|
||||
minimumContrastRatio: 1,
|
||||
|
||||
backspaceMode: "normal" as const,
|
||||
agentForwarding: false,
|
||||
environmentVariables: [] as Array<{ key: string; value: string }>,
|
||||
startupSnippetId: null as number | null,
|
||||
autoMosh: false,
|
||||
moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8",
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
+9
-1
@@ -5,12 +5,14 @@ import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import enTranslation from "../locales/en/translation.json";
|
||||
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";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "zh", "de"],
|
||||
supportedLngs: ["en", "zh", "de", "ptbr", "ru"],
|
||||
fallbackLng: "en",
|
||||
debug: false,
|
||||
|
||||
@@ -32,6 +34,12 @@ i18n
|
||||
de: {
|
||||
translation: deTranslation,
|
||||
},
|
||||
ptbr: {
|
||||
translation: ptbrTranslation,
|
||||
},
|
||||
ru: {
|
||||
translation: ruTranslation,
|
||||
},
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface LogContext {
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
|
||||
[key: string]: any;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
class FrontendLogger {
|
||||
@@ -218,7 +218,6 @@ class FrontendLogger {
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
const performanceIcon = this.getPerformanceIcon(responseTime);
|
||||
|
||||
@@ -244,7 +243,6 @@ class FrontendLogger {
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
const statusIcon = this.getStatusIcon(status);
|
||||
|
||||
this.error(`← ${statusIcon} ${status} ${errorMessage}`, undefined, {
|
||||
@@ -265,7 +263,6 @@ class FrontendLogger {
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
|
||||
...context,
|
||||
@@ -279,7 +276,6 @@ class FrontendLogger {
|
||||
|
||||
authError(method: string, url: string, context?: LogContext): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.security(`🔐 Authentication Required`, {
|
||||
...context,
|
||||
@@ -298,7 +294,6 @@ class FrontendLogger {
|
||||
context?: LogContext,
|
||||
): void {
|
||||
const cleanUrl = this.sanitizeUrl(url);
|
||||
const shortUrl = this.getShortUrl(cleanUrl);
|
||||
|
||||
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
|
||||
...context,
|
||||
@@ -384,5 +379,6 @@ export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
|
||||
@@ -311,6 +311,8 @@
|
||||
"next": "Weiter",
|
||||
"previous": "Vorherige",
|
||||
"refresh": "Aktualisieren",
|
||||
"connect": "Verbinden",
|
||||
"connecting": "Verbinde...",
|
||||
"settings": "Einstellungen",
|
||||
"profile": "Profil",
|
||||
"help": "Hilfe",
|
||||
@@ -318,6 +320,8 @@
|
||||
"language": "Sprache",
|
||||
"autoDetect": "Automatische Erkennung",
|
||||
"changeAccountPassword": "Passwort für Ihr Konto ändern",
|
||||
"passwordResetTitle": "Passwort zurücksetzen",
|
||||
"passwordResetDescription": "Sie sind dabei, Ihr Passwort zurückzusetzen. Dadurch werden Sie von allen aktiven Sitzungen abgemeldet.",
|
||||
"enterSixDigitCode": "Geben Sie den 6-stelligen Code aus den Docker-Container-Protokollen \/ logs für den Benutzer ein:",
|
||||
"enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
|
||||
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
|
||||
@@ -529,7 +533,19 @@
|
||||
"passwordRequired": "Passwort erforderlich",
|
||||
"confirmExport": "Export bestätigen",
|
||||
"exportDescription": "SSH-Hosts und Anmeldedaten als SQLite-Datei exportieren",
|
||||
"importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)"
|
||||
"importDescription": "SQLite-Datei mit inkrementellem Zusammenführen importieren (überspringt Duplikate)",
|
||||
"criticalWarning": "Kritische Warnung",
|
||||
"cannotDisablePasswordLoginWithoutOIDC": "Passwort-Login kann nicht ohne konfiguriertes OIDC deaktiviert werden! Sie müssen die OIDC-Authentifizierung konfigurieren, bevor Sie die Passwort-Anmeldung deaktivieren, sonst verlieren Sie den Zugriff auf Termix.",
|
||||
"confirmDisablePasswordLogin": "Sind Sie sicher, dass Sie die Passwort-Anmeldung deaktivieren möchten? Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist und funktioniert, bevor Sie fortfahren, sonst verlieren Sie den Zugriff auf Ihre Termix-Instanz.",
|
||||
"passwordLoginDisabled": "Passwort-Login erfolgreich deaktiviert",
|
||||
"passwordLoginAndRegistrationDisabled": "Passwort-Login und Registrierung neuer Konten erfolgreich deaktiviert",
|
||||
"requiresPasswordLogin": "Erfordert aktivierte Passwort-Anmeldung",
|
||||
"passwordLoginDisabledWarning": "Passwort-Login ist deaktiviert. Stellen Sie sicher, dass OIDC ordnungsgemäß konfiguriert ist, sonst können Sie sich nicht bei Termix anmelden.",
|
||||
"oidcRequiredWarning": "KRITISCH: Passwort-Login ist deaktiviert. Wenn Sie OIDC zurücksetzen oder falsch konfigurieren, verlieren Sie den gesamten Zugriff auf Termix und Ihre Instanz wird unbrauchbar. Fahren Sie nur fort, wenn Sie absolut sicher sind.",
|
||||
"confirmDisableOIDCWarning": "WARNUNG: Sie sind dabei, OIDC zu deaktivieren, während auch die Passwort-Anmeldung deaktiviert ist. Dies macht Ihre Termix-Instanz unbrauchbar und Sie verlieren den gesamten Zugriff. Sind Sie absolut sicher, dass Sie fortfahren möchten?",
|
||||
"allowPasswordLogin": "Benutzername/Passwort-Anmeldung zulassen",
|
||||
"failedToFetchPasswordLoginStatus": "Abrufen des Passwort-Login-Status fehlgeschlagen",
|
||||
"failedToUpdatePasswordLoginStatus": "Aktualisierung des Passwort-Login-Status fehlgeschlagen"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Host-Manager",
|
||||
@@ -623,6 +639,7 @@
|
||||
"password": "Passwort",
|
||||
"key": "Schlüssel",
|
||||
"credential": "Anmeldedaten",
|
||||
"none": "Keine",
|
||||
"selectCredential": "Anmeldeinformationen auswählen",
|
||||
"selectCredentialPlaceholder": "Wähle eine Anmeldedaten aus...",
|
||||
"credentialRequired": "Für die Anmeldeauthentifizierung ist eine Anmeldeinformation erforderlich",
|
||||
@@ -659,7 +676,34 @@
|
||||
"folderRenamed": "Ordner „ {{oldName}} “ erfolgreich in „ {{newName}} “ umbenannt",
|
||||
"failedToRenameFolder": "Ordner konnte nicht umbenannt werden",
|
||||
"movedToFolder": "Host \"{{name}}\" wurde erfolgreich nach \"{{folder}}\" verschoben",
|
||||
"failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden"
|
||||
"failedToMoveToFolder": "Host konnte nicht in den Ordner verschoben werden",
|
||||
"statistics": "Statistiken",
|
||||
"enabledWidgets": "Aktivierte Widgets",
|
||||
"enabledWidgetsDesc": "Wählen Sie aus, welche Statistik-Widgets für diesen Host angezeigt werden sollen",
|
||||
"monitoringConfiguration": "Überwachungskonfiguration",
|
||||
"monitoringConfigurationDesc": "Konfigurieren Sie, wie oft Serverstatistiken und Status überprüft werden",
|
||||
"statusCheckEnabled": "Statusüberwachung aktivieren",
|
||||
"statusCheckEnabledDesc": "Prüfen Sie, ob der Server online oder offline ist",
|
||||
"statusCheckInterval": "Statusprüfintervall",
|
||||
"statusCheckIntervalDesc": "Wie oft überprüft werden soll, ob der Host online ist (5s - 1h)",
|
||||
"metricsEnabled": "Metriküberwachung aktivieren",
|
||||
"metricsEnabledDesc": "CPU-, RAM-, Festplatten- und andere Systemstatistiken erfassen",
|
||||
"metricsInterval": "Metriken-Erfassungsintervall",
|
||||
"metricsIntervalDesc": "Wie oft Serverstatistiken erfasst werden sollen (5s - 1h)",
|
||||
"intervalSeconds": "Sekunden",
|
||||
"intervalMinutes": "Minuten",
|
||||
"intervalValidation": "Überwachungsintervalle müssen zwischen 5 Sekunden und 1 Stunde (3600 Sekunden) liegen",
|
||||
"monitoringDisabled": "Die Serverüberwachung ist für diesen Host deaktiviert",
|
||||
"enableMonitoring": "Überwachung aktivieren in Host-Manager → Statistiken-Tab",
|
||||
"monitoringDisabledBadge": "Überwachung Aus",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
@@ -1132,7 +1176,18 @@
|
||||
"enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
|
||||
"passwordResetSuccess": "Erfolgreich!",
|
||||
"passwordResetSuccessDesc": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
|
||||
"signUp": "Registrierung"
|
||||
"signUp": "Registrierung",
|
||||
"dataLossWarning": "Wenn Sie Ihr Passwort auf diese Weise zurücksetzen, werden alle Ihre gespeicherten SSH-Hosts, Anmeldeinformationen und andere verschlüsselte Daten gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. Verwenden Sie diese Option nur, wenn Sie Ihr Passwort vergessen haben und nicht angemeldet sind.",
|
||||
"sshAuthenticationRequired": "SSH-Authentifizierung erforderlich",
|
||||
"sshNoKeyboardInteractive": "Keyboard-Interactive-Authentifizierung nicht verfügbar",
|
||||
"sshAuthenticationFailed": "Authentifizierung fehlgeschlagen",
|
||||
"sshAuthenticationTimeout": "Authentifizierungs-Timeout",
|
||||
"sshNoKeyboardInteractiveDescription": "Der Server unterstützt keine Keyboard-Interactive-Authentifizierung. Bitte geben Sie Ihr Passwort oder Ihren SSH-Schlüssel ein.",
|
||||
"sshAuthFailedDescription": "Die angegebenen Anmeldeinformationen waren falsch. Bitte versuchen Sie es erneut mit gültigen Anmeldeinformationen.",
|
||||
"sshTimeoutDescription": "Der Authentifizierungsversuch ist abgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"sshProvideCredentialsDescription": "Bitte geben Sie Ihre SSH-Anmeldeinformationen ein, um eine Verbindung zu diesem Server herzustellen.",
|
||||
"sshPasswordDescription": "Geben Sie das Passwort für diese SSH-Verbindung ein.",
|
||||
"sshKeyPasswordDescription": "Wenn Ihr SSH-Schlüssel verschlüsselt ist, geben Sie hier die Passphrase ein."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Seite nicht gefunden",
|
||||
@@ -1158,6 +1213,7 @@
|
||||
"maxLength": "Die maximale Länge beträgt {{max}}",
|
||||
"invalidEmail": "Ungültige E-Mail-Adresse",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"passwordLoginDisabled": "Benutzername/Passwort-Anmeldung ist derzeit deaktiviert",
|
||||
"weakPassword": "Das Passwort ist zu schwach",
|
||||
"usernameExists": "Benutzername existiert bereits",
|
||||
"emailExists": "E-Mail existiert bereits",
|
||||
@@ -1203,7 +1259,10 @@
|
||||
"authMethod": "Authentifizierungsmethode",
|
||||
"local": "Lokal",
|
||||
"external": "Extern (OIDC)",
|
||||
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche"
|
||||
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche",
|
||||
"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."
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "Fehler beim Laden der Versionsinformationen"
|
||||
@@ -1261,7 +1320,8 @@
|
||||
"deleteAccount": "Konto löschen",
|
||||
"closeDeleteAccount": "Schließen Konto löschen",
|
||||
"deleteAccountWarning": "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden Ihr Konto und alle damit verbundenen Daten dauerhaft gelöscht.",
|
||||
"deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteAccountWarningDetails": "Wenn Sie Ihr Konto löschen, werden alle Ihre Daten entfernt, einschließlich SSH-Hosts, Konfigurationen und Einstellungen. Diese Aktion ist nicht rückgängig zu machen.",
|
||||
"deleteAccountWarningShort": "Diese Aktion kann nicht rückgängig gemacht werden und löscht Ihr Konto dauerhaft.",
|
||||
"cannotDeleteAccount": "Konto kann nicht gelöscht werden",
|
||||
"lastAdminWarning": "Sie sind der letzte Administrator. Sie können Ihr Konto nicht löschen, da das System dann ohne Administratoren wäre. Bitte benennen Sie zunächst einen anderen Benutzer als Administrator oder wenden Sie sich an den Systemsupport.",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
@@ -1381,5 +1441,38 @@
|
||||
"mobileAppInProgressDesc": "Wir arbeiten an einer speziellen mobilen App, um ein besseres Erlebnis auf Mobilgeräten zu bieten.",
|
||||
"viewMobileAppDocs": "Mobile App installieren",
|
||||
"mobileAppDocumentation": "Mobile App-Dokumentation"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"github": "GitHub",
|
||||
"support": "Support",
|
||||
"discord": "Discord",
|
||||
"donate": "Spenden",
|
||||
"serverOverview": "Serverübersicht",
|
||||
"version": "Version",
|
||||
"upToDate": "Auf dem neuesten Stand",
|
||||
"updateAvailable": "Update verfügbar",
|
||||
"uptime": "Betriebszeit",
|
||||
"database": "Datenbank",
|
||||
"healthy": "Gesund",
|
||||
"error": "Fehler",
|
||||
"totalServers": "Server gesamt",
|
||||
"totalTunnels": "Tunnel gesamt",
|
||||
"totalCredentials": "Anmeldedaten gesamt",
|
||||
"recentActivity": "Kürzliche Aktivität",
|
||||
"reset": "Zurücksetzen",
|
||||
"loadingRecentActivity": "Kürzliche Aktivität wird geladen...",
|
||||
"noRecentActivity": "Keine kürzliche Aktivität",
|
||||
"quickActions": "Schnellaktionen",
|
||||
"addHost": "Host hinzufügen",
|
||||
"addCredential": "Anmeldedaten hinzufügen",
|
||||
"adminSettings": "Admin-Einstellungen",
|
||||
"userProfile": "Benutzerprofil",
|
||||
"serverStats": "Serverstatistiken",
|
||||
"loadingServerStats": "Serverstatistiken werden geladen...",
|
||||
"noServerData": "Keine Serverdaten verfügbar",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"notAvailable": "Nicht verfügbar"
|
||||
}
|
||||
}
|
||||
|
||||
+202
-12
@@ -54,7 +54,7 @@
|
||||
"sshPrivateKey": "SSH Private Key",
|
||||
"upload": "Upload",
|
||||
"updateKey": "Update Key",
|
||||
"keyPassword": "Key Password (optional)",
|
||||
"keyPassword": "Key Password",
|
||||
"keyType": "Key Type",
|
||||
"keyTypeRSA": "RSA",
|
||||
"keyTypeECDSA": "ECDSA",
|
||||
@@ -191,6 +191,40 @@
|
||||
"enableRightClickCopyPaste": "Enable right‑click copy/paste",
|
||||
"shareIdeas": "Have ideas for what should come next for ssh tools? Share them on"
|
||||
},
|
||||
"snippets": {
|
||||
"title": "Snippets",
|
||||
"new": "New Snippet",
|
||||
"create": "Create Snippet",
|
||||
"edit": "Edit Snippet",
|
||||
"run": "Run",
|
||||
"empty": "No snippets yet",
|
||||
"emptyHint": "Create a snippet to save commonly used commands",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"content": "Command",
|
||||
"namePlaceholder": "e.g., Restart Nginx",
|
||||
"descriptionPlaceholder": "Optional description",
|
||||
"contentPlaceholder": "e.g., sudo systemctl restart nginx",
|
||||
"nameRequired": "Name is required",
|
||||
"contentRequired": "Command is required",
|
||||
"createDescription": "Create a new command snippet for quick execution",
|
||||
"editDescription": "Edit this command snippet",
|
||||
"deleteConfirmTitle": "Delete Snippet",
|
||||
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"?",
|
||||
"createSuccess": "Snippet created successfully",
|
||||
"updateSuccess": "Snippet updated successfully",
|
||||
"deleteSuccess": "Snippet deleted successfully",
|
||||
"createFailed": "Failed to create snippet",
|
||||
"updateFailed": "Failed to update snippet",
|
||||
"deleteFailed": "Failed to delete snippet",
|
||||
"failedToFetch": "Failed to fetch snippets",
|
||||
"executeSuccess": "Executing: {{name}}",
|
||||
"copySuccess": "Copied \"{{name}}\" to clipboard",
|
||||
"runTooltip": "Execute this snippet in the terminal",
|
||||
"copyTooltip": "Copy snippet to clipboard",
|
||||
"editTooltip": "Edit this snippet",
|
||||
"deleteTooltip": "Delete this snippet"
|
||||
},
|
||||
"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.",
|
||||
@@ -213,7 +247,11 @@
|
||||
"saveError": "Error saving configuration",
|
||||
"saving": "Saving...",
|
||||
"saveConfig": "Save Configuration",
|
||||
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
|
||||
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)",
|
||||
"warning": "Warning",
|
||||
"notValidatedWarning": "URL not validated - ensure it's correct",
|
||||
"changeServer": "Change Server",
|
||||
"mustIncludeProtocol": "Server URL must start with http:// or https://"
|
||||
},
|
||||
"versionCheck": {
|
||||
"error": "Version Check Error",
|
||||
@@ -249,6 +287,8 @@
|
||||
"loading": "Loading",
|
||||
"required": "Required",
|
||||
"optional": "Optional",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"clear": "Clear",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"sidebar": "Sidebar",
|
||||
@@ -333,6 +373,8 @@
|
||||
"language": "Language",
|
||||
"autoDetect": "Auto-detect",
|
||||
"changeAccountPassword": "Change your account password",
|
||||
"passwordResetTitle": "Password Reset",
|
||||
"passwordResetDescription": "You are about to reset your password. This will log you out of all active sessions.",
|
||||
"enterSixDigitCode": "Enter the 6-digit code from the docker container logs for user:",
|
||||
"enterNewPassword": "Enter your new password for user:",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
@@ -357,6 +399,7 @@
|
||||
"admin": "Admin",
|
||||
"userProfile": "User Profile",
|
||||
"tools": "Tools",
|
||||
"snippets": "Snippets",
|
||||
"newTab": "New Tab",
|
||||
"splitScreen": "Split Screen",
|
||||
"closeTab": "Close Tab",
|
||||
@@ -410,10 +453,12 @@
|
||||
"general": "General",
|
||||
"userRegistration": "User Registration",
|
||||
"allowNewAccountRegistration": "Allow new account registration",
|
||||
"allowPasswordLogin": "Allow username/password login",
|
||||
"missingRequiredFields": "Missing required fields: {{fields}}",
|
||||
"oidcConfigurationUpdated": "OIDC configuration updated successfully!",
|
||||
"failedToFetchOidcConfig": "Failed to fetch OIDC configuration",
|
||||
"failedToFetchRegistrationStatus": "Failed to fetch registration status",
|
||||
"failedToFetchPasswordLoginStatus": "Failed to fetch password login status",
|
||||
"failedToFetchUsers": "Failed to fetch users",
|
||||
"oidcConfigurationDisabled": "OIDC configuration disabled successfully!",
|
||||
"failedToUpdateOidcConfig": "Failed to update OIDC configuration",
|
||||
@@ -428,6 +473,13 @@
|
||||
"userDeletedSuccessfully": "User {{username}} deleted successfully",
|
||||
"failedToDeleteUser": "Failed to delete user",
|
||||
"overrideUserInfoUrl": "Override User Info URL (not required)",
|
||||
"failedToFetchSessions": "Failed to fetch sessions",
|
||||
"sessionRevokedSuccessfully": "Session revoked successfully",
|
||||
"failedToRevokeSession": "Failed to revoke session",
|
||||
"confirmRevokeSession": "Are you sure you want to revoke this session?",
|
||||
"confirmRevokeAllSessions": "Are you sure you want to revoke all sessions for this user?",
|
||||
"failedToRevokeSessions": "Failed to revoke sessions",
|
||||
"sessionsRevokedSuccessfully": "Sessions revoked successfully",
|
||||
"databaseSecurity": "Database Security",
|
||||
"encryptionStatus": "Encryption Status",
|
||||
"encryptionEnabled": "Encryption Enabled",
|
||||
@@ -546,7 +598,16 @@
|
||||
"passwordRequired": "Password required",
|
||||
"confirmExport": "Confirm Export",
|
||||
"exportDescription": "Export SSH hosts and credentials as SQLite file",
|
||||
"importDescription": "Import SQLite file with incremental merge (skips duplicates)"
|
||||
"importDescription": "Import SQLite file with incremental merge (skips duplicates)",
|
||||
"criticalWarning": "Critical Warning",
|
||||
"cannotDisablePasswordLoginWithoutOIDC": "Cannot disable password login without OIDC configured! You must configure OIDC authentication before disabling password login, or you will lose access to Termix.",
|
||||
"confirmDisablePasswordLogin": "Are you sure you want to disable password login? Make sure OIDC is properly configured and working before proceeding, or you will lose access to your Termix instance.",
|
||||
"passwordLoginDisabled": "Password login disabled successfully",
|
||||
"passwordLoginAndRegistrationDisabled": "Password login and new account registration disabled successfully",
|
||||
"requiresPasswordLogin": "Requires password login enabled",
|
||||
"passwordLoginDisabledWarning": "Password login is disabled. Ensure OIDC is properly configured or you will not be able to log in to Termix.",
|
||||
"oidcRequiredWarning": "CRITICAL: Password login is disabled. If you reset or misconfigure OIDC, you will lose all access to Termix and brick your instance. Only proceed if you are absolutely certain.",
|
||||
"confirmDisableOIDCWarning": "WARNING: You are about to disable OIDC while password login is also disabled. This will brick your Termix instance and you will lose all access. Are you absolutely sure you want to proceed?"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "Host Manager",
|
||||
@@ -640,6 +701,7 @@
|
||||
"password": "Password",
|
||||
"key": "Key",
|
||||
"credential": "Credential",
|
||||
"none": "None",
|
||||
"selectCredential": "Select Credential",
|
||||
"selectCredentialPlaceholder": "Choose a credential...",
|
||||
"credentialRequired": "Credential is required when using credential authentication",
|
||||
@@ -669,14 +731,58 @@
|
||||
"terminal": "Terminal",
|
||||
"tunnel": "Tunnel",
|
||||
"fileManager": "File Manager",
|
||||
"serverStats": "Server Stats",
|
||||
"hostViewer": "Host Viewer",
|
||||
"enableServerStats": "Enable Server Stats",
|
||||
"enableServerStatsDesc": "Enable/disable server statistics collection for this host",
|
||||
"displayItems": "Display Items",
|
||||
"displayItemsDesc": "Choose which metrics to display on the server stats page",
|
||||
"enableCpu": "CPU Usage",
|
||||
"enableMemory": "Memory Usage",
|
||||
"enableDisk": "Disk Usage",
|
||||
"enableNetwork": "Network Statistics (Coming Soon)",
|
||||
"enableProcesses": "Process Count (Coming Soon)",
|
||||
"enableUptime": "Uptime (Coming Soon)",
|
||||
"enableHostname": "Hostname (Coming Soon)",
|
||||
"enableOs": "Operating System (Coming Soon)",
|
||||
"customCommands": "Custom Commands (Coming Soon)",
|
||||
"customCommandsDesc": "Define custom shutdown and reboot commands for this server",
|
||||
"shutdownCommand": "Shutdown Command",
|
||||
"rebootCommand": "Reboot Command",
|
||||
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".",
|
||||
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
|
||||
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||
"failedToRenameFolder": "Failed to rename folder",
|
||||
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||
"failedToMoveToFolder": "Failed to move host to folder"
|
||||
"failedToMoveToFolder": "Failed to move host to folder",
|
||||
"statistics": "Statistics",
|
||||
"enabledWidgets": "Enabled Widgets",
|
||||
"enabledWidgetsDesc": "Select which statistics widgets to display for this host",
|
||||
"monitoringConfiguration": "Monitoring Configuration",
|
||||
"monitoringConfigurationDesc": "Configure how often server statistics and status are checked",
|
||||
"statusCheckEnabled": "Enable Status Monitoring",
|
||||
"statusCheckEnabledDesc": "Check if the server is online or offline",
|
||||
"statusCheckInterval": "Status Check Interval",
|
||||
"statusCheckIntervalDesc": "How often to check if host is online (5s - 1h)",
|
||||
"metricsEnabled": "Enable Metrics Monitoring",
|
||||
"metricsEnabledDesc": "Collect CPU, RAM, disk, and other system statistics",
|
||||
"metricsInterval": "Metrics Collection Interval",
|
||||
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
|
||||
"intervalSeconds": "seconds",
|
||||
"intervalMinutes": "minutes",
|
||||
"intervalValidation": "Monitoring intervals must be between 5 seconds and 1 hour (3600 seconds)",
|
||||
"monitoringDisabled": "Server monitoring is disabled for this host",
|
||||
"enableMonitoring": "Enable monitoring in Host Manager → Statistics tab",
|
||||
"monitoringDisabledBadge": "Monitoring Off",
|
||||
"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.",
|
||||
"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)."
|
||||
},
|
||||
"terminal": {
|
||||
"title": "Terminal",
|
||||
@@ -710,7 +816,11 @@
|
||||
"connectionTimeout": "Connection timeout",
|
||||
"terminalTitle": "Terminal - {{host}}",
|
||||
"terminalWithPath": "Terminal - {{host}}:{{path}}",
|
||||
"runTitle": "Running {{command}} - {{host}}"
|
||||
"runTitle": "Running {{command}} - {{host}}",
|
||||
"totpRequired": "Two-Factor Authentication Required",
|
||||
"totpCodeLabel": "Verification Code",
|
||||
"totpPlaceholder": "000000",
|
||||
"totpVerify": "Verify"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "File Manager",
|
||||
@@ -994,7 +1104,9 @@
|
||||
"fileComparison": "File Comparison: {{file1}} vs {{file2}}",
|
||||
"fileTooLarge": "File too large: {{error}}",
|
||||
"sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})",
|
||||
"loadFileFailed": "Failed to load file: {{error}}"
|
||||
"loadFileFailed": "Failed to load file: {{error}}",
|
||||
"connectedSuccessfully": "Connected successfully",
|
||||
"totpVerificationFailed": "TOTP verification failed"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH Tunnels",
|
||||
@@ -1083,6 +1195,7 @@
|
||||
"loadAverageNA": "Avg: N/A",
|
||||
"cpuUsage": "CPU Usage",
|
||||
"memoryUsage": "Memory Usage",
|
||||
"diskUsage": "Disk Usage",
|
||||
"rootStorageSpace": "Root Storage Space",
|
||||
"of": "of",
|
||||
"feedbackMessage": "Have ideas for what should come next for server management? Share them on",
|
||||
@@ -1094,9 +1207,29 @@
|
||||
"refreshing": "Refreshing...",
|
||||
"serverOffline": "Server Offline",
|
||||
"cannotFetchMetrics": "Cannot fetch metrics from offline server",
|
||||
"totpRequired": "TOTP Authentication Required",
|
||||
"totpUnavailable": "Server Stats unavailable for TOTP-enabled servers",
|
||||
"load": "Load",
|
||||
"free": "Free",
|
||||
"available": "Available"
|
||||
"available": "Available",
|
||||
"editLayout": "Edit Layout",
|
||||
"cancelEdit": "Cancel",
|
||||
"addWidget": "Add Widget",
|
||||
"saveLayout": "Save Layout",
|
||||
"unsavedChanges": "Unsaved changes",
|
||||
"layoutSaved": "Layout saved successfully",
|
||||
"failedToSaveLayout": "Failed to save layout",
|
||||
"systemInfo": "System Information",
|
||||
"hostname": "Hostname",
|
||||
"operatingSystem": "Operating System",
|
||||
"kernel": "Kernel",
|
||||
"totalUptime": "Total Uptime",
|
||||
"seconds": "seconds",
|
||||
"networkInterfaces": "Network Interfaces",
|
||||
"noInterfacesFound": "No network interfaces found",
|
||||
"totalProcesses": "Total Processes",
|
||||
"running": "Running",
|
||||
"noProcessesFound": "No processes found"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "Login to Termix",
|
||||
@@ -1119,6 +1252,7 @@
|
||||
"enterCode": "Enter verification code",
|
||||
"backupCode": "Or use backup code",
|
||||
"verifyCode": "Verify Code",
|
||||
"redirectingToApp": "Redirecting to app...",
|
||||
"enableTwoFactor": "Enable Two-Factor Authentication",
|
||||
"disableTwoFactor": "Disable Two-Factor Authentication",
|
||||
"scanQRCode": "Scan this QR code with your authenticator app",
|
||||
@@ -1146,6 +1280,16 @@
|
||||
"yourBackupCodes": "Your Backup Codes",
|
||||
"download": "Download",
|
||||
"setupTwoFactorTitle": "Set Up Two-Factor Authentication",
|
||||
"sshAuthenticationRequired": "SSH Authentication Required",
|
||||
"sshNoKeyboardInteractive": "Keyboard-Interactive Authentication Unavailable",
|
||||
"sshAuthenticationFailed": "Authentication Failed",
|
||||
"sshAuthenticationTimeout": "Authentication Timeout",
|
||||
"sshNoKeyboardInteractiveDescription": "The server does not support keyboard-interactive authentication. Please provide your password or SSH key.",
|
||||
"sshAuthFailedDescription": "The provided credentials were incorrect. Please try again with valid credentials.",
|
||||
"sshTimeoutDescription": "The authentication attempt timed out. Please try again.",
|
||||
"sshProvideCredentialsDescription": "Please provide your SSH credentials to connect to this server.",
|
||||
"sshPasswordDescription": "Enter the password for this SSH connection.",
|
||||
"sshKeyPasswordDescription": "If your SSH key is encrypted, enter the passphrase here.",
|
||||
"step1ScanQR": "Step 1: Scan the QR code with your authenticator app",
|
||||
"manualEntryCode": "Manual Entry Code",
|
||||
"cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app",
|
||||
@@ -1178,9 +1322,17 @@
|
||||
"newPassword": "New Password",
|
||||
"confirmNewPassword": "Confirm Password",
|
||||
"enterNewPassword": "Enter your new password for user:",
|
||||
"passwordResetSuccess": "Success!",
|
||||
"passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.",
|
||||
"signUp": "Sign Up"
|
||||
"signUp": "Sign Up",
|
||||
"mobileApp": "Mobile App",
|
||||
"loggingInToMobileApp": "Logging in to the mobile app",
|
||||
"desktopApp": "Desktop App",
|
||||
"loggingInToDesktopApp": "Logging in to the desktop app",
|
||||
"loggingInToDesktopAppViaWeb": "Logging in to the desktop app via web interface",
|
||||
"loadingServer": "Loading server...",
|
||||
"authenticating": "Authenticating...",
|
||||
"dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.",
|
||||
"authenticationDisabled": "Authentication Disabled",
|
||||
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Page not found",
|
||||
@@ -1188,7 +1340,7 @@
|
||||
"forbidden": "Access forbidden",
|
||||
"serverError": "Server error",
|
||||
"networkError": "Network error",
|
||||
"databaseConnection": "Could not connect to the database.",
|
||||
"databaseConnection": "Could not connect to the database",
|
||||
"unknownError": "Unknown error",
|
||||
"loginFailed": "Login failed",
|
||||
"failedPasswordReset": "Failed to initiate password reset",
|
||||
@@ -1206,6 +1358,7 @@
|
||||
"maxLength": "Maximum length is {{max}}",
|
||||
"invalidEmail": "Invalid email address",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordLoginDisabled": "Username/password login is currently disabled",
|
||||
"weakPassword": "Password is too weak",
|
||||
"usernameExists": "Username already exists",
|
||||
"emailExists": "Email already exists",
|
||||
@@ -1251,7 +1404,10 @@
|
||||
"authMethod": "Authentication Method",
|
||||
"local": "Local",
|
||||
"external": "External (OIDC)",
|
||||
"selectPreferredLanguage": "Select your preferred language for the interface"
|
||||
"selectPreferredLanguage": "Select your preferred language for the interface",
|
||||
"currentPassword": "Current Password",
|
||||
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
|
||||
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "Failed to load version information"
|
||||
@@ -1310,6 +1466,7 @@
|
||||
"closeDeleteAccount": "Close Delete Account",
|
||||
"deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and all associated data.",
|
||||
"deleteAccountWarningDetails": "Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible.",
|
||||
"deleteAccountWarningShort": "This action is not reversible and will permanently delete your account.",
|
||||
"cannotDeleteAccount": "Cannot Delete Account",
|
||||
"lastAdminWarning": "You are the last admin user. You cannot delete your account as this would leave the system without any administrators. Please make another user an admin first, or contact system support.",
|
||||
"confirmPassword": "Confirm Password",
|
||||
@@ -1430,5 +1587,38 @@
|
||||
"mobileAppInProgressDesc": "We're working on a dedicated mobile app to provide a better experience on mobile devices.",
|
||||
"viewMobileAppDocs": "Install Mobile App",
|
||||
"mobileAppDocumentation": "Mobile App Documentation"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"github": "GitHub",
|
||||
"support": "Support",
|
||||
"discord": "Discord",
|
||||
"donate": "Donate",
|
||||
"serverOverview": "Server Overview",
|
||||
"version": "Version",
|
||||
"upToDate": "Up to Date",
|
||||
"updateAvailable": "Update Available",
|
||||
"uptime": "Uptime",
|
||||
"database": "Database",
|
||||
"healthy": "Healthy",
|
||||
"error": "Error",
|
||||
"totalServers": "Total Servers",
|
||||
"totalTunnels": "Total Tunnels",
|
||||
"totalCredentials": "Total Credentials",
|
||||
"recentActivity": "Recent Activity",
|
||||
"reset": "Reset",
|
||||
"loadingRecentActivity": "Loading recent activity...",
|
||||
"noRecentActivity": "No recent activity",
|
||||
"quickActions": "Quick Actions",
|
||||
"addHost": "Add Host",
|
||||
"addCredential": "Add Credential",
|
||||
"adminSettings": "Admin Settings",
|
||||
"userProfile": "User Profile",
|
||||
"serverStats": "Server Stats",
|
||||
"loadingServerStats": "Loading server stats...",
|
||||
"noServerData": "No server data available",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"notAvailable": "N/A"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -189,6 +189,40 @@
|
||||
"enableRightClickCopyPaste": "启用右键复制/粘贴",
|
||||
"shareIdeas": "对 SSH 工具有什么想法?在此分享"
|
||||
},
|
||||
"snippets": {
|
||||
"title": "代码片段",
|
||||
"new": "新建片段",
|
||||
"create": "创建代码片段",
|
||||
"edit": "编辑代码片段",
|
||||
"run": "运行",
|
||||
"empty": "暂无代码片段",
|
||||
"emptyHint": "创建代码片段以保存常用命令",
|
||||
"name": "名称",
|
||||
"description": "描述",
|
||||
"content": "命令",
|
||||
"namePlaceholder": "例如: 重启 Nginx",
|
||||
"descriptionPlaceholder": "可选描述",
|
||||
"contentPlaceholder": "例如: sudo systemctl restart nginx",
|
||||
"nameRequired": "名称不能为空",
|
||||
"contentRequired": "命令不能为空",
|
||||
"createDescription": "创建新的命令片段以便快速执行",
|
||||
"editDescription": "编辑此命令片段",
|
||||
"deleteConfirmTitle": "删除代码片段",
|
||||
"deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?",
|
||||
"createSuccess": "代码片段创建成功",
|
||||
"updateSuccess": "代码片段更新成功",
|
||||
"deleteSuccess": "代码片段删除成功",
|
||||
"createFailed": "创建代码片段失败",
|
||||
"updateFailed": "更新代码片段失败",
|
||||
"deleteFailed": "删除代码片段失败",
|
||||
"failedToFetch": "获取代码片段失败",
|
||||
"executeSuccess": "正在执行: {{name}}",
|
||||
"copySuccess": "已复制 \"{{name}}\" 到剪贴板",
|
||||
"runTooltip": "在终端中执行此片段",
|
||||
"copyTooltip": "复制片段到剪贴板",
|
||||
"editTooltip": "编辑此片段",
|
||||
"deleteTooltip": "删除此片段"
|
||||
},
|
||||
"homepage": {
|
||||
"loggedInTitle": "登录成功!",
|
||||
"loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。",
|
||||
@@ -311,6 +345,8 @@
|
||||
"settingUp": "设置中...",
|
||||
"next": "下一步",
|
||||
"previous": "上一步",
|
||||
"connect": "连接",
|
||||
"connecting": "连接中...",
|
||||
"refresh": "刷新",
|
||||
"settings": "设置",
|
||||
"profile": "个人资料",
|
||||
@@ -319,6 +355,8 @@
|
||||
"language": "语言",
|
||||
"autoDetect": "自动检测",
|
||||
"changeAccountPassword": "修改您的账户密码",
|
||||
"passwordResetTitle": "重置密码",
|
||||
"passwordResetDescription": "您即将重置密码。此操作将使您从所有活动会话中注销。",
|
||||
"enterSixDigitCode": "输入来自 docker 容器日志中用户的 6 位数代码:",
|
||||
"enterNewPassword": "为用户输入新密码:",
|
||||
"passwordsDoNotMatch": "密码不匹配",
|
||||
@@ -343,6 +381,7 @@
|
||||
"admin": "管理员",
|
||||
"userProfile": "用户资料",
|
||||
"tools": "工具",
|
||||
"snippets": "代码片段",
|
||||
"newTab": "新标签页",
|
||||
"splitScreen": "分屏",
|
||||
"closeTab": "关闭标签页",
|
||||
@@ -396,10 +435,12 @@
|
||||
"general": "常规",
|
||||
"userRegistration": "用户注册",
|
||||
"allowNewAccountRegistration": "允许新账户注册",
|
||||
"allowPasswordLogin": "允许用户名/密码登录",
|
||||
"missingRequiredFields": "缺少必填字段:{{fields}}",
|
||||
"oidcConfigurationUpdated": "OIDC 配置更新成功!",
|
||||
"failedToFetchOidcConfig": "获取 OIDC 配置失败",
|
||||
"failedToFetchRegistrationStatus": "获取注册状态失败",
|
||||
"failedToFetchPasswordLoginStatus": "获取密码登录状态失败",
|
||||
"failedToFetchUsers": "获取用户列表失败",
|
||||
"oidcConfigurationDisabled": "OIDC 配置禁用成功!",
|
||||
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
|
||||
@@ -530,7 +571,17 @@
|
||||
"passwordRequired": "密码为必填项",
|
||||
"confirmExport": "确认导出",
|
||||
"exportDescription": "将SSH主机和凭据导出为SQLite文件",
|
||||
"importDescription": "导入SQLite文件并进行增量合并(跳过重复项)"
|
||||
"importDescription": "导入SQLite文件并进行增量合并(跳过重复项)",
|
||||
"criticalWarning": "严重警告",
|
||||
"cannotDisablePasswordLoginWithoutOIDC": "无法在未配置 OIDC 的情况下禁用密码登录!您必须先配置 OIDC 认证,然后再禁用密码登录,否则您将失去对 Termix 的访问权限。",
|
||||
"confirmDisablePasswordLogin": "您确定要禁用密码登录吗?在继续之前,请确保 OIDC 已正确配置并且正常工作,否则您将失去对 Termix 实例的访问权限。",
|
||||
"passwordLoginDisabled": "密码登录已成功禁用",
|
||||
"passwordLoginAndRegistrationDisabled": "密码登录和新账户注册已成功禁用",
|
||||
"requiresPasswordLogin": "需要启用密码登录",
|
||||
"passwordLoginDisabledWarning": "密码登录已禁用。请确保 OIDC 已正确配置,否则您将无法登录 Termix。",
|
||||
"oidcRequiredWarning": "严重警告:密码登录已禁用。如果您重置或错误配置 OIDC,您将失去对 Termix 的所有访问权限并使您的实例无法使用。只有在您完全确定的情况下才能继续。",
|
||||
"confirmDisableOIDCWarning": "警告:您即将在密码登录也已禁用的情况下禁用 OIDC。这将使您的 Termix 实例无法使用,您将失去所有访问权限。您确定要继续吗?",
|
||||
"failedToUpdatePasswordLoginStatus": "更新密码登录状态失败"
|
||||
},
|
||||
"hosts": {
|
||||
"title": "主机管理",
|
||||
@@ -636,6 +687,7 @@
|
||||
"password": "密码",
|
||||
"key": "密钥",
|
||||
"credential": "凭证",
|
||||
"none": "无",
|
||||
"selectCredential": "选择凭证",
|
||||
"selectCredentialPlaceholder": "选择一个凭证...",
|
||||
"credentialRequired": "使用凭证认证时需要选择凭证",
|
||||
@@ -691,19 +743,68 @@
|
||||
"terminal": "终端",
|
||||
"tunnel": "隧道",
|
||||
"fileManager": "文件管理器",
|
||||
"serverStats": "服务器统计",
|
||||
"hostViewer": "主机查看器",
|
||||
"enableServerStats": "启用服务器统计",
|
||||
"enableServerStatsDesc": "启用/禁用此主机的服务器统计信息收集",
|
||||
"displayItems": "显示项目",
|
||||
"displayItemsDesc": "选择在服务器统计页面上显示哪些指标",
|
||||
"enableCpu": "CPU使用率",
|
||||
"enableMemory": "内存使用率",
|
||||
"enableDisk": "磁盘使用率",
|
||||
"enableNetwork": "网络统计(即将推出)",
|
||||
"enableProcesses": "进程数(即将推出)",
|
||||
"enableUptime": "运行时间(即将推出)",
|
||||
"enableHostname": "主机名(即将推出)",
|
||||
"enableOs": "操作系统(即将推出)",
|
||||
"customCommands": "自定义命令(即将推出)",
|
||||
"customCommandsDesc": "为此服务器定义自定义关机和重启命令",
|
||||
"shutdownCommand": "关机命令",
|
||||
"rebootCommand": "重启命令",
|
||||
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
||||
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||
"failedToRenameFolder": "重命名文件夹失败",
|
||||
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||
"failedToMoveToFolder": "移动主机到文件夹失败"
|
||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||
"statistics": "统计",
|
||||
"enabledWidgets": "已启用组件",
|
||||
"enabledWidgetsDesc": "选择要为此主机显示的统计组件",
|
||||
"monitoringConfiguration": "监控配置",
|
||||
"monitoringConfigurationDesc": "配置服务器统计信息和状态的检查频率",
|
||||
"statusCheckEnabled": "启用状态监控",
|
||||
"statusCheckEnabledDesc": "检查服务器是在线还是离线",
|
||||
"statusCheckInterval": "状态检查间隔",
|
||||
"statusCheckIntervalDesc": "检查主机是否在线的频率 (5秒 - 1小时)",
|
||||
"metricsEnabled": "启用指标监控",
|
||||
"metricsEnabledDesc": "收集CPU、内存、磁盘和其他系统统计信息",
|
||||
"metricsInterval": "指标收集间隔",
|
||||
"metricsIntervalDesc": "收集服务器统计信息的频率 (5秒 - 1小时)",
|
||||
"intervalSeconds": "秒",
|
||||
"intervalMinutes": "分钟",
|
||||
"intervalValidation": "监控间隔必须在 5 秒到 1 小时(3600 秒)之间",
|
||||
"monitoringDisabled": "此主机的服务器监控已禁用",
|
||||
"enableMonitoring": "在主机管理器 → 统计选项卡中启用监控",
|
||||
"monitoringDisabledBadge": "监控已关闭",
|
||||
"statusMonitoring": "状态",
|
||||
"metricsMonitoring": "指标",
|
||||
"terminalCustomizationNotice": "注意:终端自定义仅在桌面网站版本中有效。移动和 Electron 应用程序使用系统默认终端设置。",
|
||||
"noneAuthTitle": "键盘交互式认证",
|
||||
"noneAuthDescription": "此认证方法在连接到 SSH 服务器时将使用键盘交互式认证。",
|
||||
"noneAuthDetails": "键盘交互式认证允许服务器在连接期间提示您输入凭据。这对于需要多因素认证或动态密码输入的服务器很有用。",
|
||||
"forceKeyboardInteractive": "强制键盘交互式认证",
|
||||
"forceKeyboardInteractiveDesc": "强制使用键盘交互式认证。这通常是使用双因素认证(TOTP/2FA)的服务器所必需的。"
|
||||
},
|
||||
"terminal": {
|
||||
"title": "终端",
|
||||
"terminalTitle": "终端 - {{host}}",
|
||||
"terminalWithPath": "终端 - {{host}}:{{path}}",
|
||||
"runTitle": "运行 {{command}} - {{host}}",
|
||||
"totpRequired": "需要双因素认证",
|
||||
"totpCodeLabel": "验证码",
|
||||
"totpPlaceholder": "000000",
|
||||
"totpVerify": "验证",
|
||||
"connect": "连接主机",
|
||||
"disconnect": "断开连接",
|
||||
"clear": "清屏",
|
||||
@@ -985,7 +1086,9 @@
|
||||
"fileComparison": "文件对比:{{file1}} 与 {{file2}}",
|
||||
"fileTooLarge": "文件过大:{{error}}",
|
||||
"sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接",
|
||||
"loadFileFailed": "加载文件失败:{{error}}"
|
||||
"loadFileFailed": "加载文件失败:{{error}}",
|
||||
"connectedSuccessfully": "连接成功",
|
||||
"totpVerificationFailed": "TOTP 验证失败"
|
||||
},
|
||||
"tunnels": {
|
||||
"title": "SSH 隧道",
|
||||
@@ -1063,6 +1166,7 @@
|
||||
"loadAverageNA": "平均: N/A",
|
||||
"cpuUsage": "CPU 使用率",
|
||||
"memoryUsage": "内存使用率",
|
||||
"diskUsage": "磁盘使用率",
|
||||
"rootStorageSpace": "根目录存储空间",
|
||||
"of": "的",
|
||||
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",
|
||||
@@ -1073,7 +1177,29 @@
|
||||
"refreshing": "正在刷新...",
|
||||
"serverOffline": "服务器离线",
|
||||
"cannotFetchMetrics": "无法从离线服务器获取指标",
|
||||
"load": "负载"
|
||||
"totpRequired": "需要 TOTP 认证",
|
||||
"totpUnavailable": "启用了 TOTP 的服务器无法使用服务器统计功能",
|
||||
"load": "负载",
|
||||
"free": "空闲",
|
||||
"available": "可用",
|
||||
"editLayout": "编辑布局",
|
||||
"cancelEdit": "取消",
|
||||
"addWidget": "添加小组件",
|
||||
"saveLayout": "保存布局",
|
||||
"unsavedChanges": "有未保存的更改",
|
||||
"layoutSaved": "布局保存成功",
|
||||
"failedToSaveLayout": "保存布局失败",
|
||||
"systemInfo": "系统信息",
|
||||
"hostname": "主机名",
|
||||
"operatingSystem": "操作系统",
|
||||
"kernel": "内核",
|
||||
"totalUptime": "总运行时间",
|
||||
"seconds": "秒",
|
||||
"networkInterfaces": "网络接口",
|
||||
"noInterfacesFound": "未找到网络接口",
|
||||
"totalProcesses": "总进程数",
|
||||
"running": "运行中",
|
||||
"noProcessesFound": "未找到进程"
|
||||
},
|
||||
"auth": {
|
||||
"loginTitle": "登录 Termix",
|
||||
@@ -1157,7 +1283,18 @@
|
||||
"enterNewPassword": "为用户输入新密码:",
|
||||
"passwordResetSuccess": "成功!",
|
||||
"passwordResetSuccessDesc": "您的密码已成功重置!您现在可以使用新密码登录。",
|
||||
"signUp": "注册"
|
||||
"signUp": "注册",
|
||||
"dataLossWarning": "以这种方式重置密码将删除所有已保存的 SSH 主机、凭据和其他加密数据。此操作无法撤销。仅当您忘记密码且未登录时才使用此功能。",
|
||||
"sshAuthenticationRequired": "需要 SSH 身份验证",
|
||||
"sshNoKeyboardInteractive": "键盘交互式身份验证不可用",
|
||||
"sshAuthenticationFailed": "身份验证失败",
|
||||
"sshAuthenticationTimeout": "身份验证超时",
|
||||
"sshNoKeyboardInteractiveDescription": "服务器不支持键盘交互式身份验证。请提供您的密码或 SSH 密钥。",
|
||||
"sshAuthFailedDescription": "提供的凭据不正确。请使用有效凭据重试。",
|
||||
"sshTimeoutDescription": "身份验证尝试超时。请重试。",
|
||||
"sshProvideCredentialsDescription": "请提供您的 SSH 凭据以连接到此服务器。",
|
||||
"sshPasswordDescription": "输入此 SSH 连接的密码。",
|
||||
"sshKeyPasswordDescription": "如果您的 SSH 密钥已加密,请在此处输入密码。"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "页面未找到",
|
||||
@@ -1183,6 +1320,7 @@
|
||||
"maxLength": "最大长度为 {{max}}",
|
||||
"invalidEmail": "邮箱地址无效",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"passwordLoginDisabled": "用户名/密码登录当前已禁用",
|
||||
"weakPassword": "密码强度太弱",
|
||||
"usernameExists": "用户名已存在",
|
||||
"emailExists": "邮箱已存在",
|
||||
@@ -1228,7 +1366,10 @@
|
||||
"authMethod": "认证方式",
|
||||
"local": "本地",
|
||||
"external": "外部 (OIDC)",
|
||||
"selectPreferredLanguage": "选择您的界面首选语言"
|
||||
"selectPreferredLanguage": "选择您的界面首选语言",
|
||||
"currentPassword": "当前密码",
|
||||
"passwordChangedSuccess": "密码修改成功!请重新登录。",
|
||||
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
|
||||
},
|
||||
"user": {
|
||||
"failedToLoadVersionInfo": "加载版本信息失败"
|
||||
@@ -1287,6 +1428,7 @@
|
||||
"closeDeleteAccount": "关闭删除账户",
|
||||
"deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。",
|
||||
"deleteAccountWarningDetails": "删除您的账户将删除所有数据,包括 SSH 主机、配置和设置。此操作不可逆。",
|
||||
"deleteAccountWarningShort": "此操作不可逆,将永久删除您的帐户。",
|
||||
"cannotDeleteAccount": "无法删除账户",
|
||||
"lastAdminWarning": "您是最后一个管理员用户。您不能删除自己的账户,否则系统将没有任何管理员。请先将其他用户设为管理员,或联系系统支持。",
|
||||
"confirmPassword": "确认密码",
|
||||
@@ -1337,5 +1479,38 @@
|
||||
"mobileAppInProgressDesc": "我们正在开发专门的移动应用,为移动设备提供更好的体验。",
|
||||
"viewMobileAppDocs": "安装移动应用",
|
||||
"mobileAppDocumentation": "移动应用文档"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表板",
|
||||
"github": "GitHub",
|
||||
"support": "支持",
|
||||
"discord": "Discord",
|
||||
"donate": "捐赠",
|
||||
"serverOverview": "服务器概览",
|
||||
"version": "版本",
|
||||
"upToDate": "已是最新",
|
||||
"updateAvailable": "有可用更新",
|
||||
"uptime": "运行时间",
|
||||
"database": "数据库",
|
||||
"healthy": "健康",
|
||||
"error": "错误",
|
||||
"totalServers": "服务器总数",
|
||||
"totalTunnels": "隧道总数",
|
||||
"totalCredentials": "凭据总数",
|
||||
"recentActivity": "最近活动",
|
||||
"reset": "重置",
|
||||
"loadingRecentActivity": "正在加载最近活动...",
|
||||
"noRecentActivity": "无最近活动",
|
||||
"quickActions": "快速操作",
|
||||
"addHost": "添加主机",
|
||||
"addCredential": "添加凭据",
|
||||
"adminSettings": "管理员设置",
|
||||
"userProfile": "用户资料",
|
||||
"serverStats": "服务器统计",
|
||||
"loadingServerStats": "正在加载服务器统计...",
|
||||
"noServerData": "无可用服务器数据",
|
||||
"cpu": "CPU",
|
||||
"ram": "内存",
|
||||
"notAvailable": "不可用"
|
||||
}
|
||||
}
|
||||
|
||||
+52
-8
@@ -1,15 +1,16 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { StrictMode, useEffect, useState, useRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import DesktopApp from "./ui/Desktop/DesktopApp.tsx";
|
||||
import { MobileApp } from "./ui/Mobile/MobileApp.tsx";
|
||||
import DesktopApp from "@/ui/desktop/DesktopApp.tsx";
|
||||
import { MobileApp } from "@/ui/mobile/MobileApp.tsx";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
|
||||
import "./i18n/i18n";
|
||||
import { isElectron } from "./ui/main-axios.ts";
|
||||
|
||||
function useWindowWidth() {
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const lastSwitchTime = useRef(0);
|
||||
const isCurrentlyMobile = useRef(window.innerWidth < 768);
|
||||
const hasSwitchedOnce = useRef(false);
|
||||
@@ -36,7 +37,6 @@ function useWindowWidth() {
|
||||
isCurrentlyMobile.current = newIsMobile;
|
||||
hasSwitchedOnce.current = true;
|
||||
setWidth(newWidth);
|
||||
setIsMobile(newIsMobile);
|
||||
} else {
|
||||
setWidth(newWidth);
|
||||
}
|
||||
@@ -56,11 +56,55 @@ function useWindowWidth() {
|
||||
function RootApp() {
|
||||
const width = useWindowWidth();
|
||||
const isMobile = width < 768;
|
||||
if (isElectron()) {
|
||||
return <DesktopApp />;
|
||||
}
|
||||
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
||||
|
||||
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||
const userAgent =
|
||||
navigator.userAgent || navigator.vendor || (window as any).opera || "";
|
||||
const isTermixMobile = /Termix-Mobile/.test(userAgent);
|
||||
|
||||
const renderApp = () => {
|
||||
if (isElectron()) {
|
||||
return <DesktopApp />;
|
||||
}
|
||||
|
||||
if (isTermixMobile) {
|
||||
return <MobileApp key="mobile" />;
|
||||
}
|
||||
|
||||
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 pointer-events-none"
|
||||
style={{
|
||||
backgroundColor: "#09090b",
|
||||
backgroundImage: `linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
transparent 49%,
|
||||
rgba(255, 255, 255, 0.03) 49%,
|
||||
rgba(255, 255, 255, 0.03) 51%,
|
||||
transparent 51%,
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: "80px 80px",
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
<div className="relative min-h-screen" style={{ zIndex: 1 }}>
|
||||
{isElectron() && showVersionCheck ? (
|
||||
<ElectronVersionCheck
|
||||
onContinue={() => setShowVersionCheck(false)}
|
||||
isAuthenticated={false}
|
||||
/>
|
||||
) : (
|
||||
renderApp()
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
|
||||
Vendored
+35
-8
@@ -1,22 +1,49 @@
|
||||
interface ServerConfig {
|
||||
serverUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DialogOptions {
|
||||
title?: string;
|
||||
defaultPath?: string;
|
||||
buttonLabel?: string;
|
||||
filters?: Array<{ name: string; extensions: string[] }>;
|
||||
properties?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DialogResult {
|
||||
canceled: boolean;
|
||||
filePath?: string;
|
||||
filePaths?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
getPlatform: () => Promise<string>;
|
||||
|
||||
getServerConfig: () => Promise<any>;
|
||||
saveServerConfig: (config: any) => Promise<any>;
|
||||
testServerConnection: (serverUrl: string) => Promise<any>;
|
||||
getServerConfig: () => Promise<ServerConfig>;
|
||||
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
|
||||
testServerConnection: (serverUrl: string) => Promise<ConnectionTestResult>;
|
||||
|
||||
showSaveDialog: (options: any) => Promise<any>;
|
||||
showOpenDialog: (options: any) => Promise<any>;
|
||||
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
|
||||
|
||||
onUpdateAvailable: (callback: Function) => void;
|
||||
onUpdateDownloaded: (callback: Function) => void;
|
||||
onUpdateAvailable: (callback: () => void) => void;
|
||||
onUpdateDownloaded: (callback: () => void) => void;
|
||||
|
||||
removeAllListeners: (channel: string) => void;
|
||||
isElectron: boolean;
|
||||
isDev: boolean;
|
||||
|
||||
invoke: (channel: string, ...args: any[]) => Promise<any>;
|
||||
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
|
||||
|
||||
createTempFile: (fileData: {
|
||||
fileName: string;
|
||||
|
||||
+160
-16
@@ -1,9 +1,5 @@
|
||||
// ============================================================================
|
||||
// CENTRAL TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
// This file contains all shared interfaces and types used across the application
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
import type { Request } from "express";
|
||||
|
||||
// ============================================================================
|
||||
// SSH HOST TYPES
|
||||
@@ -18,11 +14,12 @@ export interface SSHHost {
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: "password" | "key" | "credential";
|
||||
authType: "password" | "key" | "credential" | "none";
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
|
||||
autostartPassword?: string;
|
||||
autostartKey?: string;
|
||||
@@ -35,6 +32,8 @@ export interface SSHHost {
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: TunnelConnection[];
|
||||
statsConfig?: string;
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -47,7 +46,7 @@ export interface SSHHostData {
|
||||
folder?: string;
|
||||
tags?: string[];
|
||||
pin?: boolean;
|
||||
authType: "password" | "key" | "credential";
|
||||
authType: "password" | "key" | "credential" | "none";
|
||||
password?: string;
|
||||
key?: File | null;
|
||||
keyPassword?: string;
|
||||
@@ -57,7 +56,10 @@ export interface SSHHostData {
|
||||
enableTunnel?: boolean;
|
||||
enableFileManager?: boolean;
|
||||
defaultPath?: string;
|
||||
tunnelConnections?: any[];
|
||||
forceKeyboardInteractive?: boolean;
|
||||
tunnelConnections?: TunnelConnection[];
|
||||
statsConfig?: string | Record<string, unknown>;
|
||||
terminalConfig?: TerminalConfig;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -106,7 +108,6 @@ export interface TunnelConnection {
|
||||
endpointPort: number;
|
||||
endpointHost: string;
|
||||
|
||||
// Endpoint host credentials for tunnel authentication
|
||||
endpointPassword?: string;
|
||||
endpointKey?: string;
|
||||
endpointKeyPassword?: string;
|
||||
@@ -246,6 +247,34 @@ export interface TermixAlert {
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL CONFIGURATION TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface TerminalConfig {
|
||||
cursorBlink: boolean;
|
||||
cursorStyle: "block" | "underline" | "bar";
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
theme: string;
|
||||
|
||||
scrollback: number;
|
||||
bellStyle: "none" | "sound" | "visual" | "both";
|
||||
rightClickSelectsWord: boolean;
|
||||
fastScrollModifier: "alt" | "ctrl" | "shift";
|
||||
fastScrollSensitivity: number;
|
||||
minimumContrastRatio: number;
|
||||
|
||||
backspaceMode: "normal" | "control-h";
|
||||
agentForwarding: boolean;
|
||||
environmentVariables: Array<{ key: string; value: string }>;
|
||||
startupSnippetId: number | null;
|
||||
autoMosh: boolean;
|
||||
moshCommand: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TAB TYPES
|
||||
// ============================================================================
|
||||
@@ -261,8 +290,9 @@ export interface TabContextTab {
|
||||
| "file_manager"
|
||||
| "user_profile";
|
||||
title: string;
|
||||
hostConfig?: any;
|
||||
terminalRef?: React.RefObject<any>;
|
||||
hostConfig?: SSHHost;
|
||||
terminalRef?: any;
|
||||
initialTab?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -295,7 +325,7 @@ export type ErrorType =
|
||||
// AUTHENTICATION TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type AuthType = "password" | "key" | "credential";
|
||||
export type AuthType = "password" | "key" | "credential" | "none";
|
||||
|
||||
export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||
|
||||
@@ -303,7 +333,7 @@ export type KeyType = "rsa" | "ecdsa" | "ed25519";
|
||||
// API RESPONSE TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
export interface ApiResponse<T = unknown> {
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
@@ -337,6 +367,8 @@ export interface CredentialSelectorProps {
|
||||
export interface HostManagerProps {
|
||||
onSelectView?: (view: string) => void;
|
||||
isTopbarOpen?: boolean;
|
||||
initialTab?: string;
|
||||
hostConfig?: SSHHost;
|
||||
}
|
||||
|
||||
export interface SSHManagerHostEditorProps {
|
||||
@@ -366,13 +398,13 @@ export interface SSHTunnelViewerProps {
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>
|
||||
) => Promise<void>
|
||||
>;
|
||||
onTunnelAction?: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>;
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface FileManagerProps {
|
||||
@@ -400,7 +432,7 @@ export interface SSHTunnelObjectProps {
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>;
|
||||
) => Promise<void>;
|
||||
compact?: boolean;
|
||||
bare?: boolean;
|
||||
}
|
||||
@@ -413,6 +445,26 @@ export interface FolderStats {
|
||||
}>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SNIPPETS TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SnippetData {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BACKEND TYPES
|
||||
// ============================================================================
|
||||
@@ -439,3 +491,95 @@ export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
|
||||
export type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||
|
||||
// ============================================================================
|
||||
// EXPRESS REQUEST TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
userId: string;
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GITHUB API TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface GitHubAsset {
|
||||
id: number;
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
export interface GitHubRelease {
|
||||
id: number;
|
||||
tag_name: string;
|
||||
name: string;
|
||||
body: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
assets: GitHubAsset[];
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubAPIResponse<T> {
|
||||
data: T;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CACHE TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface CacheEntry<T = unknown> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DATABASE EXPORT/IMPORT TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface ExportSummary {
|
||||
sshHostsImported: number;
|
||||
sshCredentialsImported: number;
|
||||
fileManagerItemsImported: number;
|
||||
dismissedAlertsImported: number;
|
||||
credentialUsageImported: number;
|
||||
settingsImported: number;
|
||||
skippedItems: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
success: boolean;
|
||||
summary: ExportSummary;
|
||||
}
|
||||
|
||||
export interface ExportRequestBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ImportRequestBody {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface ExportPreviewBody {
|
||||
scope?: string;
|
||||
includeCredentials?: boolean;
|
||||
}
|
||||
|
||||
export interface RestoreRequestBody {
|
||||
backupPath: string;
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export type WidgetType =
|
||||
| "cpu"
|
||||
| "memory"
|
||||
| "disk"
|
||||
| "network"
|
||||
| "uptime"
|
||||
| "processes"
|
||||
| "system";
|
||||
|
||||
export interface StatsConfig {
|
||||
enabledWidgets: WidgetType[];
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number;
|
||||
metricsEnabled: boolean;
|
||||
metricsInterval: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
enabledWidgets: ["cpu", "memory", "disk", "network", "uptime", "system"],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 30,
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,480 +0,0 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { Cpu, HardDrive, MemoryStick } from "lucide-react";
|
||||
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { addTab, tabs } = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
toast.error(t("serverStats.failedToFetchStatus"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
setIsLoadingMetrics(true);
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentHostConfig?.id && isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some(
|
||||
(tab: any) =>
|
||||
tab.type === "file_manager" &&
|
||||
tab.hostConfig?.id === currentHostConfig.id,
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.refreshStatusAndMetrics")}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.cpuUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const coresText =
|
||||
typeof cores === "number"
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus");
|
||||
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.cpu?.percent === "number"
|
||||
? metrics!.cpu!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load
|
||||
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||
: "Load: N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.memoryUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText =
|
||||
typeof used === "number"
|
||||
? `${used.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
const totalText =
|
||||
typeof total === "number"
|
||||
? `${total.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.memory?.percent === "number"
|
||||
? metrics!.memory!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free =
|
||||
typeof used === "number" && typeof total === "number"
|
||||
? (total - used).toFixed(1)
|
||||
: "N/A";
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.rootStorageSpace")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText = used ?? "N/A";
|
||||
const totalText = total ?? "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.disk?.percent === "number"
|
||||
? metrics!.disk!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const available = metrics?.disk?.availableHuman;
|
||||
return available
|
||||
? `Available: ${available}`
|
||||
: "Available: N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("serverStats.feedbackMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,802 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
splitScreen?: boolean;
|
||||
onClose?: () => void;
|
||||
initialPath?: string;
|
||||
executeCommand?: string;
|
||||
}
|
||||
|
||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{
|
||||
hostConfig,
|
||||
isVisible,
|
||||
splitScreen = false,
|
||||
onClose,
|
||||
initialPath,
|
||||
executeCommand,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
if (typeof window !== "undefined" && !(window as any).testJWT) {
|
||||
(window as any).testJWT = () => {
|
||||
const jwt = getCookie("jwt");
|
||||
return jwt;
|
||||
};
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DEBOUNCE_MS = 140;
|
||||
|
||||
useEffect(() => {
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
setIsAuthenticated((prev) => {
|
||||
if (prev !== isAuth) {
|
||||
return isAuth;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||
|
||||
return () => clearInterval(authCheckInterval);
|
||||
}, []);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === "function") {
|
||||
(terminal as any).refresh(0, terminal.rows - 1);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = { cols, rows };
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
notifyTimerRef.current = setTimeout(() => {
|
||||
const next = pendingSizeRef.current;
|
||||
const last = lastSentSizeRef.current;
|
||||
if (!next) return;
|
||||
if (last && last.cols === next.cols && last.rows === next.rows) return;
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "resize", data: next }),
|
||||
);
|
||||
lastSentSizeRef.current = next;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
disconnect: () => {
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
},
|
||||
notifyResize: () => {
|
||||
try {
|
||||
const cols = terminal?.cols ?? undefined;
|
||||
const rows = terminal?.rows ?? undefined;
|
||||
if (typeof cols === "number" && typeof rows === "number") {
|
||||
scheduleNotify(cols, rows);
|
||||
hardRefresh();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
}),
|
||||
[terminal],
|
||||
);
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}
|
||||
|
||||
function getUseRightClickCopyPaste() {
|
||||
return getCookie("rightClickCopyPaste") === "true";
|
||||
}
|
||||
|
||||
function attemptReconnection() {
|
||||
if (
|
||||
isUnmountingRef.current ||
|
||||
shouldNotReconnectRef.current ||
|
||||
isReconnectingRef.current ||
|
||||
isConnectingRef.current ||
|
||||
wasDisconnectedBySSH.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
toast.error(t("terminal.maxReconnectAttemptsReached"));
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isReconnectingRef.current = true;
|
||||
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
reconnectAttempts.current++;
|
||||
|
||||
toast.info(
|
||||
t("terminal.reconnecting", {
|
||||
attempt: reconnectAttempts.current,
|
||||
max: maxReconnectAttempts,
|
||||
}),
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (
|
||||
isUnmountingRef.current ||
|
||||
shouldNotReconnectRef.current ||
|
||||
wasDisconnectedBySSH.current
|
||||
) {
|
||||
isReconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current > maxReconnectAttempts) {
|
||||
isReconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.warn("Reconnection cancelled - no authentication token");
|
||||
isReconnectingRef.current = false;
|
||||
setConnectionError("Authentication required for reconnection");
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal && hostConfig) {
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
connectToHost(cols, rows);
|
||||
}
|
||||
|
||||
isReconnectingRef.current = false;
|
||||
}, 2000 * reconnectAttempts.current);
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
if (isConnectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
console.error("No JWT token available for WebSocket connection");
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as any).configuredServerUrl || "http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||
) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
setConnectionError(null);
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(true);
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener("open", () => {
|
||||
connectionTimeoutRef.current = setTimeout(() => {
|
||||
if (!isConnected) {
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
toast.error(t("terminal.connectionTimeout"));
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
if (reconnectAttempts.current > 0) {
|
||||
attemptReconnection();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connectToHost",
|
||||
data: { cols, rows, hostConfig, initialPath, executeCommand },
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
terminal.write(msg.data);
|
||||
} else {
|
||||
terminal.write(String(msg.data));
|
||||
}
|
||||
} else if (msg.type === "error") {
|
||||
const errorMessage = msg.message || t("terminal.unknownError");
|
||||
|
||||
if (
|
||||
errorMessage.toLowerCase().includes("auth") ||
|
||||
errorMessage.toLowerCase().includes("password") ||
|
||||
errorMessage.toLowerCase().includes("permission") ||
|
||||
errorMessage.toLowerCase().includes("denied") ||
|
||||
errorMessage.toLowerCase().includes("invalid") ||
|
||||
errorMessage.toLowerCase().includes("failed") ||
|
||||
errorMessage.toLowerCase().includes("incorrect")
|
||||
) {
|
||||
toast.error(t("terminal.authError", { message: errorMessage }));
|
||||
shouldNotReconnectRef.current = true;
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.toLowerCase().includes("connection") ||
|
||||
errorMessage.toLowerCase().includes("timeout") ||
|
||||
errorMessage.toLowerCase().includes("network")
|
||||
) {
|
||||
toast.error(
|
||||
t("terminal.connectionError", { message: errorMessage }),
|
||||
);
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
wasDisconnectedBySSH.current = false;
|
||||
attemptReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("terminal.error", { message: errorMessage }));
|
||||
} else if (msg.type === "connected") {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
isConnectingRef.current = false;
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
if (reconnectAttempts.current > 0) {
|
||||
toast.success(t("terminal.reconnected"));
|
||||
}
|
||||
reconnectAttempts.current = 0;
|
||||
isReconnectingRef.current = false;
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(false);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("terminal.messageParseError"));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
setConnectionError("Authentication failed - please re-login");
|
||||
setIsConnecting(false);
|
||||
shouldNotReconnectRef.current = true;
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
toast.error("Authentication failed. Please log in again.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(false);
|
||||
if (
|
||||
!wasDisconnectedBySSH.current &&
|
||||
!isUnmountingRef.current &&
|
||||
!shouldNotReconnectRef.current
|
||||
) {
|
||||
wasDisconnectedBySSH.current = false;
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
setIsConnected(false);
|
||||
isConnectingRef.current = false;
|
||||
setConnectionError(t("terminal.websocketError"));
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(false);
|
||||
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
||||
wasDisconnectedBySSH.current = false;
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch (_) {}
|
||||
return "";
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
theme: { background: "#18181b", foreground: "#f7f7f7" },
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
allowProposedApi: true,
|
||||
minimumContrastRatio: 1,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.unicode.activeVersion = "11";
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) terminal.paste(pasteText);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
const handleMacKeyboard = (e: KeyboardEvent) => {
|
||||
const isMacOS =
|
||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
if (!isMacOS) return;
|
||||
|
||||
if (e.altKey && !e.metaKey && !e.ctrlKey) {
|
||||
const keyMappings: { [key: string]: string } = {
|
||||
"7": "|",
|
||||
"2": "€",
|
||||
"8": "[",
|
||||
"9": "]",
|
||||
l: "@",
|
||||
L: "@",
|
||||
Digit7: "|",
|
||||
Digit2: "€",
|
||||
Digit8: "[",
|
||||
Digit9: "]",
|
||||
KeyL: "@",
|
||||
};
|
||||
|
||||
const char = keyMappings[e.key] || keyMappings[e.code];
|
||||
if (char) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
element?.addEventListener("keydown", handleMacKeyboard, true);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
setVisible(true);
|
||||
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
resizeObserver.disconnect();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
element?.removeEventListener("keydown", handleMacKeyboard, true);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (reconnectTimeoutRef.current)
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
if (connectionTimeoutRef.current)
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !hostConfig || !visible) return;
|
||||
|
||||
if (isConnected || isConnecting) return;
|
||||
|
||||
setIsConnecting(true);
|
||||
|
||||
const readyFonts =
|
||||
(document as any).fonts?.ready instanceof Promise
|
||||
? (document as any).fonts.ready
|
||||
: Promise.resolve();
|
||||
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
connectToHost(cols, rows);
|
||||
}, 200);
|
||||
});
|
||||
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [isVisible, splitScreen, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen && isVisible) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">{t("terminal.connecting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(180,180,180,0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120,120,120,0.9);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
|
||||
font-variant-ligatures: contextual;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -1,171 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HomepageAuth } from "@/ui/Desktop/Homepage/HomepageAuth.tsx";
|
||||
import { HomepageUpdateLog } from "@/ui/Desktop/Homepage/HompageUpdateLog.tsx";
|
||||
import { HomepageAlertManager } from "@/ui/Desktop/Homepage/HomepageAlertManager.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth, getCookie } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
}
|
||||
|
||||
export function Homepage({
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
}: HomepageProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = 26;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt) {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
setDbError("Session expired - please log in again");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
});
|
||||
|
||||
getDatabaseHealth()
|
||||
.then(() => {
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||
<HomepageUpdateLog loggedIn={loggedIn} />
|
||||
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/Termix-SSH/Termix", "_blank")
|
||||
}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Termix/issues/new",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
Feedback
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://discord.com/invite/jVQGdvHDrf",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
}
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HomepageAlertManager userId={userId} loggedIn={loggedIn} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RSSResponse {
|
||||
feed: {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
updated: string;
|
||||
};
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: "up_to_date" | "requires_update";
|
||||
version: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
};
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
export function HomepageUpdateLog({ loggedIn }: HomepageUpdateLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn) {
|
||||
setLoading(true);
|
||||
Promise.all([getReleasesRSS(100), getVersionInfo()])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes);
|
||||
setVersionInfo(versionRes);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(t("common.failedToFetchUpdateInfo"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [loggedIn]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDescription = (description: string) => {
|
||||
const firstLine = description.split("\n")[0];
|
||||
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[400px] h-[600px] flex flex-col border-2 border-dark-border rounded-lg bg-dark-bg p-4 shadow-lg">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-3 text-white">
|
||||
{t("common.updatesAndReleases")}
|
||||
</h3>
|
||||
|
||||
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
|
||||
|
||||
{versionInfo && versionInfo.status === "requires_update" && (
|
||||
<Alert className="bg-dark-bg-darker border-dark-border text-white">
|
||||
<AlertTitle className="text-white">
|
||||
{t("common.updateAvailable")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
{t("common.newVersionAvailable", {
|
||||
version: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{versionInfo && versionInfo.status === "requires_update" && (
|
||||
<Separator className="p-0.25 mt-3 mb-3 bg-dark-border" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="bg-red-900/20 border-red-500 text-red-300"
|
||||
>
|
||||
<AlertTitle className="text-red-300">
|
||||
{t("common.error")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-red-300">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
|
||||
onClick={() => window.open(release.link, "_blank")}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
|
||||
{release.title}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
{t("common.preRelease")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-gray-400">
|
||||
<span>{new Date(release.pubDate).toLocaleDateString()}</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
{release.assets.length} asset
|
||||
{release.assets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
|
||||
<AlertTitle className="text-gray-300">
|
||||
{t("common.noReleases")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-gray-400">
|
||||
{t("common.noReleasesFound")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
||||
import { Server, Terminal } from "lucide-react";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { getServerStatusById } from "@/ui/main-axios.ts";
|
||||
import type { HostProps } from "../../../../types/index.js";
|
||||
|
||||
export function Host({ host }: HostProps): React.ReactElement {
|
||||
const { addTab } = useTabs();
|
||||
const [serverStatus, setServerStatus] = useState<
|
||||
"online" | "offline" | "degraded"
|
||||
>("degraded");
|
||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
||||
const hasTags = tags.length > 0;
|
||||
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("degraded");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
|
||||
intervalId = window.setInterval(fetchStatus, 30000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [host.id]);
|
||||
|
||||
const handleTerminalClick = () => {
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
};
|
||||
|
||||
const handleServerClick = () => {
|
||||
addTab({ type: "server", title, hostConfig: host });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={handleServerClick}
|
||||
>
|
||||
<Server />
|
||||
</Button>
|
||||
{host.enableTerminal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
<Terminal />
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div
|
||||
key={tag}
|
||||
className="bg-dark-bg border-1 border-dark-border pl-2 pr-2 rounded-[10px]"
|
||||
>
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,585 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { ChevronUp, User2, HardDrive, Menu, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getCookie,
|
||||
setCookie,
|
||||
isElectron,
|
||||
logoutUser,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarInset,
|
||||
SidebarHeader,
|
||||
} from "@/components/ui/sidebar.tsx";
|
||||
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@radix-ui/react-dropdown-menu";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { FolderCard } from "@/ui/Desktop/Navigation/Hosts/FolderCard.tsx";
|
||||
import { getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: any[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectView: (view: string) => void;
|
||||
getView?: () => string;
|
||||
disabled?: boolean;
|
||||
isAdmin?: boolean;
|
||||
username?: string | null;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await logoutUser();
|
||||
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function LeftSidebar({
|
||||
onSelectView,
|
||||
getView,
|
||||
disabled,
|
||||
isAdmin,
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("leftSidebarOpen");
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
|
||||
const {
|
||||
tabs: tabList,
|
||||
addTab,
|
||||
setCurrentTab,
|
||||
allSplitScreenTab,
|
||||
updateHostConfig,
|
||||
} = useTabs() as any;
|
||||
const isSplitScreenActive =
|
||||
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
const openSshManagerTab = () => {
|
||||
if (sshManagerTab || isSplitScreenActive) return;
|
||||
const id = addTab({ type: "ssh_manager" } as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
const openAdminTab = () => {
|
||||
if (isSplitScreenActive) return;
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({ type: "admin" } as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||
const openUserProfileTab = () => {
|
||||
if (isSplitScreenActive) return;
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({ type: "user_profile" } as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(false);
|
||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
const fetchHosts = React.useCallback(async () => {
|
||||
try {
|
||||
const newHosts = await getSSHHosts();
|
||||
const prevHosts = prevHostsRef.current;
|
||||
|
||||
const existingHostsMap = new Map(prevHosts.map((h) => [h.id, h]));
|
||||
const newHostsMap = new Map(newHosts.map((h) => [h.id, h]));
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
if (newHosts.length !== prevHosts.length) {
|
||||
hasChanges = true;
|
||||
} else {
|
||||
for (const [id, newHost] of newHostsMap) {
|
||||
const existingHost = existingHostsMap.get(id);
|
||||
if (!existingHost) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
newHost.name !== existingHost.name ||
|
||||
newHost.folder !== existingHost.folder ||
|
||||
newHost.ip !== existingHost.ip ||
|
||||
newHost.port !== existingHost.port ||
|
||||
newHost.username !== existingHost.username ||
|
||||
newHost.pin !== existingHost.pin ||
|
||||
newHost.enableTerminal !== existingHost.enableTerminal ||
|
||||
newHost.enableTunnel !== existingHost.enableTunnel ||
|
||||
newHost.enableFileManager !== existingHost.enableFileManager ||
|
||||
newHost.authType !== existingHost.authType ||
|
||||
newHost.password !== existingHost.password ||
|
||||
newHost.key !== existingHost.key ||
|
||||
newHost.keyPassword !== existingHost.keyPassword ||
|
||||
newHost.keyType !== existingHost.keyType ||
|
||||
newHost.defaultPath !== existingHost.defaultPath ||
|
||||
JSON.stringify(newHost.tags) !==
|
||||
JSON.stringify(existingHost.tags) ||
|
||||
JSON.stringify(newHost.tunnelConnections) !==
|
||||
JSON.stringify(existingHost.tunnelConnections)
|
||||
) {
|
||||
hasChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
setTimeout(() => {
|
||||
setHosts(newHosts);
|
||||
prevHostsRef.current = newHosts;
|
||||
|
||||
newHosts.forEach((newHost) => {
|
||||
updateHostConfig(newHost.id, newHost);
|
||||
});
|
||||
}, 50);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHostsError(t("leftSidebar.failedToLoadHosts"));
|
||||
}
|
||||
}, [updateHostConfig]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 300000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHosts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
const handleCredentialsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
window.addEventListener(
|
||||
"credentials:changed",
|
||||
handleCredentialsChanged as EventListener,
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
window.removeEventListener(
|
||||
"credentials:changed",
|
||||
handleCredentialsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
|
||||
React.useEffect(() => {
|
||||
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
|
||||
}, [isSidebarOpen]);
|
||||
|
||||
const filteredHosts = React.useMemo(() => {
|
||||
if (!debouncedSearch.trim()) return hosts;
|
||||
const q = debouncedSearch.trim().toLowerCase();
|
||||
return hosts.filter((h) => {
|
||||
const searchableText = [
|
||||
h.name || "",
|
||||
h.username,
|
||||
h.ip,
|
||||
h.folder || "",
|
||||
...(h.tags || []),
|
||||
h.authType,
|
||||
h.defaultPath || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchableText.includes(q);
|
||||
});
|
||||
}, [hosts, debouncedSearch]);
|
||||
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
filteredHosts.forEach((h) => {
|
||||
const folder =
|
||||
h.folder && h.folder.trim() ? h.folder : t("leftSidebar.noFolder");
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(h);
|
||||
});
|
||||
return map;
|
||||
}, [filteredHosts]);
|
||||
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(hostsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === t("leftSidebar.noFolder")) return -1;
|
||||
if (b === t("leftSidebar.noFolder")) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
}, [hostsByFolder]);
|
||||
|
||||
const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
|
||||
const pinned = arr
|
||||
.filter((h) => h.pin)
|
||||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
const rest = arr
|
||||
.filter((h) => !h.pin)
|
||||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||||
return [...pinned, ...rest];
|
||||
}, []);
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError(t("leftSidebar.passwordRequired"));
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteAccount(deletePassword);
|
||||
|
||||
handleLogout();
|
||||
} catch (err: any) {
|
||||
setDeleteError(
|
||||
err?.response?.data?.error || t("leftSidebar.failedToDeleteAccount"),
|
||||
);
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
<Sidebar variant="floating" className="">
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||
Termix
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
title={t("common.toggleSidebar")}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</SidebarGroupLabel>
|
||||
</SidebarHeader>
|
||||
<Separator className="p-0.25" />
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||
<Button
|
||||
className="m-2 flex flex-row font-semibold border-2 !border-dark-border"
|
||||
variant="outline"
|
||||
onClick={openSshManagerTab}
|
||||
disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={
|
||||
sshManagerTab
|
||||
? t("interface.sshManagerAlreadyOpen")
|
||||
: isSplitScreenActive
|
||||
? t("interface.disabledDuringSplitScreen")
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<HardDrive strokeWidth="2.5" />
|
||||
{t("nav.hostManager")}
|
||||
</Button>
|
||||
</SidebarGroup>
|
||||
<Separator className="p-0.25" />
|
||||
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
||||
<div className="!bg-dark-bg-input rounded-lg">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("placeholders.searchHostsAny")}
|
||||
className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hostsError && (
|
||||
<div className="!bg-dark-bg-input rounded-lg">
|
||||
<div className="w-full h-8 text-sm border-2 !bg-dark-bg-input border-dark-border rounded-md px-3 py-1.5 flex items-center text-red-500">
|
||||
{t("leftSidebar.failedToLoadHosts")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hostsLoading && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("hosts.loadingHosts")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedFolders.map((folder, idx) => (
|
||||
<FolderCard
|
||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||
folderName={folder}
|
||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === sortedFolders.length - 1}
|
||||
/>
|
||||
))}
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<Separator className="p-0.25 mt-1 mb-1" />
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:opacity-90 w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
<User2 /> {username ? username : t("common.logout")}
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => {
|
||||
openUserProfileTab();
|
||||
}}
|
||||
>
|
||||
<span>{t("profile.title")}</span>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !isElectron() && (
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => {
|
||||
if (isAdmin) openAdminTab();
|
||||
}}
|
||||
>
|
||||
<span>{t("admin.title")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<span>{t("common.logout")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
>
|
||||
<span className="text-red-400">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
{!isSidebarOpen && (
|
||||
<div
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
className="absolute top-0 left-0 w-[10px] h-full bg-dark-bg cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md"
|
||||
>
|
||||
<ChevronRight size={10} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteAccountOpen && (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] pointer-events-auto isolate"
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
willChange: "z-index",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[400px] h-full bg-dark-bg border-r-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[9999999]"
|
||||
style={{
|
||||
boxShadow: "4px 0 20px rgba(0, 0, 0, 0.5)",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("leftSidebar.deleteAccount")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("leftSidebar.closeDeleteAccount")}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{t("leftSidebar.deleteAccountWarning")}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.warning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("leftSidebar.deleteAccountWarningDetails")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">
|
||||
{t("leftSidebar.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="delete-password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder={t("placeholders.confirmPassword")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
{deleteLoading
|
||||
? t("leftSidebar.deleting")
|
||||
: t("leftSidebar.deleteAccount")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
{t("leftSidebar.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import React from "react";
|
||||
import { ButtonGroup } from "@/components/ui/button-group.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Home,
|
||||
SeparatorVertical,
|
||||
X,
|
||||
Terminal as TerminalIcon,
|
||||
Server as ServerIcon,
|
||||
Folder as FolderIcon,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface TabProps {
|
||||
tabType: string;
|
||||
title?: string;
|
||||
isActive?: boolean;
|
||||
onActivate?: () => void;
|
||||
onClose?: () => void;
|
||||
onSplit?: () => void;
|
||||
canSplit?: boolean;
|
||||
canClose?: boolean;
|
||||
disableActivate?: boolean;
|
||||
disableSplit?: boolean;
|
||||
disableClose?: boolean;
|
||||
}
|
||||
|
||||
export function Tab({
|
||||
tabType,
|
||||
title,
|
||||
isActive,
|
||||
onActivate,
|
||||
onClose,
|
||||
onSplit,
|
||||
canSplit = false,
|
||||
canClose = false,
|
||||
disableActivate = false,
|
||||
disableSplit = false,
|
||||
disableClose = false,
|
||||
}: TabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
if (tabType === "home") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
<Home />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
tabType === "terminal" ||
|
||||
tabType === "server" ||
|
||||
tabType === "file_manager" ||
|
||||
tabType === "user_profile"
|
||||
) {
|
||||
const isServer = tabType === "server";
|
||||
const isFileManager = tabType === "file_manager";
|
||||
const isUserProfile = tabType === "user_profile";
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{isServer ? (
|
||||
<ServerIcon className="mr-1 h-4 w-4" />
|
||||
) : isFileManager ? (
|
||||
<FolderIcon className="mr-1 h-4 w-4" />
|
||||
) : isUserProfile ? (
|
||||
<UserIcon className="mr-1 h-4 w-4" />
|
||||
) : (
|
||||
<TerminalIcon className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
{title ||
|
||||
(isServer
|
||||
? t("nav.serverStats")
|
||||
: isFileManager
|
||||
? t("nav.fileManager")
|
||||
: isUserProfile
|
||||
? t("nav.userProfile")
|
||||
: t("nav.terminal"))}
|
||||
</Button>
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onSplit}
|
||||
disabled={disableSplit}
|
||||
title={
|
||||
disableSplit ? t("nav.cannotSplitTab") : t("nav.splitScreen")
|
||||
}
|
||||
>
|
||||
<SeparatorVertical className="w-[28px] h-[28px]" />
|
||||
</Button>
|
||||
)}
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "ssh_manager") {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{title || t("nav.sshManager")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "admin") {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active" : ""}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{title || t("nav.admin")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-dark-border"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react";
|
||||
import { Tab } from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TabDropdown } from "@/ui/Desktop/Navigation/Tabs/TabDropdown.tsx";
|
||||
import { getCookie, setCookie } from "@/ui/main-axios.ts";
|
||||
|
||||
interface TopNavbarProps {
|
||||
isTopbarOpen: boolean;
|
||||
setIsTopbarOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TopNavbar({
|
||||
isTopbarOpen,
|
||||
setIsTopbarOpen,
|
||||
}: TopNavbarProps): React.ReactElement {
|
||||
const { state } = useSidebar();
|
||||
const {
|
||||
tabs,
|
||||
currentTab,
|
||||
setCurrentTab,
|
||||
setSplitScreenTab,
|
||||
removeTab,
|
||||
allSplitScreenTab,
|
||||
} = useTabs() as any;
|
||||
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
|
||||
const handleTabActivate = (tabId: number) => {
|
||||
setCurrentTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabSplit = (tabId: number) => {
|
||||
setSplitScreenTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabClose = (tabId: number) => {
|
||||
removeTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabToggle = (tabId: number) => {
|
||||
setSelectedTabIds((prev) =>
|
||||
prev.includes(tabId)
|
||||
? prev.filter((id) => id !== tabId)
|
||||
: [...prev, tabId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartRecording = () => {
|
||||
setIsRecording(true);
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById(
|
||||
"ssh-tools-input",
|
||||
) as HTMLInputElement;
|
||||
if (input) input.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleStopRecording = () => {
|
||||
setIsRecording(false);
|
||||
setSelectedTabIds([]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (selectedTabIds.length === 0) return;
|
||||
|
||||
let commandToSend = "";
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === "c") {
|
||||
commandToSend = "\x03"; // Ctrl+C (SIGINT)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "d") {
|
||||
commandToSend = "\x04"; // Ctrl+D (EOF)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "l") {
|
||||
commandToSend = "\x0c"; // Ctrl+L (clear screen)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "u") {
|
||||
commandToSend = "\x15"; // Ctrl+U (clear line)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "k") {
|
||||
commandToSend = "\x0b"; // Ctrl+K (clear from cursor to end)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "a") {
|
||||
commandToSend = "\x01"; // Ctrl+A (move to beginning of line)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "e") {
|
||||
commandToSend = "\x05"; // Ctrl+E (move to end of line)
|
||||
e.preventDefault();
|
||||
} else if (e.key === "w") {
|
||||
commandToSend = "\x17"; // Ctrl+W (delete word before cursor)
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if (e.key === "Enter") {
|
||||
commandToSend = "\n";
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Backspace") {
|
||||
commandToSend = "\x08"; // Backspace
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Delete") {
|
||||
commandToSend = "\x7f"; // Delete
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Tab") {
|
||||
commandToSend = "\x09"; // Tab
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Escape") {
|
||||
commandToSend = "\x1b"; // Escape
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
commandToSend = "\x1b[A"; // Up arrow
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowDown") {
|
||||
commandToSend = "\x1b[B"; // Down arrow
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
commandToSend = "\x1b[D"; // Left arrow
|
||||
e.preventDefault();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
commandToSend = "\x1b[C"; // Right arrow
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Home") {
|
||||
commandToSend = "\x1b[H"; // Home
|
||||
e.preventDefault();
|
||||
} else if (e.key === "End") {
|
||||
commandToSend = "\x1b[F"; // End
|
||||
e.preventDefault();
|
||||
} else if (e.key === "PageUp") {
|
||||
commandToSend = "\x1b[5~"; // Page Up
|
||||
e.preventDefault();
|
||||
} else if (e.key === "PageDown") {
|
||||
commandToSend = "\x1b[6~"; // Page Down
|
||||
e.preventDefault();
|
||||
} else if (e.key === "Insert") {
|
||||
commandToSend = "\x1b[2~"; // Insert
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F1") {
|
||||
commandToSend = "\x1bOP"; // F1
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F2") {
|
||||
commandToSend = "\x1bOQ"; // F2
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F3") {
|
||||
commandToSend = "\x1bOR"; // F3
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F4") {
|
||||
commandToSend = "\x1bOS"; // F4
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F5") {
|
||||
commandToSend = "\x1b[15~"; // F5
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F6") {
|
||||
commandToSend = "\x1b[17~"; // F6
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F7") {
|
||||
commandToSend = "\x1b[18~"; // F7
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F8") {
|
||||
commandToSend = "\x1b[19~"; // F8
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F9") {
|
||||
commandToSend = "\x1b[20~"; // F9
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F10") {
|
||||
commandToSend = "\x1b[21~"; // F10
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F11") {
|
||||
commandToSend = "\x1b[23~"; // F11
|
||||
e.preventDefault();
|
||||
} else if (e.key === "F12") {
|
||||
commandToSend = "\x1b[24~"; // F12
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (commandToSend) {
|
||||
selectedTabIds.forEach((tabId) => {
|
||||
const tab = tabs.find((t: any) => t.id === tabId);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(commandToSend);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (selectedTabIds.length === 0) return;
|
||||
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
|
||||
const char = e.key;
|
||||
selectedTabIds.forEach((tabId) => {
|
||||
const tab = tabs.find((t: any) => t.id === tabId);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(char);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isSplitScreenActive =
|
||||
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
||||
const currentTabObj = tabs.find((t: any) => t.id === currentTab);
|
||||
const currentTabIsHome = currentTabObj?.type === "home";
|
||||
const currentTabIsSshManager = currentTabObj?.type === "ssh_manager";
|
||||
const currentTabIsAdmin = currentTabObj?.type === "admin";
|
||||
const currentTabIsUserProfile = currentTabObj?.type === "user_profile";
|
||||
|
||||
const terminalTabs = tabs.filter((tab: any) => tab.type === "terminal");
|
||||
|
||||
const updateRightClickCopyPaste = (checked: boolean) => {
|
||||
setCookie("rightClickCopyPaste", checked.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="fixed z-10 h-[50px] bg-dark-bg border-2 border-dark-border rounded-lg transition-all duration-200 ease-linear flex flex-row transform-none m-0 p-0"
|
||||
style={{
|
||||
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
||||
left: leftPosition,
|
||||
right: "17px",
|
||||
}}
|
||||
>
|
||||
<div className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
{tabs.map((tab: any) => {
|
||||
const isActive = tab.id === currentTab;
|
||||
const isSplit =
|
||||
Array.isArray(allSplitScreenTab) &&
|
||||
allSplitScreenTab.includes(tab.id);
|
||||
const isTerminal = tab.type === "terminal";
|
||||
const isServer = tab.type === "server";
|
||||
const isFileManager = tab.type === "file_manager";
|
||||
const isSshManager = tab.type === "ssh_manager";
|
||||
const isAdmin = tab.type === "admin";
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isSplittable = isTerminal || isServer || isFileManager;
|
||||
const isSplitButtonDisabled =
|
||||
(isActive && !isSplitScreenActive) ||
|
||||
((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||
const disableSplit =
|
||||
!isSplittable ||
|
||||
isSplitButtonDisabled ||
|
||||
isActive ||
|
||||
currentTabIsHome ||
|
||||
currentTabIsSshManager ||
|
||||
currentTabIsAdmin ||
|
||||
currentTabIsUserProfile;
|
||||
const disableActivate =
|
||||
isSplit ||
|
||||
((tab.type === "home" ||
|
||||
tab.type === "ssh_manager" ||
|
||||
tab.type === "admin" ||
|
||||
tab.type === "user_profile") &&
|
||||
isSplitScreenActive);
|
||||
const disableClose = (isSplitScreenActive && isActive) || isSplit;
|
||||
return (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tabType={tab.type}
|
||||
title={tab.title}
|
||||
isActive={isActive}
|
||||
onActivate={() => handleTabActivate(tab.id)}
|
||||
onClose={
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
? () => handleTabClose(tab.id)
|
||||
: undefined
|
||||
}
|
||||
onSplit={
|
||||
isSplittable ? () => handleTabSplit(tab.id) : undefined
|
||||
}
|
||||
canSplit={isSplittable}
|
||||
canClose={
|
||||
isTerminal ||
|
||||
isServer ||
|
||||
isFileManager ||
|
||||
isSshManager ||
|
||||
isAdmin ||
|
||||
isUserProfile
|
||||
}
|
||||
disableActivate={disableActivate}
|
||||
disableSplit={disableSplit}
|
||||
disableClose={disableClose}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<TabDropdown />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px]"
|
||||
title={t("nav.tools")}
|
||||
onClick={() => setToolsSheetOpen(true)}
|
||||
>
|
||||
<Hammer className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsTopbarOpen(false)}
|
||||
className="w-[30px] h-[30px]"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTopbarOpen && (
|
||||
<div
|
||||
onClick={() => setIsTopbarOpen(true)}
|
||||
className="absolute top-0 left-0 w-full h-[10px] bg-dark-bg cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md"
|
||||
>
|
||||
<ChevronDown size={10} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsSheetOpen && (
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-1 cursor-pointer"
|
||||
onClick={() => setToolsSheetOpen(false)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
|
||||
style={{
|
||||
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("sshTools.title")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setToolsSheetOpen(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("sshTools.closeTools")}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-semibold">{t("sshTools.keyRecording")}</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
onClick={handleStartRecording}
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
>
|
||||
{t("sshTools.startKeyRecording")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStopRecording}
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("sshTools.stopKeyRecording")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("sshTools.selectTerminals")}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||
{terminalTabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
||||
selectedTabIds.includes(tab.id)
|
||||
? "text-white bg-gray-700"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
onClick={() => handleTabToggle(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("sshTools.typeCommands")}
|
||||
</label>
|
||||
<Input
|
||||
id="ssh-tools-input"
|
||||
placeholder={t("placeholders.typeHere")}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="font-mono mt-2"
|
||||
disabled={selectedTabIds.length === 0}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("sshTools.commandsWillBeSent", {
|
||||
count: selectedTabIds.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<h1 className="font-semibold">{t("sshTools.settings")}</h1>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-copy-paste"
|
||||
onCheckedChange={updateRightClickCopyPaste}
|
||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-copy-paste"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
|
||||
>
|
||||
{t("sshTools.enableRightClickCopyPaste")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<p className="pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("sshTools.shareIdeas")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card.tsx";
|
||||
import { Key } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
completePasswordReset,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function PasswordReset({ userInfo }: PasswordResetProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [resetStep, setResetStep] = useState<
|
||||
"initiate" | "verify" | "newPassword"
|
||||
>("initiate");
|
||||
const [resetCode, setResetCode] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const result = await initiatePasswordReset(userInfo.username);
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("common.failedToInitiatePasswordReset"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordState() {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleVerifyResetCode() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const response = await verifyPasswordResetCode(
|
||||
userInfo.username,
|
||||
resetCode,
|
||||
);
|
||||
setTempToken(response.tempToken);
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error || t("common.failedToVerifyResetCode"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompletePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("common.passwordsDoNotMatch"));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError(t("common.passwordMinLength"));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completePasswordReset(userInfo.username, tempToken, newPassword);
|
||||
|
||||
toast.success(t("common.passwordResetSuccess"));
|
||||
resetPasswordState();
|
||||
} catch (err: any) {
|
||||
setError(
|
||||
err?.response?.data?.error || t("common.failedToCompletePasswordReset"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const Spinner = (
|
||||
<svg
|
||||
className="animate-spin mr-2 h-4 w-4 text-white inline-block"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
{t("common.password")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("common.changeAccountPassword")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base"
|
||||
disabled={resetLoading || !userInfo.username.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("common.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("common.enterSixDigitCode")}{" "}
|
||||
<strong>{userInfo.username}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">{t("common.resetCode")}</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder={t("placeholders.enterCode")}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : t("common.verifyCode")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("common.enterNewPassword")}{" "}
|
||||
<strong>{userInfo.username}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("common.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("common.resetPassword")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { User, Shield, Key, AlertCircle } from "lucide-react";
|
||||
import { TOTPSetup } from "@/ui/Desktop/User/TOTPSetup.tsx";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
import { getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import { PasswordReset } from "@/ui/Desktop/User/PasswordReset.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [userInfo, setUserInfo] = useState<{
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
totp_enabled: boolean;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<{ version: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
fetchVersion();
|
||||
}, []);
|
||||
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const info = await getVersionInfo();
|
||||
setVersionInfo({ version: info.localVersion });
|
||||
} catch (err) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("user.failedToLoadVersionInfo"));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserInfo = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const info = await getUserInfo();
|
||||
setUserInfo({
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
totp_enabled: info.totp_enabled || false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || t("errors.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTOTPStatusChange = (enabled: boolean) => {
|
||||
if (userInfo) {
|
||||
setUserInfo({ ...userInfo, totp_enabled: enabled });
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
const wrapperStyle: React.CSSProperties = {
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="animate-pulse text-gray-300">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !userInfo) {
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="bg-red-900/20 border-red-500/50"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle className="text-red-400">
|
||||
{t("common.error")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-red-300">
|
||||
{error || t("errors.loadFailed")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">{t("nav.userProfile")}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="px-6 py-4 overflow-auto flex-1">
|
||||
<Tabs defaultValue="profile" className="w-full">
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
{t("nav.userProfile")}
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
<TabsTrigger
|
||||
value="security"
|
||||
className="flex items-center gap-2 data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("profile.security")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<div className="rounded-lg border-2 border-dark-border bg-dark-bg-darker p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("profile.accountInfo")}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.username}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">{t("profile.role")}</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.authMethod")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_oidc
|
||||
? t("profile.external")
|
||||
: t("profile.local")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.twoFactorAuth")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
<span className="text-gray-400">
|
||||
{t("auth.lockedOidcAuth")}
|
||||
</span>
|
||||
) : userInfo.totp_enabled ? (
|
||||
<span className="text-green-400 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4" />
|
||||
{t("common.enabled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">
|
||||
{t("common.disabled")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.version")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{versionInfo?.version || t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.selectPreferredLanguage")}
|
||||
</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-4">
|
||||
<TOTPSetup
|
||||
isEnabled={userInfo.totp_enabled}
|
||||
onStatusChange={handleTOTPStatusChange}
|
||||
/>
|
||||
|
||||
{!userInfo.is_oidc && <PasswordReset userInfo={userInfo} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,451 +0,0 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isElectron, getCookie } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{ hostConfig, isVisible },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DEBOUNCE_MS = 140;
|
||||
|
||||
useEffect(() => {
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuth = () => {
|
||||
const jwtToken = getCookie("jwt");
|
||||
const isAuth = !!(jwtToken && jwtToken.trim() !== "");
|
||||
|
||||
setIsAuthenticated((prev) => {
|
||||
if (prev !== isAuth) {
|
||||
return isAuth;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
const authCheckInterval = setInterval(checkAuth, 5000);
|
||||
|
||||
return () => clearInterval(authCheckInterval);
|
||||
}, []);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === "function") {
|
||||
(terminal as any).refresh(0, terminal.rows - 1);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = { cols, rows };
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
notifyTimerRef.current = setTimeout(() => {
|
||||
const next = pendingSizeRef.current;
|
||||
const last = lastSentSizeRef.current;
|
||||
if (!next) return;
|
||||
if (last && last.cols === next.cols && last.rows === next.rows) return;
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "resize", data: next }),
|
||||
);
|
||||
lastSentSizeRef.current = next;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
disconnect: () => {
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
},
|
||||
notifyResize: () => {
|
||||
try {
|
||||
const cols = terminal?.cols ?? undefined;
|
||||
const rows = terminal?.rows ?? undefined;
|
||||
if (typeof cols === "number" && typeof rows === "number") {
|
||||
scheduleNotify(cols, rows);
|
||||
hardRefresh();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
}),
|
||||
[terminal],
|
||||
);
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener("open", () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connectToHost",
|
||||
data: { cols, rows, hostConfig },
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
terminal.write(msg.data);
|
||||
} else {
|
||||
terminal.write(String(msg.data));
|
||||
}
|
||||
} else if (msg.type === "error")
|
||||
terminal.writeln(`\r\n[${t("terminal.error")}] ${msg.message}`);
|
||||
else if (msg.type === "connected") {
|
||||
isConnectingRef.current = false;
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
isConnectingRef.current = false;
|
||||
terminal.writeln(
|
||||
`\r\n[${msg.message || t("terminal.disconnected")}]`,
|
||||
);
|
||||
}
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
isConnectingRef.current = false;
|
||||
|
||||
if (event.code === 1008) {
|
||||
console.error("WebSocket authentication failed:", event.reason);
|
||||
terminal.writeln(`\r\n[Authentication failed - please re-login]`);
|
||||
|
||||
localStorage.removeItem("jwt");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln(`\r\n[${t("terminal.connectionClosed")}]`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
isConnectingRef.current = false;
|
||||
terminal.writeln(`\r\n[${t("terminal.connectionError")}]`);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: false,
|
||||
cursorStyle: "bar",
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
theme: { background: "#09090b", foreground: "#f7f7f7" },
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
allowProposedApi: true,
|
||||
disableStdin: true,
|
||||
cursorInactiveStyle: "bar",
|
||||
minimumContrastRatio: 1,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.2,
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
terminal.unicode.activeVersion = "11";
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const textarea = xtermRef.current.querySelector(
|
||||
".xterm-helper-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.readOnly = true;
|
||||
textarea.blur();
|
||||
}
|
||||
|
||||
terminal.focus = () => {};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
const readyFonts =
|
||||
(document as any).fonts?.ready instanceof Promise
|
||||
? (document as any).fonts.ready
|
||||
: Promise.resolve();
|
||||
setVisible(true);
|
||||
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
|
||||
const jwtToken = getCookie("jwt");
|
||||
if (!jwtToken || jwtToken.trim() === "") {
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionError("Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const baseWsUrl = isDev
|
||||
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as any).configuredServerUrl ||
|
||||
"http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
if (isConnectingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
if (
|
||||
webSocketRef.current &&
|
||||
webSocketRef.current.readyState !== WebSocket.CLOSED
|
||||
) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
|
||||
|
||||
setIsConnecting(true);
|
||||
setConnectionError(null);
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 0);
|
||||
}
|
||||
}, [isVisible, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 0);
|
||||
}, [isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className={`h-full w-full m-1 transition-opacity duration-200 ${visible && isVisible ? "opacity-100" : "opacity-0"} overflow-hidden`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(180,180,180,0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120,120,120,0.9);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
|
||||
font-variant-ligatures: contextual;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -1,916 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
getUserInfo,
|
||||
getRegistrationAllowed,
|
||||
getOIDCConfig,
|
||||
getSetupRequired,
|
||||
initiatePasswordReset,
|
||||
verifyPasswordResetCode,
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
setCookie,
|
||||
getCookie,
|
||||
logoutUser,
|
||||
isElectron,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
|
||||
interface HomepageAuthProps extends React.ComponentProps<"div"> {
|
||||
setLoggedIn: (loggedIn: boolean) => void;
|
||||
setIsAdmin: (isAdmin: boolean) => void;
|
||||
setUsername: (username: string | null) => void;
|
||||
setUserId: (userId: string | null) => void;
|
||||
loggedIn: boolean;
|
||||
authLoading: boolean;
|
||||
dbError: string | null;
|
||||
setDbError: (error: string | null) => void;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function HomepageAuth({
|
||||
className,
|
||||
setLoggedIn,
|
||||
setIsAdmin,
|
||||
setUsername,
|
||||
setUserId,
|
||||
loggedIn,
|
||||
authLoading,
|
||||
dbError,
|
||||
setDbError,
|
||||
onAuthSuccess,
|
||||
...props
|
||||
}: HomepageAuthProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">(
|
||||
"login",
|
||||
);
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcLoading, setOidcLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [internalLoggedIn, setInternalLoggedIn] = useState(false);
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [firstUserToastShown, setFirstUserToastShown] = useState(false);
|
||||
const [registrationAllowed, setRegistrationAllowed] = useState(true);
|
||||
const [oidcConfigured, setOidcConfigured] = useState(false);
|
||||
|
||||
const [resetStep, setResetStep] = useState<
|
||||
"initiate" | "verify" | "newPassword"
|
||||
>("initiate");
|
||||
const [resetCode, setResetCode] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [totpTempToken, setTotpTempToken] = useState("");
|
||||
const [totpLoading, setTotpLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setInternalLoggedIn(loggedIn);
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
const clearJWTOnLoad = async () => {
|
||||
try {
|
||||
await logoutUser();
|
||||
} catch (error) {
|
||||
console.log("JWT cleanup on HomepageAuth load:", error);
|
||||
}
|
||||
};
|
||||
|
||||
clearJWTOnLoad();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getRegistrationAllowed().then((res) => {
|
||||
setRegistrationAllowed(res.allowed);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registrationAllowed && !internalLoggedIn) {
|
||||
toast.warning(t("messages.registrationDisabled"));
|
||||
}
|
||||
}, [registrationAllowed, internalLoggedIn, t]);
|
||||
|
||||
useEffect(() => {
|
||||
getOIDCConfig()
|
||||
.then((response) => {
|
||||
if (response) {
|
||||
setOidcConfigured(true);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response?.status === 404) {
|
||||
setOidcConfigured(false);
|
||||
} else {
|
||||
setOidcConfigured(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
if (res.setup_required) {
|
||||
setFirstUser(true);
|
||||
setTab("signup");
|
||||
if (!firstUserToastShown) {
|
||||
toast.info(t("auth.firstUserMessage"));
|
||||
setFirstUserToastShown(true);
|
||||
}
|
||||
} else {
|
||||
setFirstUser(false);
|
||||
}
|
||||
setDbError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
setDbError(t("errors.databaseConnection"));
|
||||
});
|
||||
}, [setDbError, firstUserToastShown]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
if (!localUsername.trim()) {
|
||||
toast.error(t("errors.requiredField"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let res, meRes;
|
||||
if (tab === "login") {
|
||||
res = await loginUser(localUsername, password);
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
toast.error(t("errors.passwordMismatch"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
toast.error(t("errors.minLength", { min: 6 }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await registerUser(localUsername, password);
|
||||
res = await loginUser(localUsername, password);
|
||||
}
|
||||
|
||||
if (res.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpTempToken(res.temp_token);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res || !res.success) {
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
[meRes] = await Promise.all([getUserInfo()]);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
if (tab === "signup") {
|
||||
setSignupConfirmPassword("");
|
||||
toast.success(t("messages.registrationSuccess"));
|
||||
} else {
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
}
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err?.response?.data?.error || err?.message || t("errors.unknownError");
|
||||
toast.error(errorMessage);
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(t("errors.databaseConnection"));
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const result = await initiatePasswordReset(localUsername);
|
||||
setResetStep("verify");
|
||||
toast.success(t("messages.resetCodeSent"));
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("errors.failedPasswordReset"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerifyResetCode() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
try {
|
||||
const response = await verifyPasswordResetCode(localUsername, resetCode);
|
||||
setTempToken(response.tempToken);
|
||||
setResetStep("newPassword");
|
||||
toast.success(t("messages.codeVerified"));
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || t("errors.failedVerifyCode"));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCompletePasswordReset() {
|
||||
setError(null);
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t("errors.passwordMismatch"));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
toast.error(t("errors.minLength", { min: 6 }));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await completePasswordReset(localUsername, tempToken, newPassword);
|
||||
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
|
||||
setResetSuccess(true);
|
||||
toast.success(t("messages.passwordResetSuccess"));
|
||||
|
||||
setTab("login");
|
||||
resetPasswordState();
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.response?.data?.error || t("errors.failedCompleteReset"),
|
||||
);
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetPasswordState() {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
setResetSuccess(false);
|
||||
setSignupConfirmPassword("");
|
||||
}
|
||||
|
||||
function clearFormFields() {
|
||||
setPassword("");
|
||||
setSignupConfirmPassword("");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function handleTOTPVerification() {
|
||||
if (totpCode.length !== 6) {
|
||||
toast.error(t("auth.enterCode"));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setTotpLoading(true);
|
||||
|
||||
try {
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
|
||||
if (!res || !res.success) {
|
||||
throw new Error(t("errors.loginFailed"));
|
||||
}
|
||||
|
||||
if (isElectron() && res.token) {
|
||||
localStorage.setItem("jwt", res.token);
|
||||
}
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!res.is_admin);
|
||||
setUsername(res.username || null);
|
||||
setUserId(res.userId || null);
|
||||
setDbError(null);
|
||||
|
||||
setTimeout(() => {
|
||||
onAuthSuccess({
|
||||
isAdmin: !!res.is_admin,
|
||||
username: res.username || null,
|
||||
userId: res.userId || null,
|
||||
});
|
||||
}, 100);
|
||||
|
||||
setInternalLoggedIn(true);
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch (err: any) {
|
||||
const errorCode = err?.response?.data?.code;
|
||||
const errorMessage =
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("errors.invalidTotpCode");
|
||||
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
setTab("login");
|
||||
toast.error(t("errors.sessionExpired"));
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOIDCLogin() {
|
||||
setError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
const authResponse = await getOIDCAuthorizeUrl();
|
||||
const { auth_url: authUrl } = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === "undefined") {
|
||||
throw new Error(t("errors.invalidAuthUrl"));
|
||||
}
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("errors.failedOidcLogin");
|
||||
toast.error(errorMessage);
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const success = urlParams.get("success");
|
||||
const token = urlParams.get("token");
|
||||
const error = urlParams.get("error");
|
||||
|
||||
if (error) {
|
||||
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
setOidcLoading(true);
|
||||
setError(null);
|
||||
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setInternalLoggedIn(true);
|
||||
setLoggedIn(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
onAuthSuccess({
|
||||
isAdmin: !!meRes.is_admin,
|
||||
username: meRes.username || null,
|
||||
userId: meRes.userId || null,
|
||||
});
|
||||
setInternalLoggedIn(true);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
window.history.replaceState(
|
||||
{},
|
||||
document.title,
|
||||
window.location.pathname,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
setOidcLoading(false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const Spinner = (
|
||||
<svg
|
||||
className="animate-spin mr-2 h-4 w-4 text-white inline-block"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full max-w-md flex flex-col bg-dark-bg ${className || ""}`}
|
||||
{...props}
|
||||
>
|
||||
{dbError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{dbError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{t("auth.twoFactorAuth")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("auth.enterCode")}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="totp-code">{t("auth.verifyCode")}</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ""))}
|
||||
disabled={totpLoading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("auth.backupCode")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading || totpCode.length < 6}
|
||||
onClick={handleTOTPVerification}
|
||||
>
|
||||
{totpLoading ? Spinner : t("auth.verifyCode")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={totpLoading}
|
||||
onClick={() => {
|
||||
setTotpRequired(false);
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{internalLoggedIn && !authLoading && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{t("homepage.loggedInTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("mobile.mobileAppInProgressDesc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/install", "_blank")
|
||||
}
|
||||
>
|
||||
{t("mobile.viewMobileAppDocs")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!internalLoggedIn && !authLoading && !totpRequired && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "login"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("login");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
{t("common.login")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "signup"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("signup");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
{t("common.register")}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 py-2 text-base font-medium rounded-md transition-all",
|
||||
tab === "external"
|
||||
? "bg-primary text-primary-foreground shadow"
|
||||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
onClick={() => {
|
||||
setTab("external");
|
||||
if (tab === "reset") resetPasswordState();
|
||||
if (tab === "login" || tab === "signup") clearFormFields();
|
||||
}}
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
{t("auth.external")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login"
|
||||
? t("auth.loginTitle")
|
||||
: tab === "signup"
|
||||
? t("auth.registerTitle")
|
||||
: tab === "external"
|
||||
? t("auth.loginWithExternal")
|
||||
: t("auth.forgotPassword")}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{tab === "external" || tab === "reset" ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.loginWithExternalDesc")}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : t("auth.loginWithExternal")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{tab === "reset" && (
|
||||
<>
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>{t("auth.resetCodeDesc")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">
|
||||
{t("common.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.sendResetCode")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterResetCode")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">
|
||||
{t("auth.resetCode")}
|
||||
</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
required
|
||||
maxLength={6}
|
||||
className="h-11 text-base text-center text-lg tracking-widest"
|
||||
value={resetCode}
|
||||
onChange={(e) =>
|
||||
setResetCode(e.target.value.replace(/\D/g, ""))
|
||||
}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : t("auth.verifyCodeButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>
|
||||
{t("auth.enterNewPassword")}{" "}
|
||||
<strong>{localUsername}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">
|
||||
{t("auth.newPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="new-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">
|
||||
{t("auth.confirmNewPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="confirm-password"
|
||||
required
|
||||
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={resetLoading}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={
|
||||
resetLoading || !newPassword || !confirmPassword
|
||||
}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading
|
||||
? Spinner
|
||||
: t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={resetLoading}
|
||||
onClick={() => {
|
||||
setResetStep("verify");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">{t("common.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={localUsername}
|
||||
onChange={(e) => setLocalUsername(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">{t("common.password")}</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">
|
||||
{t("common.confirmPassword")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="signup-confirm-password"
|
||||
required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
onChange={(e) => setSignupConfirmPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
>
|
||||
{loading
|
||||
? Spinner
|
||||
: tab === "login"
|
||||
? t("common.login")
|
||||
: t("auth.signUp")}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}
|
||||
onClick={() => {
|
||||
setTab("reset");
|
||||
resetPasswordState();
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
{t("auth.resetPasswordButton")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("common.language")}
|
||||
</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full h-11 text-base font-semibold"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/install", "_blank")
|
||||
}
|
||||
>
|
||||
{t("mobile.viewMobileAppDocs")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,23 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { LeftSidebar } from "@/ui/Desktop/Navigation/LeftSidebar.tsx";
|
||||
import { Homepage } from "@/ui/Desktop/Homepage/Homepage.tsx";
|
||||
import { AppView } from "@/ui/Desktop/Navigation/AppView.tsx";
|
||||
import { HostManager } from "@/ui/Desktop/Apps/Host Manager/HostManager.tsx";
|
||||
import React, { useState, useEffect, useCallback } 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";
|
||||
import { HostManager } from "@/ui/desktop/apps/host-manager/HostManager.tsx";
|
||||
import {
|
||||
TabProvider,
|
||||
useTabs,
|
||||
} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { TopNavbar } from "@/ui/Desktop/Navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/Desktop/Admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/Desktop/User/UserProfile.tsx";
|
||||
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
|
||||
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
|
||||
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
|
||||
import { Toaster } from "@/components/ui/sonner.tsx";
|
||||
import { VersionCheckModal } from "@/components/ui/version-check-modal.tsx";
|
||||
import { getUserInfo, getCookie } from "@/ui/main-axios.ts";
|
||||
import { getUserInfo } from "@/ui/main-axios.ts";
|
||||
|
||||
function AppContent() {
|
||||
const [view, setView] = useState<string>("homepage");
|
||||
const [mountedViews, setMountedViews] = useState<Set<string>>(
|
||||
new Set(["homepage"]),
|
||||
);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [showVersionCheck, setShowVersionCheck] = useState(true);
|
||||
const [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(() => {
|
||||
const saved = localStorage.getItem("topNavbarOpen");
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
@@ -35,15 +29,14 @@ function AppContent() {
|
||||
setAuthLoading(true);
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
|
||||
if (!meRes.data_unlocked) {
|
||||
console.warn("User data is locked - re-authentication required");
|
||||
if (typeof meRes === "string" || !meRes.username) {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -56,7 +49,9 @@ function AppContent() {
|
||||
console.warn("Session expired - please log in again");
|
||||
}
|
||||
})
|
||||
.finally(() => setAuthLoading(false));
|
||||
.finally(() => {
|
||||
setAuthLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
@@ -71,25 +66,20 @@ function AppContent() {
|
||||
localStorage.setItem("topNavbarOpen", JSON.stringify(isTopbarOpen));
|
||||
}, [isTopbarOpen]);
|
||||
|
||||
const handleSelectView = (nextView: string) => {
|
||||
setMountedViews((prev) => {
|
||||
if (prev.has(nextView)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(nextView);
|
||||
return next;
|
||||
});
|
||||
setView(nextView);
|
||||
};
|
||||
const handleSelectView = () => {};
|
||||
|
||||
const handleAuthSuccess = (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
setUsername(authData.username);
|
||||
};
|
||||
const handleAuthSuccess = useCallback(
|
||||
(authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(authData.isAdmin);
|
||||
setUsername(authData.username);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const currentTabData = tabs.find((tab) => tab.id === currentTab);
|
||||
const showTerminalView =
|
||||
@@ -103,37 +93,9 @@ function AppContent() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showVersionCheck && (
|
||||
<VersionCheckModal
|
||||
onDismiss={() => setShowVersionCheck(false)}
|
||||
onContinue={() => setShowVersionCheck(false)}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && !showVersionCheck && (
|
||||
<div>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(
|
||||
135deg,
|
||||
transparent 0%,
|
||||
transparent 49%,
|
||||
rgba(255, 255, 255, 0.03) 49%,
|
||||
rgba(255, 255, 255, 0.03) 51%,
|
||||
transparent 51%,
|
||||
transparent 100%
|
||||
)`,
|
||||
backgroundSize: "80px 80px",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && !authLoading && !showVersionCheck && (
|
||||
{!isAuthenticated && !authLoading && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
|
||||
<Homepage
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
@@ -159,7 +121,7 @@ function AppContent() {
|
||||
|
||||
{showHome && (
|
||||
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
|
||||
<Homepage
|
||||
<Dashboard
|
||||
onSelectView={handleSelectView}
|
||||
isAuthenticated={isAuthenticated}
|
||||
authLoading={authLoading}
|
||||
@@ -174,6 +136,8 @@ function AppContent() {
|
||||
<HostManager
|
||||
onSelectView={handleSelectView}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
initialTab={currentTabData?.initialTab}
|
||||
hostConfig={currentTabData?.hostConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -26,26 +26,35 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Database,
|
||||
Key,
|
||||
Lock,
|
||||
Download,
|
||||
Upload,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getAdminOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getPasswordLoginAllowed,
|
||||
getUserList,
|
||||
updateRegistrationAllowed,
|
||||
updatePasswordLoginAllowed,
|
||||
updateOIDCConfig,
|
||||
disableOIDCConfig,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser,
|
||||
getUserInfo,
|
||||
getCookie,
|
||||
isElectron,
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
@@ -62,6 +71,9 @@ export function AdminSettings({
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
const [regLoading, setRegLoading] = React.useState(false);
|
||||
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = React.useState(true);
|
||||
const [passwordLoginLoading, setPasswordLoginLoading] = React.useState(false);
|
||||
|
||||
const [oidcConfig, setOidcConfig] = React.useState({
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
@@ -92,6 +104,12 @@ export function AdminSettings({
|
||||
);
|
||||
|
||||
const [securityInitialized, setSecurityInitialized] = React.useState(true);
|
||||
const [currentUser, setCurrentUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const [exportLoading, setExportLoading] = React.useState(false);
|
||||
const [importLoading, setImportLoading] = React.useState(false);
|
||||
@@ -100,15 +118,37 @@ export function AdminSettings({
|
||||
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
|
||||
const [importPassword, setImportPassword] = React.useState("");
|
||||
|
||||
const [sessions, setSessions] = React.useState<
|
||||
Array<{
|
||||
id: string;
|
||||
userId: string;
|
||||
username?: string;
|
||||
deviceType: string;
|
||||
deviceInfo: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastActiveAt: string;
|
||||
jwtToken: string;
|
||||
isRevoked?: boolean;
|
||||
}>
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
[currentUser?.is_oidc],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getOIDCConfig()
|
||||
getAdminOIDCConfig()
|
||||
.then((res) => {
|
||||
if (res) setOidcConfig(res);
|
||||
})
|
||||
@@ -117,12 +157,30 @@ export function AdminSettings({
|
||||
toast.error(t("admin.failedToFetchOidcConfig"));
|
||||
}
|
||||
});
|
||||
getUserInfo()
|
||||
.then((info) => {
|
||||
if (info) {
|
||||
setCurrentUser({
|
||||
id: info.userId,
|
||||
username: info.username,
|
||||
is_admin: info.is_admin,
|
||||
is_oidc: info.is_oidc,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
console.warn("Failed to fetch current user info", err);
|
||||
}
|
||||
});
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
@@ -141,9 +199,32 @@ export function AdminSettings({
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordLoginAllowed()
|
||||
.then((res) => {
|
||||
if (typeof res?.allowed === "boolean") {
|
||||
setAllowPasswordLogin(res.allowed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== "NO_SERVER_CONFIGURED") {
|
||||
toast.error(t("admin.failedToFetchPasswordLoginStatus"));
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as any).configuredServerUrl;
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
@@ -172,6 +253,57 @@ export function AdminSettings({
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePasswordLogin = async (checked: boolean) => {
|
||||
if (!checked) {
|
||||
const hasOIDCConfigured =
|
||||
oidcConfig.client_id &&
|
||||
oidcConfig.client_secret &&
|
||||
oidcConfig.issuer_url &&
|
||||
oidcConfig.authorization_url &&
|
||||
oidcConfig.token_url;
|
||||
|
||||
if (!hasOIDCConfigured) {
|
||||
toast.error(t("admin.cannotDisablePasswordLoginWithoutOIDC"), {
|
||||
duration: 5000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisablePasswordLogin"),
|
||||
async () => {
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
|
||||
if (allowRegistration) {
|
||||
await updateRegistrationAllowed(false);
|
||||
setAllowRegistration(false);
|
||||
toast.success(t("admin.passwordLoginAndRegistrationDisabled"));
|
||||
} else {
|
||||
toast.success(t("admin.passwordLoginDisabled"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToUpdatePasswordLoginStatus"));
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordLoginLoading(true);
|
||||
try {
|
||||
await updatePasswordLoginAllowed(checked);
|
||||
setAllowPasswordLogin(checked);
|
||||
} finally {
|
||||
setPasswordLoginLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
@@ -198,9 +330,10 @@ export function AdminSettings({
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success(t("admin.oidcConfigurationUpdated"));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
err?.response?.data?.error || t("admin.failedToUpdateOidcConfig"),
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("admin.failedToUpdateOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
@@ -221,9 +354,10 @@ export function AdminSettings({
|
||||
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
setMakeAdminError(
|
||||
err?.response?.data?.error || t("admin.failedToMakeUserAdmin"),
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data
|
||||
?.error || t("admin.failedToMakeUserAdmin"),
|
||||
);
|
||||
} finally {
|
||||
setMakeAdminLoading(false);
|
||||
@@ -236,7 +370,7 @@ export function AdminSettings({
|
||||
await removeAdminStatus(username);
|
||||
toast.success(t("admin.adminStatusRemoved", { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRemoveAdminStatus"));
|
||||
}
|
||||
});
|
||||
@@ -250,7 +384,7 @@ export function AdminSettings({
|
||||
await deleteUser(username);
|
||||
toast.success(t("admin.userDeletedSuccessfully", { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
toast.error(t("admin.failedToDeleteUser"));
|
||||
}
|
||||
},
|
||||
@@ -272,6 +406,7 @@ export function AdminSettings({
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
@@ -280,7 +415,7 @@ export function AdminSettings({
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/export`
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/export`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}/database/export`;
|
||||
@@ -321,7 +456,7 @@ export function AdminSettings({
|
||||
toast.error(error.error || t("admin.databaseExportFailed"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("admin.databaseExportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
@@ -334,7 +469,7 @@ export function AdminSettings({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!importPassword.trim()) {
|
||||
if (requiresImportPassword && !importPassword.trim()) {
|
||||
toast.error(t("admin.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -342,6 +477,7 @@ export function AdminSettings({
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
@@ -350,14 +486,16 @@ export function AdminSettings({
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${(window as any).configuredServerUrl}/database/import`
|
||||
? `${(window as { configuredServerUrl?: string }).configuredServerUrl}/database/import`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/import`
|
||||
: `${window.location.protocol}//${window.location.host}/database/import`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
formData.append("password", importPassword);
|
||||
if (requiresImportPassword) {
|
||||
formData.append("password", importPassword);
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
@@ -413,13 +551,87 @@ export function AdminSettings({
|
||||
toast.error(error.error || t("admin.databaseImportFailed"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("admin.databaseImportFailed"));
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (isElectron()) {
|
||||
const serverUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!serverUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const data = await getSessions();
|
||||
setSessions(data.sessions || []);
|
||||
} catch (err) {
|
||||
if (!err?.message?.includes("No server configured")) {
|
||||
toast.error(t("admin.failedToFetchSessions"));
|
||||
}
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const currentJWT = getCookie("jwt");
|
||||
const currentSession = sessions.find((s) => s.jwtToken === currentJWT);
|
||||
const isCurrentSession = currentSession?.id === sessionId;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeSession"),
|
||||
async () => {
|
||||
try {
|
||||
await revokeSession(sessionId);
|
||||
toast.success(t("admin.sessionRevokedSuccessfully"));
|
||||
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSession"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeAllUserSessions = async (userId: string) => {
|
||||
const isCurrentUser = currentUser?.id === userId;
|
||||
|
||||
confirmWithToast(
|
||||
t("admin.confirmRevokeAllSessions"),
|
||||
async () => {
|
||||
try {
|
||||
const data = await revokeAllUserSessions(userId);
|
||||
toast.success(data.message || t("admin.sessionsRevokedSuccessfully"));
|
||||
|
||||
if (isCurrentUser) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("admin.failedToRevokeSessions"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
@@ -460,6 +672,10 @@ export function AdminSettings({
|
||||
<Users className="h-4 w-4" />
|
||||
{t("admin.users")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Sessions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.adminManagement")}
|
||||
@@ -479,9 +695,22 @@ export function AdminSettings({
|
||||
<Checkbox
|
||||
checked={allowRegistration}
|
||||
onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading}
|
||||
disabled={regLoading || !allowPasswordLogin}
|
||||
/>
|
||||
{t("admin.allowNewAccountRegistration")}
|
||||
{!allowPasswordLogin && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("admin.requiresPasswordLogin")})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allowPasswordLogin}
|
||||
onCheckedChange={handleTogglePasswordLogin}
|
||||
disabled={passwordLoginLoading}
|
||||
/>
|
||||
{t("admin.allowPasswordLogin")}
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -507,6 +736,15 @@ export function AdminSettings({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!allowPasswordLogin && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("admin.criticalWarning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("admin.oidcRequiredWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
@@ -652,6 +890,47 @@ export function AdminSettings({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
if (!allowPasswordLogin) {
|
||||
confirmWithToast(
|
||||
t("admin.confirmDisableOIDCWarning"),
|
||||
async () => {
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
identifier_path: "",
|
||||
name_path: "",
|
||||
scopes: "",
|
||||
userinfo_url: "",
|
||||
};
|
||||
setOidcConfig(emptyConfig);
|
||||
setOidcError(null);
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(
|
||||
t("admin.oidcConfigurationDisabled"),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error ||
|
||||
t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const emptyConfig = {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
@@ -669,9 +948,13 @@ export function AdminSettings({
|
||||
try {
|
||||
await disableOIDCConfig();
|
||||
toast.success(t("admin.oidcConfigurationDisabled"));
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
setOidcError(
|
||||
err?.response?.data?.error ||
|
||||
(
|
||||
err as {
|
||||
response?: { data?: { error?: string } };
|
||||
}
|
||||
)?.response?.data?.error ||
|
||||
t("admin.failedToDisableOidcConfig"),
|
||||
);
|
||||
} finally {
|
||||
@@ -758,6 +1041,137 @@ export function AdminSettings({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sessions" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Session Management</h3>
|
||||
<Button
|
||||
onClick={fetchSessions}
|
||||
disabled={sessionsLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{sessionsLoading ? t("admin.loading") : t("admin.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionsLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Loading sessions...
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No active sessions found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Device</TableHead>
|
||||
<TableHead className="px-4">User</TableHead>
|
||||
<TableHead className="px-4">Created</TableHead>
|
||||
<TableHead className="px-4">Last Active</TableHead>
|
||||
<TableHead className="px-4">Expires</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={
|
||||
session.isRevoked ? "opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeSession(session.id)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(
|
||||
session.userId,
|
||||
)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title="Revoke all sessions for this user"
|
||||
>
|
||||
Revoke All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="admins" className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">
|
||||
@@ -966,7 +1380,7 @@ export function AdminSettings({
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && (
|
||||
{importFile && requiresImportPassword && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="import-password">Password</Label>
|
||||
<PasswordInput
|
||||
@@ -985,7 +1399,9 @@ export function AdminSettings({
|
||||
<Button
|
||||
onClick={handleImportDatabase}
|
||||
disabled={
|
||||
importLoading || !importFile || !importPassword.trim()
|
||||
importLoading ||
|
||||
!importFile ||
|
||||
(requiresImportPassword && !importPassword.trim())
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
+8
-10
@@ -42,9 +42,9 @@ export function CredentialEditor({
|
||||
onFormSubmit,
|
||||
}: CredentialEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [, setCredentials] = useState<Credential[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [, setLoading] = useState(true);
|
||||
const [fullCredentialDetails, setFullCredentialDetails] =
|
||||
useState<Credential | null>(null);
|
||||
|
||||
@@ -79,7 +79,7 @@ export function CredentialEditor({
|
||||
].sort() as string[];
|
||||
|
||||
setFolders(uniqueFolders);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function CredentialEditor({
|
||||
try {
|
||||
const fullDetails = await getCredentialDetails(editingCredential.id);
|
||||
setFullCredentialDetails(fullDetails);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||||
}
|
||||
} else {
|
||||
@@ -154,7 +154,9 @@ export function CredentialEditor({
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
resolver: zodResolver(formSchema) as unknown as Parameters<
|
||||
typeof useForm<FormData>
|
||||
>[0]["resolver"],
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
@@ -197,7 +199,7 @@ export function CredentialEditor({
|
||||
formData.publicKey = fullCredentialDetails.publicKey || "";
|
||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||
formData.keyType =
|
||||
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
||||
(fullCredentialDetails.keyType as string) || ("auto" as const);
|
||||
}
|
||||
|
||||
form.reset(formData);
|
||||
@@ -636,10 +638,6 @@ export function CredentialEditor({
|
||||
form.setValue("key", null);
|
||||
form.setValue("keyPassword", "");
|
||||
form.setValue("keyType", "auto");
|
||||
|
||||
if (newAuthType === "password") {
|
||||
} else if (newAuthType === "key") {
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
+1
-1
@@ -35,7 +35,7 @@ export function CredentialSelector({
|
||||
? data
|
||||
: data.credentials || data.data || [];
|
||||
setCredentials(credentialsArray);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("credentials.failedToFetchCredentials"));
|
||||
setCredentials([]);
|
||||
+3
-3
@@ -70,7 +70,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
try {
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||||
}
|
||||
};
|
||||
@@ -79,7 +79,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
try {
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToFetchHostsUsing"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -97,7 +97,7 @@ const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t("copiedToClipboard", { field: fieldName }));
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToCopy"));
|
||||
}
|
||||
};
|
||||
+22
-28
@@ -9,21 +9,7 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -37,7 +23,6 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Shield,
|
||||
Pin,
|
||||
Tag,
|
||||
Info,
|
||||
FolderMinus,
|
||||
@@ -75,9 +60,7 @@ export function CredentialsManager({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [viewingCredential] = useState<Credential | null>(null);
|
||||
const [draggedCredential, setDraggedCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
@@ -88,7 +71,15 @@ export function CredentialsManager({
|
||||
const [showDeployDialog, setShowDeployDialog] = useState(false);
|
||||
const [deployingCredential, setDeployingCredential] =
|
||||
useState<Credential | null>(null);
|
||||
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
|
||||
const [availableHosts, setAvailableHosts] = useState<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
}>
|
||||
>([]);
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>("");
|
||||
const [deployLoading, setDeployLoading] = useState(false);
|
||||
const [hostSearchQuery, setHostSearchQuery] = useState("");
|
||||
@@ -153,7 +144,7 @@ export function CredentialsManager({
|
||||
const data = await getCredentials();
|
||||
setCredentials(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(t("credentials.failedToFetchCredentials"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -224,10 +215,13 @@ export function CredentialsManager({
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.details) {
|
||||
} catch (err: unknown) {
|
||||
const error = err as {
|
||||
response?: { data?: { error?: string; details?: string } };
|
||||
};
|
||||
if (error.response?.data?.details) {
|
||||
toast.error(
|
||||
`${err.response.data.error}\n${err.response.data.details}`,
|
||||
`${error.response.data.error}\n${error.response.data.details}`,
|
||||
);
|
||||
} else {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
@@ -256,7 +250,7 @@ export function CredentialsManager({
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToRemoveFromFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
@@ -285,7 +279,7 @@ export function CredentialsManager({
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName("");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToRenameFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
@@ -325,7 +319,7 @@ export function CredentialsManager({
|
||||
setDragOverFolder(folderName);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
const handleDragLeave = () => {
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragOverFolder(null);
|
||||
@@ -359,7 +353,7 @@ export function CredentialsManager({
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("credentials.failedToMoveToFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
@@ -0,0 +1,704 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
|
||||
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
|
||||
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
getUserInfo,
|
||||
getDatabaseHealth,
|
||||
getCookie,
|
||||
getUptime,
|
||||
getVersionInfo,
|
||||
getSSHHosts,
|
||||
getTunnelStatuses,
|
||||
getCredentials,
|
||||
getRecentActivity,
|
||||
resetRecentActivity,
|
||||
getServerMetricsById,
|
||||
type RecentActivityItem,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
Database,
|
||||
FastForward,
|
||||
History,
|
||||
Key,
|
||||
Network,
|
||||
Server,
|
||||
UserPlus,
|
||||
Settings,
|
||||
User,
|
||||
Loader2,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { Status } from "@/components/ui/shadcn-io/status";
|
||||
import { BsLightning } from "react-icons/bs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DashboardProps {
|
||||
onSelectView: (view: string) => void;
|
||||
isAuthenticated: boolean;
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
isAuthenticated,
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [dbError, setDbError] = useState<string | null>(null);
|
||||
|
||||
const [uptime, setUptime] = useState<string>("0d 0h 0m");
|
||||
const [versionStatus, setVersionStatus] = useState<
|
||||
"up_to_date" | "requires_update"
|
||||
>("up_to_date");
|
||||
const [versionText, setVersionText] = useState<string>("v1.8.0");
|
||||
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
|
||||
const [totalServers, setTotalServers] = useState<number>(0);
|
||||
const [totalTunnels, setTotalTunnels] = useState<number>(0);
|
||||
const [totalCredentials, setTotalCredentials] = useState<number>(0);
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
[],
|
||||
);
|
||||
const [recentActivityLoading, setRecentActivityLoading] =
|
||||
useState<boolean>(true);
|
||||
const [serverStats, setServerStats] = useState<
|
||||
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
} catch {}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
setLoggedIn(isAuthenticated);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
if (getCookie("jwt")) {
|
||||
getUserInfo()
|
||||
.then((meRes) => {
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
setUsername(meRes.username || null);
|
||||
setUserId(meRes.userId || null);
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
setUserId(null);
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
setDbError("Session expired - please log in again");
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
});
|
||||
|
||||
getDatabaseHealth()
|
||||
.then(() => {
|
||||
setDbError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError(
|
||||
"Could not connect to the database. Please try again later.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const uptimeInfo = await getUptime();
|
||||
setUptime(uptimeInfo.formatted);
|
||||
|
||||
const versionInfo = await getVersionInfo();
|
||||
setVersionText(`v${versionInfo.localVersion}`);
|
||||
setVersionStatus(versionInfo.status || "up_to_date");
|
||||
|
||||
try {
|
||||
await getDatabaseHealth();
|
||||
setDbHealth("healthy");
|
||||
} catch {
|
||||
setDbHealth("error");
|
||||
}
|
||||
|
||||
const hostsResponse = await getSSHHosts();
|
||||
const hosts = Array.isArray(hostsResponse) ? hostsResponse : [];
|
||||
setTotalServers(hosts.length);
|
||||
|
||||
let totalTunnelsCount = 0;
|
||||
for (const host of hosts) {
|
||||
if (host.tunnelConnections) {
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(host.tunnelConnections)
|
||||
? host.tunnelConnections
|
||||
: JSON.parse(host.tunnelConnections);
|
||||
if (Array.isArray(tunnelConnections)) {
|
||||
totalTunnelsCount += tunnelConnections.length;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
setTotalTunnels(totalTunnelsCount);
|
||||
|
||||
const credentialsResponse = await getCredentials();
|
||||
const credentials = Array.isArray(credentialsResponse)
|
||||
? credentialsResponse
|
||||
: [];
|
||||
setTotalCredentials(credentials.length);
|
||||
|
||||
setRecentActivityLoading(true);
|
||||
const activityResponse = await getRecentActivity(35);
|
||||
const activity = Array.isArray(activityResponse)
|
||||
? activityResponse
|
||||
: [];
|
||||
setRecentActivity(activity);
|
||||
setRecentActivityLoading(false);
|
||||
|
||||
setServerStatsLoading(true);
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 50).map(async (host: { id: number; name: string }) => {
|
||||
try {
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
(server) => server.cpu !== null && server.ram !== null,
|
||||
);
|
||||
setServerStats(validServerStats);
|
||||
setServerStatsLoading(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard data:", error);
|
||||
setRecentActivityLoading(false);
|
||||
setServerStatsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loggedIn]);
|
||||
|
||||
const handleResetActivity = async () => {
|
||||
try {
|
||||
await resetRecentActivity();
|
||||
setRecentActivity([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to reset activity:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivityClick = (item: RecentActivityItem) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "file_manager") {
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: "Host Manager",
|
||||
initialTab: "add_host",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: "Host Manager",
|
||||
initialTab: "add_credential",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdminSettings = () => {
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "admin", title: "Admin Settings" });
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUserProfile = () => {
|
||||
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "user_profile", title: "User Profile" });
|
||||
setCurrentTab(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loggedIn ? (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Auth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
dbError={dbError}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col relative z-10 w-full h-full">
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3">
|
||||
<div className="text-2xl text-white font-semibold">
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3">
|
||||
<Button
|
||||
className="font-semibold"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Termix",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Support/issues/new",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.support")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://discord.com/invite/jVQGdvHDrf",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
}
|
||||
>
|
||||
{t("dashboard.donate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-3 p-0.25" />
|
||||
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0">
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="bg-dark-bg w-full h-auto border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center justify-between mb-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<History
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{versionText}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`ml-2 text-sm border-1 border-dark-border ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
|
||||
>
|
||||
{versionStatus === "up_to_date"
|
||||
? t("dashboard.upToDate")
|
||||
: t("dashboard.updateAvailable")}
|
||||
</Button>
|
||||
<UpdateLog loggedIn={loggedIn} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5">
|
||||
<div className="flex flex-row items-center">
|
||||
<Clock
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p className="leading-none text-muted-foreground">
|
||||
{uptime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<Database
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<p
|
||||
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<Server
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
{t("dashboard.totalServers")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<Network
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalTunnels}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<Key
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="m-0 leading-none text-muted-foreground font-semibold">
|
||||
{totalCredentials}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
<Clock className="mr-3" />
|
||||
{t("dashboard.recentActivity")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-2 !border-dark-border h-7"
|
||||
onClick={handleResetActivity}
|
||||
>
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{recentActivityLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</p>
|
||||
) : (
|
||||
recentActivity.map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
<Terminal size={20} className="shrink-0" />
|
||||
) : (
|
||||
<FolderOpen size={20} className="shrink-0" />
|
||||
)}
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{item.hostName}
|
||||
</p>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0">
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={handleAddHost}
|
||||
>
|
||||
<Server
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span className="font-semibold text-sm mt-2">
|
||||
{t("dashboard.addHost")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={handleAddCredential}
|
||||
>
|
||||
<Key
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span className="font-semibold text-sm mt-2">
|
||||
{t("dashboard.addCredential")}
|
||||
</span>
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={handleOpenAdminSettings}
|
||||
>
|
||||
<Settings
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span className="font-semibold text-sm mt-2">
|
||||
{t("dashboard.adminSettings")}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
onClick={handleOpenUserProfile}
|
||||
>
|
||||
<User
|
||||
className="shrink-0"
|
||||
style={{ width: "40px", height: "40px" }}
|
||||
/>
|
||||
<span className="font-semibold text-sm mt-2">
|
||||
{t("dashboard.userProfile")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{serverStatsLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
) : serverStats.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.noServerData")}
|
||||
</p>
|
||||
) : (
|
||||
serverStats.map((server) => (
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
<Server size={20} className="shrink-0" />
|
||||
<p className="truncate ml-2 font-semibold">
|
||||
{server.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
? `${server.cpu}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
<span>
|
||||
{t("dashboard.ram")}:{" "}
|
||||
{server.ram !== null
|
||||
? `${server.ram}%`
|
||||
: t("dashboard.notAvailable")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertManager userId={userId} loggedIn={loggedIn} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from "@/components/ui/sheet.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookOpen, X } from "lucide-react";
|
||||
|
||||
interface UpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
interface ReleaseItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
pubDate: string;
|
||||
version: string;
|
||||
isPrerelease: boolean;
|
||||
isDraft: boolean;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
size: number;
|
||||
download_count: number;
|
||||
download_url: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RSSResponse {
|
||||
feed: {
|
||||
title: string;
|
||||
description: string;
|
||||
link: string;
|
||||
updated: string;
|
||||
};
|
||||
items: ReleaseItem[];
|
||||
total_count: number;
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
interface VersionResponse {
|
||||
status: "up_to_date" | "requires_update";
|
||||
version: string;
|
||||
latest_release: {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
};
|
||||
cached: boolean;
|
||||
cache_age?: number;
|
||||
}
|
||||
|
||||
export function UpdateLog({ loggedIn }: UpdateLogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (loggedIn && isOpen) {
|
||||
setLoading(true);
|
||||
Promise.all([getReleasesRSS(100), getVersionInfo()])
|
||||
.then(([releasesRes, versionRes]) => {
|
||||
setReleases(releasesRes);
|
||||
setVersionInfo(versionRes);
|
||||
setError(null);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(t("common.failedToFetchUpdateInfo"));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [loggedIn, isOpen]);
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatDescription = (description: string) => {
|
||||
const firstLine = description.split("\n")[0];
|
||||
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2 text-sm border-1 border-dark-border text-muted-foreground"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{t("common.updatesAndReleases")}
|
||||
</Button>
|
||||
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-[500px] bg-dark-bg border-l-2 border-dark-border text-white sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("common.updatesAndReleases")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{versionInfo && versionInfo.status === "requires_update" && (
|
||||
<Alert className="bg-dark-bg-darker border-dark-border text-white mb-3">
|
||||
<AlertTitle className="text-white">
|
||||
{t("common.updateAvailable")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
{t("common.newVersionAvailable", {
|
||||
version: versionInfo.version,
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="bg-red-900/20 border-red-500 text-red-300 mb-3"
|
||||
>
|
||||
<AlertTitle className="text-red-300">
|
||||
{t("common.error")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-red-300">
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{releases?.items.map((release) => (
|
||||
<div
|
||||
key={release.id}
|
||||
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
|
||||
onClick={() => window.open(release.link, "_blank")}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
|
||||
{release.title}
|
||||
</h4>
|
||||
{release.isPrerelease && (
|
||||
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
{t("common.preRelease")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
|
||||
{formatDescription(release.description)}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-xs text-gray-400">
|
||||
<span>
|
||||
{new Date(release.pubDate).toLocaleDateString()}
|
||||
</span>
|
||||
{release.assets.length > 0 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
<span>
|
||||
{release.assets.length} asset
|
||||
{release.assets.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
|
||||
<AlertTitle className="text-gray-300">
|
||||
{t("common.noReleases")}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-gray-400">
|
||||
{t("common.noReleasesFound")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -17,7 +17,7 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "../../../types/index.js";
|
||||
import type { TermixAlert } from "../../../../../../types";
|
||||
|
||||
interface AlertCardProps {
|
||||
alert: TermixAlert;
|
||||
@@ -67,7 +67,7 @@ const getTypeBadgeVariant = (type?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export function HomepageAlertCard({
|
||||
export function AlertCard({
|
||||
alert,
|
||||
onDismiss,
|
||||
onClose,
|
||||
+7
-7
@@ -1,23 +1,23 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { HomepageAlertCard } from "./HomepageAlertCard.tsx";
|
||||
import { AlertCard } from "./AlertCard.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TermixAlert } from "../../../types/index.js";
|
||||
import type { TermixAlert } from "../../../../../../types";
|
||||
|
||||
interface AlertManagerProps {
|
||||
userId: string | null;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
export function HomepageAlertManager({
|
||||
export function AlertManager({
|
||||
userId,
|
||||
loggedIn,
|
||||
}: AlertManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,7 +52,7 @@ export function HomepageAlertManager({
|
||||
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("homepage.failedToLoadAlerts"));
|
||||
setError(t("homepage.failedToLoadAlerts"));
|
||||
@@ -77,7 +77,7 @@ export function HomepageAlertManager({
|
||||
return Math.max(0, newAlertsLength - 1);
|
||||
return prevIndex;
|
||||
});
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(t("homepage.failedToDismissAlert"));
|
||||
}
|
||||
};
|
||||
@@ -129,7 +129,7 @@ export function HomepageAlertManager({
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
|
||||
<div className="relative w-full max-w-2xl mx-4">
|
||||
<HomepageAlertCard
|
||||
<AlertCard
|
||||
alert={currentAlert}
|
||||
onDismiss={handleDismissAlert}
|
||||
onClose={handleCloseCurrentAlert}
|
||||
+253
-68
@@ -14,6 +14,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
import {
|
||||
Upload,
|
||||
FolderPlus,
|
||||
@@ -22,8 +24,6 @@ import {
|
||||
Search,
|
||||
Grid3X3,
|
||||
List,
|
||||
Eye,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { TerminalWindow } from "./components/TerminalWindow";
|
||||
import type { SSHHost, FileItem } from "../../../types/index.js";
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
renameSSHItem,
|
||||
moveSSHItem,
|
||||
connectSSH,
|
||||
verifySSHTOTP,
|
||||
getSSHStatus,
|
||||
keepSSHAlive,
|
||||
identifySSHSymlink,
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
removeRecentFile,
|
||||
addFolderShortcut,
|
||||
getPinnedFiles,
|
||||
logActivity,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SidebarItem } from "./FileManagerSidebar";
|
||||
|
||||
@@ -85,9 +87,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(
|
||||
initialHost || null,
|
||||
);
|
||||
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
||||
const [currentPath, setCurrentPath] = useState(
|
||||
initialHost?.defaultPath || "/",
|
||||
);
|
||||
@@ -98,6 +98,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
||||
const [authDialogReason, setAuthDialogReason] = useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
||||
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
||||
const [isClosing, setIsClosing] = useState<boolean>(false);
|
||||
@@ -140,10 +147,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
||||
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
||||
|
||||
const { selectedFiles, selectFile, selectAll, clearSelection, setSelection } =
|
||||
useFileSelection();
|
||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||
|
||||
const { isDragging, dragHandlers } = useDragAndDrop({
|
||||
const { dragHandlers } = useDragAndDrop({
|
||||
onFilesDropped: handleFilesDropped,
|
||||
onError: (error) => toast.error(error),
|
||||
maxFileSize: 5120,
|
||||
@@ -219,6 +225,32 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentLoadingPathRef = useRef<string>("");
|
||||
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activityLoggedRef = useRef(false);
|
||||
const activityLoggingRef = useRef(false);
|
||||
|
||||
const logFileManagerActivity = useCallback(async () => {
|
||||
if (
|
||||
!currentHost?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
||||
await logActivity("file_manager", currentHost.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log file manager activity:", err);
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
const handleFileDragStart = useCallback(
|
||||
(files: FileItem[]) => {
|
||||
@@ -266,8 +298,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
[systemDrag, clearSelection],
|
||||
);
|
||||
|
||||
const isConnectingRef = useRef(false);
|
||||
|
||||
async function initializeSSHConnection() {
|
||||
if (!currentHost) return;
|
||||
if (!currentHost || isConnectingRef.current) return;
|
||||
|
||||
isConnectingRef.current = true;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@@ -286,8 +322,24 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
authType: currentHost.authType,
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
||||
});
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sessionId);
|
||||
setTotpPrompt(result.prompt || "Verification code:");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setAuthDialogReason(result.reason || "no_keyboard");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
try {
|
||||
@@ -298,16 +350,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
initialLoadDoneRef.current = true;
|
||||
} catch (dirError: any) {
|
||||
|
||||
if (!result?.requires_totp) {
|
||||
logFileManagerActivity();
|
||||
}
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("SSH connection failed:", error);
|
||||
handleCloseWithError(
|
||||
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
isConnectingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +397,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (currentLoadingPathRef.current === path) {
|
||||
console.error("Failed to load directory:", error);
|
||||
|
||||
@@ -522,7 +579,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
t("fileManager.fileUploadedSuccessfully", { name: file.name }),
|
||||
);
|
||||
handleRefreshDirectory();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
toast.dismiss(progressToast);
|
||||
|
||||
if (
|
||||
@@ -571,7 +628,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
@@ -652,7 +709,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
@@ -706,28 +763,24 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
let currentSessionId = sshSessionId;
|
||||
try {
|
||||
const status = await getSSHStatus(currentSessionId);
|
||||
if (!status.connected) {
|
||||
const result = await connectSSH(currentSessionId, {
|
||||
hostId: currentHost.id,
|
||||
host: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
authType: currentHost.authType,
|
||||
password: currentHost.password,
|
||||
key: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
credentialId: currentHost.credentialId,
|
||||
});
|
||||
const currentSessionId = sshSessionId;
|
||||
const status = await getSSHStatus(currentSessionId);
|
||||
if (!status.connected) {
|
||||
const result = await connectSSH(currentSessionId, {
|
||||
hostId: currentHost.id,
|
||||
host: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
authType: currentHost.authType,
|
||||
password: currentHost.password,
|
||||
key: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
credentialId: currentHost.credentialId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||
}
|
||||
if (!result.success) {
|
||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
throw sessionErr;
|
||||
}
|
||||
|
||||
const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path);
|
||||
@@ -766,7 +819,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
component: createWindowComponent,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
error?.response?.data?.error ||
|
||||
error?.message ||
|
||||
@@ -775,7 +828,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
async function handleFileOpen(file: FileItem, editMode: boolean = false) {
|
||||
async function handleFileOpen(file: FileItem) {
|
||||
if (file.type === "directory") {
|
||||
setCurrentPath(file.path);
|
||||
} else if (file.type === "link") {
|
||||
@@ -825,14 +878,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileEdit(file: FileItem) {
|
||||
handleFileOpen(file, true);
|
||||
}
|
||||
|
||||
function handleFileView(file: FileItem) {
|
||||
handleFileOpen(file, false);
|
||||
}
|
||||
|
||||
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -905,7 +950,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to ${operation} file ${file.name}:`, error);
|
||||
toast.error(
|
||||
t("fileManager.operationFailed", {
|
||||
@@ -1006,7 +1051,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
if (operation === "cut") {
|
||||
setClipboard(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
@@ -1041,7 +1086,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Failed to delete copied file ${copiedFile.targetName}:`,
|
||||
error,
|
||||
@@ -1083,7 +1128,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Failed to move back file ${movedFile.targetName}:`,
|
||||
error,
|
||||
@@ -1123,7 +1168,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
toast.error(
|
||||
`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
@@ -1195,7 +1240,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
setCreateIntent(null);
|
||||
handleRefreshDirectory();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Create failed:", error);
|
||||
toast.error(t("fileManager.failedToCreateItem"));
|
||||
}
|
||||
@@ -1224,7 +1269,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
setEditingFile(null);
|
||||
handleRefreshDirectory();
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Rename failed:", error);
|
||||
toast.error(t("fileManager.failedToRenameItem"));
|
||||
}
|
||||
@@ -1238,6 +1283,124 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setEditingFile(null);
|
||||
}
|
||||
|
||||
async function handleTotpSubmit(code: string) {
|
||||
if (!totpSessionId || !code) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await verifySSHTOTP(totpSessionId, code);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setTotpRequired(false);
|
||||
setTotpPrompt("");
|
||||
setSshSessionId(totpSessionId);
|
||||
setTotpSessionId(null);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(totpSessionId, currentPath);
|
||||
const files = Array.isArray(response)
|
||||
? response
|
||||
: response?.files || [];
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
initialLoadDoneRef.current = true;
|
||||
toast.success(t("fileManager.connectedSuccessfully"));
|
||||
|
||||
logFileManagerActivity();
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("TOTP verification failed:", error);
|
||||
toast.error(t("fileManager.totpVerificationFailed"));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTotpCancel() {
|
||||
setTotpRequired(false);
|
||||
setTotpPrompt("");
|
||||
setTotpSessionId(null);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
async function handleAuthDialogSubmit(credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setShowAuthDialog(false);
|
||||
|
||||
const sessionId = currentHost.id.toString();
|
||||
|
||||
const result = await connectSSH(sessionId, {
|
||||
hostId: currentHost.id,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: credentials.password,
|
||||
sshKey: credentials.sshKey,
|
||||
keyPassword: credentials.keyPassword,
|
||||
authType: credentials.password ? "password" : "key",
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
userProvidedPassword: true,
|
||||
});
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sessionId);
|
||||
setTotpPrompt(result.prompt || "Verification code:");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setAuthDialogReason(result.reason || "auth_failed");
|
||||
setShowAuthDialog(true);
|
||||
setIsLoading(false);
|
||||
toast.error(t("fileManager.authenticationFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sessionId, currentPath);
|
||||
const files = Array.isArray(response)
|
||||
? response
|
||||
: response?.files || [];
|
||||
setFiles(files);
|
||||
clearSelection();
|
||||
initialLoadDoneRef.current = true;
|
||||
toast.success(t("fileManager.connectedSuccessfully"));
|
||||
logFileManagerActivity();
|
||||
} catch (dirError: unknown) {
|
||||
console.error("Failed to load initial directory:", dirError);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("SSH connection with credentials failed:", error);
|
||||
setAuthDialogReason("auth_failed");
|
||||
setShowAuthDialog(true);
|
||||
toast.error(
|
||||
t("fileManager.failedToConnect") + ": " + (error.message || error),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthDialogCancel() {
|
||||
setShowAuthDialog(false);
|
||||
if (onClose) onClose();
|
||||
}
|
||||
|
||||
function generateUniqueName(
|
||||
baseName: string,
|
||||
type: "file" | "directory",
|
||||
@@ -1290,7 +1453,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
movedItems.push(file.name);
|
||||
successCount++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to move file ${file.name}:`, error);
|
||||
toast.error(
|
||||
t("fileManager.moveFileFailed", { name: file.name }) +
|
||||
@@ -1301,18 +1464,16 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
const movedFiles = draggedFiles
|
||||
.slice(0, successCount)
|
||||
.map((file, index) => {
|
||||
const targetPath = targetFolder.path.endsWith("/")
|
||||
? `${targetFolder.path}${file.name}`
|
||||
: `${targetFolder.path}/${file.name}`;
|
||||
return {
|
||||
originalPath: file.path,
|
||||
targetPath: targetPath,
|
||||
targetName: file.name,
|
||||
};
|
||||
});
|
||||
const movedFiles = draggedFiles.slice(0, successCount).map((file) => {
|
||||
const targetPath = targetFolder.path.endsWith("/")
|
||||
? `${targetFolder.path}${file.name}`
|
||||
: `${targetFolder.path}/${file.name}`;
|
||||
return {
|
||||
originalPath: file.path,
|
||||
targetPath: targetPath,
|
||||
targetName: file.name,
|
||||
};
|
||||
});
|
||||
|
||||
const undoAction: UndoAction = {
|
||||
type: "cut",
|
||||
@@ -1338,7 +1499,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Drag move operation failed:", error);
|
||||
toast.error(t("fileManager.moveOperationFailed") + ": " + error.message);
|
||||
}
|
||||
@@ -1409,7 +1570,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
await dragToDesktop.dragFilesToDesktop(files);
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Drag to desktop failed:", error);
|
||||
toast.error(
|
||||
t("fileManager.dragFailed") +
|
||||
@@ -1504,7 +1665,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
try {
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedPaths = new Set(pinnedData.map((item: any) => item.path));
|
||||
const pinnedPaths = new Set(
|
||||
pinnedData.map((item: Record<string, unknown>) => item.path),
|
||||
);
|
||||
setPinnedFiles(pinnedPaths);
|
||||
} catch (error) {
|
||||
console.error("Failed to load pinned files:", error);
|
||||
@@ -1806,6 +1969,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authDialogReason}
|
||||
onSubmit={handleAuthDialogSubmit}
|
||||
onCancel={handleAuthDialogCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-3
@@ -13,8 +13,6 @@ import {
|
||||
RefreshCw,
|
||||
Clipboard,
|
||||
Eye,
|
||||
Share,
|
||||
ExternalLink,
|
||||
Terminal,
|
||||
Play,
|
||||
Star,
|
||||
@@ -190,7 +188,6 @@ export function FileManagerContextMenu({
|
||||
const isSingleFile = files.length === 1;
|
||||
const isMultipleFiles = files.length > 1;
|
||||
const hasFiles = files.some((f) => f.type === "file");
|
||||
const hasDirectories = files.some((f) => f.type === "directory");
|
||||
const hasExecutableFiles = files.some(
|
||||
(f) => f.type === "file" && f.executable,
|
||||
);
|
||||
+1
-17
@@ -166,7 +166,6 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
export function FileManagerGrid({
|
||||
files,
|
||||
selectedFiles,
|
||||
onFileSelect,
|
||||
onFileOpen,
|
||||
onSelectionChange,
|
||||
currentPath,
|
||||
@@ -188,7 +187,6 @@ export function FileManagerGrid({
|
||||
onUndo,
|
||||
onFileDrop,
|
||||
onFileDiff,
|
||||
onSystemDragStart,
|
||||
onSystemDragEnd,
|
||||
hasClipboard,
|
||||
createIntent,
|
||||
@@ -327,7 +325,6 @@ export function FileManagerGrid({
|
||||
dragState.files[0].type === "file"
|
||||
) {
|
||||
onFileDiff?.(dragState.files[0], targetFile);
|
||||
} else {
|
||||
}
|
||||
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
@@ -429,16 +426,6 @@ export function FileManagerGrid({
|
||||
setIsEditingPath(false);
|
||||
};
|
||||
|
||||
const handlePathInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
confirmEditingPath();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelEditingPath();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingPath) {
|
||||
setEditPathValue(currentPath);
|
||||
@@ -458,8 +445,6 @@ export function FileManagerGrid({
|
||||
type: "external",
|
||||
counter: prev.counter + 1,
|
||||
}));
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
}
|
||||
}
|
||||
},
|
||||
[dragState.type],
|
||||
@@ -620,7 +605,7 @@ export function FileManagerGrid({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseUp = (e: MouseEvent) => {
|
||||
const handleGlobalMouseUp = () => {
|
||||
if (isSelecting) {
|
||||
setIsSelecting(false);
|
||||
setSelectionStart(null);
|
||||
@@ -1087,7 +1072,6 @@ export function FileManagerGrid({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div className="space-y-1">
|
||||
{createIntent && (
|
||||
<CreateIntentListItem
|
||||
+60
-27
@@ -23,6 +23,35 @@ import {
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface RecentFileData {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
lastOpened?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface PinnedFileData {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ShortcutData {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DirectoryItemData {
|
||||
name: string;
|
||||
path: string;
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SidebarItem {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -37,7 +66,6 @@ interface FileManagerSidebarProps {
|
||||
currentHost: SSHHost;
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
onLoadDirectory?: (path: string) => void;
|
||||
onFileOpen?: (file: SidebarItem) => void;
|
||||
sshSessionId?: string;
|
||||
refreshTrigger?: number;
|
||||
@@ -47,7 +75,6 @@ export function FileManagerSidebar({
|
||||
currentHost,
|
||||
currentPath,
|
||||
onPathChange,
|
||||
onLoadDirectory,
|
||||
onFileOpen,
|
||||
sshSessionId,
|
||||
refreshTrigger,
|
||||
@@ -88,31 +115,37 @@ export function FileManagerSidebar({
|
||||
|
||||
try {
|
||||
const recentData = await getRecentFiles(currentHost.id);
|
||||
const recentItems = recentData.slice(0, 5).map((item: any) => ({
|
||||
id: `recent-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "recent" as const,
|
||||
lastAccessed: item.lastOpened,
|
||||
}));
|
||||
const recentItems = (recentData as RecentFileData[])
|
||||
.slice(0, 5)
|
||||
.map((item: RecentFileData) => ({
|
||||
id: `recent-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "recent" as const,
|
||||
lastAccessed: item.lastOpened,
|
||||
}));
|
||||
setRecentItems(recentItems);
|
||||
|
||||
const pinnedData = await getPinnedFiles(currentHost.id);
|
||||
const pinnedItems = pinnedData.map((item: any) => ({
|
||||
id: `pinned-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "pinned" as const,
|
||||
}));
|
||||
const pinnedItems = (pinnedData as PinnedFileData[]).map(
|
||||
(item: PinnedFileData) => ({
|
||||
id: `pinned-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "pinned" as const,
|
||||
}),
|
||||
);
|
||||
setPinnedItems(pinnedItems);
|
||||
|
||||
const shortcutData = await getFolderShortcuts(currentHost.id);
|
||||
const shortcutItems = shortcutData.map((item: any) => ({
|
||||
id: `shortcut-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "shortcut" as const,
|
||||
}));
|
||||
const shortcutItems = (shortcutData as ShortcutData[]).map(
|
||||
(item: ShortcutData) => ({
|
||||
id: `shortcut-${item.id}`,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type: "shortcut" as const,
|
||||
}),
|
||||
);
|
||||
setShortcuts(shortcutItems);
|
||||
} catch (error) {
|
||||
console.error("Failed to load quick access data:", error);
|
||||
@@ -230,12 +263,12 @@ export function FileManagerSidebar({
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
|
||||
const rootFiles = response.files || [];
|
||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: any) => item.type === "directory",
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
|
||||
const rootTreeItems = rootFolders.map((folder: any) => ({
|
||||
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.name}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
@@ -298,12 +331,12 @@ export function FileManagerSidebar({
|
||||
try {
|
||||
const subResponse = await listSSHFiles(sshSessionId, folderPath);
|
||||
|
||||
const subFiles = subResponse.files || [];
|
||||
const subFiles = (subResponse.files || []) as DirectoryItemData[];
|
||||
const subFolders = subFiles.filter(
|
||||
(item: any) => item.type === "directory",
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
|
||||
const subTreeItems = subFolders.map((folder: any) => ({
|
||||
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.path.replace(/\//g, "-")}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
+25
-26
@@ -61,23 +61,19 @@ export function DiffViewer({
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
} catch (reconnectError) {
|
||||
throw reconnectError;
|
||||
}
|
||||
} catch {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,15 +96,19 @@ export function DiffViewer({
|
||||
|
||||
setContent1(response1.content || "");
|
||||
setContent2(response2.content || "");
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load files for diff:", error);
|
||||
|
||||
const errorData = error?.response?.data;
|
||||
const err = error as {
|
||||
message?: string;
|
||||
response?: { data?: { tooLarge?: boolean; error?: string } };
|
||||
};
|
||||
const errorData = err?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
|
||||
} else if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
setError(
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
@@ -121,9 +121,7 @@ export function DiffViewer({
|
||||
setError(
|
||||
t("fileManager.loadFileFailed", {
|
||||
error:
|
||||
error.message ||
|
||||
errorData?.error ||
|
||||
t("fileManager.unknownError"),
|
||||
err.message || errorData?.error || t("fileManager.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -161,12 +159,13 @@ export function DiffViewer({
|
||||
t("fileManager.downloadFileSuccess", { name: file.name }),
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to download file:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
t("fileManager.downloadFileFailed") +
|
||||
": " +
|
||||
(error.message || t("fileManager.unknownError")),
|
||||
(err.message || t("fileManager.unknownError")),
|
||||
);
|
||||
}
|
||||
};
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Minus, Square, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DraggableWindowProps {
|
||||
+8
-17
@@ -60,7 +60,6 @@ import {
|
||||
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import "react-photo-view/dist/react-photo-view.css";
|
||||
import ReactPlayer from "react-player";
|
||||
import AudioPlayer from "react-h5-audio-player";
|
||||
import "react-h5-audio-player/lib/styles.css";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
@@ -290,7 +289,7 @@ function getLanguageExtension(filename: string) {
|
||||
return language ? loadLanguage(language) : null;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number, t?: any): string {
|
||||
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
|
||||
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
@@ -311,9 +310,7 @@ export function FileViewer({
|
||||
}: FileViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editedContent, setEditedContent] = useState(content);
|
||||
const [originalContent, setOriginalContent] = useState(
|
||||
savedContent || content,
|
||||
);
|
||||
const [, setOriginalContent] = useState(savedContent || content);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
|
||||
const [forceShowAsText, setForceShowAsText] = useState(false);
|
||||
@@ -326,7 +323,9 @@ export function FileViewer({
|
||||
const [pdfScale, setPdfScale] = useState(1.2);
|
||||
const [pdfError, setPdfError] = useState(false);
|
||||
const [markdownEditMode, setMarkdownEditMode] = useState(false);
|
||||
const editorRef = useRef<any>(null);
|
||||
const editorRef = useRef<{
|
||||
view?: { dispatch: (transaction: unknown) => void };
|
||||
} | null>(null);
|
||||
|
||||
const fileTypeInfo = getFileType(file.name);
|
||||
|
||||
@@ -975,13 +974,7 @@ export function FileViewer({
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({
|
||||
node,
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(
|
||||
className || "",
|
||||
);
|
||||
@@ -1091,13 +1084,12 @@ export function FileViewer({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Full preview mode */
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
code({ inline, className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
@@ -1380,8 +1372,7 @@ export function FileViewer({
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<AudioPlayer
|
||||
src={audioUrl}
|
||||
onLoadedMetadata={(e) => {
|
||||
const audio = e.currentTarget;
|
||||
onLoadedMetadata={() => {
|
||||
if (onMediaDimensionsChange) {
|
||||
onMediaDimensionsChange({
|
||||
width: 600,
|
||||
+33
-18
@@ -56,7 +56,7 @@ export function FileWindow({
|
||||
initialY = 100,
|
||||
onFileNotFound,
|
||||
}: FileWindowProps) {
|
||||
const { closeWindow, maximizeWindow, focusWindow, updateWindow, windows } =
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -157,28 +157,40 @@ export function FileWindow({
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load file:", error);
|
||||
|
||||
const errorData = error?.response?.data;
|
||||
const err = error as {
|
||||
message?: string;
|
||||
isFileNotFound?: boolean;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
tooLarge?: boolean;
|
||||
error?: string;
|
||||
fileNotFound?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
const errorData = err?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
toast.error(`File too large: ${errorData.error}`, {
|
||||
duration: 10000,
|
||||
});
|
||||
} else if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
errorData?.error || error.message || "Unknown error";
|
||||
errorData?.error || err.message || "Unknown error";
|
||||
const isFileNotFound =
|
||||
(error as any).isFileNotFound ||
|
||||
err.isFileNotFound ||
|
||||
errorData?.fileNotFound ||
|
||||
error.response?.status === 404 ||
|
||||
err.response?.status === 404 ||
|
||||
errorMessage.includes("File not found") ||
|
||||
errorMessage.includes("No such file or directory") ||
|
||||
errorMessage.includes("cannot access") ||
|
||||
@@ -229,10 +241,11 @@ export function FileWindow({
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load file content:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToLoadFile")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -258,19 +271,20 @@ export function FileWindow({
|
||||
}
|
||||
|
||||
toast.success(t("fileManager.fileSavedSuccessfully"));
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`,
|
||||
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -335,19 +349,20 @@ export function FileWindow({
|
||||
|
||||
toast.success(t("fileManager.fileDownloadedSuccessfully"));
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to download file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
error.message?.includes("connection") ||
|
||||
error.message?.includes("established")
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
`Failed to download file: ${error.message || "Unknown error"}`,
|
||||
`Failed to download file: ${err.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+11
-15
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow";
|
||||
import { Terminal } from "../../Terminal/Terminal";
|
||||
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal";
|
||||
import { useWindowManager } from "./WindowManager";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -36,11 +36,19 @@ export function TerminalWindow({
|
||||
executeCommand,
|
||||
}: TerminalWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
const terminalRef = React.useRef<any>(null);
|
||||
const terminalRef = React.useRef<{ fit?: () => void } | null>(null);
|
||||
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
@@ -50,10 +58,6 @@ export function TerminalWindow({
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMinimize = () => {
|
||||
minimizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
@@ -74,14 +78,6 @@ export function TerminalWindow({
|
||||
}, 100);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const terminalTitle = executeCommand
|
||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||
: initialPath
|
||||
+53
-11
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { HostManagerViewer } from "@/ui/desktop/apps/host-manager/HostManagerViewer.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
@@ -7,35 +7,77 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
|
||||
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||
import { HostManagerEditor } from "@/ui/desktop/apps/host-manager/HostManagerEditor.tsx";
|
||||
import { CredentialsManager } from "@/ui/desktop/apps/credentials/CredentialsManager.tsx";
|
||||
import { CredentialEditor } from "@/ui/desktop/apps/credentials/CredentialEditor.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||
|
||||
export function HostManager({
|
||||
onSelectView,
|
||||
isTopbarOpen,
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(
|
||||
hostConfig || null,
|
||||
);
|
||||
|
||||
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||
const [editingCredential, setEditingCredential] = useState<{
|
||||
id: number;
|
||||
name?: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
|
||||
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (ignoreNextHostConfigChangeRef.current) {
|
||||
ignoreNextHostConfigChangeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostConfig && initialTab === "add_host") {
|
||||
const currentHostId = hostConfig.id;
|
||||
|
||||
if (currentHostId !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = currentHostId;
|
||||
} else if (
|
||||
activeTab === "host_viewer" ||
|
||||
activeTab === "credentials" ||
|
||||
activeTab === "add_credential"
|
||||
) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
}
|
||||
}
|
||||
}, [hostConfig, initialTab]);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = host.id;
|
||||
};
|
||||
|
||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||
const handleFormSubmit = () => {
|
||||
ignoreNextHostConfigChangeRef.current = true;
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
setTimeout(() => {
|
||||
lastProcessedHostIdRef.current = undefined;
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleEditCredential = (credential: any) => {
|
||||
const handleEditCredential = (credential: {
|
||||
id: number;
|
||||
name?: string;
|
||||
username: string;
|
||||
}) => {
|
||||
setEditingCredential(credential);
|
||||
setActiveTab("add_credential");
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
+101
-12
@@ -43,11 +43,14 @@ import {
|
||||
Pencil,
|
||||
FolderMinus,
|
||||
Copy,
|
||||
Activity,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHManagerHostViewerProps,
|
||||
} from "../../../../types/index.js";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
|
||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -106,7 +109,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
|
||||
setHosts(cleanedHosts);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setError(t("hosts.failedToLoadHosts"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -122,7 +125,10 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
toast.success(t("hosts.hostDeletedSuccessfully", { name: hostName }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
} catch (err) {
|
||||
|
||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||
refreshServerPolling();
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToDeleteHost"));
|
||||
}
|
||||
},
|
||||
@@ -143,7 +149,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
});
|
||||
|
||||
confirmWithToast(confirmMessage, () => {
|
||||
performExport(host, actualAuthType);
|
||||
performExport(host);
|
||||
});
|
||||
return;
|
||||
} else if (actualAuthType === "password" || actualAuthType === "key") {
|
||||
@@ -152,21 +158,21 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
});
|
||||
|
||||
confirmWithToast(confirmMessage, () => {
|
||||
performExport(host, actualAuthType);
|
||||
performExport(host);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
performExport(host, actualAuthType);
|
||||
performExport(host);
|
||||
};
|
||||
|
||||
const performExport = async (host: SSHHost, actualAuthType: string) => {
|
||||
const performExport = async (host: SSHHost) => {
|
||||
try {
|
||||
const decryptedHost = await exportSSHHostWithCredentials(host.id);
|
||||
|
||||
const cleanExportData = Object.fromEntries(
|
||||
Object.entries(decryptedHost).filter(
|
||||
([_, value]) => value !== undefined,
|
||||
([, value]) => value !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -185,7 +191,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
toast.success(
|
||||
`Exported host configuration for ${host.name || host.username}@${host.ip}`,
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToExportHost"));
|
||||
}
|
||||
};
|
||||
@@ -222,7 +228,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
);
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRemoveFromFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
@@ -251,7 +257,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName("");
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRenameFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
@@ -291,7 +297,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
setDragOverFolder(folderName);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
const handleDragLeave = () => {
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragOverFolder(null);
|
||||
@@ -325,7 +331,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
);
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToMoveToFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
@@ -385,6 +391,47 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const getMonitoringStatus = (host: SSHHost) => {
|
||||
try {
|
||||
const statsConfig = host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
|
||||
const formatInterval = (seconds: number): string => {
|
||||
if (seconds >= 60) {
|
||||
const minutes = Math.round(seconds / 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
const statusEnabled = statsConfig.statusCheckEnabled !== false;
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
const statusInterval = statusEnabled
|
||||
? formatInterval(statsConfig.statusCheckInterval || 30)
|
||||
: null;
|
||||
const metricsInterval = metricsEnabled
|
||||
? formatInterval(statsConfig.metricsInterval || 30)
|
||||
: null;
|
||||
|
||||
return {
|
||||
statusEnabled,
|
||||
metricsEnabled,
|
||||
statusInterval,
|
||||
metricsInterval,
|
||||
bothDisabled: !statusEnabled && !metricsEnabled,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
statusEnabled: true,
|
||||
metricsEnabled: true,
|
||||
statusInterval: "30s",
|
||||
metricsInterval: "30s",
|
||||
bothDisabled: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSortedHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
|
||||
@@ -1088,6 +1135,48 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{t("hosts.fileManagerBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const monitoringStatus =
|
||||
getMonitoringStatus(host);
|
||||
|
||||
if (monitoringStatus.bothDisabled) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0 text-muted-foreground"
|
||||
>
|
||||
<Activity className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.monitoringDisabledBadge")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{monitoringStatus.statusEnabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Activity className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.statusMonitoring")}:{" "}
|
||||
{monitoringStatus.statusInterval}
|
||||
</Badge>
|
||||
)}
|
||||
{monitoringStatus.metricsEnabled && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Clock className="h-2 w-2 mr-0.5" />
|
||||
{t("hosts.metricsMonitoring")}:{" "}
|
||||
{monitoringStatus.metricsInterval}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,506 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type WidgetType,
|
||||
type StatsConfig,
|
||||
DEFAULT_STATS_CONFIG,
|
||||
} from "@/types/stats-widgets";
|
||||
import {
|
||||
CpuWidget,
|
||||
MemoryWidget,
|
||||
DiskWidget,
|
||||
NetworkWidget,
|
||||
UptimeWidget,
|
||||
ProcessesWidget,
|
||||
SystemWidget,
|
||||
} from "./widgets";
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
type: string;
|
||||
title?: string;
|
||||
hostConfig?: HostConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { addTab, tabs } = useTabs() as {
|
||||
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
||||
tabs: TabData[];
|
||||
};
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
|
||||
[],
|
||||
);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
try {
|
||||
const parsed =
|
||||
typeof currentHostConfig.statsConfig === "string"
|
||||
? JSON.parse(currentHostConfig.statsConfig)
|
||||
: currentHostConfig.statsConfig;
|
||||
return { ...DEFAULT_STATS_CONFIG, ...parsed };
|
||||
} catch (error) {
|
||||
console.error("Failed to parse statsConfig:", error);
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
}, [currentHostConfig?.statsConfig]);
|
||||
|
||||
const enabledWidgets = statsConfig.enabledWidgets;
|
||||
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
case "cpu":
|
||||
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
|
||||
|
||||
case "memory":
|
||||
return (
|
||||
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "disk":
|
||||
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
|
||||
|
||||
case "network":
|
||||
return (
|
||||
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "uptime":
|
||||
return (
|
||||
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "processes":
|
||||
return (
|
||||
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "system":
|
||||
return (
|
||||
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
|
||||
setServerStatus("offline");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
const err = error as {
|
||||
response?: { status?: number };
|
||||
};
|
||||
if (err?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (err?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
intervalId = window.setInterval(fetchStatus, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
|
||||
setShowStatsUI(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
setIsLoadingMetrics(true);
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
setMetrics(data);
|
||||
setMetricsHistory((prev) => {
|
||||
const newHistory = [...prev, data];
|
||||
return newHistory.slice(-20);
|
||||
});
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!cancelled) {
|
||||
const err = error as {
|
||||
code?: string;
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
if (err?.response?.status === 404) {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.code === "TOTP_REQUIRED" ||
|
||||
(err?.response?.status === 403 &&
|
||||
err?.response?.data?.error === "TOTP_REQUIRED")
|
||||
) {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.totpUnavailable"));
|
||||
} else {
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(fetchMetrics, 10000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible, metricsEnabled]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some(
|
||||
(tab: TabData) =>
|
||||
tab.type === "file_manager" &&
|
||||
tab.hostConfig?.id === currentHostConfig.id,
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
{statusCheckEnabled && (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
className="font-semibold"
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
setShowStatsUI(true);
|
||||
} catch (error: unknown) {
|
||||
const err = error as {
|
||||
code?: string;
|
||||
status?: number;
|
||||
response?: { status?: number; data?: { error?: string } };
|
||||
};
|
||||
if (
|
||||
err?.code === "TOTP_REQUIRED" ||
|
||||
(err?.response?.status === 403 &&
|
||||
err?.response?.data?.error === "TOTP_REQUIRED")
|
||||
) {
|
||||
toast.error(t("serverStats.totpUnavailable"));
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 503 ||
|
||||
err?.status === 503
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 504 ||
|
||||
err?.status === 504
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else if (
|
||||
err?.response?.status === 404 ||
|
||||
err?.status === 404
|
||||
) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setShowStatsUI(false);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.refreshStatusAndMetrics")}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("serverStats.feedbackMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
|
||||
const {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} = RechartsPrimitive;
|
||||
|
||||
interface CpuWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = React.useMemo(() => {
|
||||
return metricsHistory.map((m, index) => ({
|
||||
index,
|
||||
cpu: m.cpu?.percent || 0,
|
||||
}));
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.cpuUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{typeof metrics?.cpu?.percent === "number"
|
||||
? `${metrics.cpu.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{typeof metrics?.cpu?.cores === "number"
|
||||
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
|
||||
: t("serverStats.naCpus")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex-shrink-0">
|
||||
{metrics?.cpu?.load
|
||||
? t("serverStats.loadAverage", {
|
||||
avg1: metrics.cpu.load[0].toFixed(2),
|
||||
avg5: metrics.cpu.load[1].toFixed(2),
|
||||
avg15: metrics.cpu.load[2].toFixed(2),
|
||||
})
|
||||
: t("serverStats.loadAverageNA")}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1f2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [`${value.toFixed(1)}%`, "CPU"]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
animationDuration={300}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
|
||||
const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
|
||||
RechartsPrimitive;
|
||||
|
||||
interface DiskWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function DiskWidget({ metrics }: DiskWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const radialData = React.useMemo(() => {
|
||||
const percent = metrics?.disk?.percent || 0;
|
||||
return [
|
||||
{
|
||||
name: "Disk",
|
||||
value: percent,
|
||||
fill: "#fb923c",
|
||||
},
|
||||
];
|
||||
}, [metrics]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.diskUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="60%"
|
||||
outerRadius="90%"
|
||||
data={radialData}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
>
|
||||
<PolarAngleAxis
|
||||
type="number"
|
||||
domain={[0, 100]}
|
||||
angleAxisId={0}
|
||||
tick={false}
|
||||
/>
|
||||
<RadialBar
|
||||
background
|
||||
dataKey="value"
|
||||
cornerRadius={10}
|
||||
fill="#fb923c"
|
||||
/>
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="text-2xl font-bold fill-orange-400"
|
||||
>
|
||||
{typeof metrics?.disk?.percent === "number"
|
||||
? `${metrics.disk.percent}%`
|
||||
: "N/A"}
|
||||
</text>
|
||||
</RadialBarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex-shrink-0 space-y-1 text-center pb-2">
|
||||
<div className="text-xs text-gray-400">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
if (used && total) {
|
||||
return `${used} / ${total}`;
|
||||
}
|
||||
return "N/A";
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const available = metrics?.disk?.availableHuman;
|
||||
return available
|
||||
? `${t("serverStats.available")}: ${available}`
|
||||
: `${t("serverStats.available")}: N/A`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from "react";
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
|
||||
|
||||
const {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} = RechartsPrimitive;
|
||||
|
||||
interface MemoryWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chartData = React.useMemo(() => {
|
||||
return metricsHistory.map((m, index) => ({
|
||||
index,
|
||||
memory: m.memory?.percent || 0,
|
||||
}));
|
||||
}, [metricsHistory]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.memoryUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-2">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0">
|
||||
<div className="text-2xl font-bold text-green-400">
|
||||
{typeof metrics?.memory?.percent === "number"
|
||||
? `${metrics.memory.percent}%`
|
||||
: "N/A"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
if (typeof used === "number" && typeof total === "number") {
|
||||
return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`;
|
||||
}
|
||||
return "N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex-shrink-0">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free =
|
||||
typeof used === "number" && typeof total === "number"
|
||||
? (total - used).toFixed(1)
|
||||
: "N/A";
|
||||
return `${t("serverStats.free")}: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
|
||||
<stop offset="95%" stopColor="#34d399" stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
||||
<XAxis
|
||||
dataKey="index"
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
hide
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke="#9ca3af"
|
||||
tick={{ fill: "#9ca3af" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#1f2937",
|
||||
border: "1px solid #374151",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
}}
|
||||
formatter={(value: number) => [
|
||||
`${value.toFixed(1)}%`,
|
||||
"Memory",
|
||||
]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#34d399"
|
||||
strokeWidth={2}
|
||||
fill="url(#memoryGradient)"
|
||||
animationDuration={300}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { Network, Wifi, WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
|
||||
interface NetworkWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithNetwork = metrics as ServerMetrics & {
|
||||
network?: {
|
||||
interfaces?: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
ip: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const network = metricsWithNetwork?.network;
|
||||
const interfaces = network?.interfaces || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<Network className="h-5 w-5 text-indigo-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.networkInterfaces")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 overflow-auto flex-1">
|
||||
{interfaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<WifiOff className="h-10 w-10 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t("serverStats.noInterfacesFound")}</p>
|
||||
</div>
|
||||
) : (
|
||||
interfaces.map((iface, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-3 rounded-lg bg-dark-bg/50 border border-dark-border/30 hover:bg-dark-bg/60 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi
|
||||
className={`h-4 w-4 ${iface.state === "UP" ? "text-green-400" : "text-gray-500"}`}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-white font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2.5 py-0.5 rounded-full font-medium ${
|
||||
iface.state === "UP"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-gray-500/20 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{iface.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono font-medium">
|
||||
{iface.ip}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { List, Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/ui/main-axios.ts";
|
||||
|
||||
interface ProcessesWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithProcesses = metrics as ServerMetrics & {
|
||||
processes?: {
|
||||
total?: number;
|
||||
running?: number;
|
||||
top?: Array<{
|
||||
pid: number;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
command: string;
|
||||
user: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const processes = metricsWithProcesses?.processes;
|
||||
const topProcesses = processes?.top || [];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<List className="h-5 w-5 text-yellow-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.processes")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3 pb-2 border-b border-dark-border/30">
|
||||
<div className="text-sm text-gray-400">
|
||||
{t("serverStats.totalProcesses")}:{" "}
|
||||
<span className="text-white font-semibold">
|
||||
{processes?.total ?? "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{t("serverStats.running")}:{" "}
|
||||
<span className="text-green-400 font-semibold">
|
||||
{processes?.running ?? "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto flex-1">
|
||||
{topProcesses.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<Activity className="h-10 w-10 mb-3 opacity-50" />
|
||||
<p className="text-sm">{t("serverStats.noProcessesFound")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcesses.map((proc, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-2.5 rounded-lg bg-dark-bg/30 hover:bg-dark-bg/50 transition-colors border border-dark-border/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-mono text-gray-400 font-medium">
|
||||
PID: {proc.pid}
|
||||
</span>
|
||||
<div className="flex gap-3 text-xs font-medium">
|
||||
<span className="text-blue-400">CPU: {proc.cpu}%</span>
|
||||
<span className="text-green-400">MEM: {proc.mem}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white font-mono truncate mb-1">
|
||||
{proc.command}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">User: {proc.user}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user