62 Commits

Author SHA1 Message Date
Karmaa
5cd9de9ac5 v1.6.0 (#221)
* Add documentation in Chinese language (#160)

* Update file naming and structure for mobile support

* Add conditional desktop/mobile rendering

* Mobile terminal

* Fix overwritten i18n (#161)

* Add comprehensive Chinese internationalization support

- Implemented i18n framework with react-i18next for multi-language support
- Added Chinese (zh) and English (en) translation files with comprehensive coverage
- Localized Admin interface, authentication flows, and error messages
- Translated FileManager operations and UI elements
- Updated HomepageAuth component with localized authentication messages
- Localized LeftSidebar navigation and host management
- Added language switcher component (shown after login only)
- Configured default language as English with Chinese as secondary option
- Localized TOTPSetup two-factor authentication interface
- Updated Docker build to include translation files
- Achieved 95%+ UI localization coverage across core components

Co-Authored-By: Claude <noreply@anthropic.com>

* Extend Chinese localization coverage to Host Manager components

- Added comprehensive translations for HostManagerHostViewer component
- Localized all host management UI text including import/export features
- Translated error messages and confirmation dialogs for host operations
- Added translations for HostManagerHostEditor validation messages
- Localized connection details, organization settings, and form labels
- Fixed syntax error in FileManagerOperations component
- Achieved near-complete localization of SSH host management interface
- Updated placeholders and tooltips for better user guidance

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete comprehensive Chinese localization for Termix

- Added full localization support for Tunnel components (connected/disconnected states, retry messages)
- Localized all tunnel status messages and connection errors
- Added translations for port forwarding UI elements
- Verified Server, TopNavbar, and Tab components already have complete i18n support
- Achieved 99%+ localization coverage across entire application
- All core UI components now fully support Chinese and English languages

This completes the comprehensive internationalization effort for the Termix SSH management platform.

Co-Authored-By: Claude <noreply@anthropic.com>

* Localize additional Host Manager components and authentication settings

- Added translations for all authentication options (Password, Key, SSH Private Key)
- Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager)
- Translated Upload/Update Key button states
- Localized Host Viewer and Add/Edit Host tab labels
- Added Chinese translations for all host management settings
- Fixed duplicate translation keys in JSON files

Co-Authored-By: Claude <noreply@anthropic.com>

* Extend localization coverage to UI components and common strings

- Added comprehensive common translations (online/offline, success/error, etc.)
- Localized status indicator component with all status states
- Updated FileManagerLeftSidebar toast messages for rename/delete operations
- Added translations for UI elements (close, toggle sidebar, etc.)
- Expanded placeholder translations for form inputs
- Added Chinese translations for all new common strings
- Improved consistency across component status messages

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete Chinese localization for remaining UI components

- Add comprehensive Chinese translations for Host Manager component
  - Translate all form labels, buttons, and descriptions
  - Add translations for SSH configuration warnings and instructions
  - Localize tunnel connection settings and port forwarding options

- Localize SSH Tools panel
  - Translate key recording functionality
  - Add translations for settings and configuration options

- Translate homepage welcome messages and navigation elements
  - Add Chinese translations for login success messages
  - Localize "Updates & Releases" section title
  - Translate sidebar "Host Manager" button

- Fix translation key display issues
  - Remove duplicate translation keys in both language files
  - Ensure all components properly reference translation keys
  - Fix hosts.tunnelConnections key mapping

This completes the full Chinese localization of the Termix application,
achieving near 100% UI translation coverage while maintaining English
as the default language.

* Complete final Chinese localization for Host Manager tunnel configuration

- Add Chinese translations for authentication UI elements
  - Translate "Authentication", "Password", and "Key" tab labels
  - Localize SSH private key and key password fields
  - Add translations for key type selector

- Localize tunnel connection configuration descriptions
  - Translate retry attempts and retry interval descriptions
  - Add dynamic tunnel forwarding description with port parameters
  - Localize endpoint SSH configuration labels

- Fix missing translation keys
  - Add "upload" translation for file upload button
  - Ensure all FormLabel and FormDescription elements use translation keys

This completes the comprehensive Chinese localization of the entire
Termix application, achieving 100% UI translation coverage.

* Fix PR feedback: Improve Profile section translations and UX

- Fixed password reset translations in Profile section
- Moved language selector from TopNavbar to Profile page
- Added profile.selectPreferredLanguage translation key
- Improved user experience for language preferences

* Apply critical OIDC and notification system fixes while preserving i18n

- Merge OIDC authentication fixes from 3877e90:
  * Enhanced JWKS discovery mechanism with multiple backup URLs
  * Better support for non-standard OIDC providers (Authentik, etc.)
  * Improved error handling for "Failed to get user information"
- Migrate to unified Sonner toast notification system:
  * Replace custom success/error state management
  * Remove redundant alert state variables
  * Consistent user feedback across all components
- Improve code quality and function naming conventions
- PRESERVE all existing i18n functionality and Chinese translations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix OIDC errors for "Failed to get user information"

* Fix OIDC errors for "Failed to get user information"

* Fix spelling error

* Migrate everything to alert system, update user.ts for OIDC updates.

* Fix OIDC errors for "Failed to get user information"

* Fix OIDC errors for "Failed to get user information"

* Fix spelling error

* Migrate everything to alert system, update user.ts for OIDC updates.

* Update env

* Fix users.ts and schema for override

* Convert web app to Electron desktop application

- Add Electron main process with developer tools support
- Create preload script for secure context bridge
- Configure electron-builder for packaging
- Update Vite config for Electron compatibility (base: './')
- Add environment variable support for API host configuration
- Fix i18n to use relative paths for Electron file protocol
- Restore multi-port backend architecture (8081-8085)
- Add enhanced backend startup script with port checking
- Update package.json with Electron dependencies and build scripts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete Electron desktop application implementation

- Add backend auto-start functionality in main process
- Fix authentication token storage for Electron environment
- Implement localStorage-based token management in Electron
- Add proper Electron environment detection via preload script
- Fix WebSocket connections for terminal functionality
- Resolve font file loading issues in packaged application
- Update API endpoints to work with backend auto-start
- Streamline build scripts with unified electron:package command
- Fix better-sqlite3 native module compatibility issues
- Ensure all services start automatically in production mode

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove releases folder from git and force Desktop UI.

* Improve mobile support with half-baked custom keyboard

* Fix API routing

* Upgrade mobile keyboard with more keys.

* Add cross-platform support and clean up obsolete files

- Add electron-packager scripts for Windows, macOS, and Linux
- Include universal architecture support for macOS
- Add electron:package:all for building all platforms
- Remove obsolete start-backend.sh script (replaced by Electron auto-start)
- Improve ignore patterns to exclude repo-images folder
- Add platform-specific icon configurations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix build system by removing electron-builder dependency

- Remove electron-builder and @electron/rebuild packages to resolve build errors
- Clean up package.json scripts that depend on electron-builder
- Fix merge conflict markers in AdminSettings.tsx and PasswordReset.tsx
- All build commands now work correctly:
  - npm run build (frontend + backend)
  - npm run build:frontend
  - npm run build:backend
  - npm run electron:package (using electron-packager)

The build system is now stable and functional without signing requirements.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Mobile UI improvement

* Electron dev (#185)

* Add comprehensive Chinese internationalization support

- Implemented i18n framework with react-i18next for multi-language support
- Added Chinese (zh) and English (en) translation files with comprehensive coverage
- Localized Admin interface, authentication flows, and error messages
- Translated FileManager operations and UI elements
- Updated HomepageAuth component with localized authentication messages
- Localized LeftSidebar navigation and host management
- Added language switcher component (shown after login only)
- Configured default language as English with Chinese as secondary option
- Localized TOTPSetup two-factor authentication interface
- Updated Docker build to include translation files
- Achieved 95%+ UI localization coverage across core components

Co-Authored-By: Claude <noreply@anthropic.com>

* Extend Chinese localization coverage to Host Manager components

- Added comprehensive translations for HostManagerHostViewer component
- Localized all host management UI text including import/export features
- Translated error messages and confirmation dialogs for host operations
- Added translations for HostManagerHostEditor validation messages
- Localized connection details, organization settings, and form labels
- Fixed syntax error in FileManagerOperations component
- Achieved near-complete localization of SSH host management interface
- Updated placeholders and tooltips for better user guidance

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete comprehensive Chinese localization for Termix

- Added full localization support for Tunnel components (connected/disconnected states, retry messages)
- Localized all tunnel status messages and connection errors
- Added translations for port forwarding UI elements
- Verified Server, TopNavbar, and Tab components already have complete i18n support
- Achieved 99%+ localization coverage across entire application
- All core UI components now fully support Chinese and English languages

This completes the comprehensive internationalization effort for the Termix SSH management platform.

Co-Authored-By: Claude <noreply@anthropic.com>

* Localize additional Host Manager components and authentication settings

- Added translations for all authentication options (Password, Key, SSH Private Key)
- Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager)
- Translated Upload/Update Key button states
- Localized Host Viewer and Add/Edit Host tab labels
- Added Chinese translations for all host management settings
- Fixed duplicate translation keys in JSON files

Co-Authored-By: Claude <noreply@anthropic.com>

* Extend localization coverage to UI components and common strings

- Added comprehensive common translations (online/offline, success/error, etc.)
- Localized status indicator component with all status states
- Updated FileManagerLeftSidebar toast messages for rename/delete operations
- Added translations for UI elements (close, toggle sidebar, etc.)
- Expanded placeholder translations for form inputs
- Added Chinese translations for all new common strings
- Improved consistency across component status messages

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete Chinese localization for remaining UI components

- Add comprehensive Chinese translations for Host Manager component
  - Translate all form labels, buttons, and descriptions
  - Add translations for SSH configuration warnings and instructions
  - Localize tunnel connection settings and port forwarding options

- Localize SSH Tools panel
  - Translate key recording functionality
  - Add translations for settings and configuration options

- Translate homepage welcome messages and navigation elements
  - Add Chinese translations for login success messages
  - Localize "Updates & Releases" section title
  - Translate sidebar "Host Manager" button

- Fix translation key display issues
  - Remove duplicate translation keys in both language files
  - Ensure all components properly reference translation keys
  - Fix hosts.tunnelConnections key mapping

This completes the full Chinese localization of the Termix application,
achieving near 100% UI translation coverage while maintaining English
as the default language.

* Complete final Chinese localization for Host Manager tunnel configuration

- Add Chinese translations for authentication UI elements
  - Translate "Authentication", "Password", and "Key" tab labels
  - Localize SSH private key and key password fields
  - Add translations for key type selector

- Localize tunnel connection configuration descriptions
  - Translate retry attempts and retry interval descriptions
  - Add dynamic tunnel forwarding description with port parameters
  - Localize endpoint SSH configuration labels

- Fix missing translation keys
  - Add "upload" translation for file upload button
  - Ensure all FormLabel and FormDescription elements use translation keys

This completes the comprehensive Chinese localization of the entire
Termix application, achieving 100% UI translation coverage.

* Fix PR feedback: Improve Profile section translations and UX

- Fixed password reset translations in Profile section
- Moved language selector from TopNavbar to Profile page
- Added profile.selectPreferredLanguage translation key
- Improved user experience for language preferences

* Apply critical OIDC and notification system fixes while preserving i18n

- Merge OIDC authentication fixes from 3877e90:
  * Enhanced JWKS discovery mechanism with multiple backup URLs
  * Better support for non-standard OIDC providers (Authentik, etc.)
  * Improved error handling for "Failed to get user information"
- Migrate to unified Sonner toast notification system:
  * Replace custom success/error state management
  * Remove redundant alert state variables
  * Consistent user feedback across all components
- Improve code quality and function naming conventions
- PRESERVE all existing i18n functionality and Chinese translations

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix OIDC errors for "Failed to get user information"

* Fix OIDC errors for "Failed to get user information"

* Fix spelling error

* Migrate everything to alert system, update user.ts for OIDC updates.

* Fix OIDC errors for "Failed to get user information"

* Fix OIDC errors for "Failed to get user information"

* Fix spelling error

* Migrate everything to alert system, update user.ts for OIDC updates.

* Update env

* Fix users.ts and schema for override

* Convert web app to Electron desktop application

- Add Electron main process with developer tools support
- Create preload script for secure context bridge
- Configure electron-builder for packaging
- Update Vite config for Electron compatibility (base: './')
- Add environment variable support for API host configuration
- Fix i18n to use relative paths for Electron file protocol
- Restore multi-port backend architecture (8081-8085)
- Add enhanced backend startup script with port checking
- Update package.json with Electron dependencies and build scripts

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete Electron desktop application implementation

- Add backend auto-start functionality in main process
- Fix authentication token storage for Electron environment
- Implement localStorage-based token management in Electron
- Add proper Electron environment detection via preload script
- Fix WebSocket connections for terminal functionality
- Resolve font file loading issues in packaged application
- Update API endpoints to work with backend auto-start
- Streamline build scripts with unified electron:package command
- Fix better-sqlite3 native module compatibility issues
- Ensure all services start automatically in production mode

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove releases folder from git and force Desktop UI.

* Improve mobile support with half-baked custom keyboard

* Fix API routing

* Upgrade mobile keyboard with more keys.

* Add cross-platform support and clean up obsolete files

- Add electron-packager scripts for Windows, macOS, and Linux
- Include universal architecture support for macOS
- Add electron:package:all for building all platforms
- Remove obsolete start-backend.sh script (replaced by Electron auto-start)
- Improve ignore patterns to exclude repo-images folder
- Add platform-specific icon configurations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix build system by removing electron-builder dependency

- Remove electron-builder and @electron/rebuild packages to resolve build errors
- Clean up package.json scripts that depend on electron-builder
- Fix merge conflict markers in AdminSettings.tsx and PasswordReset.tsx
- All build commands now work correctly:
  - npm run build (frontend + backend)
  - npm run build:frontend
  - npm run build:backend
  - npm run electron:package (using electron-packager)

The build system is now stable and functional without signing requirements.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>

* Add navigation and hardcoded hosts

* Update mobile sidebar to use API, add auth and tab system to mobile.

* Update sidebar state

* Mobile support (#190)

* Add vibration to keyboard

* Fix keyboard keys

* Fix keyboard keys

* Fix keyboard keys

* Rename files, improve keyboard usability

* Improve keyboard view and fix various issues with it

* Add mobile chinese translation

* Disable OS keyboard from appearing

* Fix fit addon not resizing with "more" on keyboard

* Disable OS keyboard on terminal load

* Merge Luke and Zac

* feat: add export  option for ssh hosts (#173) (#187)

* Update issue templates

* feat: add export JSON option for SSH hosts (#173)

---------

Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* feat(profile): display version number from .env in profile menu (#182)

* feat(profile): display version number from .env in profile menu

* Update version checking process

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Add pretier

* feat(auth): Add password visibility toggle to auth forms (#166)

* added hide and unhide password button

* Undo admin settings changes

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Re-added password input

* Remove encrpytion, improve logging and merge interfaces.

* Improve logging (backend and frontend) and added dedicde OIDC clear

* feat: Added option to paste private key (#203)

* Improve logging frontend/backend, fix host form being reversed.

* Improve logging more, fix credentials sync issues, migrate more to be toasts

* Improve logging more, fix credentials sync issues, migrate more to be toasts

* More error to toast migration

* Remove more inline styles and run npm updates

* Update homepage appearing over everything and terminal incorrect bg

* Improved server stat generation and UI by caching and supporting more platforms

* Update mobile app with the same stat changes and remove rate limiting

* Put user profle in its own tab, add code rabbit support

* Improve code rabbit yaml

* Update chinese translation and fix z indexs causing delay to hide

* Bump vite from 7.1.3 to 7.1.5 (#204)

Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.3 to 7.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.5
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Update read me

* Update electron builder and fix mobile terminal background

* Update logo, move translations, update electron building.

* Remove backend from electon, switching to server manager

* Add electron server configurator

* Fix backend builder on Dockerfile

* Fix langauge file for Dockerfile

* Fix architecture issues in Dockerfile

* Fix architecture issues in Dockerfile

* Fix architecture issues in Dockerfile

* Fix backend building for docker image

* Add electron builder

* Fix node starting in entrypoint and remove release from electron build

* Remove double packaing in electron build

* Fix folder nesting for electron gbuilder

* Fix native module docker build (better-sql and bcrypt)

* Fix api routes and missing translations and improve reconnection for terminals

* Update read me for new installation method

* Update CONTRIBUTING.md with color scheme

* Fix terrminal not closing afer 3 tries

* Fix electronm api routing, fikx ssh not connecting, and OIDC redirect errors

* Fix more electron API issues (ssh/oidc), make server manager force API check, and login saving.

* Add electron API routes

* Fix more electron APi routes and issues

* Hide admin settings on electron and fix server manager URl verification

* Hide admin settings on electron and fix server manager URl verification

* Fix admin setting visiblity on electron

* Add links to docs in respective places

* Migrate all getCookies to use main-axios.

* Migrate all isElectron to use main-axios.

* Clean up backend files

* Clean up frontend files and read me translations

* Run prettier

* Fix terminal in web, and update translations and prep for release.

* Update API to work on devs and remove random letter

* Run prettier

* Update read me for release

* Update read me for release

* Fixed delete issue (ready for release)

* Ensure retention days for artifact upload are set

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: starry <115192496+sky22333@users.noreply.github.com>
Co-authored-by: ZacharyZcR <PayasoNorahC@protonmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Shivam Kumar <155747305+maishivamhoo123@users.noreply.github.com>
Co-authored-by: Abhilash Gandhamalla <150357125+AbhilashG12@users.noreply.github.com>
Co-authored-by: jedi04 <78037206+jedi04@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-12 14:42:00 -05:00
Karmaa
d85fb26a5d Update electron-build.yml 2025-09-10 21:45:24 -05:00
Karmaa
7756dc6a4c Remove create-release job from workflow 2025-09-10 21:30:14 -05:00
Karmaa
ff76137339 Add GitHub Actions workflow for Electron app build 2025-09-10 21:27:51 -05:00
Karmaa
61db35daad Dev 1.5.0 (#159)
* Add comprehensive Chinese internationalization support

- Implemented i18n framework with react-i18next for multi-language support
- Added Chinese (zh) and English (en) translation files with comprehensive coverage
- Localized Admin interface, authentication flows, and error messages
- Translated FileManager operations and UI elements
- Updated HomepageAuth component with localized authentication messages
- Localized LeftSidebar navigation and host management
- Added language switcher component (shown after login only)
- Configured default language as English with Chinese as secondary option
- Localized TOTPSetup two-factor authentication interface
- Updated Docker build to include translation files
- Achieved 95%+ UI localization coverage across core components

Co-Authored-By: Claude <noreply@anthropic.com>

* Extend Chinese localization coverage to Host Manager components

- Added comprehensive translations for HostManagerHostViewer component
- Localized all host management UI text including import/export features
- Translated error messages and confirmation dialogs for host operations
- Added translations for HostManagerHostEditor validation messages
- Localized connection details, organization settings, and form labels
- Fixed syntax error in FileManagerOperations component
- Achieved near-complete localization of SSH host management interface
- Updated placeholders and tooltips for better user guidance

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete comprehensive Chinese localization for Termix

- Added full localization support for Tunnel components (connected/disconnected states, retry messages)
- Localized all tunnel status messages and connection errors
- Added translations for port forwarding UI elements
- Verified Server, TopNavbar, and Tab components already have complete i18n support
- Achieved 99%+ localization coverage across entire application
- All core UI components now fully support Chinese and English languages

This completes the comprehensive internationalization effort for the Termix SSH management platform.

Co-Authored-By: Claude <noreply@anthropic.com>

* Localize additional Host Manager components and authentication settings

- Added translations for all authentication options (Password, Key, SSH Private Key)
- Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager)
- Translated Upload/Update Key button states
- Localized Host Viewer and Add/Edit Host tab labels
- Added Chinese translations for all host management settings
- Fixed duplicate translation keys in JSON files

Co-Authored-By: Claude <noreply@anthropic.com>

* Extend localization coverage to UI components and common strings

- Added comprehensive common translations (online/offline, success/error, etc.)
- Localized status indicator component with all status states
- Updated FileManagerLeftSidebar toast messages for rename/delete operations
- Added translations for UI elements (close, toggle sidebar, etc.)
- Expanded placeholder translations for form inputs
- Added Chinese translations for all new common strings
- Improved consistency across component status messages

Co-Authored-By: Claude <noreply@anthropic.com>

* Complete Chinese localization for remaining UI components

- Add comprehensive Chinese translations for Host Manager component
  - Translate all form labels, buttons, and descriptions
  - Add translations for SSH configuration warnings and instructions
  - Localize tunnel connection settings and port forwarding options

- Localize SSH Tools panel
  - Translate key recording functionality
  - Add translations for settings and configuration options

- Translate homepage welcome messages and navigation elements
  - Add Chinese translations for login success messages
  - Localize "Updates & Releases" section title
  - Translate sidebar "Host Manager" button

- Fix translation key display issues
  - Remove duplicate translation keys in both language files
  - Ensure all components properly reference translation keys
  - Fix hosts.tunnelConnections key mapping

This completes the full Chinese localization of the Termix application,
achieving near 100% UI translation coverage while maintaining English
as the default language.

* Complete final Chinese localization for Host Manager tunnel configuration

- Add Chinese translations for authentication UI elements
  - Translate "Authentication", "Password", and "Key" tab labels
  - Localize SSH private key and key password fields
  - Add translations for key type selector

- Localize tunnel connection configuration descriptions
  - Translate retry attempts and retry interval descriptions
  - Add dynamic tunnel forwarding description with port parameters
  - Localize endpoint SSH configuration labels

- Fix missing translation keys
  - Add "upload" translation for file upload button
  - Ensure all FormLabel and FormDescription elements use translation keys

This completes the comprehensive Chinese localization of the entire
Termix application, achieving 100% UI translation coverage.

* Fix OIDC errors for "Failed to get user information"

* Fix OIDC errors for "Failed to get user information"

* Fix spelling error

* Fix PR feedback: Improve Profile section translations and UX

- Fixed password reset translations in Profile section
- Moved language selector from TopNavbar to Profile page
- Added profile.selectPreferredLanguage translation key
- Improved user experience for language preferences

* Migrate everything to alert system, update user.ts for OIDC updates.

* Update env

* Fix OIDC errors for "Failed to get user information"

* Fix OIDC errors for "Failed to get user information"

* Fix spelling error

* Migrate everything to alert system, update user.ts for OIDC updates.

* Translation update

* Translation update

* Translation update

* Translate tunnels

* Comment update

* Update build workflow naming

* Add more translations, fix user delete failing

* Fix config editor erorrs causing user delete failure

---------

Co-authored-by: ZacharyZcR <PayasoNorahC@protonmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-03 00:14:49 -05:00
Karmaa
26c1cacc9d Merge pull request #138 from LukeGus/dev-1.4.0
Dev 1.4.0
2025-09-01 00:11:26 -05:00
LukeGus
8dddbaa86d Read me update 2025-09-01 00:00:26 -05:00
LukeGus
d46fafb421 Update status refreshing to have a better interval and updated sidebar UI 2025-08-31 23:58:00 -05:00
LukeGus
25178928a0 Update status refreshing to have a better interval 2025-08-31 23:41:05 -05:00
LukeGus
2fe9c0f854 Update version 2025-08-31 20:27:17 -05:00
LukeGus
2f68dc018e Add SSH password reset, fix TOTP errors, and update json-import guide. 2025-08-31 20:18:08 -05:00
LukeGus
8b8e77214c Rename apps folder 2025-08-31 19:26:38 -05:00
LukeGus
89589dcf9f Update repo info and grammer 2025-08-31 19:25:44 -05:00
LukeGus
250ad975d4 Fix contributing md 2025-08-31 00:47:16 -05:00
LukeGus
b649e73c80 Fix contributing md 2025-08-31 00:43:23 -05:00
LukeGus
839e36adb9 Update TOTP Pr, begin password reset, add openapi.json for clarity. 2025-08-31 00:42:50 -05:00
Karmaa
f02c0c3163 Merge pull request #122 from rodrigopolo/main
SSH connection timeout and TOTP integration
2025-08-29 23:24:27 -05:00
Rodrigo Polo
83c41751ea Implementation of TOTP (Time-based One-Time Password) authentication 2025-08-29 20:29:33 -06:00
Rodrigo Polo
8058ffd217 Timeout issue 2025-08-29 01:47:08 -06:00
LukeGus
a3db62b0f8 Merge remote-tracking branch 'origin/main' 2025-08-28 01:25:31 -05:00
LukeGus
dc43bf1329 Update update log to include full release description 2025-08-28 01:25:23 -05:00
Karmaa
0a7b1b2bd0 Merge pull request #114 from LukeGus/dev-1.3.1
Dev 1.3.1
2025-08-28 01:00:47 -05:00
LukeGus
94e6617638 Update Terminal to use new isDev var 2025-08-28 00:59:20 -05:00
LukeGus
76995dbc1b Fix nginx error 2025-08-28 00:18:03 -05:00
LukeGus
be6cda7d8a Improve SSH stability and reconnection 2025-08-28 00:05:27 -05:00
LukeGus
9130eb68a8 Improve server stats and tunnel stability 2025-08-27 22:58:08 -05:00
LukeGus
200428498f Fix build error 2025-08-27 22:24:49 -05:00
LukeGus
f60c9c72aa Improve File Manger UI scaling 2025-08-27 22:20:39 -05:00
LukeGus
487919cedc Improve File Manger UI scaling, fix file manager disconnect, disable more than one file manager at a time. 2025-08-27 22:17:28 -05:00
LukeGus
b046aedcee Improve auth page 2025-08-27 16:07:11 -05:00
LukeGus
0c5216933a Update env 2025-08-27 15:34:12 -05:00
LukeGus
191dc8ba24 Hide password 2025-08-27 15:23:42 -05:00
LukeGus
d88c890ba7 Reduce automatic pinging 2025-08-27 11:34:38 -05:00
LukeGus
a34c60947d Finish migration into main-axios 2025-08-27 11:24:17 -05:00
LukeGus
56fddb6fcb Merge remote-tracking branch 'origin/dev-1.3.1' into dev-1.3.1 2025-08-25 18:00:51 -05:00
LukeGus
b9bd00f86e Migrate everytihng into the main-axios and update the routing to fix localhost issues. 2025-08-25 18:00:34 -05:00
Karmaa
d1ae7f659d Merge pull request #89 from LukeGus/dependabot/npm_and_yarn/dev-minor-updates-e674c81da0
Bump the dev-minor-updates group with 6 updates
2025-08-24 16:26:20 -05:00
Karmaa
b127c5ff4f Merge pull request #90 from LukeGus/dependabot/npm_and_yarn/prod-patch-updates-dc9bb06971
Bump the prod-patch-updates group with 19 updates
2025-08-24 16:26:00 -05:00
LukeGus
75a87400eb Merge remote-tracking branch 'origin/dev-1.3.1' into dependabot/npm_and_yarn/prod-patch-updates-dc9bb06971
# Conflicts:
#	package-lock.json
#	package.json
2025-08-24 16:25:13 -05:00
Karmaa
d4dccdb574 Merge pull request #88 from LukeGus/dependabot/npm_and_yarn/dev-patch-updates-7ca590a96b
Bump tw-animate-css from 1.3.5 to 1.3.7 in the dev-patch-updates group
2025-08-24 16:19:14 -05:00
Karmaa
a34421f5b2 Merge pull request #87 from LukeGus/dependabot/docker/docker/node-24-alpine
Bump node from 22-alpine to 24-alpine in /docker
2025-08-24 16:18:51 -05:00
Karmaa
7a5cf94d45 Merge pull request #86 from LukeGus/dependabot/github_actions/actions/cache-4
Bump actions/cache from 3 to 4
2025-08-24 16:18:33 -05:00
Karmaa
134f58b38b Merge pull request #85 from LukeGus/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-24 16:18:15 -05:00
Karmaa
84dcda9363 Merge pull request #84 from LukeGus/dependabot/github_actions/docker/build-push-action-6
Bump docker/build-push-action from 5 to 6
2025-08-24 16:17:55 -05:00
Karmaa
d81cf20f5e Bump the prod-minor-updates group with 10 updates (#91)
Bumps the prod-minor-updates group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.1.1` | `5.2.1` |
| [@uiw/codemirror-extensions-hyper-link](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-themes](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [axios](https://github.com/axios/axios) | `1.10.0` | `1.11.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.525.0` | `0.541.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.60.0` | `7.62.0` |
| [ssh2](https://github.com/mscdex/ssh2) | `1.16.0` | `1.17.0` |
| [zod](https://github.com/colinhacks/zod) | `4.0.5` | `4.1.1` |


Updates `@hookform/resolvers` from 5.1.1 to 5.2.1
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.1.1...v5.2.1)

Updates `@uiw/codemirror-extensions-hyper-link` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-extensions-langs` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-themes` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/react-codemirror` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `axios` from 1.10.0 to 1.11.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.10.0...v1.11.0)

Updates `lucide-react` from 0.525.0 to 0.541.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.541.0/packages/lucide-react)

Updates `react-hook-form` from 7.60.0 to 7.62.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.60.0...v7.62.0)

Updates `ssh2` from 1.16.0 to 1.17.0
- [Commits](https://github.com/mscdex/ssh2/compare/v1.16.0...v1.17.0)

Updates `zod` from 4.0.5 to 4.1.1
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.1.1)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-hyper-link"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-themes"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: axios
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: lucide-react
  dependency-version: 0.541.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ssh2
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: zod
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-24 16:16:41 -05:00
Karmaa
86fd8574f1 Bump @vitejs/plugin-react-swc from 3.10.2 to 4.0.1 (#92)
* Update README.md

* Bump @vitejs/plugin-react-swc from 3.10.2 to 4.0.1

Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc) from 3.10.2 to 4.0.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.0.1/packages/plugin-react-swc)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-24 16:16:20 -05:00
dependabot[bot]
6e175e2b36 Bump @vitejs/plugin-react-swc from 3.10.2 to 4.0.1
Bumps [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react-swc) from 3.10.2 to 4.0.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@4.0.1/packages/plugin-react-swc)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react-swc"
  dependency-version: 4.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:14:55 +00:00
dependabot[bot]
c7fba8dbb1 Bump the prod-minor-updates group with 10 updates
Bumps the prod-minor-updates group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.1.1` | `5.2.1` |
| [@uiw/codemirror-extensions-hyper-link](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/codemirror-themes](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.24.1` | `4.25.1` |
| [axios](https://github.com/axios/axios) | `1.10.0` | `1.11.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.525.0` | `0.541.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.60.0` | `7.62.0` |
| [ssh2](https://github.com/mscdex/ssh2) | `1.16.0` | `1.17.0` |
| [zod](https://github.com/colinhacks/zod) | `4.0.5` | `4.1.1` |


Updates `@hookform/resolvers` from 5.1.1 to 5.2.1
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.1.1...v5.2.1)

Updates `@uiw/codemirror-extensions-hyper-link` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-extensions-langs` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/codemirror-themes` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `@uiw/react-codemirror` from 4.24.1 to 4.25.1
- [Release notes](https://github.com/uiwjs/react-codemirror/releases)
- [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.24.1...v4.25.1)

Updates `axios` from 1.10.0 to 1.11.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.10.0...v1.11.0)

Updates `lucide-react` from 0.525.0 to 0.541.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.541.0/packages/lucide-react)

Updates `react-hook-form` from 7.60.0 to 7.62.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.60.0...v7.62.0)

Updates `ssh2` from 1.16.0 to 1.17.0
- [Commits](https://github.com/mscdex/ssh2/compare/v1.16.0...v1.17.0)

Updates `zod` from 4.0.5 to 4.1.1
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.0.5...v4.1.1)

---
updated-dependencies:
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.2.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-hyper-link"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-extensions-langs"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/codemirror-themes"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@uiw/react-codemirror"
  dependency-version: 4.25.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: axios
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: lucide-react
  dependency-version: 0.541.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.62.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ssh2
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: zod
  dependency-version: 4.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:14:46 +00:00
dependabot[bot]
0c2ac176a7 Bump the prod-patch-updates group with 19 updates
Bumps the prod-patch-updates group with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-accordion](https://github.com/radix-ui/primitives) | `1.2.11` | `1.2.12` |
| [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives) | `1.3.2` | `1.3.3` |
| [@radix-ui/react-collapsible](https://github.com/radix-ui/primitives) | `1.1.11` | `1.1.12` |
| [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives) | `2.1.15` | `2.1.16` |
| [@radix-ui/react-popover](https://github.com/radix-ui/primitives) | `1.1.14` | `1.1.15` |
| [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives) | `1.2.9` | `1.2.10` |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives) | `2.2.5` | `2.2.6` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives) | `1.3.5` | `1.3.6` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives) | `1.2.5` | `1.2.6` |
| [@radix-ui/react-tabs](https://github.com/radix-ui/primitives) | `1.1.12` | `1.1.13` |
| [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.1.11` | `4.1.12` |
| [dotenv](https://github.com/motdotla/dotenv) | `17.2.0` | `17.2.1` |
| [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.44.3` | `0.44.4` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.1.0` | `19.1.1` |
| [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.1.8` | `19.1.11` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.1.0` | `19.1.1` |
| [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom) | `19.1.6` | `19.1.7` |
| [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) | `3.0.3` | `3.0.5` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.11` | `4.1.12` |


Updates `@radix-ui/react-accordion` from 1.2.11 to 1.2.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-checkbox` from 1.3.2 to 1.3.3
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-collapsible` from 1.1.11 to 1.1.12
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-dropdown-menu` from 2.1.15 to 2.1.16
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-popover` from 1.1.14 to 1.1.15
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-scroll-area` from 1.2.9 to 1.2.10
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-select` from 2.2.5 to 2.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-slider` from 1.3.5 to 1.3.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-switch` from 1.2.5 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@radix-ui/react-tabs` from 1.1.12 to 1.1.13
- [Changelog](https://github.com/radix-ui/primitives/blob/main/release-process.md)
- [Commits](https://github.com/radix-ui/primitives/commits)

Updates `@tailwindcss/vite` from 4.1.11 to 4.1.12
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.12/packages/@tailwindcss-vite)

Updates `dotenv` from 17.2.0 to 17.2.1
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.2.0...v17.2.1)

Updates `drizzle-orm` from 0.44.3 to 0.44.4
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.44.3...0.44.4)

Updates `react` from 19.1.0 to 19.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.1/packages/react)

Updates `@types/react` from 19.1.8 to 19.1.11
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.1.0 to 19.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.1/packages/react-dom)

Updates `@types/react-dom` from 19.1.6 to 19.1.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

Updates `react-resizable-panels` from 3.0.3 to 3.0.5
- [Release notes](https://github.com/bvaughn/react-resizable-panels/releases)
- [Commits](https://github.com/bvaughn/react-resizable-panels/compare/3.0.3...3.0.5)

Updates `tailwindcss` from 4.1.11 to 4.1.12
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.12/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-accordion"
  dependency-version: 1.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-checkbox"
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-collapsible"
  dependency-version: 1.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-dropdown-menu"
  dependency-version: 2.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-popover"
  dependency-version: 1.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-scroll-area"
  dependency-version: 1.2.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@radix-ui/react-tabs"
  dependency-version: 1.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@tailwindcss/vite"
  dependency-version: 4.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: dotenv
  dependency-version: 17.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: drizzle-orm
  dependency-version: 0.44.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react
  dependency-version: 19.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-dom
  dependency-version: 19.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@types/react-dom"
  dependency-version: 19.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: react-resizable-panels
  dependency-version: 3.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: tailwindcss
  dependency-version: 4.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:13:27 +00:00
dependabot[bot]
59a38dad0a Bump the dev-minor-updates group with 6 updates
Bumps the dev-minor-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.31.0` | `9.34.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.0.13` | `24.3.0` |
| [eslint](https://github.com/eslint/eslint) | `9.31.0` | `9.34.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.3` | `5.9.2` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.37.0` | `8.40.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.0.4` | `7.1.3` |


Updates `@eslint/js` from 9.31.0 to 9.34.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/commits/v9.34.0/packages/js)

Updates `@types/node` from 24.0.13 to 24.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 9.31.0 to 9.34.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.31.0...v9.34.0)

Updates `typescript` from 5.8.3 to 5.9.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.2)

Updates `typescript-eslint` from 8.37.0 to 8.40.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.40.0/packages/typescript-eslint)

Updates `vite` from 7.0.4 to 7.1.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 24.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint
  dependency-version: 9.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript
  dependency-version: 5.9.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: vite
  dependency-version: 7.1.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:10:31 +00:00
dependabot[bot]
6ac536ebad Bump tw-animate-css from 1.3.5 to 1.3.7 in the dev-patch-updates group
Bumps the dev-patch-updates group with 1 update: [tw-animate-css](https://github.com/Wombosvideo/tw-animate-css).


Updates `tw-animate-css` from 1.3.5 to 1.3.7
- [Release notes](https://github.com/Wombosvideo/tw-animate-css/releases)
- [Commits](https://github.com/Wombosvideo/tw-animate-css/compare/v1.3.5...v1.3.7)

---
updated-dependencies:
- dependency-name: tw-animate-css
  dependency-version: 1.3.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:09:29 +00:00
dependabot[bot]
12418eb5b2 Bump node from 22-alpine to 24-alpine in /docker
Bumps node from 22-alpine to 24-alpine.

---
updated-dependencies:
- dependency-name: node
  dependency-version: 24-alpine
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:59 +00:00
dependabot[bot]
e364e9b38e Bump actions/cache from 3 to 4
Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:53 +00:00
dependabot[bot]
402f9fa909 Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:51 +00:00
dependabot[bot]
5ac25e2a26 Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-24 21:08:49 +00:00
Karmaa
5fbac89e67 Update README.md 2025-08-24 15:39:53 -05:00
LukeGus
f2fb938e5f Clean up main-axios.ts 2025-08-24 01:28:18 -05:00
LukeGus
cef420d1d8 Clean up main-axios.ts 2025-08-24 01:27:41 -05:00
LukeGus
94f69cee3f Migrate to new websocket link for locahlost 2025-08-24 01:10:59 -05:00
LukeGus
23e72aedfd Migrate to new websocket link for locahlost 2025-08-24 00:59:39 -05:00
LukeGus
7957ed06e4 Fix localhost connection issues 2025-08-21 22:47:38 -05:00
Karmaa
5f1a2d9a17 Update README.md 2025-08-19 12:36:11 -05:00
184 changed files with 45161 additions and 17708 deletions

578
.coderabbit.yaml Normal file
View File

@@ -0,0 +1,578 @@
language: "en"
early_access: false
reviews:
request_changes_workflow: false
high_level_summary: true
poem: false
review_status: true
collapse_walkthrough: false
path_filters:
- "!**/.xml"
- "!**/__generated__/**"
- "!**/generated/**"
- "!**/*.json"
- "!**/*.svg"
- "!**/*.png"
- "!**/*.jpg"
- "!**/*.gif"
- "!**/*.lock"
- "!**/node_modules/**"
- "!**/dist/**"
- "!**/public/locales/**"
- "!**/repo-images/**"
path_instructions:
- path: "**/*.{ts,tsx}"
instructions: |
Review TypeScript and React code for Termix server management platform. Key considerations:
**Architecture & Patterns:**
- Follow the established multi-port backend architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
- Use proper separation between Desktop and Mobile UI components
- Maintain consistent state management patterns with React hooks and context
- Follow the established tab-based navigation system
**Database & Backend:**
- Use Drizzle ORM with SQLite for database operations
- Implement proper JWT authentication middleware patterns
- Follow the established API error handling patterns in main-axios.ts
- Use proper logging with the structured logger system (apiLogger, authLogger, sshLogger, etc.)
- Maintain proper input validation and sanitization
**UI/UX Guidelines:**
- Use Shadcn/UI components with Tailwind CSS for consistent styling
- Follow the established theme system with dark/light mode support
- Use proper responsive design patterns for Desktop/Mobile views
- Implement proper loading states and error handling
- Use the established confirmation patterns with useConfirmation hook
- Use CSS variables and classes from index.css instead of hardcoding colors
- Follow the established color token system (--primary, --secondary, --background, etc.)
- Use proper Tailwind CSS classes instead of inline styles
- Implement proper focus states and accessibility indicators
**SSH & Security:**
- Implement proper SSH connection management with session handling
- Use secure credential storage and management patterns
- Follow the established authentication flow (password, key, credential-based)
- Implement proper file operation security and validation
**Code Quality:**
- Use proper TypeScript types from the centralized types/index.ts
- Follow the established API patterns in main-axios.ts
- Implement proper error boundaries and fallback UI
- Use proper React patterns (hooks, context, refs)
- Maintain consistent naming conventions and file organization
- All API interactions should go through main-axios.ts functions, not direct axios calls
- Use proper component interaction patterns through props and callbacks
- Follow the established state management patterns with useState and useEffect
- Use proper event handling and form submission patterns
**Bug Detection & Fixes:**
- Identify and fix memory leaks in useEffect cleanup functions
- Fix missing dependency arrays in useEffect hooks
- Resolve infinite re-render loops caused by object/array dependencies
- Fix race conditions in async operations and API calls
- Identify and fix potential null/undefined access errors
- Fix improper state updates that cause stale closures
- Resolve event handler memory leaks and proper cleanup
- Fix improper error handling that could crash the application
- Identify and fix accessibility issues and keyboard navigation problems
- Fix responsive design issues and mobile compatibility problems
- Resolve TypeScript type errors and missing type definitions
- Fix improper form validation and submission handling
- Identify and fix performance issues and unnecessary re-renders
- Fix improper API error handling and user feedback
- Resolve authentication state inconsistencies and token management issues
**Internationalization:**
- Use the i18next translation system with proper t() function calls
- Support both English and Chinese locales
- Use proper translation keys and fallbacks
**Performance:**
- Implement proper cleanup in useEffect hooks
- Use proper memoization where appropriate
- Follow the established polling and refresh patterns
- Implement proper connection pooling and resource management
**Specific to Termix:**
- Maintain compatibility with Electron and web versions
- Follow the established terminal integration patterns with xterm.js
- Use proper file manager operations and SSH session management
- Implement proper tunnel management and status tracking
- Follow the established alert and notification system patterns
Highlight any deviations from these patterns and suggest improvements for maintainability, security, and user experience.
**General Bug Detection & Fixes:**
- Identify and fix common React bugs (missing keys, improper state updates, memory leaks)
- Fix TypeScript errors and type safety issues
- Resolve accessibility violations and keyboard navigation problems
- Fix responsive design issues and mobile compatibility problems
- Identify and fix performance bottlenecks and unnecessary re-renders
- Fix improper error handling that could crash the application
- Resolve security vulnerabilities and improper data handling
- Fix improper form validation and user input handling
- Identify and fix race conditions and async operation issues
- Fix improper cleanup and resource management
- Resolve improper authentication and authorization issues
- Fix improper API error handling and user feedback
- Identify and fix potential null/undefined access errors
- Fix improper event handling and memory leaks
- Resolve improper state management and data flow issues
- path: "**/backend/**/*.{ts,js}"
instructions: |
Review backend code for Termix server management platform. Key considerations:
**Backend Architecture:**
- Follow the multi-port microservice architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
- Use Express.js with proper middleware patterns
- Implement proper CORS and security headers
- Use proper request/response logging with structured logging
**Database Operations:**
- Use Drizzle ORM with proper schema definitions
- Implement proper database migrations and schema updates
- Use proper transaction handling for critical operations
- Follow the established database connection patterns
**Authentication & Security:**
- Implement proper JWT token validation and refresh
- Use bcryptjs for password hashing with proper salt rounds
- Implement proper input validation and sanitization
- Use proper CORS configuration for security
- Implement proper rate limiting and security headers
**SSH Operations:**
- Use ssh2 library with proper connection management
- Implement proper SSH key handling and validation
- Use proper session management and cleanup
- Implement proper error handling for SSH operations
- Use proper file operation security and validation
**API Design:**
- Follow RESTful API patterns with proper HTTP status codes
- Implement proper error response formatting
- Use proper request/response validation
- Implement proper API versioning and backward compatibility
- All API routes should be defined in main-axios.ts, not scattered across components
- Use the established multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
- Follow the established error handling patterns with handleApiError function
- Use proper structured logging with service-specific loggers (apiLogger, authLogger, sshLogger, etc.)
**Logging & Monitoring:**
- Use the structured logging system with proper context
- Implement proper error tracking and reporting
- Use proper performance monitoring and metrics
- Implement proper health checks and status endpoints
Highlight any security vulnerabilities, performance issues, or architectural deviations.
- path: "**/components/**/*.{ts,tsx}"
instructions: |
Review UI components for Termix server management platform. Key considerations:
**Component Design:**
- Use Shadcn/UI components as the foundation
- Implement proper component composition and reusability
- Use proper TypeScript interfaces and prop types
- Follow the established component naming conventions
**Styling & Theming:**
- Use Tailwind CSS with proper responsive design
- Implement proper dark/light theme support
- Use proper color tokens and design system consistency
- Implement proper accessibility features (ARIA labels, keyboard navigation)
- Use CSS variables from index.css instead of hardcoding colors (--primary, --secondary, --background, etc.)
- Follow the established color scheme and design tokens
- Use proper Tailwind CSS utility classes instead of custom CSS
- Implement proper focus states and hover effects
**State Management:**
- Use proper React hooks and context patterns
- Implement proper state lifting and prop drilling avoidance
- Use proper memoization with useMemo and useCallback
- Implement proper cleanup in useEffect hooks
**Form Handling:**
- Use react-hook-form with proper validation
- Implement proper form state management
- Use proper error handling and user feedback
- Implement proper accessibility for form elements
**SSH Integration:**
- Implement proper SSH connection status indicators
- Use proper terminal integration with xterm.js
- Implement proper file manager operations
- Use proper tunnel status and management UI
Highlight any UI/UX issues, accessibility problems, or performance concerns.
- path: "**/types/**/*.{ts,js}"
instructions: |
Review type definitions for Termix server management platform. Key considerations:
**Type Design:**
- Use proper TypeScript interfaces and type definitions
- Implement proper type safety and validation
- Use proper generic types and utility types
- Follow the established type naming conventions
**API Types:**
- Define proper request/response types for all API endpoints
- Use proper error types and status codes
- Implement proper validation types and schemas
- Use proper pagination and filtering types
**SSH Types:**
- Define proper SSH connection and configuration types
- Use proper tunnel and credential types
- Implement proper file operation types
- Use proper authentication and security types
**Type Safety:**
- Ensure proper type coverage and completeness
- Use proper strict type checking
- Implement proper type narrowing and guards
- Use proper type assertions and casting
Highlight any type safety issues, missing types, or type inconsistencies.
- path: "**/hooks/**/*.{ts,tsx}"
instructions: |
Review custom hooks for Termix server management platform. Key considerations:
**Hook Design:**
- Use proper React hooks patterns and conventions
- Implement proper hook composition and reusability
- Use proper TypeScript types for hook parameters and return values
- Follow the established hook naming conventions
**State Management:**
- Implement proper state management with useState and useReducer
- Use proper context and provider patterns
- Implement proper state persistence and synchronization
- Use proper state cleanup and memory management
**Side Effects:**
- Use proper useEffect patterns with proper dependencies
- Implement proper cleanup functions and resource management
- Use proper async operations and error handling
- Implement proper polling and refresh patterns
**Performance:**
- Use proper memoization with useMemo and useCallback
- Implement proper debouncing and throttling
- Use proper lazy loading and code splitting
- Implement proper optimization patterns
**SSH Integration:**
- Implement proper SSH connection management hooks
- Use proper terminal integration hooks
- Implement proper file manager operation hooks
- Use proper tunnel management hooks
**Hook-Specific Bug Detection:**
- Fix missing cleanup functions in useEffect hooks that cause memory leaks
- Resolve infinite loops caused by incorrect dependency arrays
- Fix stale closure issues in event handlers and async operations
- Identify and fix improper state updates that cause unnecessary re-renders
- Fix race conditions in async hooks and API calls
- Resolve improper ref usage and null reference errors
- Fix improper context usage and provider nesting issues
- Identify and fix custom hook dependency issues
- Resolve improper memoization that causes stale data
- Fix improper error handling in custom hooks
Highlight any hook design issues, performance problems, or reusability concerns.
- path: "**/lib/**/*.{ts,js}"
instructions: |
Review utility libraries and helper functions for Termix server management platform. Key considerations:
**Utility Functions:**
- Implement proper utility functions with clear purposes
- Use proper TypeScript types and JSDoc documentation
- Implement proper error handling and validation
- Follow the established utility naming conventions
**Logging System:**
- Use proper structured logging with context and metadata
- Implement proper log levels and filtering
- Use proper log formatting and output
- Implement proper log rotation and cleanup
**API Utilities:**
- Implement proper API client configuration and management
- Use proper request/response interceptors
- Implement proper error handling and retry logic
- Use proper authentication and authorization handling
- All API functions should be centralized in main-axios.ts
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
- Follow the established error handling patterns with handleApiError function
- Use proper structured logging with service-specific loggers
**Security Utilities:**
- Implement proper input validation and sanitization
- Use proper encryption and decryption functions
- Implement proper secure random generation
- Use proper security headers and CORS handling
**SSH Utilities:**
- Implement proper SSH connection utilities
- Use proper SSH key handling and validation
- Implement proper SSH command execution
- Use proper SSH file operation utilities
**Utility Bug Detection:**
- Fix improper error handling in utility functions that could crash the application
- Resolve null/undefined access errors in utility functions
- Fix improper input validation that could cause security vulnerabilities
- Identify and fix memory leaks in utility functions
- Fix improper async/await usage and promise handling
- Resolve improper type checking and validation errors
- Fix improper logging that could expose sensitive information
- Identify and fix performance bottlenecks in utility functions
- Fix improper data transformation and serialization issues
- Resolve improper configuration and environment variable handling
Highlight any utility design issues, performance problems, or security concerns.
- path: "**/main-axios.ts"
instructions: |
Review main-axios.ts API client configuration for Termix server management platform. Key considerations:
**API Client Architecture:**
- Maintain the multi-port API architecture (SSH: 8081, Tunnel: 8083, File Manager: 8084, Stats: 8085)
- Use proper service-specific API instances (sshHostApi, tunnelApi, fileManagerApi, statsApi, authApi)
- Implement proper API instance creation with createApiInstance function
- Use proper base URL configuration for different environments (dev, production, Electron)
**Error Handling:**
- Use the centralized handleApiError function for consistent error handling
- Implement proper error classification (auth, network, validation, server errors)
- Use proper error logging with service-specific loggers
- Implement proper error response formatting and user-friendly messages
**Request/Response Interceptors:**
- Implement proper JWT token handling in request interceptors
- Use proper request timing and performance logging
- Implement proper response logging and error tracking
- Use proper authentication token refresh and cleanup
**API Function Organization:**
- Group API functions by service (SSH Host Management, Tunnel Management, File Manager, etc.)
- Use proper TypeScript types for all API functions
- Implement proper parameter validation and sanitization
- Use proper return type definitions and error handling
**Authentication:**
- Implement proper JWT token management and refresh
- Use proper cookie handling for web and Electron environments
- Implement proper authentication state management
- Use proper token expiration and cleanup
**Logging:**
- Use proper structured logging with context and metadata
- Implement proper request/response logging with performance metrics
- Use proper error logging with appropriate log levels
- Implement proper service-specific logger selection
**Performance:**
- Implement proper request timeout and retry logic
- Use proper connection pooling and resource management
- Implement proper request deduplication and caching
- Use proper performance monitoring and metrics
**Security:**
- Implement proper input validation and sanitization
- Use proper CORS and security header handling
- Implement proper authentication and authorization
- Use proper secure communication and data handling
**API Bug Detection:**
- Fix improper error handling that could expose sensitive information
- Resolve race conditions in concurrent API calls
- Fix improper token management and authentication state issues
- Identify and fix memory leaks in API interceptors
- Fix improper request/response validation that could cause crashes
- Resolve improper timeout handling and retry logic
- Fix improper error response formatting and user feedback
- Identify and fix performance issues in API calls
- Fix improper request deduplication and caching issues
- Resolve improper authentication token refresh and cleanup
- Fix improper CORS and security header configuration
- Identify and fix potential security vulnerabilities in API handling
Highlight any API design issues, error handling problems, or security concerns.
- path: "**/electron/**/*.{ts,js,cjs}"
instructions: |
Review Electron application code for Termix server management platform. Key considerations:
**Electron Architecture:**
- Use proper Electron main and renderer process separation
- Implement proper IPC (Inter-Process Communication) patterns
- Use proper security and sandboxing configurations
- Follow the established Electron best practices
**Security:**
- Implement proper security policies and configurations
- Use proper context isolation and node integration
- Implement proper CSP and security headers
- Use proper authentication and authorization handling
**Performance:**
- Implement proper memory management and cleanup
- Use proper resource optimization and caching
- Implement proper background processing and threading
- Use proper performance monitoring and profiling
**Electron Bug Detection:**
- Fix improper IPC communication that could cause crashes
- Resolve memory leaks in Electron main and renderer processes
- Fix improper window management and lifecycle issues
- Identify and fix security vulnerabilities in Electron configuration
- Fix improper context isolation and node integration issues
- Resolve improper event handling and cleanup in Electron
- Fix improper file system access and permission issues
- Identify and fix performance issues in Electron processes
- Fix improper auto-updater and version management
- Resolve improper tray and menu functionality issues
- Fix improper security policies and CSP configuration
- Identify and fix potential security vulnerabilities in Electron setup
Highlight any Electron-specific issues, security vulnerabilities, or performance problems.
- path: "**/docker/**/*"
instructions: |
Review Docker configuration files for Termix server management platform. Key considerations:
**Dockerfile Design:**
- Use proper multi-stage builds for optimization
- Implement proper layer caching and optimization
- Use proper security and minimal base images
- Follow the established Docker best practices
**Security:**
- Implement proper user and permission management
- Use proper security scanning and vulnerability assessment
- Implement proper secrets and credential management
- Use proper network security and isolation
**Performance:**
- Implement proper resource optimization and allocation
- Use proper caching and build optimization
- Implement proper monitoring and logging
- Use proper health checks and status monitoring
**Docker Bug Detection:**
- Fix improper multi-stage build optimization that causes large images
- Resolve security vulnerabilities in base images and dependencies
- Fix improper volume and data persistence configuration
- Identify and fix resource limit and constraint issues
- Fix improper networking and port configuration
- Resolve improper environment variable and secret management
- Fix improper health check and status monitoring configuration
- Identify and fix performance issues in container startup
- Fix improper logging and monitoring configuration
- Resolve improper backup and recovery procedures
- Fix improper scaling and load balancing configuration
- Identify and fix potential security vulnerabilities in Docker setup
Highlight any Docker configuration issues, security vulnerabilities, or performance problems.
- path: "**/*.md"
instructions: |
Review documentation files for Termix server management platform. Key considerations:
**Documentation Quality:**
- Ensure proper grammar, spelling, and clarity
- Use proper formatting and structure
- Implement proper code examples and snippets
- Follow the established documentation standards
**Content Accuracy:**
- Ensure proper technical accuracy and completeness
- Use proper up-to-date information and examples
- Implement proper cross-references and links
- Use proper version and compatibility information
**User Experience:**
- Ensure proper user-friendly language and explanations
- Use proper step-by-step instructions and guides
- Implement proper troubleshooting and FAQ sections
- Use proper visual aids and diagrams where appropriate
Highlight any documentation issues, inaccuracies, or missing information.
- path: "**/index.css"
instructions: |
Review index.css styling configuration for Termix server management platform. Key considerations:
**CSS Variable System:**
- Define proper CSS custom properties for colors, spacing, and typography
- Use consistent naming conventions for CSS variables (--primary, --secondary, --background, etc.)
- Implement proper dark/light theme variable definitions
- Use proper semantic color naming (--destructive, --muted, --accent, etc.)
**Design System:**
- Follow the established design token system
- Use proper color palette definitions with proper contrast ratios
- Implement proper typography scale and font family definitions
- Use proper spacing and sizing scale definitions
**Theme Support:**
- Implement proper dark and light theme variable definitions
- Use proper CSS custom property fallbacks
- Implement proper theme switching support
- Use proper color scheme media queries
**Component Styling:**
- Define proper base styles for common components
- Use proper utility classes and helper styles
- Implement proper responsive design utilities
- Use proper accessibility-focused styling
**Color Management:**
- Avoid hardcoded color values, use CSS variables instead
- Implement proper color contrast and accessibility
- Use proper semantic color definitions
- Implement proper color state variations (hover, focus, active)
**Typography:**
- Define proper font family and weight definitions
- Use proper line height and letter spacing
- Implement proper text size and hierarchy
- Use proper font loading and fallback strategies
**Layout Utilities:**
- Define proper spacing and margin utilities
- Use proper flexbox and grid utilities
- Implement proper responsive breakpoint utilities
- Use proper container and layout helpers
**Accessibility:**
- Implement proper focus indicators and states
- Use proper color contrast ratios
- Implement proper reduced motion support
- Use proper screen reader friendly styling
**Performance:**
- Use efficient CSS selectors and properties
- Implement proper CSS organization and structure
- Use proper CSS custom property optimization
- Implement proper critical CSS and loading strategies
Highlight any styling issues, accessibility problems, or design system inconsistencies.
auto_review:
enabled: true
ignore_title_keywords:
- "WIP"
- "DO NOT MERGE"
- "DRAFT"
- "EXPERIMENTAL"
- "TEST"
drafts: false
chat:
auto_reply: true

3
.env
View File

@@ -1 +1,2 @@
VERSION=1.3.0
VERSION=1.6.0
VITE_API_HOST=localhost

2
.github/FUNDING.yml vendored
View File

@@ -1 +1 @@
github: [LukeGus]
github: [LukeGus]

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Bug report
about: Create a report to help Termix improve
title: "[BUG]"
labels: bug
assignees: ""
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots/Logs**
If applicable, add screenshots or console/Docker logs to help explain your problem.
**Environment (please complete the following information):**
- Browser [e.g. chrome, safari]
- Version [e.g. 1.6.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for Termix
title: "[FEATURE]"
labels: enhancement
assignees: ""
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -21,7 +21,7 @@ updates:
dependency-type: "production"
update-types:
- "minor"
- package-ecosystem: "docker"
directory: "/docker"
schedule:
@@ -37,4 +37,4 @@ updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
interval: "weekly"

View File

@@ -5,8 +5,8 @@ on:
branches:
- development
paths-ignore:
- '**.md'
- '.gitignore'
- "**.md"
- ".gitignore"
workflow_dispatch:
inputs:
tag_name:
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
network=host
- name: Cache npm dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: |
~/.npm
@@ -48,7 +48,7 @@ jobs:
${{ runner.os }}-node-
- name: Cache Docker layers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.ref_name }}-${{ hashFiles('docker/Dockerfile') }}
@@ -73,12 +73,12 @@ jobs:
elif [ "${{ github.ref }}" == "refs/heads/development" ]; then
IMAGE_TAG="development-latest"
else
IMAGE_TAG="${{ github.ref_name }}-development-latest"
IMAGE_TAG="${{ github.ref_name }}"
fi
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
- name: Build and Push Multi-Arch Docker Image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile

100
.github/workflows/electron-build.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
name: Build Electron App
on:
push:
branches:
- development
paths-ignore:
- '**.md'
- '.gitignore'
- 'docker/**'
workflow_dispatch:
inputs:
build_type:
description: "Build type to run"
required: true
default: "all"
type: choice
options:
- all
- windows
- linux
jobs:
build-windows:
runs-on: windows-latest
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'windows' || github.event.inputs.build_type == ''
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Windows Portable
run: npm run build:win-portable
- name: Build Windows Installer
run: npm run build:win-installer
- name: Create Windows Portable zip
run: |
Compress-Archive -Path "release/win-unpacked/*" -DestinationPath "Termix-Windows-Portable.zip"
- name: Upload Windows Portable Artifact
uses: actions/upload-artifact@v4
with:
name: Termix-Windows-Portable
path: Termix-Windows-Portable.zip
retention-days: 30
- name: Upload Windows Installer Artifact
uses: actions/upload-artifact@v4
with:
name: Termix-Windows-Installer
path: release/*.exe
retention-days: 30
build-linux:
runs-on: ubuntu-latest
if: github.event.inputs.build_type == 'all' || github.event.inputs.build_type == 'linux' || github.event.inputs.build_type == ''
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Linux Portable
run: npm run build:linux-portable
- name: Create Linux Portable zip
run: |
cd release/linux-unpacked
zip -r ../../Termix-Linux-Portable.zip *
cd ../..
- name: Upload Linux Portable Artifact
uses: actions/upload-artifact@v4
with:
name: Termix-Linux-Portable
path: Termix-Linux-Portable.zip
retention-days: 30

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ dist-ssr
*.sln
*.sw?
/db/
/release/
/.claude/

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Ignore artifacts:
build
coverage

1
.prettierrc Normal file
View File

@@ -0,0 +1 @@
{}

107
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,107 @@
\_# Contributing
## Prerequisites
- [Node.js](https://nodejs.org/en/download/) (built with v24)
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- [Git](https://git-scm.com/downloads)
## Installation
1. Clone the repository:
```sh
git clone https://github.com/LukeGus/Termix
```
2. Install the dependencies:
```sh
npm install
```
## Running the development server
Run the following commands:
```sh
npm run dev
npm run dev:backend
```
a
This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.
## Contributing
1. **Fork the repository**: Click the "Fork" button at the top right of
the [repository page](https://github.com/LukeGus/Termix).
2. **Create a new branch**:
```sh
git checkout -b feature/my-new-feature
```
3. **Make your changes**: Implement your feature, fix, or improvement.
4. **Commit your changes**:
```sh
git commit -m "Feature request my new feature"
```
5. **Push to your fork**:
```sh
git push origin feature/my-feature-request
```
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.
## 📝 Guidelines
- Follow the existing code style. Use Tailwind CSS with shadcn components.
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
- Include meaningful commit messages.
- Link related issues when applicable.
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.
## Color Scheme
### Background Colors
| CSS Variable | Color Value | Usage | Description |
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
| `--color-dark-bg` | `#18181b` | Main dark background | Primary dark background color |
| `--color-dark-bg-darker` | `#0e0e10` | Darker backgrounds | Darker variant for panels and containers |
| `--color-dark-bg-darkest` | `#09090b` | Darkest backgrounds | Darkest background (terminal) |
| `--color-dark-bg-light` | `#141416` | Light dark backgrounds | Lighter variant of dark background |
| `--color-dark-bg-very-light` | `#101014` | Very light dark backgrounds | Very light variant of dark background |
| `--color-dark-bg-panel` | `#1b1b1e` | Panel backgrounds | Background for panels and cards |
| `--color-dark-bg-panel-hover` | `#232327` | Panel hover states | Background for panels on hover |
### Element-Specific Backgrounds
| CSS Variable | Color Value | Usage | Description |
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
| `--color-dark-bg-input` | `#222225` | Input fields | Background for input fields and form elements |
| `--color-dark-bg-button` | `#23232a` | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-active` | `#1d1d1f` | Active states | Background for active/selected elements |
| `--color-dark-bg-header` | `#131316` | Header backgrounds | Background for headers and navigation bars |
### Border Colors
| CSS Variable | Color Value | Usage | Description |
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
| `--color-dark-border` | `#303032` | Default borders | Standard border color |
| `--color-dark-border-active` | `#2d2d30` | Active borders | Border color for active elements |
| `--color-dark-border-hover` | `#434345` | Hover borders | Border color on hover states |
| `--color-dark-border-light` | `#5a5a5d` | Light borders | Lighter border color for subtle elements |
| `--color-dark-border-medium` | `#373739` | Medium borders | Medium weight border color |
| `--color-dark-border-panel` | `#222224` | Panel borders | Border color for panels and cards |
### Interactive States
| CSS Variable | Color Value | Usage | Description |
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
| `--color-dark-hover` | `#2d2d30` | Hover states | Background color for hover effects |
| `--color-dark-active` | `#2a2a2c` | Active states | Background color for active elements |
| `--color-dark-pressed` | `#1a1a1c` | Pressed states | Background color for pressed/clicked elements |
| `--color-dark-hover-alt` | `#2a2a2d` | Alternative hover | Alternative hover state color |
## Support
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
repo.

109
README-CN.md Normal file
View File

@@ -0,0 +1,109 @@
# 仓库统计
<p align="center">
<a href="README.md"><img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> 英文</a> |
<img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文
</p>
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
#### 核心技术
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#)
[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#)
<br />
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./repo-images/HeaderImage.png style="width: auto; height: auto;"> </a>
</p>
如果你愿意,可以在这里支持这个项目!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# 概览
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p>
Termix 是一个开源、永久免费、自托管的一体化服务器管理平台。它提供了一个基于网页的解决方案通过一个直观的界面管理你的服务器和基础设施。Termix
提供 SSH 终端访问、SSH 隧道功能以及远程文件编辑,还会陆续添加更多工具。
# 功能
- **SSH 终端访问** - 功能完整的终端,支持分屏(最多 4 个面板)和标签系统
- **SSH 隧道管理** - 创建和管理 SSH 隧道,支持自动重连和健康监控
- **远程文件编辑器** - 直接在远程服务器编辑文件,支持语法高亮和文件管理功能(上传、删除、重命名等)
- **SSH 主机管理器** - 保存、组织和管理 SSH 连接,支持标签和文件夹
- **服务器统计** - 查看任意 SSH 服务器的 CPU、内存和硬盘使用情况
- **用户认证** - 安全的用户管理支持管理员控制、OIDC 和双因素认证TOTP
- **现代化界面** - 使用 React、Tailwind CSS 和 Shadcn 构建的简洁界面
- **语言支持** - 内置中英文支持
# 计划功能
- **增强管理员控制** - 提供更精细的用户和管理员权限控制、共享主机等功能
- **主题定制** - 修改所有工具的主题风格
- **增强终端支持** - 添加更多终端协议,如 VNC 和 RDP有类似 Apache Guacamole 的 RDP 集成经验者请通过创建 issue 联系我)
- **移动端支持** - 支持移动应用或 Termix 网站移动版,让你在手机上管理服务器
# 安装
访问 Termix [文档](https://docs.termix.site/install) 获取安装信息。或者可以参考以下示例 docker-compose 文件:
```yaml
services:
termix:
image: ghcr.io/lukegus/termix:latest
container_name: termix
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- termix-data:/app/data
environment:
PORT: "8080"
volumes:
termix-data:
driver: local
```
# 支持
如果你需要 Termix 的帮助,可以加入 [Discord](https://discord.gg/jVQGdvHDrf)
服务器并访问支持频道。你也可以在 [GitHub](https://github.com/LukeGus/Termix/issues) 仓库提交 issue 或 pull request。
# 展示
<p align="center">
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
<img src="./repo-images/Image 2.png" width="400" alt="Termix Demo 2"/>
</p>
<p align="center">
<img src="./repo-images/Image 3.png" width="250" alt="Termix Demo 3"/>
<img src="./repo-images/Image 4.png" width="250" alt="Termix Demo 4"/>
<img src="./repo-images/Image 5.png" width="250" alt="Termix Demo 5"/>
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
你的浏览器不支持 video 标签。
</video>
</p>
# 许可证
根据 Apache 2.0 许可证发布。更多信息请参见 LICENSE。

View File

@@ -1,9 +1,17 @@
# Repo Stats
<p align="center">
<img src="https://flagcdn.com/us.svg" alt="English" width="24" height="16"> English |
<a href="README-CN.md"><img src="https://flagcdn.com/cn.svg" alt="中文" width="24" height="16"> 中文</a>
</p>
![GitHub Repo stars](https://img.shields.io/github/stars/LukeGus/Termix?style=flat&label=Stars)
![GitHub forks](https://img.shields.io/github/forks/LukeGus/Termix?style=flat&label=Forks)
![GitHub Release](https://img.shields.io/github/v/release/LukeGus/Termix?style=flat&label=Release)
<a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720"></a>
#### Top Technologies
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
@@ -23,26 +31,40 @@ If you would like, you can support the project here!\
[![GitHub Sponsor](https://img.shields.io/badge/Sponsor-LukeGus-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/sponsors/LukeGus)
# Overview
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
<p align="center">
<a href="https://github.com/LukeGus/Termix">
<img alt="Termix Banner" src=./public/icon.svg style="width: 250px; height: 250px;"> </a>
</p>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a web-based
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
access, SSH tunneling capabilities, and remote file editing, with many more tools to come.
# Features
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) and tab system
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files)
- **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (
uploading, removing, renaming, deleting files)
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Modern UI** - Clean desktop/mobile friendly (in progress) interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English and Chinese
- **Improved Platform Support** - Now includes an installable Electron app (in progress) for desktop, with a dedicated
mobile app also planned.
# Planned Features
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
- **More auth types** - Add 2FA, TOTP, etc
- **Theming** - Modify themeing for all tools
- **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue)
- **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone
See [Projects](https://github.com/users/LukeGus/projects/3). If you are looking to contribute,
see [Contributing](https://github.com/LukeGus/Termix/blob/main/CONTRIBUTING.md),
# Installation
Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here:
Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view
a sample docker-compose file here:
```yaml
services:
termix:
@@ -58,11 +80,18 @@ services:
volumes:
termix-data:
driver: local
driver: local
```
Pre-built binaries are now available for download, including a Windows installer/portable app and a Linux portable app (
built with Electron). See [Docs](http://localhost:5174/install#pre-built-binaries) for details. A native iOS/Android app
is planned.
# Support
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues) repo.
If you need help with Termix, you can join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel. You can also open an issue or open a pull request on the [GitHub](https://github.com/LukeGus/Termix/issues)
repo.
# Show-off
@@ -78,10 +107,11 @@ If you need help with Termix, you can join the [Discord](https://discord.gg/jVQG
</p>
<p align="center">
<video src="https://github.com/user-attachments/assets/29e086e5-fabf-413e-a7d9-e535bf63efde" width="800" controls>
<video src="https://github.com/user-attachments/assets/f9caa061-10dc-4173-ae7d-c6d42f05cf56" width="800" controls>
Your browser does not support the video tag.
</video>
</p>
# License
Distributed under the Apache License Version 2.0. See LICENSE for more information.

View File

@@ -18,4 +18,4 @@
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
}

View File

@@ -1,12 +1,16 @@
# Stage 1: Install dependencies and build frontend
FROM node:22-alpine AS deps
FROM node:24-alpine AS deps
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci --force && \
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm ci --force --ignore-scripts && \
npm cache clean --force
# Stage 2: Build frontend
@@ -23,30 +27,46 @@ WORKDIR /app
COPY . .
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm rebuild better-sqlite3 --force
RUN npm run build:backend
# Stage 4: Production dependencies
FROM node:22-alpine AS production-deps
FROM node:24-alpine AS production-deps
WORKDIR /app
COPY package*.json ./
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
RUN npm ci --only=production --ignore-scripts --force && \
npm cache clean --force
# Stage 5: Build native modules
FROM node:22-alpine AS native-builder
FROM node:24-alpine AS native-builder
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY package*.json ./
RUN npm ci --only=production bcryptjs better-sqlite3 --force && \
ENV npm_config_target_platform=linux
ENV npm_config_target_arch=x64
ENV npm_config_target_libc=glibc
# Install native modules and compile them properly
RUN npm ci --only=production --force && \
npm rebuild better-sqlite3 bcryptjs --force && \
npm cache clean --force
# Stage 6: Final image
FROM node:22-alpine
FROM node:24-alpine
ENV DATA_DIR=/app/data \
PORT=8080 \
NODE_ENV=production
@@ -57,13 +77,12 @@ RUN apk add --no-cache nginx gettext su-exec && \
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales
RUN chown -R nginx:nginx /usr/share/nginx/html
WORKDIR /app
COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=native-builder /app/node_modules/bcryptjs /app/node_modules/bcryptjs
COPY --from=native-builder /app/node_modules/better-sqlite3 /app/node_modules/better-sqlite3
COPY --from=native-builder /app/node_modules /app/node_modules
COPY --from=backend-builder /app/dist/backend ./dist/backend
COPY package.json ./

View File

@@ -12,4 +12,4 @@ services:
volumes:
termix-data:
driver: local
driver: local

View File

@@ -19,9 +19,9 @@ cd /app
export NODE_ENV=production
if command -v su-exec > /dev/null 2>&1; then
su-exec node node dist/backend/starter.js
su-exec node node dist/backend/backend/starter.js
else
su -s /bin/sh node -c "node dist/backend/starter.js"
su -s /bin/sh node -c "node dist/backend/backend/starter.js"
fi
echo "All services started"

View File

@@ -18,7 +18,7 @@ http {
index index.html index.htm;
}
location /users/ {
location ~ ^/users(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -27,7 +27,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /version/ {
location ~ ^/version(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -36,7 +36,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /releases/ {
location ~ ^/releases(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -45,7 +45,16 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /alerts/ {
location ~ ^/alerts(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/credentials(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -71,6 +80,14 @@ http {
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_connect_timeout 75s;
proxy_set_header Connection "";
proxy_buffering off;
proxy_request_buffering off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
@@ -85,7 +102,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
# File manager recent, pinned, shortcuts (handled by SSH service)
location /ssh/file_manager/recent {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
@@ -113,7 +129,6 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
# SSH file manager operations (handled by file manager service)
location /ssh/file_manager/ssh/ {
proxy_pass http://127.0.0.1:8084;
proxy_http_version 1.1;
@@ -123,7 +138,16 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /status/ {
location /health {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/status(/.*)?$ {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -132,7 +156,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /metrics/ {
location ~ ^/metrics(/.*)?$ {
proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1;
proxy_set_header Host $host;

44
electron-builder.json Normal file
View File

@@ -0,0 +1,44 @@
{
"appId": "com.termix.app",
"productName": "Termix",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"electron/**/*",
"public/**/*",
"!**/node_modules/**/*",
"!src/**/*",
"!*.md",
"!tsconfig*.json",
"!vite.config.ts",
"!eslint.config.js"
],
"asarUnpack": ["node_modules/node-fetch/**/*"],
"extraMetadata": {
"main": "electron/main.cjs"
},
"buildDependenciesFromSource": false,
"nodeGypRebuild": false,
"npmRebuild": false,
"win": {
"target": "nsis",
"icon": "public/icon.ico",
"executableName": "Termix"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"artifactName": "${productName}-Setup-${version}.${ext}",
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Termix",
"uninstallDisplayName": "Termix"
},
"linux": {
"target": "AppImage",
"icon": "public/icon.png",
"category": "Development"
}
}

334
electron/main.cjs Normal file
View File

@@ -0,0 +1,334 @@
const { app, BrowserWindow, shell, ipcMain } = require("electron");
const path = require("path");
const fs = require("fs");
let mainWindow = null;
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
console.log("Another instance is already running, quitting...");
app.quit();
process.exit(0);
} else {
app.on("second-instance", (event, commandLine, workingDirectory) => {
console.log("Second instance detected, focusing existing window...");
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
mainWindow.show();
}
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: "Termix",
icon: isDev
? path.join(__dirname, "..", "public", "icon.png")
: path.join(process.resourcesPath, "public", "icon.png"),
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
webSecurity: !isDev,
preload: path.join(__dirname, "preload.js"),
},
show: false,
});
if (process.platform !== "darwin") {
mainWindow.setMenuBarVisibility(false);
}
if (isDev) {
mainWindow.loadURL("http://localhost:5173");
mainWindow.webContents.openDevTools();
} else {
const indexPath = path.join(__dirname, "..", "dist", "index.html");
console.log("Loading frontend from:", indexPath);
mainWindow.loadFile(indexPath);
}
mainWindow.once("ready-to-show", () => {
console.log("Window ready to show");
mainWindow.show();
});
mainWindow.webContents.on(
"did-fail-load",
(event, errorCode, errorDescription, validatedURL) => {
console.error(
"Failed to load:",
errorCode,
errorDescription,
validatedURL,
);
},
);
mainWindow.webContents.on("did-finish-load", () => {
console.log("Frontend loaded successfully");
});
mainWindow.on("close", (event) => {
if (process.platform === "darwin") {
event.preventDefault();
mainWindow.hide();
}
});
mainWindow.on("closed", () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
}
ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
ipcMain.handle("get-platform", () => {
return process.platform;
});
ipcMain.handle("get-server-config", () => {
try {
const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, "server-config.json");
if (fs.existsSync(configPath)) {
const configData = fs.readFileSync(configPath, "utf8");
return JSON.parse(configData);
}
return null;
} catch (error) {
console.error("Error reading server config:", error);
return null;
}
});
ipcMain.handle("save-server-config", (event, config) => {
try {
const userDataPath = app.getPath("userData");
const configPath = path.join(userDataPath, "server-config.json");
if (!fs.existsSync(userDataPath)) {
fs.mkdirSync(userDataPath, { recursive: true });
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { success: true };
} catch (error) {
console.error("Error saving server config:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
try {
let fetch;
try {
fetch = globalThis.fetch || require("node:fetch");
} catch (e) {
const https = require("https");
const http = require("http");
const { URL } = require("url");
fetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http;
const req = client.request(
url,
{
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 5000,
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
};
}
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
const healthUrl = `${normalizedServerUrl}/health`;
try {
const response = await fetch(healthUrl, {
method: "GET",
timeout: 5000,
});
if (response.ok) {
const data = await response.text();
if (
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Health endpoint returned HTML instead of JSON - not a Termix server",
);
return {
success: false,
error:
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
};
}
try {
const healthData = JSON.parse(data);
if (
healthData &&
(healthData.status === "ok" ||
healthData.status === "healthy" ||
healthData.healthy === true ||
healthData.database === "connected")
) {
return {
success: true,
status: response.status,
testedUrl: healthUrl,
};
}
} catch (parseError) {
console.log("Health endpoint did not return valid JSON");
}
}
} catch (urlError) {
console.error("Health check failed:", urlError);
}
try {
const versionUrl = `${normalizedServerUrl}/version`;
const response = await fetch(versionUrl, {
method: "GET",
timeout: 5000,
});
if (response.ok) {
const data = await response.text();
if (
data.includes("<html") ||
data.includes("<!DOCTYPE") ||
data.includes("<head>") ||
data.includes("<body>")
) {
console.log(
"Version endpoint returned HTML instead of JSON - not a Termix server",
);
return {
success: false,
error:
"Server returned HTML instead of JSON. This does not appear to be a Termix server.",
};
}
try {
const versionData = JSON.parse(data);
if (
versionData &&
(versionData.status === "up_to_date" ||
versionData.status === "requires_update" ||
(versionData.localVersion &&
versionData.version &&
versionData.latest_release))
) {
return {
success: true,
status: response.status,
testedUrl: versionUrl,
warning:
"Health endpoint not available, but server appears to be running",
};
}
} catch (parseError) {
console.log("Version endpoint did not return valid JSON");
}
}
} catch (versionError) {
console.error("Version check failed:", versionError);
}
return {
success: false,
error:
"Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
};
} catch (error) {
return { success: false, error: error.message };
}
});
app.whenReady().then(() => {
createWindow();
console.log("Termix started successfully");
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
} else if (mainWindow) {
mainWindow.show();
}
});
app.on("before-quit", () => {
console.log("App is quitting...");
});
app.on("will-quit", () => {
console.log("App will quit...");
});
process.on("uncaughtException", (error) => {
console.error("Uncaught Exception:", error);
});
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});

29
electron/preload.js Normal file
View File

@@ -0,0 +1,29 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) =>
ipcRenderer.invoke("save-server-config", config),
testServerConnection: (serverUrl) =>
ipcRenderer.invoke("test-server-connection", serverUrl),
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
onUpdateAvailable: (callback) => ipcRenderer.on("update-available", callback),
onUpdateDownloaded: (callback) =>
ipcRenderer.on("update-downloaded", callback),
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true,
isDev: process.env.NODE_ENV === "development",
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
});
window.IS_ELECTRON = true;
console.log("electronAPI exposed to window");

View File

@@ -1,18 +1,18 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
export default tseslint.config([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactHooks.configs["recommended-latest"],
reactRefresh.configs.vite,
],
languageOptions: {
@@ -20,4 +20,4 @@ export default tseslint.config([
globals: globals.browser,
},
},
])
]);

2305
openapi.json Normal file

File diff suppressed because it is too large Load Diff

9988
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,24 @@
{
"name": "termix",
"private": true,
"version": "0.0.0",
"version": "1.6.0",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"author": "Karmaa",
"main": "electron/main.cjs",
"type": "module",
"scripts": {
"clean": "npx prettier . --write",
"dev": "vite",
"build": "vite build",
"build": "vite build && tsc -p tsconfig.node.json",
"build:backend": "tsc -p tsconfig.node.json",
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/starter.js",
"dev:backend": "tsc -p tsconfig.node.json && node ./dist/backend/backend/starter.js",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"electron": "electron .",
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && electron .\"",
"build:win-portable": "npm run build && electron-builder --win --dir",
"build:win-installer": "npm run build && electron-builder --win --publish=never",
"build:linux-portable": "npm run build && electron-builder --linux --dir"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
@@ -33,6 +42,8 @@
"@tailwindcss/vite": "^4.1.11",
"@types/bcryptjs": "^2.4.6",
"@types/multer": "^2.0.0",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@uiw/codemirror-extensions-hyper-link": "^4.24.1",
"@uiw/codemirror-extensions-langs": "^4.24.1",
"@uiw/codemirror-themes": "^4.24.1",
@@ -55,6 +66,9 @@
"dotenv": "^17.2.0",
"drizzle-orm": "^0.44.3",
"express": "^5.1.0",
"i18next": "^25.4.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jose": "^5.2.3",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.525.0",
@@ -62,12 +76,16 @@
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"node-fetch": "^3.3.2",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.60.0",
"react-i18next": "^15.7.3",
"react-resizable-panels": "^3.0.3",
"react-simple-keyboard": "^3.8.120",
"react-xtermjs": "^1.0.10",
"sonner": "^2.0.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.16.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.11",
@@ -76,26 +94,33 @@
"zod": "^4.0.5"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@eslint/js": "^9.34.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.0.13",
"@types/node": "^24.3.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"@vitejs/plugin-react-swc": "^3.10.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.30.1",
"concurrently": "^9.2.1",
"electron": "^38.0.0",
"electron-builder": "^26.0.12",
"electron-icon-builder": "^2.0.1",
"electron-packager": "^17.1.2",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"prettier": "3.6.2",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.3.5",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.4"
"typescript": "~5.9.2",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.5",
"wait-on": "^8.0.4"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 168 KiB

BIN
public/icon.icns Normal file

Binary file not shown.

BIN
public/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/icons/1024x1024.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
public/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
public/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 B

BIN
public/icons/24x24.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

BIN
public/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

BIN
public/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

BIN
public/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/icons/icon.icns Normal file

Binary file not shown.

BIN
public/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

@@ -1,227 +0,0 @@
import React, {useState, useEffect} from "react"
import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx"
import {Homepage} from "@/ui/Homepage/Homepage.tsx"
import {AppView} from "@/ui/Navigation/AppView.tsx"
import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx"
import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"
import axios from "axios"
import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx";
import { AdminSettings } from "@/ui/Admin/AdminSettings";
import { Toaster } from "@/components/ui/sonner";
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({baseURL: apiBase});
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
function setCookie(name: string, value: string, days = 7) {
const expires = new Date(Date.now() + days * 864e5).toUTCString();
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
}
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 [isTopbarOpen, setIsTopbarOpen] = useState<boolean>(true)
const {currentTab, tabs} = useTabs();
useEffect(() => {
const checkAuth = () => {
const jwt = getCookie("jwt");
if (jwt) {
setAuthLoading(true);
API.get("/me", {headers: {Authorization: `Bearer ${jwt}`}})
.then((meRes) => {
setIsAuthenticated(true);
setIsAdmin(!!meRes.data.is_admin);
setUsername(meRes.data.username || null);
})
.catch((err) => {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
})
.finally(() => setAuthLoading(false));
} else {
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setAuthLoading(false);
}
}
checkAuth()
const handleStorageChange = () => checkAuth()
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [])
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 handleAuthSuccess = (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 = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'file_manager';
const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin';
return (
<div>
{!isAuthenticated && !authLoading && (
<div
className="fixed inset-0 bg-gradient-to-br from-background via-muted/20 to-background z-[9999]"
aria-hidden="true"
>
<div className="absolute inset-0 opacity-20">
<div className="absolute inset-0" style={{
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 20px,
hsl(var(--primary) / 0.4) 20px,
hsl(var(--primary) / 0.4) 40px
)`
}} />
</div>
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0" style={{
backgroundImage: `linear-gradient(hsl(var(--border) / 0.3) 1px, transparent 1px),
linear-gradient(90deg, hsl(var(--border) / 0.3) 1px, transparent 1px)`,
backgroundSize: '40px 40px'
}} />
</div>
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-background/60" />
</div>
)}
{!isAuthenticated && !authLoading && (
<div className="fixed inset-0 flex items-center justify-center z-[10000]">
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
)}
{isAuthenticated && (
<LeftSidebar
onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin}
username={username}
>
<div
className="h-screen w-full"
style={{
visibility: showTerminalView ? "visible" : "hidden",
pointerEvents: showTerminalView ? "auto" : "none",
height: showTerminalView ? "100vh" : 0,
width: showTerminalView ? "100%" : 0,
position: showTerminalView ? "static" : "absolute",
overflow: "hidden",
}}
>
<AppView isTopbarOpen={isTopbarOpen} />
</div>
<div
className="h-screen w-full"
style={{
visibility: showHome ? "visible" : "hidden",
pointerEvents: showHome ? "auto" : "none",
height: showHome ? "100vh" : 0,
width: showHome ? "100%" : 0,
position: showHome ? "static" : "absolute",
overflow: "hidden",
}}
>
<Homepage
onSelectView={handleSelectView}
isAuthenticated={isAuthenticated}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/>
</div>
<div
className="h-screen w-full"
style={{
visibility: showSshManager ? "visible" : "hidden",
pointerEvents: showSshManager ? "auto" : "none",
height: showSshManager ? "100vh" : 0,
width: showSshManager ? "100%" : 0,
position: showSshManager ? "static" : "absolute",
overflow: "hidden",
}}
>
<HostManager onSelectView={handleSelectView} isTopbarOpen={isTopbarOpen} />
</div>
<div
className="h-screen w-full"
style={{
visibility: showAdmin ? "visible" : "hidden",
pointerEvents: showAdmin ? "auto" : "none",
height: showAdmin ? "100vh" : 0,
width: showAdmin ? "100%" : 0,
position: showAdmin ? "static" : "absolute",
overflow: "hidden",
}}
>
<AdminSettings isTopbarOpen={isTopbarOpen} />
</div>
<TopNavbar isTopbarOpen={isTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}/>
</LeftSidebar>
)}
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
)
}
function App() {
return (
<TabProvider>
<AppContent />
</TabProvider>
);
}
export default App

View File

@@ -1,249 +1,295 @@
import express from 'express';
import bodyParser from 'body-parser';
import userRoutes from './routes/users.js';
import sshRoutes from './routes/ssh.js';
import alertRoutes from './routes/alerts.js';
import chalk from 'chalk';
import cors from 'cors';
import fetch from 'node-fetch';
import 'dotenv/config';
import express from "express";
import bodyParser from "body-parser";
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 cors from "cors";
import fetch from "node-fetch";
import fs from "fs";
import path from "path";
import "dotenv/config";
import { databaseLogger, apiLogger } from "../utils/logger.js";
const app = express();
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
app.use(
cors({
origin: "*",
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"User-Agent",
"X-Electron-App",
],
}),
);
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
data: any;
timestamp: number;
expiresAt: number;
}
class GitHubCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 30 * 60 * 1000;
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION
});
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
}
const githubCache = new GitHubCache();
const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'LukeGus';
const REPO_NAME = 'Termix';
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "LukeGus";
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;
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;
size: number;
download_count: number;
browser_download_url: string;
}>;
prerelease: boolean;
draft: boolean;
}
async function fetchGitHubAPI(endpoint: string, cacheKey: string): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
return {
data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp
};
async function fetchGitHubAPI(
endpoint: string,
cacheKey: string,
): Promise<any> {
const cachedData = githubCache.get(cacheKey);
if (cachedData) {
return {
data: cachedData,
cached: true,
cache_age: Date.now() - cachedData.timestamp,
};
}
try {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
try {
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'TermixUpdateChecker/1.0',
'X-GitHub-Api-Version': '2022-11-28'
}
});
const data = await response.json();
githubCache.set(cacheKey, data);
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
githubCache.set(cacheKey, data);
return {
data: data,
cached: false
};
} catch (error) {
logger.error(`Failed to fetch from GitHub API: ${endpoint}`, error);
throw error;
}
return {
data: data,
cached: false,
};
} catch (error) {
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
operation: "github_api",
endpoint,
});
throw error;
}
}
app.use(bodyParser.json());
app.get('/health', (req, res) => {
res.json({status: 'ok'});
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.get('/version', async (req, res) => {
const localVersion = process.env.VERSION;
if (!localVersion) {
return res.status(401).send('Local Version Not Set');
}
app.get("/version", async (req, res) => {
let localVersion = process.env.VERSION;
if (!localVersion) {
try {
const cacheKey = 'latest_release';
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || '';
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
return res.status(401).send('Remote Version Not Found');
}
const response = {
status: localVersion === remoteVersion ? 'up_to_date' : 'requires_update',
version: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url
},
cached: releaseData.cached,
cache_age: releaseData.cache_age
};
res.json(response);
} catch (err) {
logger.error('Version check failed', err);
res.status(500).send('Fetch Error');
}
});
app.get('/releases/rss', async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(parseInt(req.query.per_page as string) || 20, 100);
const cacheKey = `releases_rss_${page}_${per_page}`;
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map(asset => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url
}))
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString()
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age
};
res.json(response);
const packagePath = path.resolve(process.cwd(), "package.json");
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
localVersion = packageJson.version;
} catch (error) {
logger.error('Failed to generate RSS format', error)
res.status(500).json({
error: 'Failed to generate RSS format',
details: error instanceof Error ? error.message : 'Unknown error'
});
databaseLogger.error("Failed to read version from package.json", error, {
operation: "version_check",
});
}
}
if (!localVersion) {
databaseLogger.error("No version information available", undefined, {
operation: "version_check",
});
return res.status(404).send("Local Version Not Set");
}
try {
const cacheKey = "latest_release";
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
cacheKey,
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
databaseLogger.warn("Remote version not found in GitHub response", {
operation: "version_check",
rawTag,
});
return res.status(401).send("Remote Version Not Found");
}
const isUpToDate = localVersion === remoteVersion;
const response = {
status: isUpToDate ? "up_to_date" : "requires_update",
localVersion: localVersion,
version: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
res.json(response);
} catch (err) {
databaseLogger.error("Version check failed", err, {
operation: "version_check",
});
res.status(500).send("Fetch Error");
}
});
app.use('/users', userRoutes);
app.use('/ssh', sshRoutes);
app.use('/alerts', alertRoutes);
app.get("/releases/rss", async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const per_page = Math.min(
parseInt(req.query.per_page as string) || 20,
100,
);
const cacheKey = `releases_rss_${page}_${per_page}`;
app.use((err: unknown, req: express.Request, res: express.Response, next: express.NextFunction) => {
logger.error('Unhandled error:', err);
res.status(500).json({error: 'Internal Server Error'});
const releasesData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases?page=${page}&per_page=${per_page}`,
cacheKey,
);
const rssItems = releasesData.data.map((release: GitHubRelease) => ({
id: release.id,
title: release.name || release.tag_name,
description: release.body,
link: release.html_url,
pubDate: release.published_at,
version: release.tag_name,
isPrerelease: release.prerelease,
isDraft: release.draft,
assets: release.assets.map((asset) => ({
name: asset.name,
size: asset.size,
download_count: asset.download_count,
download_url: asset.browser_download_url,
})),
}));
const response = {
feed: {
title: `${REPO_NAME} Releases`,
description: `Latest releases from ${REPO_NAME} repository`,
link: `https://github.com/${REPO_OWNER}/${REPO_NAME}/releases`,
updated: new Date().toISOString(),
},
items: rssItems,
total_count: rssItems.length,
cached: releasesData.cached,
cache_age: releasesData.cache_age,
};
res.json(response);
} catch (error) {
databaseLogger.error("Failed to generate RSS format", error, {
operation: "rss_releases",
});
res.status(500).json({
error: "Failed to generate RSS format",
details: error instanceof Error ? error.message : "Unknown error",
});
}
});
app.use("/users", userRoutes);
app.use("/ssh", sshRoutes);
app.use("/alerts", alertRoutes);
app.use("/credentials", credentialsRoutes);
app.use(
(
err: unknown,
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
apiLogger.error("Unhandled error in request", err, {
operation: "error_handler",
method: req.method,
url: req.url,
userAgent: req.get("User-Agent"),
});
res.status(500).json({ error: "Internal Server Error" });
},
);
const PORT = 8081;
app.listen(PORT, () => {
});
databaseLogger.success(`Database API server started on port ${PORT}`, {
operation: "server_start",
port: PORT,
routes: [
"/users",
"/ssh",
"/alerts",
"/credentials",
"/health",
"/version",
"/releases/rss",
],
});
});

View File

@@ -1,451 +1,306 @@
import {drizzle} from 'drizzle-orm/better-sqlite3';
import Database from 'better-sqlite3';
import * as schema from './schema.js';
import chalk from 'chalk';
import fs from 'fs';
import path from 'path';
import { drizzle } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import * as schema from "./schema.js";
import fs from "fs";
import path from "path";
import { databaseLogger } from "../../utils/logger.js";
const dbIconSymbol = '🗄️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${dbIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
const dataDir = process.env.DATA_DIR || './db/data';
const dataDir = process.env.DATA_DIR || "./db/data";
const dbDir = path.resolve(dataDir);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, {recursive: true});
databaseLogger.info(`Creating database directory`, {
operation: "db_init",
path: dbDir,
});
fs.mkdirSync(dbDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'db.sqlite');
const dbPath = path.join(dataDir, "db.sqlite");
databaseLogger.info(`Initializing SQLite database`, {
operation: "db_init",
path: dbPath,
});
const sqlite = new Database(dbPath);
sqlite.exec(`
CREATE TABLE IF NOT EXISTS users
(
id
TEXT
PRIMARY
KEY,
username
TEXT
NOT
NULL,
password_hash
TEXT
NOT
NULL,
is_admin
INTEGER
NOT
NULL
DEFAULT
0,
is_oidc
INTEGER
NOT
NULL
DEFAULT
0,
client_id
TEXT
NOT
NULL,
client_secret
TEXT
NOT
NULL,
issuer_url
TEXT
NOT
NULL,
authorization_url
TEXT
NOT
NULL,
token_url
TEXT
NOT
NULL,
redirect_uri
TEXT,
identifier_path
TEXT
NOT
NULL,
name_path
TEXT
NOT
NULL,
scopes
TEXT
NOT
NULL
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_oidc INTEGER NOT NULL DEFAULT 0,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL,
issuer_url TEXT NOT NULL,
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
redirect_uri TEXT,
identifier_path TEXT NOT NULL,
name_path TEXT NOT NULL,
scopes TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings
(
key
TEXT
PRIMARY
KEY,
value
TEXT
NOT
NULL
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ssh_data
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
name
TEXT,
ip
TEXT
NOT
NULL,
port
INTEGER
NOT
NULL,
username
TEXT
NOT
NULL,
folder
TEXT,
tags
TEXT,
pin
INTEGER
NOT
NULL
DEFAULT
0,
auth_type
TEXT
NOT
NULL,
password
TEXT,
key
TEXT,
key_password
TEXT,
key_type
TEXT,
enable_terminal
INTEGER
NOT
NULL
DEFAULT
1,
enable_tunnel
INTEGER
NOT
NULL
DEFAULT
1,
tunnel_connections
TEXT,
enable_file_manager
INTEGER
NOT
NULL
DEFAULT
1,
default_path
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 ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
folder TEXT,
tags TEXT,
pin INTEGER NOT NULL DEFAULT 0,
auth_type TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1,
default_path 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 file_manager_recent
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
last_opened
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
CREATE TABLE IF NOT EXISTS file_manager_pinned
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
pinned_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
host_id
INTEGER
NOT
NULL,
name
TEXT
NOT
NULL,
path
TEXT
NOT
NULL,
created_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
),
FOREIGN KEY
(
host_id
) REFERENCES ssh_data
(
id
)
);
CREATE TABLE IF NOT EXISTS file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts
(
id
INTEGER
PRIMARY
KEY
AUTOINCREMENT,
user_id
TEXT
NOT
NULL,
alert_id
TEXT
NOT
NULL,
dismissed_at
TEXT
NOT
NULL
DEFAULT
CURRENT_TIMESTAMP,
FOREIGN
KEY
(
user_id
) REFERENCES users
(
id
)
);
CREATE TABLE IF NOT EXISTS dismissed_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
alert_id TEXT NOT NULL,
dismissed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
folder TEXT,
tags TEXT,
auth_type TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT,
key TEXT,
key_password TEXT,
key_type TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE TABLE IF NOT EXISTS ssh_credential_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
credential_id INTEGER NOT NULL,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
used_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id),
FOREIGN KEY (host_id) REFERENCES ssh_data (id),
FOREIGN KEY (user_id) REFERENCES users (id)
);
`);
const addColumnIfNotExists = (table: string, column: string, definition: string) => {
const addColumnIfNotExists = (
table: string,
column: string,
definition: string,
) => {
try {
sqlite
.prepare(
`SELECT ${column}
FROM ${table} LIMIT 1`,
)
.get();
} catch (e) {
try {
sqlite.prepare(`SELECT ${column}
FROM ${table} LIMIT 1`).get();
} catch (e) {
try {
sqlite.exec(`ALTER TABLE ${table}
databaseLogger.debug(`Adding column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
});
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN ${column} ${definition};`);
} catch (alterError) {
logger.warn(`Failed to add column ${column} to ${table}: ${alterError}`);
}
databaseLogger.success(`Column ${column} added to ${table}`, {
operation: "schema_migration",
table,
column,
});
} catch (alterError) {
databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
operation: "schema_migration",
table,
column,
error: alterError,
});
}
}
};
const migrateSchema = () => {
logger.info('Checking for schema updates...');
databaseLogger.info("Checking for schema updates...", {
operation: "schema_migration",
});
addColumnIfNotExists('users', 'is_admin', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists('users', 'is_oidc', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('users', 'oidc_identifier', 'TEXT');
addColumnIfNotExists('users', 'client_id', 'TEXT');
addColumnIfNotExists('users', 'client_secret', 'TEXT');
addColumnIfNotExists('users', 'issuer_url', 'TEXT');
addColumnIfNotExists('users', 'authorization_url', 'TEXT');
addColumnIfNotExists('users', 'token_url', 'TEXT');
try {
sqlite.prepare(`ALTER TABLE users DROP COLUMN redirect_uri`).run();
logger.info('Removed redirect_uri column from users table');
} catch (e) {
}
addColumnIfNotExists("users", "is_oidc", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "oidc_identifier", "TEXT");
addColumnIfNotExists("users", "client_id", "TEXT");
addColumnIfNotExists("users", "client_secret", "TEXT");
addColumnIfNotExists("users", "issuer_url", "TEXT");
addColumnIfNotExists("users", "authorization_url", "TEXT");
addColumnIfNotExists("users", "token_url", "TEXT");
addColumnIfNotExists('users', 'identifier_path', 'TEXT');
addColumnIfNotExists('users', 'name_path', 'TEXT');
addColumnIfNotExists('users', 'scopes', 'TEXT');
addColumnIfNotExists("users", "identifier_path", "TEXT");
addColumnIfNotExists("users", "name_path", "TEXT");
addColumnIfNotExists("users", "scopes", "TEXT");
addColumnIfNotExists('ssh_data', 'name', 'TEXT');
addColumnIfNotExists('ssh_data', 'folder', 'TEXT');
addColumnIfNotExists('ssh_data', 'tags', 'TEXT');
addColumnIfNotExists('ssh_data', 'pin', 'INTEGER NOT NULL DEFAULT 0');
addColumnIfNotExists('ssh_data', 'auth_type', 'TEXT NOT NULL DEFAULT "password"');
addColumnIfNotExists('ssh_data', 'password', 'TEXT');
addColumnIfNotExists('ssh_data', 'key', 'TEXT');
addColumnIfNotExists('ssh_data', 'key_password', 'TEXT');
addColumnIfNotExists('ssh_data', 'key_type', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_terminal', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'enable_tunnel', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'tunnel_connections', 'TEXT');
addColumnIfNotExists('ssh_data', 'enable_file_manager', 'INTEGER NOT NULL DEFAULT 1');
addColumnIfNotExists('ssh_data', 'default_path', 'TEXT');
addColumnIfNotExists('ssh_data', 'created_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists('ssh_data', 'updated_at', 'TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP');
addColumnIfNotExists("users", "totp_secret", "TEXT");
addColumnIfNotExists("users", "totp_enabled", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists("users", "totp_backup_codes", "TEXT");
addColumnIfNotExists('file_manager_recent', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_pinned', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists('file_manager_shortcuts', 'host_id', 'INTEGER NOT NULL');
addColumnIfNotExists("ssh_data", "name", "TEXT");
addColumnIfNotExists("ssh_data", "folder", "TEXT");
addColumnIfNotExists("ssh_data", "tags", "TEXT");
addColumnIfNotExists("ssh_data", "pin", "INTEGER NOT NULL DEFAULT 0");
addColumnIfNotExists(
"ssh_data",
"auth_type",
'TEXT NOT NULL DEFAULT "password"',
);
addColumnIfNotExists("ssh_data", "password", "TEXT");
addColumnIfNotExists("ssh_data", "key", "TEXT");
addColumnIfNotExists("ssh_data", "key_password", "TEXT");
addColumnIfNotExists("ssh_data", "key_type", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_terminal",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists(
"ssh_data",
"enable_tunnel",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "tunnel_connections", "TEXT");
addColumnIfNotExists(
"ssh_data",
"enable_file_manager",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "default_path", "TEXT");
addColumnIfNotExists(
"ssh_data",
"created_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
addColumnIfNotExists(
"ssh_data",
"updated_at",
"TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP",
);
logger.success('Schema migration completed');
addColumnIfNotExists(
"ssh_data",
"credential_id",
"INTEGER REFERENCES ssh_credentials(id)",
);
addColumnIfNotExists("file_manager_recent", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
};
migrateSchema();
const initializeDatabase = async () => {
migrateSchema();
try {
const row = sqlite.prepare("SELECT value FROM settings WHERE key = 'allow_registration'").get();
try {
const row = sqlite
.prepare("SELECT value FROM settings WHERE key = 'allow_registration'")
.get();
if (!row) {
sqlite.prepare("INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')").run();
databaseLogger.info("Initializing default settings", {
operation: "db_init",
setting: "allow_registration",
});
sqlite
.prepare(
"INSERT INTO settings (key, value) VALUES ('allow_registration', 'true')",
)
.run();
databaseLogger.success("Default settings initialized", {
operation: "db_init",
});
} else {
databaseLogger.debug("Default settings already exist", {
operation: "db_init",
});
}
} catch (e) {
logger.warn('Could not initialize default settings');
}
} catch (e) {
databaseLogger.warn("Could not initialize default settings", {
operation: "db_init",
error: e,
});
}
};
export const db = drizzle(sqlite, {schema});
initializeDatabase().catch((error) => {
databaseLogger.error("Failed to initialize database", error, {
operation: "db_init",
});
process.exit(1);
});
databaseLogger.success("Database connection established", {
operation: "db_init",
path: dbPath,
});
export const db = drizzle(sqlite, { schema });

View File

@@ -1,83 +1,167 @@
import {sqliteTable, text, integer} from 'drizzle-orm/sqlite-core';
import {sql} from 'drizzle-orm';
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
username: text('username').notNull(),
password_hash: text('password_hash').notNull(),
is_admin: integer('is_admin', {mode: 'boolean'}).notNull().default(false),
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull(),
password_hash: text("password_hash").notNull(),
is_admin: integer("is_admin", { mode: "boolean" }).notNull().default(false),
is_oidc: integer('is_oidc', {mode: 'boolean'}).notNull().default(false),
oidc_identifier: text('oidc_identifier'),
client_id: text('client_id'),
client_secret: text('client_secret'),
issuer_url: text('issuer_url'),
authorization_url: text('authorization_url'),
token_url: text('token_url'),
identifier_path: text('identifier_path'),
name_path: text('name_path'),
scopes: text().default("openid email profile"),
is_oidc: integer("is_oidc", { mode: "boolean" }).notNull().default(false),
oidc_identifier: text("oidc_identifier"),
client_id: text("client_id"),
client_secret: text("client_secret"),
issuer_url: text("issuer_url"),
authorization_url: text("authorization_url"),
token_url: text("token_url"),
identifier_path: text("identifier_path"),
name_path: text("name_path"),
scopes: text().default("openid email profile"),
totp_secret: text("totp_secret"),
totp_enabled: integer("totp_enabled", { mode: "boolean" })
.notNull()
.default(false),
totp_backup_codes: text("totp_backup_codes"),
});
export const settings = sqliteTable('settings', {
key: text('key').primaryKey(),
value: text('value').notNull(),
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
});
export const sshData = sqliteTable('ssh_data', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
name: text('name'),
ip: text('ip').notNull(),
port: integer('port').notNull(),
username: text('username').notNull(),
folder: text('folder'),
tags: text('tags'),
pin: integer('pin', {mode: 'boolean'}).notNull().default(false),
authType: text('auth_type').notNull(),
password: text('password'),
key: text('key', {length: 8192}),
keyPassword: text('key_password'),
keyType: text('key_type'),
enableTerminal: integer('enable_terminal', {mode: 'boolean'}).notNull().default(true),
enableTunnel: integer('enable_tunnel', {mode: 'boolean'}).notNull().default(true),
tunnelConnections: text('tunnel_connections'),
enableFileManager: integer('enable_file_manager', {mode: 'boolean'}).notNull().default(true),
defaultPath: text('default_path'),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
export const sshData = sqliteTable("ssh_data", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name"),
ip: text("ip").notNull(),
port: integer("port").notNull(),
username: text("username").notNull(),
folder: text("folder"),
tags: text("tags"),
pin: integer("pin", { mode: "boolean" }).notNull().default(false),
authType: text("auth_type").notNull(),
password: text("password"),
key: text("key", { length: 8192 }),
keyPassword: text("key_password"),
keyType: text("key_type"),
credentialId: integer("credential_id").references(() => sshCredentials.id),
enableTerminal: integer("enable_terminal", { mode: "boolean" })
.notNull()
.default(true),
enableTunnel: integer("enable_tunnel", { mode: "boolean" })
.notNull()
.default(true),
tunnelConnections: text("tunnel_connections"),
enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull()
.default(true),
defaultPath: text("default_path"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const fileManagerRecent = sqliteTable('file_manager_recent', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
lastOpened: text('last_opened').notNull().default(sql`CURRENT_TIMESTAMP`),
export const fileManagerRecent = sqliteTable("file_manager_recent", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
lastOpened: text("last_opened")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const fileManagerPinned = sqliteTable('file_manager_pinned', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
pinnedAt: text('pinned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
export const fileManagerPinned = sqliteTable("file_manager_pinned", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
pinnedAt: text("pinned_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const fileManagerShortcuts = sqliteTable('file_manager_shortcuts', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
hostId: integer('host_id').notNull().references(() => sshData.id),
name: text('name').notNull(),
path: text('path').notNull(),
createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
export const fileManagerShortcuts = sqliteTable("file_manager_shortcuts", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
name: text("name").notNull(),
path: text("path").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const dismissedAlerts = sqliteTable('dismissed_alerts', {
id: integer('id').primaryKey({autoIncrement: true}),
userId: text('user_id').notNull().references(() => users.id),
alertId: text('alert_id').notNull(),
dismissedAt: text('dismissed_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const dismissedAlerts = sqliteTable("dismissed_alerts", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
alertId: text("alert_id").notNull(),
dismissedAt: text("dismissed_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshCredentials = sqliteTable("ssh_credentials", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id),
name: text("name").notNull(),
description: text("description"),
folder: text("folder"),
tags: text("tags"),
authType: text("auth_type").notNull(),
username: text("username").notNull(),
password: text("password"),
key: text("key", { length: 16384 }),
keyPassword: text("key_password"),
keyType: text("key_type"),
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});
export const sshCredentialUsage = sqliteTable("ssh_credential_usage", {
id: integer("id").primaryKey({ autoIncrement: true }),
credentialId: integer("credential_id")
.notNull()
.references(() => sshCredentials.id),
hostId: integer("host_id")
.notNull()
.references(() => sshData.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
usedAt: text("used_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
});

View File

@@ -1,270 +1,261 @@
import express from 'express';
import {db} from '../db/index.js';
import {dismissedAlerts} from '../db/schema.js';
import {eq, and} from 'drizzle-orm';
import chalk from 'chalk';
import fetch from 'node-fetch';
import type {Request, Response, NextFunction} from 'express';
const dbIconSymbol = '🚨';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#dc2626')(`[${dbIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
import express from "express";
import { db } from "../db/index.js";
import { dismissedAlerts } from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import fetch from "node-fetch";
import { authLogger } from "../../utils/logger.js";
interface CacheEntry {
data: any;
timestamp: number;
expiresAt: number;
data: any;
timestamp: number;
expiresAt: number;
}
class AlertCache {
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
private cache: Map<string, CacheEntry> = new Map();
private readonly CACHE_DURATION = 5 * 60 * 1000;
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION
});
set(key: string, data: any): void {
const now = Date.now();
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + this.CACHE_DURATION,
});
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data;
}
}
const alertCache = new AlertCache();
const GITHUB_RAW_BASE = 'https://raw.githubusercontent.com';
const REPO_OWNER = 'LukeGus';
const REPO_NAME = 'Termix-Docs';
const ALERTS_FILE = 'main/termix-alerts.json';
const GITHUB_RAW_BASE = "https://raw.githubusercontent.com";
const REPO_OWNER = "LukeGus";
const REPO_NAME = "Termix-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;
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);
if (cachedData) {
return cachedData;
const cacheKey = "termix_alerts";
const cachedData = alertCache.get(cacheKey);
if (cachedData) {
return cachedData;
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "TermixAlertChecker/1.0",
},
});
if (!response.ok) {
authLogger.warn("GitHub API returned error status", {
operation: "alerts_fetch",
status: response.status,
statusText: response.statusText,
});
throw new Error(
`GitHub raw content error: ${response.status} ${response.statusText}`,
);
}
try {
const url = `${GITHUB_RAW_BASE}/${REPO_OWNER}/${REPO_NAME}/${ALERTS_FILE}`;
const alerts: TermixAlert[] = (await response.json()) as TermixAlert[];
const response = await fetch(url, {
headers: {
'Accept': 'application/json',
'User-Agent': 'TermixAlertChecker/1.0'
}
});
const now = new Date();
if (!response.ok) {
throw new Error(`GitHub raw content error: ${response.status} ${response.statusText}`);
}
const validAlerts = alerts.filter((alert) => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
const alerts: TermixAlert[] = await response.json() as TermixAlert[];
const now = new Date();
const validAlerts = alerts.filter(alert => {
const expiryDate = new Date(alert.expiresAt);
const isValid = expiryDate > now;
return isValid;
});
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
logger.error('Failed to fetch alerts from GitHub', error);
return [];
}
alertCache.set(cacheKey, validAlerts);
return validAlerts;
} catch (error) {
authLogger.error("Failed to fetch alerts from GitHub", {
operation: "alerts_fetch",
error: error instanceof Error ? error.message : "Unknown error",
});
return [];
}
}
const router = express.Router();
// Route: Get all active alerts
// GET /alerts
router.get('/', async (req, res) => {
try {
const alerts = await fetchAlertsFromGitHub();
res.json({
alerts,
cached: alertCache.get('termix_alerts') !== null,
total_count: alerts.length
});
} catch (error) {
logger.error('Failed to get alerts', error);
res.status(500).json({error: 'Failed to fetch alerts'});
}
router.get("/", async (req, res) => {
try {
const alerts = await fetchAlertsFromGitHub();
res.json({
alerts,
cached: alertCache.get("termix_alerts") !== null,
total_count: alerts.length,
});
} catch (error) {
authLogger.error("Failed to get alerts", error);
res.status(500).json({ error: "Failed to fetch alerts" });
}
});
// Route: Get alerts for a specific user (excluding dismissed ones)
// GET /alerts/user/:userId
router.get('/user/:userId', async (req, res) => {
try {
const {userId} = req.params;
router.get("/user/:userId", async (req, res) => {
try {
const { userId } = req.params;
if (!userId) {
return res.status(400).json({error: 'User ID is required'});
}
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
.select({alertId: dismissedAlerts.alertId})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(dismissedAlertRecords.map(record => record.alertId));
const userAlerts = allAlerts.filter(alert => !dismissedAlertIds.has(alert.id));
res.json({
alerts: userAlerts,
total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size
});
} catch (error) {
logger.error('Failed to get user alerts', error);
res.status(500).json({error: 'Failed to fetch user alerts'});
if (!userId) {
return res.status(400).json({ error: "User ID is required" });
}
const allAlerts = await fetchAlertsFromGitHub();
const dismissedAlertRecords = await db
.select({ alertId: dismissedAlerts.alertId })
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
const dismissedAlertIds = new Set(
dismissedAlertRecords.map((record) => record.alertId),
);
const userAlerts = allAlerts.filter(
(alert) => !dismissedAlertIds.has(alert.id),
);
res.json({
alerts: userAlerts,
total_count: userAlerts.length,
dismissed_count: dismissedAlertIds.size,
});
} catch (error) {
authLogger.error("Failed to get user alerts", error);
res.status(500).json({ error: "Failed to fetch user alerts" });
}
});
// Route: Dismiss an alert for a user
// POST /alerts/dismiss
router.post('/dismiss', async (req, res) => {
try {
const {userId, alertId} = req.body;
router.post("/dismiss", async (req, res) => {
try {
const { userId, alertId } = req.body;
if (!userId || !alertId) {
logger.warn('Missing userId or alertId in dismiss request');
return res.status(400).json({error: 'User ID and Alert ID are required'});
}
const existingDismissal = await db
.select()
.from(dismissedAlerts)
.where(and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId)
));
if (existingDismissal.length > 0) {
logger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({error: 'Alert already dismissed'});
}
const result = await db.insert(dismissedAlerts).values({
userId,
alertId
});
logger.success(`Alert ${alertId} dismissed by user ${userId}. Insert result: ${JSON.stringify(result)}`);
res.json({message: 'Alert dismissed successfully'});
} catch (error) {
logger.error('Failed to dismiss alert', error);
res.status(500).json({error: 'Failed to dismiss alert'});
if (!userId || !alertId) {
authLogger.warn("Missing userId or alertId in dismiss request");
return res
.status(400)
.json({ error: "User ID and Alert ID are required" });
}
const existingDismissal = await db
.select()
.from(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
if (existingDismissal.length > 0) {
authLogger.warn(`Alert ${alertId} already dismissed by user ${userId}`);
return res.status(409).json({ error: "Alert already dismissed" });
}
const result = await db.insert(dismissedAlerts).values({
userId,
alertId,
});
res.json({ message: "Alert dismissed successfully" });
} catch (error) {
authLogger.error("Failed to dismiss alert", error);
res.status(500).json({ error: "Failed to dismiss alert" });
}
});
// Route: Get dismissed alerts for a user
// GET /alerts/dismissed/:userId
router.get('/dismissed/:userId', async (req, res) => {
try {
const {userId} = req.params;
router.get("/dismissed/:userId", async (req, res) => {
try {
const { userId } = req.params;
if (!userId) {
return res.status(400).json({error: 'User ID is required'});
}
const dismissedAlertRecords = await db
.select({
alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt
})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length
});
} catch (error) {
logger.error('Failed to get dismissed alerts', error);
res.status(500).json({error: 'Failed to fetch dismissed alerts'});
if (!userId) {
return res.status(400).json({ error: "User ID is required" });
}
const dismissedAlertRecords = await db
.select({
alertId: dismissedAlerts.alertId,
dismissedAt: dismissedAlerts.dismissedAt,
})
.from(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
res.json({
dismissed_alerts: dismissedAlertRecords,
total_count: dismissedAlertRecords.length,
});
} catch (error) {
authLogger.error("Failed to get dismissed alerts", error);
res.status(500).json({ error: "Failed to fetch dismissed alerts" });
}
});
// Route: Undismiss an alert for a user (remove from dismissed list)
// DELETE /alerts/dismiss
router.delete('/dismiss', async (req, res) => {
try {
const {userId, alertId} = req.body;
router.delete("/dismiss", async (req, res) => {
try {
const { userId, alertId } = req.body;
if (!userId || !alertId) {
return res.status(400).json({error: 'User ID and Alert ID are required'});
}
const result = await db
.delete(dismissedAlerts)
.where(and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId)
));
if (result.changes === 0) {
return res.status(404).json({error: 'Dismissed alert not found'});
}
logger.success(`Alert ${alertId} undismissed by user ${userId}`);
res.json({message: 'Alert undismissed successfully'});
} catch (error) {
logger.error('Failed to undismiss alert', error);
res.status(500).json({error: 'Failed to undismiss alert'});
if (!userId || !alertId) {
return res
.status(400)
.json({ error: "User ID and Alert ID are required" });
}
const result = await db
.delete(dismissedAlerts)
.where(
and(
eq(dismissedAlerts.userId, userId),
eq(dismissedAlerts.alertId, alertId),
),
);
if (result.changes === 0) {
return res.status(404).json({ error: "Dismissed alert not found" });
}
res.json({ message: "Alert undismissed successfully" });
} catch (error) {
authLogger.error("Failed to undismiss alert", error);
res.status(500).json({ error: "Failed to undismiss alert" });
}
});
export default router;

View File

@@ -0,0 +1,664 @@
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 { authLogger } from "../../utils/logger.js";
const router = express.Router();
interface JWTPayload {
userId: string;
iat?: number;
exp?: number;
}
function isNonEmptyString(val: any): val is string {
return typeof val === "string" && val.trim().length > 0;
}
function authenticateJWT(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers["authorization"];
if (!authHeader || !authHeader.startsWith("Bearer ")) {
authLogger.warn("Missing or invalid Authorization header");
return res
.status(401)
.json({ error: "Missing or invalid Authorization header" });
}
const token = authHeader.split(" ")[1];
const jwtSecret = process.env.JWT_SECRET || "secret";
try {
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
(req as any).userId = payload.userId;
next();
} catch (err) {
authLogger.warn("Invalid or expired token");
return res.status(401).json({ error: "Invalid or expired token" });
}
}
// Create a new credential
// POST /credentials
router.post("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const {
name,
description,
folder,
tags,
authType,
username,
password,
key,
keyPassword,
keyType,
} = req.body;
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(name) ||
!isNonEmptyString(username)
) {
authLogger.warn("Invalid credential creation data validation failed", {
operation: "credential_create",
userId,
hasName: !!name,
hasUsername: !!username,
});
return res.status(400).json({ error: "Name and username are required" });
}
if (!["password", "key"].includes(authType)) {
authLogger.warn("Invalid auth type provided", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: 'Auth type must be "password" or "key"' });
}
try {
if (authType === "password" && !password) {
authLogger.warn("Password required for password authentication", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: "Password is required for password authentication" });
}
if (authType === "key" && !key) {
authLogger.warn("SSH key required for key authentication", {
operation: "credential_create",
userId,
name,
authType,
});
return res
.status(400)
.json({ error: "SSH key is required for key authentication" });
}
const plainPassword = authType === "password" && password ? password : null;
const plainKey = authType === "key" && key ? key : null;
const plainKeyPassword =
authType === "key" && keyPassword ? keyPassword : null;
const credentialData = {
userId,
name: name.trim(),
description: description?.trim() || null,
folder: folder?.trim() || null,
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
authType,
username: username.trim(),
password: plainPassword,
key: plainKey,
keyPassword: plainKeyPassword,
keyType: keyType || null,
usageCount: 0,
lastUsed: null,
};
const result = await db
.insert(sshCredentials)
.values(credentialData)
.returning();
const created = result[0];
authLogger.success(
`SSH credential created: ${name} (${authType}) by user ${userId}`,
{
operation: "credential_create_success",
userId,
credentialId: created.id,
name,
authType,
username,
},
);
res.status(201).json(formatCredentialOutput(created));
} catch (err) {
authLogger.error("Failed to create credential in database", err, {
operation: "credential_create",
userId,
name,
authType,
username,
});
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to create credential",
});
}
});
// Get all credentials for the authenticated user
// GET /credentials
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for credential fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId))
.orderBy(desc(sshCredentials.updatedAt));
res.json(credentials.map((cred) => formatCredentialOutput(cred)));
} catch (err) {
authLogger.error("Failed to fetch credentials", err);
res.status(500).json({ error: "Failed to fetch credentials" });
}
});
// Get all unique credential folders for the authenticated user
// GET /credentials/folders
router.get("/folders", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
if (!isNonEmptyString(userId)) {
authLogger.warn("Invalid userId for credential folder fetch");
return res.status(400).json({ error: "Invalid userId" });
}
try {
const result = await db
.select({ folder: sshCredentials.folder })
.from(sshCredentials)
.where(eq(sshCredentials.userId, userId));
const folderCounts: Record<string, number> = {};
result.forEach((r) => {
if (r.folder && r.folder.trim() !== "") {
folderCounts[r.folder] = (folderCounts[r.folder] || 0) + 1;
}
});
const folders = Object.keys(folderCounts).filter(
(folder) => folderCounts[folder] > 0,
);
res.json(folders);
} catch (err) {
authLogger.error("Failed to fetch credential folders", err);
res.status(500).json({ error: "Failed to fetch credential folders" });
}
});
// Get a specific credential by ID (with plain text secrets)
// GET /credentials/:id
router.get("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for credential fetch");
return res.status(400).json({ error: "Invalid request" });
}
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
if (credentials.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const credential = credentials[0];
const output = formatCredentialOutput(credential);
if (credential.password) {
(output as any).password = credential.password;
}
if (credential.key) {
(output as any).key = credential.key;
}
if (credential.keyPassword) {
(output as any).keyPassword = credential.keyPassword;
}
res.json(output);
} catch (err) {
authLogger.error("Failed to fetch credential", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to fetch credential",
});
}
});
// Update a credential
// PUT /credentials/:id
router.put("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
const updateData = req.body;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for credential update");
return res.status(400).json({ error: "Invalid request" });
}
try {
const existing = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
if (existing.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const updateFields: any = {};
if (updateData.name !== undefined)
updateFields.name = updateData.name.trim();
if (updateData.description !== undefined)
updateFields.description = updateData.description?.trim() || null;
if (updateData.folder !== undefined)
updateFields.folder = updateData.folder?.trim() || null;
if (updateData.tags !== undefined) {
updateFields.tags = Array.isArray(updateData.tags)
? updateData.tags.join(",")
: updateData.tags || "";
}
if (updateData.username !== undefined)
updateFields.username = updateData.username.trim();
if (updateData.authType !== undefined)
updateFields.authType = updateData.authType;
if (updateData.keyType !== undefined)
updateFields.keyType = updateData.keyType;
if (updateData.password !== undefined) {
updateFields.password = updateData.password || null;
}
if (updateData.key !== undefined) {
updateFields.key = updateData.key || null;
}
if (updateData.keyPassword !== undefined) {
updateFields.keyPassword = updateData.keyPassword || null;
}
if (Object.keys(updateFields).length === 0) {
const existing = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id)));
return res.json(formatCredentialOutput(existing[0]));
}
await db
.update(sshCredentials)
.set(updateFields)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
const updated = await db
.select()
.from(sshCredentials)
.where(eq(sshCredentials.id, parseInt(id)));
const credential = updated[0];
authLogger.success(
`SSH credential updated: ${credential.name} (${credential.authType}) by user ${userId}`,
{
operation: "credential_update_success",
userId,
credentialId: parseInt(id),
name: credential.name,
authType: credential.authType,
username: credential.username,
},
);
res.json(formatCredentialOutput(updated[0]));
} catch (err) {
authLogger.error("Failed to update credential", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to update credential",
});
}
});
// Delete a credential
// DELETE /credentials/:id
router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id } = req.params;
if (!isNonEmptyString(userId) || !id) {
authLogger.warn("Invalid request for credential deletion");
return res.status(400).json({ error: "Invalid request" });
}
try {
const credentialToDelete = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
if (credentialToDelete.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const hostsUsingCredential = await db
.select()
.from(sshData)
.where(
and(eq(sshData.credentialId, parseInt(id)), eq(sshData.userId, userId)),
);
if (hostsUsingCredential.length > 0) {
await db
.update(sshData)
.set({
credentialId: null,
password: null,
key: null,
keyPassword: null,
authType: "password",
})
.where(
and(
eq(sshData.credentialId, parseInt(id)),
eq(sshData.userId, userId),
),
);
}
await db
.delete(sshCredentialUsage)
.where(
and(
eq(sshCredentialUsage.credentialId, parseInt(id)),
eq(sshCredentialUsage.userId, userId),
),
);
await db
.delete(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(id)),
eq(sshCredentials.userId, userId),
),
);
const credential = credentialToDelete[0];
authLogger.success(
`SSH credential deleted: ${credential.name} (${credential.authType}) by user ${userId}`,
{
operation: "credential_delete_success",
userId,
credentialId: parseInt(id),
name: credential.name,
authType: credential.authType,
username: credential.username,
},
);
res.json({ message: "Credential deleted successfully" });
} catch (err) {
authLogger.error("Failed to delete credential", err);
res.status(500).json({
error: err instanceof Error ? err.message : "Failed to delete credential",
});
}
});
// Apply a credential to an SSH host (for quick application)
// POST /credentials/:id/apply-to-host/:hostId
router.post(
"/:id/apply-to-host/:hostId",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id: credentialId, hostId } = req.params;
if (!isNonEmptyString(userId) || !credentialId || !hostId) {
authLogger.warn("Invalid request for credential application");
return res.status(400).json({ error: "Invalid request" });
}
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, parseInt(credentialId)),
eq(sshCredentials.userId, userId),
),
);
if (credentials.length === 0) {
return res.status(404).json({ error: "Credential not found" });
}
const credential = credentials[0];
await db
.update(sshData)
.set({
credentialId: parseInt(credentialId),
username: credential.username,
authType: credential.authType,
password: null,
key: null,
keyPassword: null,
keyType: null,
updatedAt: new Date().toISOString(),
})
.where(
and(eq(sshData.id, parseInt(hostId)), eq(sshData.userId, userId)),
);
await db.insert(sshCredentialUsage).values({
credentialId: parseInt(credentialId),
hostId: parseInt(hostId),
userId,
});
await db
.update(sshCredentials)
.set({
usageCount: sql`${sshCredentials.usageCount}
+ 1`,
lastUsed: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
.where(eq(sshCredentials.id, parseInt(credentialId)));
res.json({ message: "Credential applied to host successfully" });
} catch (err) {
authLogger.error("Failed to apply credential to host", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to apply credential to host",
});
}
},
);
// Get hosts using a specific credential
// GET /credentials/:id/hosts
router.get(
"/:id/hosts",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { id: credentialId } = req.params;
if (!isNonEmptyString(userId) || !credentialId) {
authLogger.warn("Invalid request for credential hosts fetch");
return res.status(400).json({ error: "Invalid request" });
}
try {
const hosts = await db
.select()
.from(sshData)
.where(
and(
eq(sshData.credentialId, parseInt(credentialId)),
eq(sshData.userId, userId),
),
);
res.json(hosts.map((host) => formatSSHHostOutput(host)));
} catch (err) {
authLogger.error("Failed to fetch hosts using credential", err);
res.status(500).json({
error:
err instanceof Error
? err.message
: "Failed to fetch hosts using credential",
});
}
},
);
function formatCredentialOutput(credential: any): any {
return {
id: credential.id,
name: credential.name,
description: credential.description,
folder: credential.folder,
tags:
typeof credential.tags === "string"
? credential.tags
? credential.tags.split(",").filter(Boolean)
: []
: [],
authType: credential.authType,
username: credential.username,
keyType: credential.keyType,
usageCount: credential.usageCount || 0,
lastUsed: credential.lastUsed,
createdAt: credential.createdAt,
updatedAt: credential.updatedAt,
};
}
function formatSSHHostOutput(host: any): any {
return {
id: host.id,
userId: host.userId,
name: host.name,
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags:
typeof host.tags === "string"
? host.tags
? host.tags.split(",").filter(Boolean)
: []
: [],
pin: !!host.pin,
authType: host.authType,
enableTerminal: !!host.enableTerminal,
enableTunnel: !!host.enableTunnel,
tunnelConnections: host.tunnelConnections
? JSON.parse(host.tunnelConnections)
: [],
enableFileManager: !!host.enableFileManager,
defaultPath: host.defaultPath,
createdAt: host.createdAt,
updatedAt: host.updatedAt,
};
}
// Rename a credential folder
// PUT /credentials/folders/rename
router.put(
"/folders/rename",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as any).userId;
const { oldName, newName } = req.body;
if (!isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
return res
.status(400)
.json({ error: "Both oldName and newName are required" });
}
if (oldName === newName) {
return res
.status(400)
.json({ error: "Old name and new name cannot be the same" });
}
try {
await db
.update(sshCredentials)
.set({ folder: newName })
.where(
and(
eq(sshCredentials.userId, userId),
eq(sshCredentials.folder, oldName),
),
);
res.json({ success: true, message: "Folder renamed successfully" });
} catch (error) {
authLogger.error("Error renaming credential folder:", error);
res.status(500).json({ error: "Failed to rename folder" });
}
},
);
export default router;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,363 +1,498 @@
import {WebSocketServer, WebSocket, type RawData} from 'ws';
import {Client, type ClientChannel, type PseudoTtyOptions} from 'ssh2';
import chalk from 'chalk';
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { Client, type ClientChannel, type PseudoTtyOptions } from "ssh2";
import { db } from "../database/db/index.js";
import { sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { sshLogger } from "../utils/logger.js";
const wss = new WebSocketServer({port: 8082});
const wss = new WebSocketServer({ port: 8082 });
const sshIconSymbol = '🖥️';
const getTimeStamp = (): string => chalk.gray(`[${new Date().toLocaleTimeString()}]`);
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${sshIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
wss.on('connection', (ws: WebSocket) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on('close', () => {
cleanupSSH();
});
ws.on('message', (msg: RawData) => {
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
} catch (e) {
logger.error('Invalid JSON received: ' + msg.toString());
ws.send(JSON.stringify({type: 'error', message: 'Invalid JSON'}));
return;
}
const {type, data} = parsed;
switch (type) {
case 'connectToHost':
handleConnectToHost(data);
break;
case 'resize':
handleResize(data);
break;
case 'disconnect':
cleanupSSH();
break;
case 'input':
if (sshStream) {
if (data === '\t') {
sshStream.write(data);
} else if (data.startsWith('\x1b')) {
sshStream.write(data);
} else {
sshStream.write(Buffer.from(data, 'utf8'));
}
}
break;
case 'ping':
ws.send(JSON.stringify({type: 'pong'}));
break;
default:
logger.warn('Unknown message type: ' + type);
}
});
function handleConnectToHost(data: {
cols: number;
rows: number;
hostConfig: {
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
authType?: string;
};
}) {
const {cols, rows, hostConfig} = data;
const {ip, port, username, password, key, keyPassword, keyType, authType} = hostConfig;
if (!username || typeof username !== 'string' || username.trim() === '') {
logger.error('Invalid username provided');
ws.send(JSON.stringify({type: 'error', message: 'Invalid username provided'}));
return;
}
if (!ip || typeof ip !== 'string' || ip.trim() === '') {
logger.error('Invalid IP provided');
ws.send(JSON.stringify({type: 'error', message: 'Invalid IP provided'}));
return;
}
if (!port || typeof port !== 'number' || port <= 0) {
logger.error('Invalid port provided');
ws.send(JSON.stringify({type: 'error', message: 'Invalid port provided'}));
return;
}
sshConn = new Client();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
logger.error('SSH connection timeout');
ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'}));
cleanupSSH(connectionTimeout);
}
}, 15000);
sshConn.on('ready', () => {
clearTimeout(connectionTimeout);
const pseudoTtyOpts: PseudoTtyOptions = {
term: 'xterm-256color',
cols,
rows,
modes: {
ECHO: 1,
ECHOCTL: 0,
ICANON: 1,
ISIG: 1,
ICRNL: 1,
IXON: 1,
IXOFF: 0,
ISTRIP: 0,
OPOST: 1,
ONLCR: 1,
OCRNL: 0,
ONOCR: 0,
ONLRET: 0,
CS7: 0,
CS8: 1,
PARENB: 0,
PARODD: 0,
TTY_OP_ISPEED: 38400,
TTY_OP_OSPEED: 38400,
}
};
sshConn!.shell(pseudoTtyOpts, (err, stream) => {
if (err) {
logger.error('Shell error: ' + err.message);
ws.send(JSON.stringify({type: 'error', message: 'Shell error: ' + err.message}));
return;
}
sshStream = stream;
stream.on('data', (chunk: Buffer) => {
let data: string;
try {
data = chunk.toString('utf8');
} catch (e) {
data = chunk.toString('binary');
}
ws.send(JSON.stringify({type: 'data', data}));
});
stream.on('close', () => {
cleanupSSH(connectionTimeout);
});
stream.on('error', (err: Error) => {
logger.error('SSH stream error: ' + err.message);
const isConnectionError = err.message.includes('ECONNRESET') ||
err.message.includes('EPIPE') ||
err.message.includes('ENOTCONN') ||
err.message.includes('ETIMEDOUT');
if (isConnectionError) {
ws.send(JSON.stringify({type: 'disconnected', message: 'Connection lost'}));
} else {
ws.send(JSON.stringify({type: 'error', message: 'SSH stream error: ' + err.message}));
}
});
setupPingInterval();
ws.send(JSON.stringify({type: 'connected', message: 'SSH connected'}));
});
});
sshConn.on('error', (err: Error) => {
clearTimeout(connectionTimeout);
logger.error('SSH connection error: ' + err.message);
let errorMessage = 'SSH error: ' + err.message;
if (err.message.includes('No matching key exchange algorithm')) {
errorMessage = 'SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('No matching cipher')) {
errorMessage = 'SSH error: No compatible cipher found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('No matching MAC')) {
errorMessage = 'SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.';
} else if (err.message.includes('ENOTFOUND') || err.message.includes('ENOENT')) {
errorMessage = 'SSH error: Could not resolve hostname or connect to server.';
} else if (err.message.includes('ECONNREFUSED')) {
errorMessage = 'SSH error: Connection refused. The server may not be running or the port may be incorrect.';
} else if (err.message.includes('ETIMEDOUT')) {
errorMessage = 'SSH error: Connection timed out. Check your network connection and server availability.';
} else if (err.message.includes('ECONNRESET') || err.message.includes('EPIPE')) {
errorMessage = 'SSH error: Connection was reset. This may be due to network issues or server timeout.';
} else if (err.message.includes('authentication failed') || err.message.includes('Permission denied')) {
errorMessage = 'SSH error: Authentication failed. Please check your username and password/key.';
}
ws.send(JSON.stringify({type: 'error', message: errorMessage}));
cleanupSSH(connectionTimeout);
});
sshConn.on('close', () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 10000,
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: [
'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'
],
cipher: [
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-cbc',
'aes192-cbc',
'aes256-cbc',
'3des-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
'hmac-sha1',
'hmac-md5'
],
compress: [
'none',
'zlib@openssh.com',
'zlib'
]
}
};
if (authType === 'key' && key) {
connectConfig.privateKey = key;
if (keyPassword) {
connectConfig.passphrase = keyPassword;
}
if (keyType && keyType !== 'auto') {
connectConfig.privateKeyType = keyType;
}
} else if (authType === 'key') {
logger.error('SSH key authentication requested but no key provided');
ws.send(JSON.stringify({type: 'error', message: 'SSH key authentication requested but no key provided'}));
return;
} else {
connectConfig.password = password;
}
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(JSON.stringify({type: 'resized', cols: data.cols, rows: data.rows}));
}
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
logger.error('Error closing stream: ' + e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
logger.error('Error closing connection: ' + e.message);
}
sshConn = null;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write('\x00');
} catch (e: any) {
logger.error('SSH keepalive failed: ' + e.message);
cleanupSSH();
}
}
}, 60000);
}
sshLogger.success("SSH Terminal WebSocket server started", {
operation: "server_start",
port: 8082,
});
wss.on("connection", (ws: WebSocket) => {
let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
ws.on("close", () => {
cleanupSSH();
});
ws.on("message", (msg: RawData) => {
let parsed: any;
try {
parsed = JSON.parse(msg.toString());
} catch (e) {
sshLogger.error("Invalid JSON received", e, {
operation: "websocket_message",
messageLength: msg.toString().length,
});
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
return;
}
const { type, data } = parsed;
switch (type) {
case "connectToHost":
handleConnectToHost(data).catch((error) => {
sshLogger.error("Failed to connect to host", error, {
operation: "ssh_connect",
hostId: data.hostConfig?.id,
ip: data.hostConfig?.ip,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Failed to connect to host: " +
(error instanceof Error ? error.message : "Unknown error"),
}),
);
});
break;
case "resize":
handleResize(data);
break;
case "disconnect":
cleanupSSH();
break;
case "input":
if (sshStream) {
if (data === "\t") {
sshStream.write(data);
} else if (data.startsWith("\x1b")) {
sshStream.write(data);
} else {
sshStream.write(Buffer.from(data, "utf8"));
}
}
break;
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message",
messageType: type,
});
}
});
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;
};
}) {
const { cols, rows, hostConfig } = data;
const {
id,
ip,
port,
username,
password,
key,
keyPassword,
keyType,
authType,
credentialId,
} = hostConfig;
if (!username || typeof username !== "string" || username.trim() === "") {
sshLogger.error("Invalid username provided", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid username provided" }),
);
return;
}
if (!ip || typeof ip !== "string" || ip.trim() === "") {
sshLogger.error("Invalid IP provided", undefined, {
operation: "ssh_connect",
hostId: id,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid IP provided" }),
);
return;
}
if (!port || typeof port !== "number" || port <= 0) {
sshLogger.error("Invalid port provided", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
username,
port,
});
ws.send(
JSON.stringify({ type: "error", message: "Invalid port provided" }),
);
return;
}
sshConn = new Client();
const connectionTimeout = setTimeout(() => {
if (sshConn) {
sshLogger.error("SSH connection timeout", undefined, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({ type: "error", message: "SSH connection timeout" }),
);
cleanupSSH(connectionTimeout);
}
}, 60000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
if (credentialId && id && hostConfig.userId) {
try {
const credentials = await db
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.id, credentialId),
eq(sshCredentials.userId, hostConfig.userId),
),
);
if (credentials.length > 0) {
const credential = credentials[0];
resolvedCredentials = {
password: credential.password,
key: credential.key,
keyPassword: credential.keyPassword,
keyType: credential.keyType,
authType: credential.authType,
};
} else {
sshLogger.warn(`No credentials found for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
userId: hostConfig.userId,
});
}
} catch (error) {
sshLogger.warn(`Failed to resolve credentials for host ${id}`, {
operation: "ssh_credentials",
hostId: id,
credentialId,
error: error instanceof Error ? error.message : "Unknown error",
});
}
} else if (credentialId && id) {
sshLogger.warn("Missing userId for credential resolution in terminal", {
operation: "ssh_credentials",
hostId: id,
credentialId,
hasUserId: !!hostConfig.userId,
});
}
sshConn.on("ready", () => {
clearTimeout(connectionTimeout);
sshConn!.shell(
{
rows: data.rows,
cols: data.cols,
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
if (err) {
sshLogger.error("Shell error", err, {
operation: "ssh_shell",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "Shell error: " + err.message,
}),
);
return;
}
sshStream = stream;
stream.on("data", (data: Buffer) => {
ws.send(JSON.stringify({ type: "data", data: data.toString() }));
});
stream.on("close", () => {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection lost",
}),
);
});
stream.on("error", (err: Error) => {
sshLogger.error("SSH stream error", err, {
operation: "ssh_stream",
hostId: id,
ip,
port,
username,
});
ws.send(
JSON.stringify({
type: "error",
message: "SSH stream error: " + err.message,
}),
);
});
setupPingInterval();
ws.send(
JSON.stringify({ type: "connected", message: "SSH connected" }),
);
},
);
});
sshConn.on("error", (err: Error) => {
clearTimeout(connectionTimeout);
sshLogger.error("SSH connection error", err, {
operation: "ssh_connect",
hostId: id,
ip,
port,
username,
authType: resolvedCredentials.authType,
});
let errorMessage = "SSH error: " + err.message;
if (err.message.includes("No matching key exchange algorithm")) {
errorMessage =
"SSH error: No compatible key exchange algorithm found. This may be due to an older SSH server or network device.";
} else if (err.message.includes("No matching cipher")) {
errorMessage =
"SSH error: No compatible cipher found. This may be due to an older SSH server or network device.";
} else if (err.message.includes("No matching MAC")) {
errorMessage =
"SSH error: No compatible MAC algorithm found. This may be due to an older SSH server or network device.";
} else if (
err.message.includes("ENOTFOUND") ||
err.message.includes("ENOENT")
) {
errorMessage =
"SSH error: Could not resolve hostname or connect to server.";
} else if (err.message.includes("ECONNREFUSED")) {
errorMessage =
"SSH error: Connection refused. The server may not be running or the port may be incorrect.";
} else if (err.message.includes("ETIMEDOUT")) {
errorMessage =
"SSH error: Connection timed out. Check your network connection and server availability.";
} else if (
err.message.includes("ECONNRESET") ||
err.message.includes("EPIPE")
) {
errorMessage =
"SSH error: Connection was reset. This may be due to network issues or server timeout.";
} else if (
err.message.includes("authentication failed") ||
err.message.includes("Permission denied")
) {
errorMessage =
"SSH error: Authentication failed. Please check your username and password/key.";
}
ws.send(JSON.stringify({ type: "error", message: errorMessage }));
cleanupSSH(connectionTimeout);
});
sshConn.on("close", () => {
clearTimeout(connectionTimeout);
cleanupSSH(connectionTimeout);
});
const connectConfig: any = {
host: ip,
port,
username,
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: [
"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",
],
cipher: [
"aes128-ctr",
"aes192-ctr",
"aes256-ctr",
"aes128-gcm@openssh.com",
"aes256-gcm@openssh.com",
"aes128-cbc",
"aes192-cbc",
"aes256-cbc",
"3des-cbc",
],
hmac: ["hmac-sha2-256", "hmac-sha2-512", "hmac-sha1", "hmac-md5"],
compress: ["none", "zlib@openssh.com", "zlib"],
},
};
if (resolvedCredentials.authType === "key" && resolvedCredentials.key) {
try {
if (
!resolvedCredentials.key.includes("-----BEGIN") ||
!resolvedCredentials.key.includes("-----END")
) {
throw new Error("Invalid private key format");
}
const cleanKey = resolvedCredentials.key
.trim()
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n");
connectConfig.privateKey = Buffer.from(cleanKey, "utf8");
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(
JSON.stringify({
type: "error",
message: "SSH key format error: Invalid private key format",
}),
);
return;
}
} else if (resolvedCredentials.authType === "key") {
sshLogger.error("SSH key authentication requested but no key provided");
ws.send(
JSON.stringify({
type: "error",
message: "SSH key authentication requested but no key provided",
}),
);
return;
} else {
connectConfig.password = resolvedCredentials.password;
}
sshConn.connect(connectConfig);
}
function handleResize(data: { cols: number; rows: number }) {
if (sshStream && sshStream.setWindow) {
sshStream.setWindow(data.rows, data.cols, data.rows, data.cols);
ws.send(
JSON.stringify({ type: "resized", cols: data.cols, rows: data.rows }),
);
}
}
function cleanupSSH(timeoutId?: NodeJS.Timeout) {
if (timeoutId) {
clearTimeout(timeoutId);
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) {
try {
sshStream.end();
} catch (e: any) {
sshLogger.error("Error closing stream: " + e.message);
}
sshStream = null;
}
if (sshConn) {
try {
sshConn.end();
} catch (e: any) {
sshLogger.error("Error closing connection: " + e.message);
}
sshConn = null;
}
}
function setupPingInterval() {
pingInterval = setInterval(() => {
if (sshConn && sshStream) {
try {
sshStream.write("\x00");
} catch (e: any) {
sshLogger.error("SSH keepalive failed: " + e.message);
cleanupSSH();
}
}
}, 60000);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,65 @@
// npx tsc -p tsconfig.node.json
// node ./dist/backend/starter.js
import './database/database.js'
import './ssh/terminal.js';
import './ssh/tunnel.js';
import './ssh/file-manager.js';
import './ssh/server-stats.js';
import chalk from 'chalk';
const fixedIconSymbol = '🚀';
const getTimeStamp = (): string => {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
};
const formatMessage = (level: string, colorFn: chalk.Chalk, message: string): string => {
return `${getTimeStamp()} ${colorFn(`[${level.toUpperCase()}]`)} ${chalk.hex('#1e3a8a')(`[${fixedIconSymbol}]`)} ${message}`;
};
const logger = {
info: (msg: string): void => {
console.log(formatMessage('info', chalk.cyan, msg));
},
warn: (msg: string): void => {
console.warn(formatMessage('warn', chalk.yellow, msg));
},
error: (msg: string, err?: unknown): void => {
console.error(formatMessage('error', chalk.redBright, msg));
if (err) console.error(err);
},
success: (msg: string): void => {
console.log(formatMessage('success', chalk.greenBright, msg));
},
debug: (msg: string): void => {
if (process.env.NODE_ENV !== 'production') {
console.debug(formatMessage('debug', chalk.magenta, msg));
}
}
};
import "./database/database.js";
import "./ssh/terminal.js";
import "./ssh/tunnel.js";
import "./ssh/file-manager.js";
import "./ssh/server-stats.js";
import { systemLogger, versionLogger } from "./utils/logger.js";
import "dotenv/config";
(async () => {
try {
logger.info("Starting all backend servers...");
try {
const version = process.env.VERSION || "unknown";
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: "startup",
version: version,
});
logger.success("All servers started successfully");
systemLogger.info("Initializing backend services...", {
operation: "startup",
});
process.on('SIGINT', () => {
logger.info("Shutting down servers...");
process.exit(0);
});
} catch (error) {
logger.error("Failed to start servers:", error);
process.exit(1);
}
})();
systemLogger.success("All backend services initialized successfully", {
operation: "startup_complete",
services: ["database", "terminal", "tunnel", "file_manager", "stats"],
version: version,
});
process.on("SIGINT", () => {
systemLogger.info(
"Received SIGINT signal, initiating graceful shutdown...",
{ operation: "shutdown" },
);
process.exit(0);
});
process.on("SIGTERM", () => {
systemLogger.info(
"Received SIGTERM signal, initiating graceful shutdown...",
{ operation: "shutdown" },
);
process.exit(0);
});
process.on("uncaughtException", (error) => {
systemLogger.error("Uncaught exception occurred", error, {
operation: "error_handling",
});
process.exit(1);
});
process.on("unhandledRejection", (reason, promise) => {
systemLogger.error("Unhandled promise rejection", reason, {
operation: "error_handling",
});
process.exit(1);
});
} catch (error) {
systemLogger.error("Failed to initialize backend services", error, {
operation: "startup_failed",
});
process.exit(1);
}
})();

174
src/backend/utils/logger.ts Normal file
View File

@@ -0,0 +1,174 @@
import chalk from "chalk";
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
export interface LogContext {
service?: string;
operation?: string;
userId?: string;
hostId?: number;
tunnelName?: string;
sessionId?: string;
requestId?: string;
duration?: number;
[key: string]: any;
}
class Logger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName;
this.serviceIcon = serviceIcon;
this.serviceColor = serviceColor;
}
private getTimeStamp(): string {
return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
}
private formatMessage(
level: LogLevel,
message: string,
context?: LogContext,
): string {
const timestamp = this.getTimeStamp();
const levelColor = this.getLevelColor(level);
const serviceTag = chalk.hex(this.serviceColor)(`[${this.serviceIcon}]`);
const levelTag = levelColor(`[${level.toUpperCase()}]`);
let contextStr = "";
if (context) {
const contextParts = [];
if (context.operation) contextParts.push(`op:${context.operation}`);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.requestId) contextParts.push(`req:${context.requestId}`);
if (context.duration) contextParts.push(`duration:${context.duration}ms`);
if (contextParts.length > 0) {
contextStr = chalk.gray(` [${contextParts.join(",")}]`);
}
}
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
}
private getLevelColor(level: LogLevel): chalk.Chalk {
switch (level) {
case "debug":
return chalk.magenta;
case "info":
return chalk.cyan;
case "warn":
return chalk.yellow;
case "error":
return chalk.redBright;
case "success":
return chalk.greenBright;
default:
return chalk.white;
}
}
private shouldLog(level: LogLevel): boolean {
if (level === "debug" && process.env.NODE_ENV === "production") {
return false;
}
return true;
}
debug(message: string, context?: LogContext): void {
if (!this.shouldLog("debug")) return;
console.debug(this.formatMessage("debug", message, context));
}
info(message: string, context?: LogContext): void {
if (!this.shouldLog("info")) return;
console.log(this.formatMessage("info", message, context));
}
warn(message: string, context?: LogContext): void {
if (!this.shouldLog("warn")) return;
console.warn(this.formatMessage("warn", message, context));
}
error(message: string, error?: unknown, context?: LogContext): void {
if (!this.shouldLog("error")) return;
console.error(this.formatMessage("error", message, context));
if (error) {
console.error(error);
}
}
success(message: string, context?: LogContext): void {
if (!this.shouldLog("success")) return;
console.log(this.formatMessage("success", message, context));
}
auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
}
db(message: string, context?: LogContext): void {
this.info(`DB: ${message}`, { ...context, operation: "database" });
}
ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
}
tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
}
file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: "file" });
}
api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, { ...context, operation: "api" });
}
request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
}
response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
}
connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, {
...context,
operation: "connection",
});
}
disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, {
...context,
operation: "disconnect",
});
}
retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
}
}
export const databaseLogger = new Logger("DATABASE", "🗄️", "#6366f1");
export const sshLogger = new Logger("SSH", "🖥️", "#0ea5e9");
export const tunnelLogger = new Logger("TUNNEL", "📡", "#a855f7");
export const fileLogger = new Logger("FILE", "📁", "#f59e0b");
export const statsLogger = new Logger("STATS", "📊", "#22c55e");
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 logger = systemLogger;

View File

@@ -1,73 +1,73 @@
import {createContext, useContext, useEffect, useState} from "react"
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system"
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode
defaultTheme?: Theme
storageKey?: string
}
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme
setTheme: (theme: Theme) => void
}
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
}
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
)
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark")
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light"
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme)
return
}
root.classList.add(theme)
}, [theme])
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme)
setTheme(theme)
},
root.classList.add(systemTheme);
return;
}
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
)
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext)
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider")
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context
}
return context;
};

View File

@@ -1,13 +1,13 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
@@ -20,7 +20,7 @@ function AccordionItem({
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
);
}
function AccordionTrigger({
@@ -34,7 +34,7 @@ function AccordionTrigger({
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
className,
)}
{...props}
>
@@ -42,7 +42,7 @@ function AccordionTrigger({
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
);
}
function AccordionContent({
@@ -58,7 +58,7 @@ function AccordionContent({
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
@@ -16,8 +16,8 @@ const alertVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Alert({
className,
@@ -31,7 +31,7 @@ function Alert({
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -40,11 +40,11 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
data-slot="alert-title"
className={cn(
"col-start-2 font-medium tracking-tight whitespace-normal break-words",
className
className,
)}
{...props}
/>
)
);
}
function AlertDescription({
@@ -56,11 +56,11 @@ function AlertDescription({
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
className,
)}
{...props}
/>
)
);
}
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -32,7 +32,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,37 +1,37 @@
import { Children, ReactElement, cloneElement, isValidElement } from 'react';
import { Children, ReactElement, cloneElement, isValidElement } from "react";
import { ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ButtonGroupProps {
className?: string;
orientation?: 'horizontal' | 'vertical';
orientation?: "horizontal" | "vertical";
children: ReactElement<ButtonProps>[] | React.ReactNode;
}
export const ButtonGroup = ({
className,
orientation = 'horizontal',
orientation = "horizontal",
children,
}: ButtonGroupProps) => {
const isHorizontal = orientation === 'horizontal';
const isVertical = orientation === 'vertical';
const isHorizontal = orientation === "horizontal";
const isVertical = orientation === "vertical";
// Normalize and filter only valid React elements
const childArray = Children.toArray(children).filter((child): child is ReactElement<ButtonProps> =>
isValidElement(child)
const childArray = Children.toArray(children).filter(
(child): child is ReactElement<ButtonProps> => isValidElement(child),
);
const totalButtons = childArray.length;
return (
<div
className={cn(
'flex',
"flex",
{
'flex-col': isVertical,
'w-fit': isVertical,
"flex-col": isVertical,
"w-fit": isVertical,
},
className
className,
)}
>
{childArray.map((child, index) => {
@@ -41,18 +41,18 @@ export const ButtonGroup = ({
return cloneElement(child, {
className: cn(
{
'rounded-l-none': isHorizontal && !isFirst,
'rounded-r-none': isHorizontal && !isLast,
'border-l-0': isHorizontal && !isFirst,
"rounded-l-none": isHorizontal && !isFirst,
"rounded-r-none": isHorizontal && !isLast,
"border-l-0": isHorizontal && !isFirst,
'rounded-t-none': isVertical && !isFirst,
'rounded-b-none': isVertical && !isLast,
'border-t-0': isVertical && !isFirst,
"rounded-t-none": isVertical && !isFirst,
"rounded-b-none": isVertical && !isLast,
"border-t-0": isVertical && !isFirst,
},
child.props.className
child.props.className,
),
});
})}
</div>
);
};
};

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -32,8 +32,14 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
export interface ButtonProps
extends React.ComponentProps<"button">,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
function Button({
className,
@@ -41,11 +47,8 @@ function Button({
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -53,7 +56,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants, type ButtonProps };

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Checkbox({
className,
@@ -13,7 +13,7 @@ function Checkbox({
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
@@ -24,7 +24,7 @@ function Checkbox({
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };

View File

@@ -0,0 +1,198 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"focus:bg-accent data-[state=open]:bg-accent flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("bg-muted -mx-1 my-1 h-px", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
@@ -9,23 +9,23 @@ import {
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
} from "react-hook-form";
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
@@ -37,21 +37,21 @@ const FormField = <
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext
const { id } = itemContext;
return {
id,
@@ -60,19 +60,19 @@ const useFormField = () => {
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
};
};
type FormItemContextValue = {
id: string
}
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
@@ -82,14 +82,14 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) {
{...props}
/>
</FormItemContext.Provider>
)
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
const { error, formItemId } = useFormField();
return (
<Label
@@ -99,11 +99,12 @@ function FormLabel({
htmlFor={formItemId}
{...props}
/>
)
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
@@ -117,11 +118,11 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
aria-invalid={!!error}
{...props}
/>
)
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
const { formDescriptionId } = useFormField();
return (
<p
@@ -130,15 +131,15 @@ function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null
return null;
}
return (
@@ -150,7 +151,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
>
{body}
</p>
)
);
}
export {
@@ -162,4 +163,4 @@ export {
FormDescription,
FormMessage,
FormField,
}
};

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
@@ -11,11 +11,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
className,
)}
{...props}
/>
)
);
}
export { Input }
export { Input };

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Label({
className,
@@ -12,11 +12,11 @@ function Label({
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
className,
)}
{...props}
/>
)
);
}
export { Label }
export { Label };

View File

@@ -0,0 +1,41 @@
"use client";
import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface PasswordInputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export const PasswordInput = React.forwardRef<
HTMLInputElement,
PasswordInputProps
>(({ className, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
return (
<div className="relative w-full">
<Input
ref={ref}
type={showPassword ? "text" : "password"}
className={cn("h-11 text-base pr-12", className)} // extra padding-right
{...props}
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";

View File

@@ -1,18 +1,18 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
@@ -29,18 +29,18 @@ function PopoverContent({
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Progress({
className,
@@ -13,7 +13,7 @@ function Progress({
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
className,
)}
{...props}
>
@@ -23,7 +23,7 @@ function Progress({
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
);
}
export { Progress }
export { Progress };

View File

@@ -1,8 +1,8 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
@@ -13,17 +13,17 @@ function ResizablePanelGroup({
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
className,
)}
{...props}
/>
)
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
@@ -31,24 +31,24 @@ function ResizableHandle({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] transition-colors duration-150",
className
"relative flex w-1 items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-1 data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed transition-colors duration-150",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-[#434345] hover:bg-[#2a2a2c] active:bg-[#1a1a1c] z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
<div className="bg-dark-border-hover hover:bg-dark-active active:bg-dark-pressed z-10 flex h-4 w-3 items-center justify-center rounded-xs border transition-colors duration-150">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function ScrollArea({
className,
@@ -23,7 +23,7 @@ function ScrollArea({
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
);
}
function ScrollBar({
@@ -41,7 +41,7 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
className,
)}
{...props}
>
@@ -50,7 +50,7 @@ function ScrollBar({
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
);
}
export { ScrollArea, ScrollBar }
export { ScrollArea, ScrollBar };

View File

@@ -1,25 +1,25 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
@@ -28,7 +28,7 @@ function SelectTrigger({
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
@@ -36,7 +36,7 @@ function SelectTrigger({
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
>
@@ -45,7 +45,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
);
}
function SelectContent({
@@ -62,7 +62,7 @@ function SelectContent({
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
className,
)}
position={position}
{...props}
@@ -72,7 +72,7 @@ function SelectContent({
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
@@ -80,7 +80,7 @@ function SelectContent({
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
);
}
function SelectLabel({
@@ -93,7 +93,7 @@ function SelectLabel({
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
);
}
function SelectItem({
@@ -106,7 +106,7 @@ function SelectItem({
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
className,
)}
{...props}
>
@@ -117,7 +117,7 @@ function SelectItem({
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
);
}
function SelectSeparator({
@@ -130,7 +130,7 @@ function SelectSeparator({
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
);
}
function SelectScrollUpButton({
@@ -142,13 +142,13 @@ function SelectScrollUpButton({
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
);
}
function SelectScrollDownButton({
@@ -160,13 +160,13 @@ function SelectScrollDownButton({
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
);
}
export {
@@ -180,4 +180,4 @@ export {
SelectSeparator,
SelectTrigger,
SelectValue,
}
};

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Separator({
className,
@@ -18,11 +18,11 @@ function Separator({
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
className,
)}
{...props}
/>
)
);
}
export { Separator }
export { Separator };

View File

@@ -1,14 +1,15 @@
import type { ComponentProps, HTMLAttributes } from 'react';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { ComponentProps, HTMLAttributes } from "react";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export type StatusProps = ComponentProps<typeof Badge> & {
status: 'online' | 'offline' | 'maintenance' | 'degraded';
status: "online" | "offline" | "maintenance" | "degraded";
};
export const Status = ({ className, status, ...props }: StatusProps) => (
<Badge
className={cn('flex items-center gap-2', 'group', status, className)}
className={cn("flex items-center gap-2", "group", status, className)}
variant="secondary"
{...props}
/>
@@ -23,20 +24,20 @@ export const StatusIndicator = ({
<span className="relative flex h-2 w-2" {...props}>
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
'group-[.online]:bg-emerald-500',
'group-[.offline]:bg-red-500',
'group-[.maintenance]:bg-blue-500',
'group-[.degraded]:bg-amber-500'
"absolute inline-flex h-full w-full animate-ping rounded-full opacity-75",
"group-[.online]:bg-emerald-500",
"group-[.offline]:bg-red-500",
"group-[.maintenance]:bg-blue-500",
"group-[.degraded]:bg-amber-500",
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
'group-[.online]:bg-emerald-500',
'group-[.offline]:bg-red-500',
'group-[.maintenance]:bg-blue-500',
'group-[.degraded]:bg-amber-500'
"relative inline-flex h-2 w-2 rounded-full",
"group-[.online]:bg-emerald-500",
"group-[.offline]:bg-red-500",
"group-[.maintenance]:bg-blue-500",
"group-[.degraded]:bg-amber-500",
)}
/>
</span>
@@ -48,15 +49,26 @@ export const StatusLabel = ({
className,
children,
...props
}: StatusLabelProps) => (
<span className={cn('text-muted-foreground', className)} {...props}>
{children ?? (
<>
<span className="hidden group-[.online]:block">Online</span>
<span className="hidden group-[.offline]:block">Offline</span>
<span className="hidden group-[.maintenance]:block">Maintenance</span>
<span className="hidden group-[.degraded]:block">Degraded</span>
</>
)}
</span>
);
}: StatusLabelProps) => {
const { t } = useTranslation();
return (
<span className={cn("text-muted-foreground", className)} {...props}>
{children ?? (
<>
<span className="hidden group-[.online]:block">
{t("common.online")}
</span>
<span className="hidden group-[.offline]:block">
{t("common.offline")}
</span>
<span className="hidden group-[.maintenance]:block">
{t("common.maintenance")}
</span>
<span className="hidden group-[.degraded]:block">
{t("common.degraded")}
</span>
</>
)}
</span>
);
};

View File

@@ -1,29 +1,29 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
@@ -35,11 +35,11 @@ function SheetOverlay({
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:pointer-events-none",
className
className,
)}
{...props}
/>
)
);
}
function SheetContent({
@@ -48,7 +48,7 @@ function SheetContent({
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
@@ -58,14 +58,14 @@ function SheetContent({
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=closed]:pointer-events-none",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
className,
)}
{...props}
>
@@ -76,7 +76,7 @@ function SheetContent({
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,7 +86,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -96,7 +96,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
);
}
function SheetTitle({
@@ -109,7 +109,7 @@ function SheetTitle({
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
);
}
function SheetDescription({
@@ -122,7 +122,7 @@ function SheetDescription({
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -134,4 +134,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
}
};

View File

@@ -1,54 +1,54 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { useIsMobile } from "@/hooks/use-mobile";
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"
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
} from "@/components/ui/tooltip";
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"
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";
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext)
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context
return context;
}
function SidebarProvider({
@@ -60,36 +60,36 @@ function SidebarProvider({
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState)
setOpenProp(openState);
} else {
_setOpen(openState)
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
)
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
@@ -98,18 +98,18 @@ function SidebarProvider({
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
event.preventDefault();
toggleSidebar();
}
}
};
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
@@ -121,8 +121,8 @@ function SidebarProvider({
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
@@ -138,7 +138,7 @@ function SidebarProvider({
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
className,
)}
{...props}
>
@@ -146,7 +146,7 @@ function SidebarProvider({
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
);
}
function Sidebar({
@@ -157,11 +157,11 @@ function Sidebar({
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
@@ -169,43 +169,44 @@ function Sidebar({
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
className,
)}
{...props}
>
{children}
</div>
)
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
// Commented out mobile behavior to keep sidebar always visible
// if (isMobile) {
// return (
// <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
// <SheetContent
// data-sidebar="sidebar"
// data-slot="sidebar"
// data-mobile="true"
// className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
// style={
// {
// "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
// } as React.CSSProperties
// }
// side={side}
// >
// <SheetHeader className="sr-only">
// <SheetTitle>Sidebar</SheetTitle>
// <SheetDescription>Displays the mobile sidebar.</SheetDescription>
// </SheetHeader>
// <div className="flex h-full w-full flex-col">{children}</div>
// </SheetContent>
// </Sheet>
// )
// }
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className="group peer text-sidebar-foreground block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@@ -221,13 +222,13 @@ function Sidebar({
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -235,7 +236,7 @@ function Sidebar({
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
className,
)}
{...props}
>
@@ -248,7 +249,7 @@ function Sidebar({
</div>
</div>
</div>
)
);
}
function SidebarTrigger({
@@ -256,7 +257,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<Button
@@ -266,19 +267,19 @@ function SidebarTrigger({
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar } = useSidebar();
return (
<button
@@ -295,11 +296,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
className,
)}
{...props}
/>
)
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
@@ -309,11 +310,11 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
className,
)}
{...props}
/>
)
);
}
function SidebarInput({
@@ -327,7 +328,7 @@ function SidebarInput({
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -338,7 +339,7 @@ function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -349,7 +350,7 @@ function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
);
}
function SidebarSeparator({
@@ -363,7 +364,7 @@ function SidebarSeparator({
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -373,11 +374,11 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
@@ -388,7 +389,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
);
}
function SidebarGroupLabel({
@@ -396,7 +397,7 @@ function SidebarGroupLabel({
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
const Comp = asChild ? Slot : "div";
return (
<Comp
@@ -405,11 +406,11 @@ function SidebarGroupLabel({
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroupAction({
@@ -417,7 +418,7 @@ function SidebarGroupAction({
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -428,11 +429,11 @@ function SidebarGroupAction({
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarGroupContent({
@@ -446,7 +447,7 @@ function SidebarGroupContent({
className={cn("w-full text-sm", className)}
{...props}
/>
)
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
@@ -457,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
@@ -468,7 +469,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
className={cn("group/menu-item relative", className)}
{...props}
/>
)
);
}
const sidebarMenuButtonVariants = cva(
@@ -490,8 +491,8 @@ const sidebarMenuButtonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function SidebarMenuButton({
asChild = false,
@@ -502,12 +503,12 @@ function SidebarMenuButton({
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
@@ -518,16 +519,16 @@ function SidebarMenuButton({
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
);
if (!tooltip) {
return button
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
};
}
return (
@@ -540,7 +541,7 @@ function SidebarMenuButton({
{...tooltip}
/>
</Tooltip>
)
);
}
function SidebarMenuAction({
@@ -549,10 +550,10 @@ function SidebarMenuAction({
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -568,11 +569,11 @@ function SidebarMenuAction({
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuBadge({
@@ -590,11 +591,11 @@ function SidebarMenuBadge({
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSkeleton({
@@ -602,12 +603,12 @@ function SidebarMenuSkeleton({
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
@@ -632,7 +633,7 @@ function SidebarMenuSkeleton({
}
/>
</div>
)
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
@@ -643,11 +644,11 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
function SidebarMenuSubItem({
@@ -661,7 +662,7 @@ function SidebarMenuSubItem({
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
);
}
function SidebarMenuSubButton({
@@ -671,11 +672,11 @@ function SidebarMenuSubButton({
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a"
const Comp = asChild ? Slot : "a";
return (
<Comp
@@ -689,11 +690,11 @@ function SidebarMenuSubButton({
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
className,
)}
{...props}
/>
)
);
}
export {
@@ -721,4 +722,4 @@ export {
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
};

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -7,7 +7,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
);
}
export { Skeleton }
export { Skeleton };

View File

@@ -1,8 +1,8 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
@@ -17,7 +17,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Switch({
className,
@@ -12,18 +12,18 @@ function Switch({
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
)
);
}
export { Switch }
export { Switch };

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
@@ -14,7 +14,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
{...props}
/>
</div>
)
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
@@ -24,7 +24,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
@@ -34,7 +34,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
@@ -43,11 +43,11 @@ function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
className,
)}
{...props}
/>
)
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -56,11 +56,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
className,
)}
{...props}
/>
)
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -69,11 +69,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
className,
)}
{...props}
/>
)
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -82,11 +82,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
className,
)}
{...props}
/>
)
);
}
function TableCaption({
@@ -99,7 +99,7 @@ function TableCaption({
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
);
}
export {
@@ -111,4 +111,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View File

@@ -1,7 +1,7 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Tabs({
className,
@@ -13,7 +13,7 @@ function Tabs({
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
);
}
function TabsList({
@@ -25,11 +25,11 @@ function TabsList({
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
className,
)}
{...props}
/>
)
);
}
function TabsTrigger({
@@ -41,11 +41,11 @@ function TabsTrigger({
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>
)
);
}
function TabsContent({
@@ -58,7 +58,7 @@ function TabsContent({
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
@@ -15,7 +15,7 @@ function TooltipProvider({
delayDuration={delayDuration}
{...props}
/>
)
);
}
function Tooltip({
@@ -25,13 +25,13 @@ function Tooltip({
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
@@ -47,14 +47,14 @@ function TooltipContent({
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
className,
)}
{...props}
>
{children}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -0,0 +1,68 @@
import { useState } from "react";
import { toast } from "sonner";
interface ConfirmationOptions {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive";
}
export function useConfirmation() {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
setOptions(opts);
setOnConfirm(() => callback);
setIsOpen(true);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
setIsOpen(false);
setOptions(null);
setOnConfirm(null);
};
const handleCancel = () => {
setIsOpen(false);
setOptions(null);
setOnConfirm(null);
};
const confirmWithToast = (
message: string,
callback: () => void,
variant: "default" | "destructive" = "default",
) => {
const actionText = variant === "destructive" ? "Delete" : "Confirm";
const cancelText = "Cancel";
toast(message, {
action: {
label: actionText,
onClick: callback,
},
cancel: {
label: cancelText,
onClick: () => {},
},
duration: 10000,
className: variant === "destructive" ? "border-red-500" : "",
});
};
return {
isOpen,
options,
confirm,
handleConfirm,
handleCancel,
confirmWithToast,
};
}

View File

@@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}

42
src/i18n/i18n.ts Normal file
View File

@@ -0,0 +1,42 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "../locales/en/translation.json";
import zhTranslation from "../locales/zh/translation.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
supportedLngs: ["en", "zh"],
fallbackLng: "en",
debug: false,
detection: {
order: ["localStorage", "cookie"],
caches: ["localStorage", "cookie"],
lookupLocalStorage: "i18nextLng",
lookupCookie: "i18nextLng",
checkWhitelist: true,
},
resources: {
en: {
translation: enTranslation,
},
zh: {
translation: zhTranslation,
},
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
export default i18n;

View File

@@ -4,153 +4,180 @@
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #09090b;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #09090b;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-dark-bg: #18181b;
--color-dark-bg-darker: #0e0e10;
--color-dark-bg-darkest: #09090b;
--color-dark-bg-input: #222225;
--color-dark-bg-button: #23232a;
--color-dark-bg-active: #1d1d1f;
--color-dark-bg-header: #131316;
--color-dark-border: #303032;
--color-dark-border-active: #2d2d30;
--color-dark-border-hover: #434345;
--color-dark-hover: #2d2d30;
--color-dark-active: #2a2a2c;
--color-dark-pressed: #1a1a1c;
--color-dark-hover-alt: #2a2a2d;
--color-dark-border-light: #5a5a5d;
--color-dark-bg-light: #141416;
--color-dark-border-medium: #373739;
--color-dark-bg-very-light: #101014;
--color-dark-bg-panel: #1b1b1e;
--color-dark-border-panel: #222224;
--color-dark-bg-panel-hover: #232327;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body {
height: 100%;
}
body {
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: #303032 transparent;
scrollbar-width: thin;
scrollbar-color: #303032 transparent;
}
.thin-scrollbar::-webkit-scrollbar {
height: 6px;
width: 6px;
height: 6px;
width: 6px;
}
.thin-scrollbar::-webkit-scrollbar-track {
background: transparent;
background: transparent;
}
.thin-scrollbar::-webkit-scrollbar-thumb {
background-color: #303032;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
background-color: #303032;
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
.thin-scrollbar::-webkit-scrollbar {
@@ -174,4 +201,4 @@
.thin-scrollbar {
scrollbar-width: thin;
scrollbar-color: #434345 #18181b;
}
}

388
src/lib/frontend-logger.ts Normal file
View File

@@ -0,0 +1,388 @@
export type LogLevel = "debug" | "info" | "warn" | "error" | "success";
export interface LogContext {
operation?: string;
userId?: string;
hostId?: number;
tunnelName?: string;
sessionId?: string;
requestId?: string;
duration?: number;
method?: string;
url?: string;
status?: number;
statusText?: string;
responseTime?: number;
retryCount?: number;
errorCode?: string;
errorMessage?: string;
[key: string]: any;
}
class FrontendLogger {
private serviceName: string;
private serviceIcon: string;
private serviceColor: string;
private isDevelopment: boolean;
constructor(serviceName: string, serviceIcon: string, serviceColor: string) {
this.serviceName = serviceName;
this.serviceIcon = serviceIcon;
this.serviceColor = serviceColor;
this.isDevelopment = process.env.NODE_ENV === "development";
}
private getTimeStamp(): string {
const now = new Date();
return `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, "0")}]`;
}
private formatMessage(
level: LogLevel,
message: string,
context?: LogContext,
): string {
const timestamp = this.getTimeStamp();
const levelTag = this.getLevelTag(level);
const serviceTag = this.getServiceTag();
let contextStr = "";
if (context && this.isDevelopment) {
const contextParts = [];
if (context.operation) contextParts.push(context.operation);
if (context.userId) contextParts.push(`user:${context.userId}`);
if (context.hostId) contextParts.push(`host:${context.hostId}`);
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
if (context.status) contextParts.push(`status:${context.status}`);
if (context.errorCode) contextParts.push(`code:${context.errorCode}`);
if (contextParts.length > 0) {
contextStr = ` (${contextParts.join(", ")})`;
}
}
return `${timestamp} ${levelTag} ${serviceTag} ${message}${contextStr}`;
}
private getLevelTag(level: LogLevel): string {
const symbols = {
debug: "🔍",
info: "",
warn: "⚠️",
error: "❌",
success: "✅",
};
return `${symbols[level]} [${level.toUpperCase()}]`;
}
private getServiceTag(): string {
return `${this.serviceIcon} [${this.serviceName}]`;
}
private shouldLog(level: LogLevel): boolean {
if (level === "debug" && !this.isDevelopment) {
return false;
}
return true;
}
private log(
level: LogLevel,
message: string,
context?: LogContext,
error?: unknown,
): void {
if (!this.shouldLog(level)) return;
const formattedMessage = this.formatMessage(level, message, context);
switch (level) {
case "debug":
console.debug(formattedMessage);
break;
case "info":
console.log(formattedMessage);
break;
case "warn":
console.warn(formattedMessage);
break;
case "error":
console.error(formattedMessage);
if (error) {
console.error("Error details:", error);
}
break;
case "success":
console.log(formattedMessage);
break;
}
}
debug(message: string, context?: LogContext): void {
this.log("debug", message, context);
}
info(message: string, context?: LogContext): void {
this.log("info", message, context);
}
warn(message: string, context?: LogContext): void {
this.log("warn", message, context);
}
error(message: string, error?: unknown, context?: LogContext): void {
this.log("error", message, context, error);
}
success(message: string, context?: LogContext): void {
this.log("success", message, context);
}
api(message: string, context?: LogContext): void {
this.info(`API: ${message}`, { ...context, operation: "api" });
}
request(message: string, context?: LogContext): void {
this.info(`REQUEST: ${message}`, { ...context, operation: "request" });
}
response(message: string, context?: LogContext): void {
this.info(`RESPONSE: ${message}`, { ...context, operation: "response" });
}
auth(message: string, context?: LogContext): void {
this.info(`AUTH: ${message}`, { ...context, operation: "auth" });
}
ssh(message: string, context?: LogContext): void {
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
}
tunnel(message: string, context?: LogContext): void {
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
}
file(message: string, context?: LogContext): void {
this.info(`FILE: ${message}`, { ...context, operation: "file" });
}
connection(message: string, context?: LogContext): void {
this.info(`CONNECTION: ${message}`, {
...context,
operation: "connection",
});
}
disconnect(message: string, context?: LogContext): void {
this.info(`DISCONNECT: ${message}`, {
...context,
operation: "disconnect",
});
}
retry(message: string, context?: LogContext): void {
this.warn(`RETRY: ${message}`, { ...context, operation: "retry" });
}
performance(message: string, context?: LogContext): void {
this.info(`PERFORMANCE: ${message}`, {
...context,
operation: "performance",
});
}
security(message: string, context?: LogContext): void {
this.warn(`SECURITY: ${message}`, { ...context, operation: "security" });
}
requestStart(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
console.group(`🚀 ${method.toUpperCase()} ${shortUrl}`);
this.request(`→ Starting request to ${cleanUrl}`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
});
}
requestSuccess(
method: string,
url: string,
status: number,
responseTime: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
this.response(
`${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
{
...context,
method: method.toUpperCase(),
url: cleanUrl,
status,
responseTime,
},
);
console.groupEnd();
}
requestError(
method: string,
url: string,
status: number,
errorMessage: string,
responseTime?: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
this.error(`${statusIcon} ${status} ${errorMessage}`, undefined, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
status,
errorMessage,
responseTime,
});
console.groupEnd();
}
networkError(
method: string,
url: string,
errorMessage: string,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.error(`🌐 Network Error: ${errorMessage}`, undefined, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
errorMessage,
errorCode: "NETWORK_ERROR",
});
console.groupEnd();
}
authError(method: string, url: string, context?: LogContext): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.security(`🔐 Authentication Required`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
errorCode: "AUTH_REQUIRED",
});
console.groupEnd();
}
retryAttempt(
method: string,
url: string,
attempt: number,
maxAttempts: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
this.retry(`🔄 Retry ${attempt}/${maxAttempts}`, {
...context,
method: method.toUpperCase(),
url: cleanUrl,
retryCount: attempt,
});
}
apiOperation(operation: string, details: string, context?: LogContext): void {
this.info(`🔧 ${operation}: ${details}`, {
...context,
operation: "api_operation",
});
}
requestSummary(
method: string,
url: string,
status: number,
responseTime: number,
context?: LogContext,
): void {
const cleanUrl = this.sanitizeUrl(url);
const shortUrl = this.getShortUrl(cleanUrl);
const statusIcon = this.getStatusIcon(status);
const performanceIcon = this.getPerformanceIcon(responseTime);
console.log(
`%c📊 ${method} ${shortUrl} ${statusIcon} ${status} ${performanceIcon} ${responseTime}ms`,
"color: #666; font-style: italic; font-size: 0.9em;",
context,
);
}
private getShortUrl(url: string): string {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const query = urlObj.search;
return `${urlObj.hostname}${path}${query}`;
} catch {
return url.length > 50 ? url.substring(0, 47) + "..." : url;
}
}
private getStatusIcon(status: number): string {
if (status >= 200 && status < 300) return "✅";
if (status >= 300 && status < 400) return "↩️";
if (status >= 400 && status < 500) return "⚠️";
if (status >= 500) return "❌";
return "❓";
}
private getPerformanceIcon(responseTime: number): string {
if (responseTime < 100) return "⚡";
if (responseTime < 500) return "🚀";
if (responseTime < 1000) return "🏃";
if (responseTime < 3000) return "🚶";
return "🐌";
}
private sanitizeUrl(url: string): string {
try {
const urlObj = new URL(url);
if (
urlObj.searchParams.has("password") ||
urlObj.searchParams.has("token")
) {
urlObj.search = "";
}
return urlObj.toString();
} catch {
return url;
}
}
}
export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
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 logger = systemLogger;

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,72 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import {ThemeProvider} from "@/components/theme-provider"
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 { ThemeProvider } from "@/components/theme-provider";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<App/>
</ThemeProvider>
</StrictMode>,
)
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);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
const newWidth = window.innerWidth;
const newIsMobile = newWidth < 768;
const now = Date.now();
if (hasSwitchedOnce.current && now - lastSwitchTime.current < 10000) {
setWidth(newWidth);
return;
}
if (
newIsMobile !== isCurrentlyMobile.current &&
now - lastSwitchTime.current > 5000
) {
lastSwitchTime.current = now;
isCurrentlyMobile.current = newIsMobile;
hasSwitchedOnce.current = true;
setWidth(newWidth);
setIsMobile(newIsMobile);
} else {
setWidth(newWidth);
}
}, 2000);
};
window.addEventListener("resize", handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener("resize", handleResize);
};
}, []);
return width;
}
function RootApp() {
const width = useWindowWidth();
const isMobile = width < 768;
if (isElectron()) {
return <DesktopApp />;
}
return isMobile ? <MobileApp key="mobile" /> : <DesktopApp key="desktop" />;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<RootApp />
</ThemeProvider>
</StrictMode>,
);

440
src/types/index.ts Normal file
View File

@@ -0,0 +1,440 @@
// ============================================================================
// CENTRAL TYPE DEFINITIONS
// ============================================================================
// This file contains all shared interfaces and types used across the application
// to avoid duplication and ensure consistency.
import type { Client } from "ssh2";
// ============================================================================
// SSH HOST TYPES
// ============================================================================
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: "password" | "key" | "credential";
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
credentialId?: number;
userId?: string;
enableTerminal: boolean;
enableTunnel: boolean;
enableFileManager: boolean;
defaultPath: string;
tunnelConnections: TunnelConnection[];
createdAt: string;
updatedAt: string;
}
export interface SSHHostData {
name?: string;
ip: string;
port: number;
username: string;
folder?: string;
tags?: string[];
pin?: boolean;
authType: "password" | "key" | "credential";
password?: string;
key?: File | null;
keyPassword?: string;
keyType?: string;
credentialId?: number | null;
enableTerminal?: boolean;
enableTunnel?: boolean;
enableFileManager?: boolean;
defaultPath?: string;
tunnelConnections?: any[];
}
// ============================================================================
// CREDENTIAL TYPES
// ============================================================================
export interface Credential {
id: number;
name: string;
description?: string;
folder?: string;
tags: string[];
authType: "password" | "key";
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
usageCount: number;
lastUsed?: string;
createdAt: string;
updatedAt: string;
}
export interface CredentialData {
name: string;
description?: string;
folder?: string;
tags: string[];
authType: "password" | "key";
username: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
}
// ============================================================================
// TUNNEL TYPES
// ============================================================================
export interface TunnelConnection {
sourcePort: number;
endpointPort: number;
endpointHost: string;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
}
export interface TunnelConfig {
name: string;
hostName: string;
sourceIP: string;
sourceSSHPort: number;
sourceUsername: string;
sourcePassword?: string;
sourceAuthMethod: string;
sourceSSHKey?: string;
sourceKeyPassword?: string;
sourceKeyType?: string;
sourceCredentialId?: number;
sourceUserId?: string;
endpointIP: string;
endpointSSHPort: number;
endpointUsername: string;
endpointPassword?: string;
endpointAuthMethod: string;
endpointSSHKey?: string;
endpointKeyPassword?: string;
endpointKeyType?: string;
endpointCredentialId?: number;
endpointUserId?: string;
sourcePort: number;
endpointPort: number;
maxRetries: number;
retryInterval: number;
autoStart: boolean;
isPinned: boolean;
}
export interface TunnelStatus {
connected: boolean;
status: ConnectionState;
retryCount?: number;
maxRetries?: number;
nextRetryIn?: number;
reason?: string;
errorType?: ErrorType;
manualDisconnect?: boolean;
retryExhausted?: boolean;
}
// ============================================================================
// FILE MANAGER TYPES
// ============================================================================
export interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
dirty?: boolean;
}
export interface FileManagerFile {
name: string;
path: string;
type?: "file" | "directory";
isSSH?: boolean;
sshSessionId?: string;
}
export interface FileManagerShortcut {
name: string;
path: string;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory";
sshSessionId?: string;
}
export interface ShortcutItem {
name: string;
path: string;
}
export interface SSHConnection {
id: number;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
// ============================================================================
// HOST INFO TYPES
// ============================================================================
export interface HostInfo {
id: number;
name?: string;
ip: string;
port: number;
createdAt: string;
}
// ============================================================================
// ALERT TYPES
// ============================================================================
export interface TermixAlert {
id: string;
title: string;
message: string;
expiresAt: string;
priority?: "low" | "medium" | "high" | "critical";
type?: "info" | "warning" | "error" | "success";
actionUrl?: string;
actionText?: string;
}
// ============================================================================
// TAB TYPES
// ============================================================================
export interface TabContextTab {
id: number;
type:
| "home"
| "terminal"
| "ssh_manager"
| "server"
| "admin"
| "file_manager"
| "user_profile";
title: string;
hostConfig?: any;
terminalRef?: React.RefObject<any>;
}
// ============================================================================
// CONNECTION STATES
// ============================================================================
export const CONNECTION_STATES = {
DISCONNECTED: "disconnected",
CONNECTING: "connecting",
CONNECTED: "connected",
VERIFYING: "verifying",
FAILED: "failed",
UNSTABLE: "unstable",
RETRYING: "retrying",
WAITING: "waiting",
DISCONNECTING: "disconnecting",
} as const;
export type ConnectionState =
(typeof CONNECTION_STATES)[keyof typeof CONNECTION_STATES];
export type ErrorType =
| "CONNECTION_FAILED"
| "AUTHENTICATION_FAILED"
| "TIMEOUT"
| "NETWORK_ERROR"
| "UNKNOWN";
// ============================================================================
// AUTHENTICATION TYPES
// ============================================================================
export type AuthType = "password" | "key" | "credential";
export type KeyType = "rsa" | "ecdsa" | "ed25519";
// ============================================================================
// API RESPONSE TYPES
// ============================================================================
export interface ApiResponse<T = any> {
data?: T;
error?: string;
message?: string;
status?: number;
}
// ============================================================================
// COMPONENT PROP TYPES
// ============================================================================
export interface CredentialsManagerProps {
onEditCredential?: (credential: Credential) => void;
}
export interface CredentialEditorProps {
editingCredential?: Credential | null;
onFormSubmit?: () => void;
}
export interface CredentialViewerProps {
credential: Credential;
onClose: () => void;
onEdit: () => void;
}
export interface CredentialSelectorProps {
value?: number | null;
onValueChange: (value: number | null) => void;
}
export interface HostManagerProps {
onSelectView?: (view: string) => void;
isTopbarOpen?: boolean;
}
export interface SSHManagerHostEditorProps {
editingHost?: SSHHost | null;
onFormSubmit?: () => void;
}
export interface SSHManagerHostViewerProps {
onEditHost?: (host: SSHHost) => void;
}
export interface HostProps {
host: SSHHost;
onHostConnect?: () => void;
}
export interface SSHTunnelProps {
filterHostKey?: string;
}
export interface SSHTunnelViewerProps {
hosts?: SSHHost[];
tunnelStatuses?: Record<string, TunnelStatus>;
tunnelActions?: Record<
string,
(
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>
>;
onTunnelAction?: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
}
export interface FileManagerProps {
onSelectView?: (view: string) => void;
embedded?: boolean;
initialHost?: SSHHost | null;
}
export interface FileManagerLeftSidebarProps {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: Tab[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
}
export interface FileManagerOperationsProps {
currentPath: string;
sshSessionId: string | null;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
}
export interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;
}
export interface AlertManagerProps {
alerts: TermixAlert[];
onDismiss: (alertId: string) => void;
loggedIn: boolean;
}
export interface SSHTunnelObjectProps {
host: SSHHost;
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<any>;
compact?: boolean;
bare?: boolean;
}
export interface FolderStats {
totalHosts: number;
hostsByType: Array<{
type: string;
count: number;
}>;
}
// ============================================================================
// BACKEND TYPES
// ============================================================================
export interface HostConfig {
host: SSHHost;
tunnels: TunnelConfig[];
}
export interface VerificationData {
conn: Client;
timeout: NodeJS.Timeout;
startTime: number;
attempts: number;
maxAttempts: number;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
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>;

View File

@@ -1,444 +0,0 @@
import React from "react";
import {useSidebar} from "@/components/ui/sidebar";
import {Separator} from "@/components/ui/separator.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Input} from "@/components/ui/input.tsx";
import {Label} from "@/components/ui/label.tsx";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react";
import axios from "axios";
const apiBase = import.meta.env.DEV ? "http://localhost:8081/users" : "/users";
const API = axios.create({baseURL: apiBase});
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
const parts = v.split('=');
return parts[0] === name ? decodeURIComponent(parts[1]) : r;
}, "");
}
interface AdminSettingsProps {
isTopbarOpen?: boolean;
}
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
API.get("/oidc-config", {headers: {Authorization: `Bearer ${jwt}`}})
.then(res => {
if (res.data) setOidcConfig(res.data);
})
.catch(() => {
});
fetchUsers();
}, []);
React.useEffect(() => {
API.get("/registration-allowed")
.then(res => {
if (typeof res?.data?.allowed === 'boolean') {
setAllowRegistration(res.data.allowed);
}
})
.catch(() => {
});
}, []);
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt) return;
setUsersLoading(true);
try {
const response = await API.get("/list", {headers: {Authorization: `Bearer ${jwt}`}});
setUsers(response.data.users);
} finally {
setUsersLoading(false);
}
};
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await API.patch("/registration-allowed", {allowed: checked}, {headers: {Authorization: `Bearer ${jwt}`}});
setAllowRegistration(checked);
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
if (missing.length > 0) {
setOidcError(`Missing required fields: ${missing.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post("/oidc-config", oidcConfig, {headers: {Authorization: `Bearer ${jwt}`}});
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({...prev, [field]: value}));
};
const makeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
try {
await API.post("/make-admin", {username: newAdminUsername.trim()}, {headers: {Authorization: `Bearer ${jwt}`}});
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
};
const removeAdminStatus = async (username: string) => {
if (!confirm(`Remove admin status from ${username}?`)) return;
const jwt = getCookie("jwt");
try {
await API.post("/remove-admin", {username}, {headers: {Authorization: `Bearer ${jwt}`}});
fetchUsers();
} catch {
}
};
const deleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
const jwt = getCookie("jwt");
try {
await API.delete("/delete-user", {headers: {Authorization: `Bearer ${jwt}`}, data: {username}});
fetchUsers();
} catch {
}
};
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)`
};
return (
<div style={wrapperStyle}
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] 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">Admin Settings</h1>
</div>
<Separator className="p-0.25 w-full"/>
<div className="px-6 py-4 overflow-auto">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
General
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile" required/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>Reset</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
)}
</TableCell>
<TableCell
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}>
<Trash2 className="h-4 w-4"/>
</Button>
</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">Admin Management</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin" required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(u => u.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
</TableCell>
<TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
export default AdminSettings;

View File

@@ -0,0 +1,690 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Shield, Trash2, Users } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getOIDCConfig,
getRegistrationAllowed,
getUserList,
updateRegistrationAllowed,
updateOIDCConfig,
disableOIDCConfig,
makeUserAdmin,
removeAdminStatus,
deleteUser,
getCookie,
isElectron,
} from "@/ui/main-axios.ts";
interface AdminSettingsProps {
isTopbarOpen?: boolean;
}
export function AdminSettings({
isTopbarOpen = true,
}: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { state: sidebarState } = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: "",
client_secret: "",
issuer_url: "",
authorization_url: "",
token_url: "",
identifier_path: "sub",
name_path: "name",
scopes: "openid email profile",
userinfo_url: "",
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [users, setUsers] = React.useState<
Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
}>
>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(
null,
);
React.useEffect(() => {
const jwt = getCookie("jwt");
if (!jwt) return;
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
return;
}
}
getOIDCConfig()
.then((res) => {
if (res) setOidcConfig(res);
})
.catch((err) => {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchOidcConfig"));
}
});
fetchUsers();
}, []);
React.useEffect(() => {
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
return;
}
}
getRegistrationAllowed()
.then((res) => {
if (typeof res?.allowed === "boolean") {
setAllowRegistration(res.allowed);
}
})
.catch((err) => {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchRegistrationStatus"));
}
});
}, []);
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt) return;
if (isElectron()) {
const serverUrl = (window as any).configuredServerUrl;
if (!serverUrl) {
return;
}
}
setUsersLoading(true);
try {
const response = await getUserList();
setUsers(response.users);
} catch (err) {
if (!err.message?.includes("No server configured")) {
toast.error(t("admin.failedToFetchUsers"));
}
} finally {
setUsersLoading(false);
}
};
const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await updateRegistrationAllowed(checked);
setAllowRegistration(checked);
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setOidcLoading(true);
setOidcError(null);
const required = [
"client_id",
"client_secret",
"issuer_url",
"authorization_url",
"token_url",
];
const missing = required.filter(
(f) => !oidcConfig[f as keyof typeof oidcConfig],
);
if (missing.length > 0) {
setOidcError(
t("admin.missingRequiredFields", { fields: missing.join(", ") }),
);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await updateOIDCConfig(oidcConfig);
toast.success(t("admin.oidcConfigurationUpdated"));
} catch (err: any) {
setOidcError(
err?.response?.data?.error || t("admin.failedToUpdateOidcConfig"),
);
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig((prev) => ({ ...prev, [field]: value }));
};
const handleMakeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
setMakeAdminLoading(true);
setMakeAdminError(null);
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
toast.success(t("admin.userIsNowAdmin", { username: newAdminUsername }));
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(
err?.response?.data?.error || t("admin.failedToMakeUserAdmin"),
);
} finally {
setMakeAdminLoading(false);
}
};
const handleRemoveAdminStatus = async (username: string) => {
confirmWithToast(t("admin.removeAdminStatus", { username }), async () => {
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
toast.success(t("admin.adminStatusRemoved", { username }));
fetchUsers();
} catch (err: any) {
toast.error(t("admin.failedToRemoveAdminStatus"));
}
});
};
const handleDeleteUser = async (username: string) => {
confirmWithToast(
t("admin.deleteUser", { username }),
async () => {
const jwt = getCookie("jwt");
try {
await deleteUser(username);
toast.success(t("admin.userDeletedSuccessfully", { username }));
fetchUsers();
} catch (err: any) {
toast.error(t("admin.failedToDeleteUser"));
}
},
"destructive",
);
};
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)`,
};
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("admin.title")}</h1>
</div>
<Separator className="p-0.25 w-full" />
<div className="px-6 py-4 overflow-auto">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="registration"
className="flex items-center gap-2"
>
<Users className="h-4 w-4" />
{t("admin.general")}
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4" />
{t("admin.users")}
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4" />
{t("admin.adminManagement")}
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">
{t("admin.userRegistration")}
</h3>
<label className="flex items-center gap-2">
<Checkbox
checked={allowRegistration}
onCheckedChange={handleToggleRegistration}
disabled={regLoading}
/>
{t("admin.allowNewAccountRegistration")}
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-3">
<h3 className="text-lg font-semibold">
{t("admin.externalAuthentication")}
</h3>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t("admin.configureExternalProvider")}
</p>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
onClick={() =>
window.open("https://docs.termix.site/oidc", "_blank")
}
>
{t("common.documentation")}
</Button>
</div>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">{t("admin.clientId")}</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) =>
handleOIDCConfigChange("client_id", e.target.value)
}
placeholder={t("placeholders.clientId")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">
{t("admin.clientSecret")}
</Label>
<PasswordInput
id="client_secret"
value={oidcConfig.client_secret}
onChange={(e) =>
handleOIDCConfigChange("client_secret", e.target.value)
}
placeholder={t("placeholders.clientSecret")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">
{t("admin.authorizationUrl")}
</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) =>
handleOIDCConfigChange(
"authorization_url",
e.target.value,
)
}
placeholder={t("placeholders.authUrl")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">{t("admin.issuerUrl")}</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) =>
handleOIDCConfigChange("issuer_url", e.target.value)
}
placeholder={t("placeholders.redirectUrl")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">{t("admin.tokenUrl")}</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) =>
handleOIDCConfigChange("token_url", e.target.value)
}
placeholder={t("placeholders.tokenUrl")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">
{t("admin.userIdentifierPath")}
</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) =>
handleOIDCConfigChange(
"identifier_path",
e.target.value,
)
}
placeholder={t("placeholders.userIdField")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">
{t("admin.displayNamePath")}
</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) =>
handleOIDCConfigChange("name_path", e.target.value)
}
placeholder={t("placeholders.usernameField")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">{t("admin.scopes")}</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) =>
handleOIDCConfigChange("scopes", e.target.value)
}
placeholder={t("placeholders.scopes")}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="userinfo_url">
{t("admin.overrideUserInfoUrl")}
</Label>
<Input
id="userinfo_url"
value={oidcConfig.userinfo_url}
onChange={(e) =>
handleOIDCConfigChange("userinfo_url", e.target.value)
}
placeholder="https://your-provider.com/application/o/userinfo/"
/>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading
? t("admin.saving")
: t("admin.saveConfiguration")}
</Button>
<Button
type="button"
variant="outline"
onClick={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: any) {
setOidcError(
err?.response?.data?.error ||
t("admin.failedToDisableOidcConfig"),
);
} finally {
setOidcLoading(false);
}
}}
disabled={oidcLoading}
>
{t("admin.reset")}
</Button>
</div>
</form>
</div>
</TabsContent>
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{t("admin.userManagement")}
</h3>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? t("admin.loading") : t("admin.refresh")}
</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
{t("admin.loadingUsers")}
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">
{t("admin.username")}
</TableHead>
<TableHead className="px-4">
{t("admin.type")}
</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
)}
</TableCell>
<TableCell className="px-4">
{user.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" />
</Button>
</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">
{t("admin.adminManagement")}
</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">{t("admin.makeUserAdmin")}</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">
{t("admin.username")}
</Label>
<div className="flex gap-2">
<Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder={t("admin.enterUsernameToMakeAdmin")}
required
/>
<Button
type="submit"
disabled={
makeAdminLoading || !newAdminUsername.trim()
}
>
{makeAdminLoading
? t("admin.adding")
: t("admin.makeAdmin")}
</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>{t("common.error")}</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
</form>
</div>
<div className="space-y-4">
<h4 className="font-medium">{t("admin.currentAdmins")}</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">
{t("admin.username")}
</TableHead>
<TableHead className="px-4">
{t("admin.type")}
</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users
.filter((u) => u.is_admin)
.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")}
</span>
</TableCell>
<TableCell className="px-4">
{admin.is_oidc
? t("admin.external")
: t("admin.local")}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRemoveAdminStatus(admin.username)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
>
<Shield className="h-4 w-4" />
{t("admin.removeAdminButton")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
export default AdminSettings;

View File

@@ -0,0 +1,849 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
createCredential,
updateCredential,
getCredentials,
getCredentialDetails,
} from "@/ui/main-axios";
import { useTranslation } from "react-i18next";
import type {
Credential,
CredentialEditorProps,
CredentialData,
} from "../../../../types/index.js";
export function CredentialEditor({
editingCredential,
onFormSubmit,
}: CredentialEditorProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [fullCredentialDetails, setFullCredentialDetails] =
useState<Credential | null>(null);
const [authTab, setAuthTab] = useState<"password" | "key">("password");
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
"upload",
);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const credentialsData = await getCredentials();
setCredentials(credentialsData);
const uniqueFolders = [
...new Set(
credentialsData
.filter(
(credential) =>
credential.folder && credential.folder.trim() !== "",
)
.map((credential) => credential.folder!),
),
].sort() as string[];
setFolders(uniqueFolders);
} catch (error) {
} finally {
setLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
const fetchCredentialDetails = async () => {
if (editingCredential) {
try {
const fullDetails = await getCredentialDetails(editingCredential.id);
setFullCredentialDetails(fullDetails);
} catch (error) {
toast.error(t("credentials.failedToFetchCredentialDetails"));
}
} else {
setFullCredentialDetails(null);
}
};
fetchCredentialDetails();
}, [editingCredential, t]);
const formSchema = z
.object({
name: z.string().min(1),
description: z.string().optional(),
folder: z.string().optional(),
tags: z.array(z.string().min(1)).default([]),
authType: z.enum(["password", "key"]),
username: z.string().min(1),
password: z.string().optional(),
key: z.any().optional().nullable(),
keyPassword: z.string().optional(),
keyType: z
.enum([
"auto",
"ssh-rsa",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-dss",
"ssh-rsa-sha2-256",
"ssh-rsa-sha2-512",
])
.optional(),
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
if (!data.password || data.password.trim() === "") {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("credentials.passwordRequired"),
path: ["password"],
});
}
} else if (data.authType === "key") {
if (!data.key && !editingCredential) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("credentials.sshKeyRequired"),
path: ["key"],
});
}
}
});
type FormData = z.infer<typeof formSchema>;
const form = useForm<FormData>({
resolver: zodResolver(formSchema) as any,
defaultValues: {
name: "",
description: "",
folder: "",
tags: [],
authType: "password",
username: "",
password: "",
key: null,
keyPassword: "",
keyType: "auto",
},
});
useEffect(() => {
if (editingCredential && fullCredentialDetails) {
const defaultAuthType = fullCredentialDetails.authType;
setAuthTab(defaultAuthType);
setTimeout(() => {
const formData = {
name: fullCredentialDetails.name || "",
description: fullCredentialDetails.description || "",
folder: fullCredentialDetails.folder || "",
tags: fullCredentialDetails.tags || [],
authType: defaultAuthType as "password" | "key",
username: fullCredentialDetails.username || "",
password: "",
key: null,
keyPassword: "",
keyType: "auto" as const,
};
if (defaultAuthType === "password") {
formData.password = fullCredentialDetails.password || "";
} else if (defaultAuthType === "key") {
formData.key = "existing_key";
formData.keyPassword = fullCredentialDetails.keyPassword || "";
formData.keyType =
(fullCredentialDetails.keyType as any) || ("auto" as const);
}
form.reset(formData);
setTagInput("");
}, 100);
} else if (!editingCredential) {
setAuthTab("password");
form.reset({
name: "",
description: "",
folder: "",
tags: [],
authType: "password",
username: "",
password: "",
key: null,
keyPassword: "",
keyType: "auto",
});
setTagInput("");
}
}, [editingCredential?.id, fullCredentialDetails, form]);
const onSubmit = async (data: FormData) => {
try {
if (!data.name || data.name.trim() === "") {
data.name = data.username;
}
const submitData: CredentialData = {
name: data.name,
description: data.description,
folder: data.folder,
tags: data.tags,
authType: data.authType,
username: data.username,
keyType: data.keyType,
};
submitData.password = null;
submitData.key = null;
submitData.keyPassword = null;
submitData.keyType = null;
if (data.authType === "password") {
submitData.password = data.password;
} else if (data.authType === "key") {
if (data.key instanceof File) {
const keyContent = await data.key.text();
submitData.key = keyContent;
} else if (data.key === "existing_key") {
delete submitData.key;
} else {
submitData.key = data.key;
}
submitData.keyPassword = data.keyPassword;
submitData.keyType = data.keyType;
}
if (editingCredential) {
await updateCredential(editingCredential.id, submitData);
toast.success(
t("credentials.credentialUpdatedSuccessfully", { name: data.name }),
);
} else {
await createCredential(submitData);
toast.success(
t("credentials.credentialAddedSuccessfully", { name: data.name }),
);
}
if (onFormSubmit) {
onFormSubmit();
}
window.dispatchEvent(new CustomEvent("credentials:changed"));
form.reset();
} catch (error) {
toast.error(t("credentials.failedToSaveCredential"));
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const folderDropdownRef = useRef<HTMLDivElement>(null);
const folderValue = form.watch("folder");
const filteredFolders = React.useMemo(() => {
if (!folderValue) return folders;
return folders.filter((f) =>
f.toLowerCase().includes(folderValue.toLowerCase()),
);
}, [folderValue, folders]);
const handleFolderClick = (folder: string) => {
form.setValue("folder", folder);
setFolderDropdownOpen(false);
};
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
folderDropdownRef.current &&
!folderDropdownRef.current.contains(event.target as Node) &&
folderInputRef.current &&
!folderInputRef.current.contains(event.target as Node)
) {
setFolderDropdownOpen(false);
}
}
if (folderDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [folderDropdownOpen]);
const keyTypeOptions = [
{ value: "auto", label: t("hosts.autoDetect") },
{ value: "ssh-rsa", label: t("hosts.rsa") },
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
{ value: "ssh-dss", label: t("hosts.dsa") },
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(event: MouseEvent) {
if (
keyTypeDropdownOpen &&
keyTypeDropdownRef.current &&
!keyTypeDropdownRef.current.contains(event.target as Node) &&
keyTypeButtonRef.current &&
!keyTypeButtonRef.current.contains(event.target as Node)
) {
setKeyTypeDropdownOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [keyTypeDropdownOpen]);
return (
<div
className="flex-1 flex flex-col h-full min-h-0 w-full"
key={editingCredential?.id || "new"}
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">
{t("credentials.general")}
</TabsTrigger>
<TabsTrigger value="authentication">
{t("credentials.authentication")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">
{t("credentials.basicInformation")}
</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.credentialName")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.credentialName")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem className="col-span-6">
<FormLabel>{t("credentials.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
{t("credentials.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-4">
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("credentials.description")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.description")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="folder"
render={({ field }) => (
<FormItem className="col-span-10 relative">
<FormLabel>{t("credentials.folder")}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder={t("placeholders.folder")}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
onFocus={() => setFolderDropdownOpen(true)}
onChange={(e) => {
field.onChange(e);
setFolderDropdownOpen(true);
}}
/>
</FormControl>
{folderDropdownOpen && filteredFolders.length > 0 && (
<div
ref={folderDropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{filteredFolders.map((folder) => (
<Button
key={folder}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => handleFolderClick(folder)}
>
{folder}
</Button>
))}
</div>
</div>
)}
</FormItem>
)}
/>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>{t("credentials.tags")}</FormLabel>
<FormControl>
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-dark-bg-input focus-within:ring-2 ring-ring min-h-[40px]">
{(field.value || []).map(
(tag: string, idx: number) => (
<span
key={`${tag}-${idx}`}
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const newTags = (
field.value || []
).filter(
(_: string, i: number) => i !== idx,
);
field.onChange(newTags);
}}
>
×
</button>
</span>
),
)}
<input
type="text"
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === " " && tagInput.trim() !== "") {
e.preventDefault();
const currentTags = field.value || [];
if (!currentTags.includes(tagInput.trim())) {
field.onChange([
...currentTags,
tagInput.trim(),
]);
}
setTagInput("");
} else if (
e.key === "Enter" &&
tagInput.trim() !== ""
) {
e.preventDefault();
const currentTags = field.value || [];
if (!currentTags.includes(tagInput.trim())) {
field.onChange([
...currentTags,
tagInput.trim(),
]);
}
setTagInput("");
} else if (
e.key === "Backspace" &&
tagInput === "" &&
(field.value || []).length > 0
) {
const currentTags = field.value || [];
field.onChange(currentTags.slice(0, -1));
}
}}
placeholder={t("credentials.addTagsSpaceToAdd")}
/>
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="authentication">
<FormLabel className="mb-3 font-bold">
{t("credentials.authentication")}
</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
const newAuthType = value as "password" | "key";
setAuthTab(newAuthType);
form.setValue("authType", newAuthType);
form.setValue("password", "");
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"
>
<TabsList>
<TabsTrigger value="password">
{t("credentials.password")}
</TabsTrigger>
<TabsTrigger value="key">
{t("credentials.key")}
</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("credentials.password")}</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.password")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="key">
<Tabs
value={keyInputMethod}
onValueChange={(value) => {
setKeyInputMethod(value as "upload" | "paste");
if (value === "upload") {
form.setValue("key", null);
} else {
form.setValue("key", "");
}
}}
className="w-full"
>
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
<TabsTrigger value="upload">
{t("hosts.uploadFile")}
</TabsTrigger>
<TabsTrigger value="paste">
{t("hosts.pasteKey")}
</TabsTrigger>
</TabsList>
<TabsContent value="upload" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl>
<div className="relative inline-block">
<input
id="key-upload"
type="file"
accept=".pem,.key,.txt,.ppk"
onChange={(e) => {
const file = e.target.files?.[0];
field.onChange(file || null);
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="justify-start text-left"
>
<span
className="truncate"
title={
field.value?.name ||
t("credentials.upload")
}
>
{field.value === "existing_key"
? t("hosts.existingKey")
: field.value
? editingCredential
? t("credentials.updateKey")
: field.value.name
: t("credentials.upload")}
</span>
</Button>
</div>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
>
{keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
<TabsContent value="paste" className="mt-4">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4">
<FormLabel>
{t("credentials.sshPrivateKey")}
</FormLabel>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) =>
field.onChange(e.target.value)
}
/>
</FormControl>
</FormItem>
)}
/>
<div className="grid grid-cols-15 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="keyType"
render={({ field }) => (
<FormItem className="relative col-span-3">
<FormLabel>
{t("credentials.keyType")}
</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={keyTypeButtonRef}
type="button"
variant="outline"
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
onClick={() =>
setKeyTypeDropdownOpen((open) => !open)
}
>
{keyTypeOptions.find(
(opt) => opt.value === field.value,
)?.label || t("credentials.keyTypeRSA")}
</Button>
{keyTypeDropdownOpen && (
<div
ref={keyTypeDropdownRef}
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
>
<div className="grid grid-cols-1 gap-1 p-0">
{keyTypeOptions.map((opt) => (
<Button
key={opt.value}
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
onClick={() => {
field.onChange(opt.value);
setKeyTypeDropdownOpen(false);
}}
>
{opt.label}
</Button>
))}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</TabsContent>
</Tabs>
</ScrollArea>
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingCredential
? t("credentials.updateCredential")
: t("credentials.addCredential")}
</Button>
</footer>
</form>
</Form>
</div>
);
}

View File

@@ -0,0 +1,226 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { Credential } from "../../../../types";
interface CredentialSelectorProps {
value?: number | null;
onValueChange: (credentialId: number | null) => void;
onCredentialSelect?: (credential: Credential | null) => void;
}
export function CredentialSelector({
value,
onValueChange,
onCredentialSelect,
}: CredentialSelectorProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchCredentials = async () => {
try {
setLoading(true);
const data = await getCredentials();
const credentialsArray = Array.isArray(data)
? data
: data.credentials || data.data || [];
setCredentials(credentialsArray);
} catch (error) {
const { toast } = await import("sonner");
toast.error(t("credentials.failedToFetchCredentials"));
setCredentials([]);
} finally {
setLoading(false);
}
};
fetchCredentials();
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
const selectedCredential = credentials.find((c) => c.id === value);
const filteredCredentials = credentials.filter((credential) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
credential.name.toLowerCase().includes(searchLower) ||
credential.username.toLowerCase().includes(searchLower) ||
(credential.folder &&
credential.folder.toLowerCase().includes(searchLower))
);
});
const handleCredentialSelect = (credential: Credential) => {
onValueChange(credential.id);
if (onCredentialSelect) {
onCredentialSelect(credential);
}
setDropdownOpen(false);
setSearchQuery("");
};
const handleClear = () => {
onValueChange(null);
if (onCredentialSelect) {
onCredentialSelect(null);
}
setDropdownOpen(false);
setSearchQuery("");
};
return (
<FormItem>
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={buttonRef}
type="button"
variant="outline"
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{loading ? (
t("common.loading")
) : value === "existing_credential" ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">
{t("hosts.existingCredential")}
</span>
</div>
</div>
) : selectedCredential ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">{selectedCredential.name}</span>
<span className="text-sm text-muted-foreground ml-2">
({selectedCredential.username} {" "}
{selectedCredential.authType})
</span>
</div>
</div>
) : (
t("hosts.selectCredentialPlaceholder")
)}
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{dropdownOpen && (
<div
ref={dropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
>
<div className="p-2 border-b border-border">
<Input
placeholder={t("credentials.searchCredentials")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-60 overflow-y-auto p-2">
{loading ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : filteredCredentials.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{searchQuery
? t("credentials.noCredentialsMatchFilters")
: t("credentials.noCredentialsYet")}
</div>
) : (
<div className="grid grid-cols-1 gap-2.5">
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
onClick={handleClear}
>
{t("common.clear")}
</Button>
)}
{filteredCredentials.map((credential) => (
<Button
key={credential.id}
type="button"
variant="ghost"
size="sm"
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
credential.id === value ? "bg-muted" : ""
}`}
onClick={() => handleCredentialSelect(credential)}
>
<div className="w-full">
<div className="flex items-center justify-between">
<span className="font-medium">
{credential.name}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{credential.username} {credential.authType}
{credential.description &&
`${credential.description}`}
</div>
</div>
</Button>
))}
</div>
)}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
);
}

View File

@@ -0,0 +1,533 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Key,
User,
Calendar,
Hash,
Folder,
Edit3,
Copy,
Shield,
Clock,
Server,
Eye,
EyeOff,
AlertTriangle,
CheckCircle,
FileText,
} from "lucide-react";
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type {
Credential,
HostInfo,
CredentialViewerProps,
} from "../../../types/index.js";
const CredentialViewer: React.FC<CredentialViewerProps> = ({
credential,
onClose,
onEdit,
}) => {
const { t } = useTranslation();
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
null,
);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true);
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
{},
);
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
"overview",
);
useEffect(() => {
fetchCredentialDetails();
fetchHostsUsing();
}, [credential.id]);
const fetchCredentialDetails = async () => {
try {
const response = await getCredentialDetails(credential.id);
setCredentialDetails(response);
} catch (error) {
toast.error(t("credentials.failedToFetchCredentialDetails"));
}
};
const fetchHostsUsing = async () => {
try {
const response = await getCredentialHosts(credential.id);
setHostsUsing(response);
} catch (error) {
toast.error(t("credentials.failedToFetchHostsUsing"));
} finally {
setLoading(false);
}
};
const toggleSensitiveVisibility = (field: string) => {
setShowSensitive((prev) => ({
...prev,
[field]: !prev[field],
}));
};
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t("copiedToClipboard", { field: fieldName }));
} catch (error) {
toast.error(t("credentials.failedToCopy"));
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getAuthIcon = (authType: string) => {
return authType === "password" ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
);
};
const renderSensitiveField = (
value: string | undefined,
fieldName: string,
label: string,
isMultiline = false,
) => {
if (!value) return null;
const isVisible = showSensitive[fieldName];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
{label}
</label>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSensitiveVisibility(fieldName)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(value, label)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
>
{isVisible ? (
<pre
className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
>
{value}
</pre>
) : (
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{"•".repeat(isMultiline ? 50 : 20)}
</div>
)}
</div>
</div>
);
};
if (loading || !credentialDetails) {
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw]">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
</div>
</SheetContent>
</Sheet>
);
}
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
<SheetHeader className="space-y-6 pb-8">
<SheetTitle className="flex items-center space-x-4">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
{getAuthIcon(credentialDetails.authType)}
</div>
<div className="flex-1">
<div className="text-xl font-semibold">
{credentialDetails.name}
</div>
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
{credentialDetails.description}
</div>
</div>
<div className="flex items-center space-x-2">
<Badge
variant="outline"
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
>
{credentialDetails.authType}
</Badge>
{credentialDetails.keyType && (
<Badge
variant="secondary"
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
>
{credentialDetails.keyType}
</Badge>
)}
</div>
</SheetTitle>
</SheetHeader>
<div className="space-y-10">
{/* Tab Navigation */}
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<Button
variant={activeTab === "overview" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("overview")}
className="flex-1 h-10"
>
<FileText className="h-4 w-4 mr-2" />
{t("credentials.overview")}
</Button>
<Button
variant={activeTab === "security" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("security")}
className="flex-1 h-10"
>
<Shield className="h-4 w-4 mr-2" />
{t("credentials.security")}
</Button>
<Button
variant={activeTab === "usage" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("usage")}
className="flex-1 h-10"
>
<Server className="h-4 w-4 mr-2" />
{t("credentials.usage")}
</Button>
</div>
{/* Tab Content */}
{activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700">
<CardHeader className="pb-8">
<CardTitle className="text-lg font-semibold">
{t("credentials.basicInformation")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<div className="flex items-center space-x-5">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("common.username")}
</div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{credentialDetails.username}
</div>
</div>
</div>
{credentialDetails.folder && (
<div className="flex items-center space-x-4">
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("common.folder")}
</div>
<div className="font-medium">
{credentialDetails.folder}
</div>
</div>
</div>
)}
{credentialDetails.tags.length > 0 && (
<div className="flex items-start space-x-4">
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
<div className="flex-1">
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
{t("hosts.tags")}
</div>
<div className="flex flex-wrap gap-2">
{credentialDetails.tags.map((tag, index) => (
<Badge
key={index}
variant="outline"
className="text-xs"
>
{tag}
</Badge>
))}
</div>
</div>
</div>
)}
<Separator />
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.created")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.createdAt)}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.lastModified")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.updatedAt)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t("credentials.usageStatistics")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
{credentialDetails.usageCount}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{t("credentials.timesUsed")}
</div>
</div>
{credentialDetails.lastUsed && (
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.lastUsed")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.lastUsed)}
</div>
</div>
</div>
)}
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.connectedHosts")}
</div>
<div className="font-medium">{hostsUsing.length}</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === "security" && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t("credentials.securityDetails")}</span>
</CardTitle>
<CardDescription>
{t("credentials.securityDetailsDescription")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
<div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{t("credentials.credentialSecured")}
</div>
<div className="text-sm text-zinc-700 dark:text-zinc-300">
{t("credentials.credentialSecuredDescription")}
</div>
</div>
</div>
{credentialDetails.authType === "password" && (
<div>
<h3 className="font-semibold mb-4">
{t("credentials.passwordAuthentication")}
</h3>
{renderSensitiveField(
credentialDetails.password,
"password",
t("common.password"),
)}
</div>
)}
{credentialDetails.authType === "key" && (
<div className="space-y-6">
<h3 className="font-semibold mb-2">
{t("credentials.keyAuthentication")}
</h3>
<div className="grid gap-6 md:grid-cols-2">
<div>
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
{t("credentials.keyType")}
</div>
<Badge variant="outline" className="text-sm">
{credentialDetails.keyType?.toUpperCase() ||
t("unknown").toUpperCase()}
</Badge>
</div>
</div>
{renderSensitiveField(
credentialDetails.key,
"key",
t("credentials.privateKey"),
true,
)}
{credentialDetails.keyPassword &&
renderSensitiveField(
credentialDetails.keyPassword,
"keyPassword",
t("credentials.keyPassphrase"),
)}
</div>
)}
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
<div className="text-sm">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
{t("credentials.securityReminder")}
</div>
<div className="text-zinc-700 dark:text-zinc-300">
{t("credentials.securityReminderText")}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === "usage" && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t("credentials.hostsUsingCredential")}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{hostsUsing.length === 0 ? (
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
<p>{t("credentials.noHostsUsingCredential")}</p>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-3">
{hostsUsing.map((host) => (
<div
key={host.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
</div>
<div>
<div className="font-medium">
{host.name || `${host.ip}:${host.port}`}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{host.ip}:{host.port}
</div>
</div>
</div>
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
{formatDate(host.createdAt)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)}
</div>
<SheetFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={onEdit}>
<Edit3 className="h-4 w-4 mr-2" />
{t("credentials.editCredential")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default CredentialViewer;

Some files were not shown because too many files have changed in this diff Show More