* feat: enhance terminal theme preview and persistence (#637)

- Refactor Terminal.tsx to optimize theme update logic and eliminate flashes
- Implement localStorage persistence for terminal themes per host
- Fix hover preview to redraw buffer content instantly
- Ensure theme preferences survive cookie clears

Co-authored-by: Gemini CLI <gemini@cli.local>

* fix: update darwin platform identifier to osx (#626)

* feat: implement SSH algorithms mapping and refactor cipher usage across SSH modules (#627)

* feat: enhance WebSocket connection handling for embedded mode (#628)

* fix(auth): pass JWT token via URL param for Electron/mobile OIDC callback (#630)

The OIDC callback redirect did not include the JWT token as a URL
parameter for desktop/mobile device types, causing Electron and
React Native webviews to have jwt = undefined after login.

Closes Termix-SSH/Support#562

* fix: remove hardcoded version number from dashboard (#632)

The version text was initialized to "v1.8.0" which displayed incorrect
version on the dashboard before the API response. Changed to empty
string so it shows nothing until the real version is fetched.

Closes Termix-SSH/Support#550

* fix: admin role toggle showing incorrect state after update (#633)

After successfully toggling admin status, onSuccess() closes the dialog
and clears the user reference, but onOpenChange(true) then reopens the
dialog with null user, causing isAdmin state to not sync properly.

Removed the redundant dialog reopen after success - let onSuccess
handle the cleanup normally.

Closes Termix-SSH/Support#549

* fix: allow disabling password login when OIDC is configured via env vars (#634)

The admin OIDC config endpoint only checked the database for OIDC
configuration, ignoring environment variables. This caused the frontend
to incorrectly block disabling password login when OIDC was configured
via OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, etc.

Now falls back to getOIDCConfigFromEnv() when no database config exists,
matching the behavior of the public /oidc-config endpoint.

Closes Termix-SSH/Support#561

* fix: sync snippet selected terminals count when tabs are closed (#635)

selectedSnippetTabIds was not cleaned up when terminal tabs were closed,
causing the snippet dialog to show stale terminal count. Added useEffect
to filter out IDs of closed tabs.

Closes Termix-SSH/Support#534

* feature: toggle history globally (#636)

* Fix RDP audio output and dynamic session resize (#625)

Co-authored-by: AllX <contact@alexmaftei.com>

* fix: check connection state before fallback exec in file manager (#644)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: check connection state before fallback exec in file manager

When SFTP operations fail and tryFallbackMethod is called, the SSH
client may already be disconnected. Calling client.exec() on a
disconnected client throws an unhandled exception that crashes the
backend process.

Added connection state check at the start of all three
tryFallbackMethod closures (listFiles, writeFile, uploadFile).

Closes Termix-SSH/Support#451

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: restrict postMessage targetOrigin to prevent JWT leakage (#645)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: restrict postMessage targetOrigin to prevent JWT leakage

Multiple postMessage calls used wildcard "*" as targetOrigin, allowing
any parent window to intercept JWT tokens if Termix is embedded in an
iframe.

Changes:
- main-axios.ts: Only send postMessage in Electron iframe context
  (added isElectron() check), use window.location.origin as target
- Auth.tsx: Replace "*" with window.location.origin for all three
  AUTH_SUCCESS postMessage calls (already gated by isInElectronWebView)
- ElectronLoginForm.tsx: Use server URL origin for iframe postMessage,
  fall back to "*" only if origin parsing fails

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: stats monitoring resolves SSH key from credential privateKey field (#643)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: stats monitoring resolves SSH key from credential privateKey field

When loading credentials for status monitoring, only the `key` field
was checked but not `privateKey`. The ssh_credentials table has both
fields and some credentials store the key in `privateKey`. This caused
stats polling to fail with auth errors for key-based credentials.

Closes Termix-SSH/Support#429

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* feat: add collapse/expand all button for host manager folders (#642)

* Update sha256 value for v2.0.0 universal dmg (#629)

* feat: add collapse/expand all button for host manager folders

All folders were always auto-expanded with no way to collapse them all
at once. Added a toggle button in the toolbar that collapses or expands
all folder accordions. Icon switches between ChevronsDownUp (collapse)
and ChevronsUpDown (expand) to indicate current action.

Closes Termix-SSH/Support#488

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: prevent invalid SSH key from crashing stats polling loop (#640)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: prevent invalid SSH key from crashing stats polling loop

Two fixes:
1. Add .catch() to pollHostMetrics() call inside setInterval to prevent
   unhandled promise rejections from crashing the process
2. Add "Invalid SSH key format" to the auth failure error patterns in
   collectMetrics so it's properly tracked instead of re-thrown

Closes Termix-SSH/Support#478

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: revoke all sessions when password is changed or reset (#647)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: revoke all sessions when password is changed or reset

logoutUser() without sessionId only cleared in-memory crypto state
but did not delete session records from the database. This meant
old JWT tokens remained valid after password change/reset.

Now deletes all session records for the user when no specific
sessionId is provided, which is the code path used by password
reset and password change handlers.

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: isolate RDP keyboard input to active tab (#663)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: disable RDP keyboard input when tab is not visible

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* Fix clipboard paste browser popup (#667)

* feat: switch to adjacent tab when closing current tab (#661)

* Update sha256 value for v2.0.0 universal dmg (#629)

* feat: switch to adjacent tab when closing current tab

Previously closing the current tab always switched to the first
remaining tab (often Dashboard). Now switches to the adjacent tab —
the next one in order, or the previous if the closed tab was last.

This matches the tab-close behavior of browsers and IDEs.

Closes Termix-SSH/Support#606

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: add auth token to database export/import in Electron app (#664)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: add Bearer token to database export/import requests in Electron

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* Fix WebSocket reconnection and add connection lost overlay (#668)

* fix: skip metrics collection for hosts with authType none or opkssh (#639)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: skip metrics collection for hosts with authType none or opkssh

supportsMetrics() only checked connectionType but ignored authType.
Hosts configured with Authentication: None (e.g. Tailscale SSH) or
opkssh would trigger SSH metrics polling, causing repeated auth
failures since no credentials are available.

Closes Termix-SSH/Support#515

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: align cookie maxAge with JWT expiration to prevent early logout (#658)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: align cookie maxAge with JWT expiration to prevent early logout

The JWT cookie maxAge for regular (non-rememberMe) logins was set to
2 hours, while the JWT token itself was valid for 24 hours. After 2
hours the cookie expired and the user was logged out, even during
active SSH sessions.

Changed cookie maxAge from 2h to 24h for regular logins to match
the JWT expiration. Affects both password login and OIDC login paths.

Closes Termix-SSH/Support#595
Closes Termix-SSH/Support#583

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: remove sensitive data from log output (#649)

- Password reset: stop logging the 6-digit reset code in plaintext.
  The code is still stored in the settings table for retrieval.
- Password reset: return identical response for non-existent users
  and OIDC users to prevent username enumeration.
- OPKSSH callback: remove URL, query params, and forwarded headers
  from log output to prevent token/code leakage.

* feat: display file owner and group in file manager list view (#654)

* Update sha256 value for v2.0.0 universal dmg (#629)

* feat: display file owner and group in file manager list view

Added an Owner column to the file manager list view showing owner:group
for each file. The data was already returned by the backend (SFTP
returns uid/gid, ls fallback returns usernames) but not displayed.

Column is hidden on small screens (md:block) to avoid crowding.

Closes Termix-SSH/Support#603

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: prevent file manager from showing stale directory contents (#655)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: prevent file manager from showing stale directory contents

Two issues caused the file browser to get stuck showing outdated
directory contents after folder operations:

1. handleRefreshDirectory could be blocked by a lingering isLoading
   state from a previous request. Now force-resets loading state
   before initiating the refresh.

2. debouncedLoadDirectory skipped requests when the path hadn't
   changed (path === lastPathChangeRef), but after create/move/delete
   operations the path stays the same while contents change. Added
   force parameter to bypass the path equality check.

Closes Termix-SSH/Support#599

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: fallback to default layout when dashboard preferences lack cards (#652)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: fallback to default layout when dashboard preferences lack cards

getDashboardPreferences may return null, empty object, or an object
without a cards array (e.g. when behind a reverse proxy that alters
the response, or on first load for a new user). This caused
layout.cards.filter() to throw, leaving the dashboard as a black
screen after login.

Now validates that the response has a cards array before using it,
falling back to DEFAULT_LAYOUT otherwise.

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: bind SSH sessions to userId and verify ownership on access (#650)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: bind SSH sessions to userId and verify ownership on access

SSHSession objects in file-manager and docker did not store userId,
allowing any authenticated user to operate on another user's session
if they knew the sessionId.

Changes:
- Added userId field to SSHSession interface in both modules
- Store userId when creating sessions (connect, TOTP, Warpgate paths)
- Added verifySessionOwnership() helper in file-manager
- Applied ownership checks to sudo-password, status, keepalive,
  listFiles endpoints in file-manager
- Applied ownership check to keepalive endpoint in docker
- Session creation in docker now stores userId in all 3 paths

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: use cookie-based auth for WebSocket instead of URL token (#646)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: use cookie-based auth for WebSocket instead of URL token

JWT tokens in WebSocket URL query strings are exposed in nginx access
logs, browser history, and proxy logs.

Backend: terminal and docker-console WebSocket servers now read JWT
from the cookie header as fallback when no URL token is provided.

Frontend: desktop terminal and docker console no longer append token
to WebSocket URL, relying on cookies sent automatically by the browser.

Mobile and Guacamole WebSocket connections are unchanged as they may
not have cookie access.

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: prevent long Docker container names from overflowing card bounds (#653)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: prevent long Docker container names from overflowing card bounds

Container names were not constrained to the card width, causing long
names to overlay adjacent container cards. Added overflow-hidden and
min-w-0 to the Card root element so the existing truncate class on
CardTitle takes effect within the grid layout.

Closes Termix-SSH/Support#601

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: preserve original timestamps in SSH login statistics (#657)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: preserve original timestamps in SSH login statistics

Failed login attempts showed the current time instead of the actual
attempt time. Two issues:

1. auth.log dates (e.g. "Mar 15 10:23:45") were parsed with a format
   that could fail on some platforms, falling back to new Date() which
   gives the current time. Changed to a more reliable format
   ("Mar 15, 2026 10:23:45") and fall back to the raw string instead
   of the current time.

2. Dates from previous years (e.g. December logs viewed in January)
   were assigned the current year, producing future dates. Now checks
   if the parsed date is in the future and subtracts a year.

Also fixed the same fallback issue for successful login timestamps.

Closes Termix-SSH/Support#570

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: remove plaintext credentials from internal host API responses (#651)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: remove plaintext credentials from internal host API responses

/db/host/internal and /db/host/internal/all returned password, key,
keyPassword, and autostart credentials in plaintext, protected only
by a static INTERNAL_AUTH_TOKEN. If the token leaked, all SSH
credentials would be exposed.

Changes:
- Stripped password, key, keyPassword, autostartPassword, autostartKey,
  autostartKeyPassword from both internal API responses
- Only return hostId, userId, and non-sensitive connection metadata
- Updated tunnel.ts endpoint resolution to use resolveHostById() for
  credentials instead of reading from HTTP response
- Autostart tunnel initialization no longer receives credentials from
  internal API, relying on server-side DB resolution at connect time

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: default keyType to auto instead of blocking host update (#641)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: default keyType to auto instead of blocking host update

When editing a host with key authentication, missing keyType value
caused form validation to fail silently, preventing the Update Host
button from saving changes. Now defaults keyType to "auto" instead
of raising a validation error.

Closes Termix-SSH/Support#510

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: downgrade credential migration errors to warnings (#659)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: downgrade credential migration errors to warnings

Credential migration failures during login (e.g. corrupted encrypted
data from older versions) were logged at ERROR level, causing alarm
in Docker logs. The migration is non-blocking — login succeeds
regardless — so these should be warnings.

Changed individual credential decryption failures and overall migration
failures from error to warn level. Also improved log messages to be
more descriptive.

Closes Termix-SSH/Support#541

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* feat: add Select All / Deselect All buttons for snippet terminal selection (#660)

* Update sha256 value for v2.0.0 universal dmg (#629)

* feat: add Select All / Deselect All buttons for snippet terminal selection

When running snippets on many terminals, users had to click each
terminal individually. Added Select All and Deselect All buttons
above the terminal list for batch selection.

Closes Termix-SSH/Support#535

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: admin user list not reading OIDC and admin status correctly (#665)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: align admin user list field names with API response (camelCase)

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: enable clipboard paste from host to RDP session (#666)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: sync host clipboard to RDP session on tab focus and mouse enter

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* fix: validate containerId and timestamp params to prevent command injection (#648)

* Update sha256 value for v2.0.0 universal dmg (#629)

* fix: validate containerId and timestamp params to prevent command injection

Docker API endpoints passed containerId, since, and until parameters
directly into shell commands via SSH exec without validation. An
authenticated user with Docker access could inject arbitrary shell
commands on the remote host.

Added Express param middleware to validate containerId against
^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ for all 9 endpoints. Also validate
since/until timestamps in the logs endpoint against a strict regex.

---------

Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>

* Merge commit from fork

* Merge commit from fork

Replace double-quoted shell string interpolation with single-quoted
escaping in extractArchive and compressFiles endpoints. Double quotes
allow $(command) substitution, enabling arbitrary command execution
on the remote SSH host via crafted archive paths or file names.

Now uses the same single-quote escaping pattern used by all other
file manager operations in this file.

* Merge commit from fork

CORS: Replace permissive origin checks (any http/https) across all 6
microservices with a shared cors-config module that only allows:
- Same-origin requests (derived from Host header)
- Configured origins via CORS_ALLOWED_ORIGINS env var
- Dev origins (localhost:5173)

Docker console: Validate containerId against ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$
and restrict shell to allowlist [bash, sh, ash, zsh] to prevent command
injection via WebSocket messages.

* Merge commit from fork

* refactor: add shared host-resolver for server-side credential resolution

Creates resolveHostById() utility that loads a host from DB and resolves
its credentials entirely server-side. This will be used by connection
modules to avoid receiving credentials from the frontend.

Also adds checkHostAccess() for permission validation.

* fix: strip sensitive credentials from host API responses

Remove password, key, keyPassword, sudoPassword, and other credential
fields from GET /db/host and GET /db/host/:id responses. Add boolean
indicators (hasPassword, hasKey, hasSudoPassword) so the frontend
knows capabilities without seeing actual values.

Add GET /db/host/:id/password endpoint for the copy-password feature
to fetch a specific password on demand.

* refactor: docker-console resolves credentials server-side by hostId

Instead of receiving the full hostConfig with credentials from the
frontend WebSocket message, docker-console now extracts hostId and
uses resolveHostById() to load credentials from the database.

Also validates containerId format and restricts shell to allowlist.

* feat: add getHostPassword API and update copy-password to use it

Add getHostPassword() frontend function that calls the new server-side
password endpoint instead of reading from the host object.

Update Tab component to use boolean indicators (hasPassword, hasKey,
hasSudoPassword) from the sanitized API response, with backward
compatibility for the old response format.

Add boolean indicator fields to Host type definition.

* refactor: file-manager resolves credentials server-side via host-resolver

When frontend doesn't provide password/sshKey (due to API stripping),
file-manager now uses resolveHostById() to load credentials from DB.
Falls back to provided credentials for backward compatibility.

* refactor: terminal resolves credentials server-side via host-resolver

When frontend doesn't provide password/key (due to API stripping),
terminal now uses resolveHostById() to load credentials from DB.
Preserves backward compatibility with reconnect_with_credentials
where user provides credentials interactively.

* refactor: tunnel resolves source credentials server-side via host-resolver

When frontend doesn't provide sourcePassword/sourceSSHKey (due to API
stripping), tunnel now uses resolveHostById() to load credentials from
DB for both the connect and cleanup paths.

* fix: terminal sudo auto-fill fetches password from server on demand

After credentials are stripped from API responses, hostConfig.password
is no longer available. Sudo auto-fill now checks boolean indicators
to show the prompt, then fetches the actual password via getHostPassword
API only when the user confirms the auto-fill action.

* fix: host editor fetches full credentials via export API for editing

After credentials are stripped from the host list API, the editor would
show empty password/key fields. Now uses exportSSHHostWithCredentials()
to fetch the full host data with credentials when opening the editor.
Applies to all paths: direct edit, sidebar click, and external navigation.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: update jsdoc comments for /host instead of /ssh

* fix: align OIDC login cookie maxAge with JWT expiration (2h → 24h) (#671)

* fix: persist OIDC JWT token to localStorage in Electron app (#672)

* fix: add error toast for empty file download and remove stray prop in tab bar (#674)

* fix: prevent server status failure from blocking host list loading (#673)

* fix: show server config dialog on first launch instead of auto-selecting embedded (#675)

* fix: remove unnecessary registration disabled toast on login page (#670)

* fix: add clipboard fallback and toast feedback for Copy Password button (#669)

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: allow file origin for packaged Electron desktop app (#676)

* Add AWS logo to README

* fix: allow file origin for packaged Electron desktop app

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: backend compliation errors

* feat: remove theme selector from nav bar

* fix: validate and fallback credentialId during JSON host bulk import (#677)

* Add AWS logo to README

* fix: validate and fallback credentialId during JSON host bulk import

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: await tunnel cleanup to prevent new connection from being killed (#678)

* Add AWS logo to README

* fix: await tunnel cleanup before creating new connection to prevent race condition

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: disable keyboard-interactive when host auth is set to None (#682)

* Add AWS logo to README

* fix: disable keyboard-interactive auth when host authType is none

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: use carriage return for mobile startup snippet execution (#680)

* Add AWS logo to README

* fix: use carriage return for mobile startup snippet execution

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: prevent file upload from crashing backend on permission denied (#681)

* Add AWS logo to README

* fix: add stderr error handlers and connection check to prevent upload crash

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: add missing stream error handlers in Docker console (#684)

* Add AWS logo to README

* fix: add missing stream error handlers in Docker console to prevent crashes

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: use carriage return for snippet execution to support PowerShell (#679)

* Add AWS logo to README

* fix: use carriage return instead of line feed for snippet and command execution

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: restrict remaining postMessage targetOrigin from wildcard to origin (#685)

* Add AWS logo to README

* fix: restrict postMessage targetOrigin to prevent JWT token leakage

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add SESSION_TIMEOUT_HOURS environment variable for customizable session duration (#662)

* feat: add SESSION_TIMEOUT_HOURS environment variable for session duration

Session timeout was hardcoded to 24h (JWT) and 2h (cookie). Now both
are configurable via SESSION_TIMEOUT_HOURS env var (default: 24).

Set in docker-compose.yml:
  environment:
    SESSION_TIMEOUT_HOURS: "72"

Also fixes the cookie maxAge mismatch (was 2h, now matches JWT).
Remember Me sessions remain at 30 days regardless of this setting.

Closes Termix-SSH/Support#609
Closes Termix-SSH/Support#595

* refactor: move session timeout from env var to Admin Settings

Replace SESSION_TIMEOUT_HOURS environment variable with a database-backed
setting configurable from Admin Settings UI. Default remains 24 hours.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add port knocking support for SSH connections (#694)

* Add AWS logo to README

* feat: add port knocking support for SSH connections

Send TCP/UDP knock packets to a configurable port sequence before
establishing SSH connections. Configured per-host in the host editor
under a new Port Knocking accordion section. Supports custom protocol
(TCP/UDP) and delay between knocks. Knocking failures don't block
the connection attempt.

Closes Termix-SSH/Support#524

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add Wake-on-LAN support for hosts (#696)

* Add AWS logo to README

* feat: add Wake-on-LAN support for hosts

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add export all hosts as JSON (#688)

* Add AWS logo to README

* feat: add export all hosts as JSON

Add GET /ssh/db/hosts/export endpoint and Export All button in the host
manager toolbar. Exported format is compatible with existing bulk import.
Includes sensitive data warning confirmation before download.

Closes Termix-SSH/Support#582

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add snippet sharing with users and roles (#691)

* Add AWS logo to README

* feat: add snippet sharing with users and roles

Add snippetAccess table and RBAC routes for sharing snippets, following
the same pattern as host sharing. Users can share snippets with other
users or roles via a share dialog. Shared snippets appear in a dedicated
section in the snippets sidebar as read-only with copy support.

Closes Termix-SSH/Support#474

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: auth errors and ws connection errors in dev env

* feat: add opt-in tmux integration for persistent terminal sessions (#683)

* Add backend tmux integration with native scrollback

Detect tmux on remote hosts via SSH exec channel, auto-attach or create
sessions with mouse mode, history-limit 50000, set-clipboard on, and
allow-passthrough on for native scrollback, OSC 52 clipboard sync, and
safe paste handling. Use && exit so the shell only closes if tmux
started successfully. Query session name after auto-creation.

* Add frontend tmux session handling and picker dialog

Desktop: handle tmux WebSocket messages, show session picker with window
count, attached clients, and last activity when multiple sessions exist.
Toast warning when Auto-tmux is enabled but tmux is missing on remote.
Mobile: auto-attach to first available session. All user-facing strings
are localized via i18n.

* Add Auto-tmux toggle in host settings and i18n strings

Per-host opt-in toggle following the existing autoMosh pattern.
English i18n strings for all tmux-related UI elements.

* Show a toast hint on first drag inside a tmux session

When the user drags the mouse inside a tmux-wrapped terminal, show a
localized toast ("Adjust selection and press Enter to copy") once per
tab session. Purely frontend so the hint is i18n-ready and doesn't
pollute the tmux status bar.

* chore: increment ver

* feat: add right-click context menu in terminal to open file manager (#695)

* Add AWS logo to README

* feat: add right-click context menu in terminal to open file manager

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <lukegustafson06@gmail.com>

* Fix/desktop guac connect flow (#687)

* fix: use direct guacamole websocket port in embedded electron mode

* Fix desktop remote token flow for redacted hosts

---------

Co-authored-by: LukeGus <lukegustafson06@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: allow file:// origin in shared cors middleware (#686)

Co-authored-by: LukeGus <lukegustafson06@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add command history toggle and sensitive command filtering (#693)

* Add AWS logo to README

* feat: add command history toggle and sensitive command filtering

Add on/off toggle for command history recording in User Profile settings.
Commands matching sensitive patterns (passwords, secrets, tokens, API keys)
are automatically filtered on both frontend and backend, never stored.

Closes Termix-SSH/Support#461

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <lukegustafson06@gmail.com>

* OPKSSH proxy, certificate auth, and inline provider selection (#692)

* fix: OPKSSH proxy integration for remote deployments

Migrate proxy routes from /ssh/ to /host/ prefix. Use session's
remote redirect URI for callback path instead of hardcoded
/login-callback. Add OAuth callback fallback for external browser
redirects with state parameter binding to prevent cross-session
mixup. Reject cookie-less callbacks that can't be identified.

* fix: implement OPKSSH certificate authentication for ssh2

Extract OPKSSH certificate auth into shared module that works
around ssh2's lack of native certificate support: grafts cert
blob onto parsed key, wraps ECDSA sign() for DER-to-SSH
conversion, and patches Protocol.authPK for correct algorithm.
Applied across terminal, file-manager, docker, and server-stats.
Removes legacy temp file approach in favor of in-memory keys.

* feat: inline OPKSSH provider selection in dialog

Parse OIDC provider aliases and issuers from config.yml using
js-yaml and send them to the frontend via the WebSocket message.
The dialog renders a "Sign in with {Provider}" button per provider,
opening the browser directly to the OAuth flow and skipping the
external chooser page. Falls back to the existing "Open in Browser"
behavior when providers aren't available.

---------

Co-authored-by: LukeGus <lukegustafson06@gmail.com>
Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: add configurable log level via Admin Settings (#690)

* Add AWS logo to README

* feat: add configurable log level via Admin Settings

Add log verbosity control (debug/info/warn/error) through Admin Settings
UI and LOG_LEVEL environment variable. Database setting takes precedence.
Changes take effect immediately without restart.

Closes Termix-SSH/Support#499

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <lukegustafson06@gmail.com>

* feat: add reconnect button for disconnected SSH sessions (#689)

* Add AWS logo to README

* feat: add reconnect button for disconnected SSH sessions

When an SSH connection drops, show a reconnect overlay instead of
closing the tab. Users can click Reconnect to re-establish the
connection or Close to dismiss. Also triggers after auto-reconnect
attempts are exhausted.

Closes Termix-SSH/Support#596
Closes Termix-SSH/Support#542
Closes Termix-SSH/Support#604

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <lukegustafson06@gmail.com>

* feat: fix port knocking and mac address not saving to backend

* fix: fix snippets table not being created

* fix: command history logic error, snippet sharing failing and improved UI for it

* Reset stale trust state when TOTP is enabled (#697)

* Add AWS logo to README

* Reset stale trust state when TOTP is enabled

Enablement now updates the user record, revokes existing sessions, clears trusted devices, and persists the result using the existing route flow. The change stays narrow and avoids introducing a one-off auth-manager wrapper or changing the save helper contract.

Constraint: Keep the change close to the existing auth route and avoid extra abstractions
Rejected: Keep the dedicated auth-manager helper | it was single-use and widened the surface area
Confidence: high
Scope-risk: narrow
Directive: If this behavior changes again, keep the reset logic at the route boundary unless another caller appears
Tested: tsc -p tsconfig.node.json --pretty false, git diff --check
Not-tested: full frontend build

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: version disabling in user profile not properly disabling and mac address not saving in host manager

* feat: improved reconnect ui for terminals

* feat: improve right click copy/paste

* fix: some themes not including all the needed colors

* chore: remove donate button

* fix: schema errors, password logic errors, sswapped line order in host.ts

* fix: cors being too strict

* fix: passphrase erorr and tmux error

* fix: guacd improvements, ui bugs, connection problems, etc

* fix: shrink image, opkssh fixes, desktop ui changes

* feat: dont require password to export and fixed export failures

* feat: opkssh fixes, guacamole ui fixes, update readme for release

* fix: tabs closing fast causiung no tab to be active and electron header persistance issue

* fix: guacd params getting malformed

* fix: desktop app header persistance

* fix: desktop app header persistance

* feat: desktop app not logging in

* feat: improve okpkssh implementation and fix redirect uri bug

* fix: opkssh redirect

* fix: backend hang (ongoing)

* fix: tunnels not being able to be saved

* fix: c2s networking stability (activity/log, metrics, status) (#701)

- /activity/log: the trim-over-100 path called SimpleDBOps.delete with a
  userId instead of a where clause and 500'd every call. Use inArray on
  the actual ids, best-effort (trim failures don't fail the log).

- /metrics/register-viewer: now a graceful 200 no-op when the host
  can't be found, metrics are disabled, or the connection type doesn't
  support metrics. Any internal error is reported as skipped instead
  of a 500, and the fire-and-forget startMetricsForHost can no longer
  leak an unhandled rejection.

- /metrics/🆔 treat 404 as "no metrics yet / disabled" rather than
  an error. Dashboard skips hosts known to be offline before asking
  for metrics.

- /status: retry with 2s/5s/8s timeouts and 3s/5s pauses (23s worst
  case, fits in the 30s poll cycle) before surfacing a network error;
  intermediate attempts stay silent.

- Replace the blocking "connection lost" overlay with a persistent,
  non-dismissible toast ("Unstable server connection, recovering…")
  carrying a Reload action. Users keep full access to the UI; if they
  try to connect to a host and it fails, that's on them. The toast
  clears to the usual "Server connection restored" success toast on
  the next healthy API response. The toast triggers on any of
  ERR_NETWORK / ECONNREFUSED / ECONNABORTED / ECONNRESET / ETIMEDOUT
  / ERR_CANCELED, "Request aborted"/timeout messages, or
  database/drizzle/sqlite errors.

* fix(guacamole): honor host RDP DPI in client and tab params (#703)

* fix(file-manager): preserve remote file mode after SFTP write (#704)

* fix(admin): target admin toggle APIs by user id (#705)

* fix(terminal): resolve Electron SSH websocket URL from server config (#706)

* fix(snippets): accept snippets or legacy updates in reorder API (#707)

* fix(admin): fetch users list when users tab is opened (#708)

* fix(docker): improve list layout and overflow for container cards (#709)

* fix(guacamole): gate keyboard capture on focus and visibility (#710)

* fix: remove snippets test file

* chore: run linter

* fix: increase macos memory for building

* Potential fix for pull request finding 'Unused variable, import, function or class'

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

---------

Co-authored-by: Will Moore <will@clevercode.ca>
Co-authored-by: Gemini CLI <gemini@cli.local>
Co-authored-by: Chakyiu <49145984+Chakyiu@users.noreply.github.com>
Co-authored-by: Jozef Rebjak <jozefrebjak@icloud.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Daniel Quinan <68088383+DanielQuinan@users.noreply.github.com>
Co-authored-by: allxm4 <77125344+allxm4@users.noreply.github.com>
Co-authored-by: AllX <contact@alexmaftei.com>
Co-authored-by: Razvan Aurariu <38325118+rzv-me@users.noreply.github.com>
Co-authored-by: Dylan Ysmal <Xenthys@users.noreply.github.com>
Co-authored-by: vvbbnn00 <vvbbnn00@foxmail.com>
Co-authored-by: Lbubeer <Lbubeer1@gmail.com>
Co-authored-by: Dominik <DL6ER@users.noreply.github.com>
Co-authored-by: LukeGus <lukegustafson06@gmail.com>
Co-authored-by: TerrifiedBug <35064668+TerrifiedBug@users.noreply.github.com>
Co-authored-by: JIHUN <asdfgl98@naver.com>
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-04-22 16:55:23 -05:00
committed by GitHub
parent 9b1cc3dbf4
commit e3cb1f82af
103 changed files with 10356 additions and 5115 deletions
+44 -44
View File
@@ -52,10 +52,13 @@ function AppContent({
const { theme, setTheme } = useTheme();
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const isDarkMode =
theme === "dark" ||
theme === "dracula" ||
theme === "gentlemansChoice" ||
theme === "midnightEspresso" ||
theme === "catppuccinMocha" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
@@ -65,12 +68,29 @@ function AppContent({
const lastAltPressTime = useRef(0);
useEffect(() => {
const handleDatabaseConnectionLost = () => {
setDbConnectionFailed(true);
const DEGRADED_TOAST_ID = "db-connection-degraded";
const handleDatabaseConnectionDegraded = () => {
// Non-blocking, non-dismissible status toast that stays visible until
// connectivity is recovered. A Reload action lets users force-refresh
// the page if they want to, but the app itself remains fully usable.
toast.loading(
t("common.connectionDegraded", "Server connection lost, recovering…"),
{
id: DEGRADED_TOAST_ID,
duration: Infinity,
dismissible: false,
closeButton: false,
action: {
label: t("common.reload", "Reload"),
onClick: () => window.location.reload(),
},
},
);
};
const handleDatabaseConnectionRestored = () => {
setDbConnectionFailed(false);
const handleDatabaseConnectionDegradedCleared = () => {
toast.dismiss(DEGRADED_TOAST_ID);
toast.success(t("common.backendReconnected"));
};
@@ -79,27 +99,28 @@ function AppContent({
};
dbHealthMonitor.on(
"database-connection-lost",
handleDatabaseConnectionLost,
"database-connection-degraded",
handleDatabaseConnectionDegraded,
);
dbHealthMonitor.on(
"database-connection-restored",
handleDatabaseConnectionRestored,
"database-connection-degraded-cleared",
handleDatabaseConnectionDegradedCleared,
);
dbHealthMonitor.on("session-expired", handleSessionExpired);
return () => {
dbHealthMonitor.off(
"database-connection-lost",
handleDatabaseConnectionLost,
"database-connection-degraded",
handleDatabaseConnectionDegraded,
);
dbHealthMonitor.off(
"database-connection-restored",
handleDatabaseConnectionRestored,
"database-connection-degraded-cleared",
handleDatabaseConnectionDegradedCleared,
);
dbHealthMonitor.off("session-expired", handleSessionExpired);
toast.dismiss(DEGRADED_TOAST_ID);
};
}, []);
}, [t]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -156,8 +177,9 @@ function AppContent({
if (hostIdentifier) {
const openTerminal = async () => {
try {
const { getSSHHostById, getSSHHosts } =
await import("@/ui/main-axios.ts");
const { getSSHHostById, getSSHHosts } = await import(
"@/ui/main-axios.ts"
);
let host = null;
if (/^\d+$/.test(hostIdentifier)) {
@@ -188,8 +210,12 @@ function AppContent({
}
}, [addTab]);
const isCheckingAuth = useRef(false);
useEffect(() => {
const checkAuth = () => {
if (isCheckingAuth.current) return;
isCheckingAuth.current = true;
setAuthLoading(true);
getUserInfo()
.then((meRes) => {
@@ -197,7 +223,6 @@ function AppContent({
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
localStorage.removeItem("jwt");
} else {
setIsAuthenticated(true);
setIsAdmin(!!meRes.is_admin);
@@ -209,8 +234,6 @@ function AppContent({
setIsAdmin(false);
setUsername(null);
localStorage.removeItem("jwt");
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
@@ -218,6 +241,7 @@ function AppContent({
})
.finally(() => {
setAuthLoading(false);
isCheckingAuth.current = false;
});
};
@@ -292,7 +316,7 @@ function AppContent({
const showAdmin = currentTabData?.type === "admin";
const showProfile = currentTabData?.type === "user_profile";
if (authLoading && !dbConnectionFailed) {
if (authLoading) {
return (
<div
className="fixed inset-0 flex items-center justify-center"
@@ -321,30 +345,6 @@ function AppContent({
);
}
if (dbConnectionFailed) {
return (
<div className="h-screen w-screen overflow-hidden bg-background">
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-background">
<Dashboard
isAuthenticated={false}
authLoading={false}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
onSelectView={() => {}}
initialDbError="Database connection failed"
/>
</div>
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
);
}
return (
<div className="h-screen w-screen overflow-hidden bg-background">
<CommandPalette
+15 -8
View File
@@ -68,9 +68,9 @@ export function AdminSettings({
Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash?: string;
}>
>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
@@ -80,9 +80,9 @@ export function AdminSettings({
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash?: string;
} | null>(null);
const [currentUser, setCurrentUser] = React.useState<{
@@ -148,7 +148,6 @@ export function AdminSettings({
console.warn("Failed to fetch current user info", err);
}
});
fetchUsers();
fetchSessions();
}, []);
@@ -340,7 +339,15 @@ export function AdminSettings({
<Separator className="p-0.25 w-full" />
<div className="px-6 py-4 overflow-auto thin-scrollbar">
<Tabs defaultValue="registration" className="w-full">
<Tabs
defaultValue="registration"
onValueChange={(value) => {
if (value === "users") {
fetchUsers();
}
}}
className="w-full"
>
<TabsList className="mb-4 bg-elevated border-2 border-edge">
<TabsTrigger
value="registration"
@@ -42,9 +42,9 @@ import {
interface User {
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash?: string;
}
interface UserEditDialogProps {
@@ -81,7 +81,7 @@ export function UserEditDialog({
useEffect(() => {
if (open && user) {
setIsAdmin(user.is_admin);
setIsAdmin(user.isAdmin);
loadRoles();
}
}, [open, user]);
@@ -135,19 +135,18 @@ export function UserEditDialog({
setAdminLoading(true);
try {
if (checked) {
await makeUserAdmin(userToUpdate.username);
await makeUserAdmin(userToUpdate.id);
toast.success(
t("admin.userIsNowAdmin", { username: userToUpdate.username }),
);
} else {
await removeAdminStatus(userToUpdate.username);
await removeAdminStatus(userToUpdate.id);
toast.success(
t("admin.adminStatusRemoved", { username: userToUpdate.username }),
);
}
setIsAdmin(checked);
onSuccess();
onOpenChange(true);
} catch (error) {
console.error("Failed to toggle admin status:", error);
toast.error(
@@ -332,9 +331,9 @@ export function UserEditDialog({
const getAuthTypeDisplay = (): string => {
if (!user) return "";
if (user.is_oidc && user.password_hash) {
if (user.isOidc && user.passwordHash) {
return t("admin.dualAuth");
} else if (user.is_oidc) {
} else if (user.isOidc) {
return t("admin.externalOIDC");
} else {
return t("admin.localPassword");
@@ -344,7 +343,7 @@ export function UserEditDialog({
if (!user) return null;
const showPasswordReset =
allowPasswordLogin && (user.password_hash || !user.is_oidc);
allowPasswordLogin && (user.passwordHash || !user.isOidc);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -1,7 +1,5 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Label } from "@/components/ui/label.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Download, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -22,33 +20,8 @@ export function DatabaseSecurityTab({
const [exportLoading, setExportLoading] = React.useState(false);
const [importLoading, setImportLoading] = React.useState(false);
const [importFile, setImportFile] = React.useState<File | null>(null);
const [exportPassword, setExportPassword] = React.useState("");
const [showPasswordInput, setShowPasswordInput] = React.useState(false);
const [importPassword, setImportPassword] = React.useState("");
const requiresImportPassword = React.useMemo(
() => !currentUser?.is_oidc,
[currentUser?.is_oidc],
);
const requiresExportPassword = React.useMemo(
() => !currentUser?.is_oidc,
[currentUser?.is_oidc],
);
const handleExportDatabase = async () => {
if (requiresExportPassword) {
if (!showPasswordInput) {
setShowPasswordInput(true);
return;
}
if (!exportPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
}
setExportLoading(true);
try {
const isDev =
@@ -66,15 +39,21 @@ export function DatabaseSecurityTab({
? `http://localhost:30001/database/export`
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (isElectron()) {
const token = localStorage.getItem("jwt");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers,
credentials: "include",
body: JSON.stringify(
requiresExportPassword ? { password: exportPassword } : {},
),
body: JSON.stringify({}),
});
if (response.ok) {
@@ -94,15 +73,9 @@ export function DatabaseSecurityTab({
document.body.removeChild(a);
toast.success(t("admin.databaseExportedSuccessfully"));
setExportPassword("");
setShowPasswordInput(false);
} else {
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseExportFailed"));
}
toast.error(error.error || t("admin.databaseExportFailed"));
}
} catch {
toast.error(t("admin.databaseExportFailed"));
@@ -117,11 +90,6 @@ export function DatabaseSecurityTab({
return;
}
if (requiresImportPassword && !importPassword.trim()) {
toast.error(t("admin.passwordRequired"));
return;
}
setImportLoading(true);
try {
const isDev =
@@ -141,12 +109,18 @@ export function DatabaseSecurityTab({
const formData = new FormData();
formData.append("file", importFile);
if (requiresImportPassword) {
formData.append("password", importPassword);
const importHeaders: Record<string, string> = {};
if (isElectron()) {
const token = localStorage.getItem("jwt");
if (token) {
importHeaders["Authorization"] = `Bearer ${token}`;
}
}
const response = await fetch(apiUrl, {
method: "POST",
headers: importHeaders,
credentials: "include",
body: formData,
});
@@ -181,7 +155,6 @@ export function DatabaseSecurityTab({
`Import completed: ${imported} items imported${details.length > 0 ? ` (${details.join(", ")})` : ""}, ${skipped} items skipped`,
);
setImportFile(null);
setImportPassword("");
setTimeout(() => {
window.location.reload();
@@ -193,11 +166,7 @@ export function DatabaseSecurityTab({
}
} else {
const error = await response.json();
if (error.code === "PASSWORD_REQUIRED") {
toast.error(t("admin.passwordRequired"));
} else {
toast.error(error.error || t("admin.databaseImportFailed"));
}
toast.error(error.error || t("admin.databaseImportFailed"));
}
} catch {
toast.error(t("admin.databaseImportFailed"));
@@ -220,45 +189,13 @@ export function DatabaseSecurityTab({
<p className="text-xs text-muted-foreground">
{t("admin.exportDescription")}
</p>
{showPasswordInput && requiresExportPassword && (
<div className="space-y-2">
<Label htmlFor="export-password">Password</Label>
<PasswordInput
id="export-password"
value={exportPassword}
onChange={(e) => setExportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleExportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleExportDatabase}
disabled={exportLoading}
className="w-full"
>
{exportLoading
? t("admin.exporting")
: showPasswordInput && requiresExportPassword
? t("admin.confirmExport")
: t("admin.export")}
{exportLoading ? t("admin.exporting") : t("admin.export")}
</Button>
{showPasswordInput && requiresExportPassword && (
<Button
variant="outline"
onClick={() => {
setShowPasswordInput(false);
setExportPassword("");
}}
className="w-full"
>
Cancel
</Button>
)}
</div>
</div>
@@ -294,29 +231,9 @@ export function DatabaseSecurityTab({
</span>
</Button>
</div>
{importFile && requiresImportPassword && (
<div className="space-y-2">
<Label htmlFor="import-password">Password</Label>
<PasswordInput
id="import-password"
value={importPassword}
onChange={(e) => setImportPassword(e.target.value)}
placeholder="Enter your password"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleImportDatabase();
}
}}
/>
</div>
)}
<Button
onClick={handleImportDatabase}
disabled={
importLoading ||
!importFile ||
(requiresImportPassword && !importPassword.trim())
}
disabled={importLoading || !importFile}
className="w-full"
>
{importLoading ? t("admin.importing") : t("admin.import")}
@@ -19,6 +19,10 @@ import {
updateGlobalMonitoringSettings,
getGuacamoleSettings,
updateGuacamoleSettings,
getLogLevel,
updateLogLevel,
getSessionTimeout,
updateSessionTimeout,
} from "@/ui/main-axios.ts";
import { Button } from "@/components/ui/button.tsx";
@@ -66,10 +70,64 @@ export function GeneralSettingsTab({
);
const [monitoringLoading, setMonitoringLoading] = React.useState(false);
const [logLevel, setLogLevel] = React.useState("info");
const [logLevelLoading, setLogLevelLoading] = React.useState(false);
const [sessionTimeoutHours, setSessionTimeoutHours] = React.useState(24);
const [sessionTimeoutInput, setSessionTimeoutInput] = React.useState("24");
const [sessionTimeoutLoading, setSessionTimeoutLoading] =
React.useState(false);
const [guacEnabled, setGuacEnabled] = React.useState(true);
const [guacUrl, setGuacUrl] = React.useState("guacd:4822");
const [guacLoading, setGuacLoading] = React.useState(false);
React.useEffect(() => {
getLogLevel()
.then((data) => {
setLogLevel(data.level);
})
.catch(() => {});
}, []);
React.useEffect(() => {
getSessionTimeout()
.then((data) => {
setSessionTimeoutHours(data.timeoutHours);
setSessionTimeoutInput(String(data.timeoutHours));
})
.catch(() => {});
}, []);
const handleLogLevelChange = async (value: string) => {
setLogLevel(value);
setLogLevelLoading(true);
try {
await updateLogLevel(value);
toast.success(t("admin.logLevelSaved"));
} catch {
toast.error(t("admin.failedToSaveLogLevel"));
} finally {
setLogLevelLoading(false);
}
};
const handleSessionTimeoutBlur = async () => {
const num = parseInt(sessionTimeoutInput) || 24;
const clamped = Math.max(1, Math.min(720, num));
setSessionTimeoutHours(clamped);
setSessionTimeoutInput(String(clamped));
setSessionTimeoutLoading(true);
try {
await updateSessionTimeout(clamped);
toast.success(t("admin.sessionTimeoutSaved"));
} catch {
toast.error(t("admin.failedToSaveSessionTimeout"));
} finally {
setSessionTimeoutLoading(false);
}
};
React.useEffect(() => {
getGuacamoleSettings()
.then((data) => {
@@ -275,6 +333,34 @@ export function GeneralSettingsTab({
</label>
</div>
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<h3 className="text-lg font-semibold">{t("admin.sessionTimeout")}</h3>
<p className="text-sm text-muted-foreground">
{t("admin.sessionTimeoutDesc")}
</p>
<div>
<label className="text-sm font-medium">
{t("admin.sessionTimeoutHours")}
</label>
<div className="flex gap-2 mt-1">
<Input
type="number"
min={1}
max={720}
value={sessionTimeoutInput}
onChange={(e) => setSessionTimeoutInput(e.target.value)}
onBlur={handleSessionTimeoutBlur}
disabled={sessionTimeoutLoading}
className="flex-1"
/>
<span className="text-sm font-medium py-2">{t("admin.hours")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t("admin.sessionTimeoutNote")}
</p>
</div>
</div>
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<h3 className="text-lg font-semibold">
{t("admin.monitoringDefaults")}
@@ -410,6 +496,36 @@ export function GeneralSettingsTab({
</div>
)}
</div>
<div className="rounded-lg border-2 border-border bg-card p-4 space-y-4">
<h3 className="text-lg font-semibold">{t("admin.logLevel")}</h3>
<p className="text-sm text-muted-foreground">
{t("admin.logLevelDesc")}
</p>
<div>
<label className="text-sm font-medium">
{t("admin.logVerbosity")}
</label>
<Select
value={logLevel}
onValueChange={handleLogLevelChange}
disabled={logLevelLoading}
>
<SelectTrigger className="w-[200px] mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="debug">Debug</SelectItem>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warn">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
{t("admin.logLevelNote")}
</p>
</div>
</div>
</div>
);
}
@@ -17,9 +17,9 @@ import { deleteUser } from "@/ui/main-axios.ts";
interface User {
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
password_hash?: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash?: string;
}
interface UserManagementTabProps {
@@ -47,9 +47,9 @@ export function UserManagementTab({
const { confirmWithToast } = useConfirmation();
const getAuthTypeDisplay = (user: User): string => {
if (user.is_oidc && user.password_hash) {
if (user.isOidc && user.passwordHash) {
return t("admin.dualAuth");
} else if (user.is_oidc) {
} else if (user.isOidc) {
return t("admin.externalOIDC");
} else {
return t("admin.localPassword");
@@ -111,7 +111,7 @@ export function UserManagementTab({
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.username}
{user.is_admin && (
{user.isAdmin && (
<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>
@@ -129,7 +129,7 @@ export function UserManagementTab({
>
<Edit className="h-4 w-4" />
</Button>
{user.is_oidc && !user.password_hash && (
{user.isOidc && !user.passwordHash && (
<Button
variant="ghost"
size="sm"
@@ -145,7 +145,7 @@ export function UserManagementTab({
<Link2 className="h-4 w-4" />
</Button>
)}
{user.is_oidc && user.password_hash && (
{user.isOidc && user.passwordHash && (
<Button
variant="ghost"
size="sm"
@@ -161,7 +161,7 @@ export function UserManagementTab({
size="sm"
onClick={() => handleDeleteUserQuick(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
disabled={user.isAdmin}
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -33,7 +33,8 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
getRecentActivity,
getSSHHosts,
getGuacamoleToken,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
logActivity,
} from "@/ui/main-axios.ts";
import type { RecentActivityItem } from "@/ui/main-axios.ts";
@@ -235,17 +236,7 @@ export function CommandPalette({
) {
try {
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
const result = await getGuacamoleToken({
protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
});
const result = await getGuacamoleTokenFromHost(host.id);
addTab({
type: protocol,
@@ -262,6 +253,7 @@ export function CommandPalette({
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
+122 -106
View File
@@ -12,10 +12,12 @@ import {
getCredentials,
getRecentActivity,
resetRecentActivity,
getAllServerStatuses,
getServerMetricsById,
registerMetricsViewer,
sendMetricsHeartbeat,
getGuacamoleToken,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
type RecentActivityItem,
} from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
@@ -68,7 +70,7 @@ export function Dashboard({
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update"
>("up_to_date");
const [versionText, setVersionText] = useState<string>("v1.8.0");
const [versionText, setVersionText] = useState<string>("");
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
const [totalServers, setTotalServers] = useState<number>(0);
const [totalTunnels, setTotalTunnels] = useState<number>(0);
@@ -223,72 +225,108 @@ export function Dashboard({
setRecentActivityLoading(false);
setServerStatsLoading(true);
// Fetch current host statuses once so we can skip offline hosts
// before issuing per-host register-viewer / metrics requests.
let hostStatuses: Record<number, { status?: string }> = {};
try {
hostStatuses = (await getAllServerStatuses()) as Record<
number,
{ status?: string }
>;
} catch {
// Best-effort: if the status endpoint is unavailable, fall back
// to the previous behavior and still attempt each host.
hostStatuses = {};
}
const newViewerSessions = new Map<number, string>();
const serversWithStats = await Promise.all(
hosts
.slice(0, 50)
.map(
async (host: {
id: number;
name: string;
authType?: string;
statsConfig?: string | { metricsEnabled?: boolean };
}) => {
try {
let statsConfig: { metricsEnabled?: boolean } = {
metricsEnabled: true,
hosts.slice(0, 50).map(
async (host: {
id: number;
name: string;
authType?: string;
statsConfig?:
| string
| {
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
};
if (host.statsConfig) {
if (typeof host.statsConfig === "string") {
statsConfig = JSON.parse(host.statsConfig);
} else {
statsConfig = host.statsConfig;
}
}
if (statsConfig.metricsEnabled === false) {
return null;
}
if (host.authType === "none") {
return null;
}
if (host.authType === "opkssh") {
return null;
}
const existingSession = viewerSessions.get(host.id);
let sessionId = existingSession;
if (!existingSession) {
try {
const viewerResult = await registerMetricsViewer(host.id);
if (
viewerResult.success &&
viewerResult.viewerSessionId
) {
sessionId = viewerResult.viewerSessionId;
newViewerSessions.set(host.id, sessionId);
}
} catch (error) {
console.error(
`Failed to register viewer for host ${host.id}:`,
error,
);
}
}) => {
try {
let statsConfig: {
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
} = {
metricsEnabled: true,
statusCheckEnabled: true,
};
if (host.statsConfig) {
if (typeof host.statsConfig === "string") {
statsConfig = JSON.parse(host.statsConfig);
} else {
newViewerSessions.set(host.id, existingSession);
statsConfig = host.statsConfig;
}
}
const metrics = await getServerMetricsById(host.id);
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: metrics.cpu.percent,
ram: metrics.memory.percent,
};
} catch {
if (statsConfig.metricsEnabled === false) {
return null;
}
if (host.authType === "none") {
return null;
}
if (host.authType === "opkssh") {
return null;
}
// Skip hosts that are known to be offline: no metrics can
// possibly exist for them, and hitting /metrics/:id would
// just 404. If the status is unknown (e.g. no entry yet
// or statusCheckEnabled === false) we still attempt.
if (statsConfig.statusCheckEnabled !== false) {
const knownStatus = hostStatuses?.[host.id]?.status;
if (knownStatus === "offline") {
return null;
}
}
const existingSession = viewerSessions.get(host.id);
let sessionId = existingSession;
let registrationSkipped = false;
if (!existingSession) {
try {
const viewerResult = await registerMetricsViewer(host.id);
if (viewerResult.skipped) {
// Metrics disabled/unsupported on this host; don't
// poll and don't surface this as an error.
registrationSkipped = true;
} else if (
viewerResult.success &&
viewerResult.viewerSessionId
) {
sessionId = viewerResult.viewerSessionId;
newViewerSessions.set(host.id, sessionId);
}
} catch (error) {
console.error(
`Failed to register viewer for host ${host.id}:`,
error,
);
}
} else {
newViewerSessions.set(host.id, existingSession);
}
if (registrationSkipped) {
return null;
}
const metrics = await getServerMetricsById(host.id);
if (!metrics) {
return {
id: host.id,
name: host.name || `Host ${host.id}`,
@@ -296,8 +334,22 @@ export function Dashboard({
ram: null,
};
}
},
),
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: metrics.cpu?.percent ?? null,
ram: metrics.memory?.percent ?? null,
};
} catch {
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: null,
ram: null,
};
}
},
),
);
setViewerSessions(newViewerSessions);
const validServerStats = serversWithStats.filter(
@@ -388,17 +440,7 @@ export function Dashboard({
hostConfig: host,
});
} else if (item.type === "telnet") {
getGuacamoleToken({
protocol: "telnet",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
})
getGuacamoleTokenFromHost(host.id)
.then((result) => {
addTab({
type: "telnet",
@@ -415,6 +457,7 @@ export function Dashboard({
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
})
@@ -422,17 +465,7 @@ export function Dashboard({
console.error("Failed to get telnet token:", error);
});
} else if (item.type === "vnc") {
getGuacamoleToken({
protocol: "vnc",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
})
getGuacamoleTokenFromHost(host.id)
.then((result) => {
addTab({
type: "vnc",
@@ -449,6 +482,7 @@ export function Dashboard({
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
})
@@ -456,17 +490,7 @@ export function Dashboard({
console.error("Failed to get vnc token:", error);
});
} else if (item.type === "rdp") {
getGuacamoleToken({
protocol: "rdp",
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
})
getGuacamoleTokenFromHost(host.id)
.then((result) => {
addTab({
type: "rdp",
@@ -483,6 +507,7 @@ export function Dashboard({
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
})
@@ -650,15 +675,6 @@ export function Dashboard({
>
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
}
>
{t("dashboard.donate")}
</Button>
<Button
className="font-semibold shrink-0 !bg-canvas"
variant="outline"
@@ -110,7 +110,7 @@ export function ServerOverviewCard({
<div className="flex flex-row items-center min-w-0">
<Server size={16} className="mr-3 shrink-0" />
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
{t("dashboard.totalHosts")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
@@ -30,7 +30,11 @@ export function useDashboardPreferences(enabled: boolean = true) {
const fetchPreferences = async () => {
try {
const preferences = await getDashboardPreferences();
setLayout(preferences);
if (preferences?.cards && Array.isArray(preferences.cards)) {
setLayout(preferences);
} else {
setLayout(DEFAULT_LAYOUT);
}
} catch (error) {
setLayout(DEFAULT_LAYOUT);
} finally {
@@ -661,7 +661,7 @@ function DockerManagerInner({
<div className="flex-1 overflow-hidden min-h-0 relative">
{viewMode === "list" ? (
<div className="h-full px-4 py-4">
<div className="h-full min-h-0 px-4 py-4">
{sessionId ? (
isLoadingContainers && containers.length === 0 ? (
<SimpleLoader
@@ -263,8 +263,7 @@ export function ConsoleTerminal({
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(token)}`;
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(baseWsUrl);
ws.onopen = () => {
const cols = terminal.cols || 80;
@@ -246,7 +246,7 @@ export function ContainerCard({
return (
<>
<Card
className={`cursor-pointer transition-all hover:shadow-lg ${
className={`cursor-pointer transition-all hover:shadow-lg overflow-hidden min-w-0 ${
isSelected
? "ring-2 ring-primary border-primary"
: `border-2 ${colors.border}`
@@ -267,27 +267,27 @@ export function ContainerCard({
</CardHeader>
<CardContent className="space-y-3 px-4 pb-3">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.image")}
</span>
<span className="truncate text-foreground text-xs">
<span className="flex-1 min-w-0 truncate text-foreground text-xs">
{container.image}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.idLabel")}
</span>
<span className="font-mono text-xs text-foreground">
<span className="flex-1 min-w-0 truncate font-mono text-xs text-foreground">
{container.id.substring(0, 12)}
</span>
</div>
<div className="flex items-start gap-2">
<div className="flex items-start gap-2 min-w-0">
<span className="text-muted-foreground min-w-[50px] text-xs shrink-0">
{t("docker.ports")}
</span>
<div className="flex flex-wrap gap-1">
<div className="flex flex-1 min-w-0 flex-wrap gap-1">
{portsList.length > 0 ? (
portsList.map((port, idx) => (
<Badge
@@ -308,11 +308,11 @@ export function ContainerCard({
)}
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-muted-foreground min-w-[50px] text-xs">
{t("docker.created")}
</span>
<span className="text-foreground text-xs">
<span className="flex-1 min-w-0 truncate text-foreground text-xs">
{formatCreatedDate(container.created)}
</span>
</div>
@@ -55,7 +55,7 @@ export function ContainerList({
if (containers.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="flex items-center justify-center h-full min-h-0">
<div className="text-center space-y-2">
<p className="text-muted-foreground text-lg">
{t("docker.noContainersFound")}
@@ -69,7 +69,7 @@ export function ContainerList({
}
return (
<div className="flex flex-col h-full gap-3">
<div className="flex flex-col h-full min-h-0 gap-3">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -106,7 +106,7 @@ export function ContainerList({
</div>
{filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1">
<div className="flex items-center justify-center flex-1 min-h-0">
<div className="text-center space-y-2">
<p className="text-muted-foreground">
{t("docker.noContainersMatchFilters")}
@@ -117,17 +117,19 @@ export function ContainerList({
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto thin-scrollbar pb-2">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full pb-2">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
</div>
)}
</div>
@@ -599,13 +599,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
const debouncedLoadDirectory = useCallback(
(path: string) => {
(path: string, force?: boolean) => {
if (pathChangeTimerRef.current) {
clearTimeout(pathChangeTimerRef.current);
}
pathChangeTimerRef.current = setTimeout(() => {
if (path !== lastPathChangeRef.current && sshSessionId) {
if ((force || path !== lastPathChangeRef.current) && sshSessionId) {
lastPathChangeRef.current = path;
loadDirectory(path);
}
@@ -641,6 +641,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
setLastRefreshTime(now);
// Force reset loading state to ensure refresh is not blocked
setIsLoading(false);
currentLoadingPathRef.current = "";
loadDirectory(currentPath);
}, [currentPath, lastRefreshTime, loadDirectory]);
@@ -778,6 +781,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
}
} catch (error: unknown) {
if (
@@ -1177,6 +1177,9 @@ export function FileManagerGrid({
<ArrowDown className="w-3 h-3" />
))}
</div>
<div className="flex-shrink-0 w-24 text-right hidden md:block">
{t("fileManager.owner", "Owner")}
</div>
<div className="flex-shrink-0 w-20 text-right">
{t("fileManager.permissions")}
</div>
@@ -1275,6 +1278,14 @@ export function FileManagerGrid({
)}
</div>
<div className="flex-shrink-0 text-right w-24 hidden md:block">
{file.owner && (
<p className="text-xs text-muted-foreground truncate">
{file.owner}
{file.group ? `:${file.group}` : ""}
</p>
)}
</div>
<div className="flex-shrink-0 text-right w-20">
{file.permissions && (
<p className="text-xs text-muted-foreground font-mono">
@@ -8,7 +8,7 @@ import {
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import { getCookie, isElectron } from "@/ui/main-axios.ts";
import { getCookie, isElectron, isEmbeddedMode } from "@/ui/main-axios.ts";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
@@ -55,9 +55,15 @@ export const GuacamoleDisplay = forwardRef<
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const displayElementRef = useRef<HTMLElement | null>(null);
const clientRef = useRef<Guacamole.Client | null>(null);
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
const scaleRef = useRef<number>(1);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasKeyboardFocusRef = useRef(false);
const windowFocusedRef = useRef(
typeof document === "undefined" ? true : document.hasFocus(),
);
const [isConnecting, setIsConnecting] = useState(false);
const [isReady, setIsReady] = useState(false);
@@ -138,22 +144,44 @@ export const GuacamoleDisplay = forwardRef<
token = data.token;
}
const width = connectionConfig.width || containerWidth || 1280;
const height = connectionConfig.height || containerHeight || 720;
const dpi = connectionConfig.dpi || 96;
const width = connectionConfig.width ?? containerWidth ?? 1280;
const height = connectionConfig.height ?? containerHeight ?? 720;
const protocol = connectionConfig.protocol ?? connectionConfig.type;
const dpi = protocol === "rdp" ? (connectionConfig.dpi ?? 96) : null;
const wsBase = isDev
? `ws://localhost:30008`
: isElectron()
? (() => {
const base =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
return `${base.startsWith("https://") ? "wss://" : "ws://"}${base.replace(/^https?:\/\//, "")}/guacamole/websocket/`;
const configuredUrl = (
window as { configuredServerUrl?: string }
).configuredServerUrl;
// Embedded mode or no configured remote server: connect directly
// to the local guacamole websocket service.
if (isEmbeddedMode() || !configuredUrl) {
return "ws://127.0.0.1:30008";
}
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
return `${wsBase}?token=${encodeURIComponent(token)}&width=${width}&height=${height}&dpi=${dpi}`;
const params = new URLSearchParams({
token,
width: String(width),
height: String(height),
});
if (dpi !== null && dpi !== undefined) {
params.set("dpi", String(dpi));
}
return `${wsBase}?${params.toString()}`;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
@@ -164,6 +192,63 @@ export const GuacamoleDisplay = forwardRef<
[connectionConfig, onError],
);
const refreshKeyboardHandlers = useCallback(() => {
const keyboard = keyboardRef.current;
const client = clientRef.current;
const displayElement = displayElementRef.current;
if (!keyboard) return;
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const displayIsFocused =
!!displayElement &&
typeof document !== "undefined" &&
document.activeElement === displayElement;
const shouldCaptureInput =
!!client &&
!!displayElement &&
isVisible &&
documentVisible &&
windowFocusedRef.current &&
(hasKeyboardFocusRef.current || displayIsFocused);
if (!shouldCaptureInput) {
keyboard.onkeydown = null;
keyboard.onkeyup = null;
keyboard.reset();
return;
}
keyboard.onkeydown = (keysym: number) => {
if (!clientRef.current) return;
if (!isVisible || !windowFocusedRef.current) return;
const activeDisplay = displayElementRef.current;
const stillFocused =
!!activeDisplay &&
typeof document !== "undefined" &&
document.activeElement === activeDisplay;
if (!hasKeyboardFocusRef.current && !stillFocused) return;
clientRef.current.sendKeyEvent(1, keysym);
};
keyboard.onkeyup = (keysym: number) => {
if (!clientRef.current) return;
if (!isVisible || !windowFocusedRef.current) return;
const activeDisplay = displayElementRef.current;
const stillFocused =
!!activeDisplay &&
typeof document !== "undefined" &&
document.activeElement === activeDisplay;
if (!hasKeyboardFocusRef.current && !stillFocused) return;
clientRef.current.sendKeyEvent(0, keysym);
};
}, [isVisible]);
const rescaleDisplay = useCallback((immediate: boolean = false) => {
if (!clientRef.current || !containerRef.current) return;
@@ -220,12 +305,16 @@ export const GuacamoleDisplay = forwardRef<
const display = client.getDisplay();
const displayElement = display.getElement();
displayElementRef.current = displayElement;
if (displayRef.current) {
displayRef.current.innerHTML = "";
displayRef.current.appendChild(displayElement);
}
displayElement.setAttribute("tabindex", "0");
displayElement.style.outline = "none";
display.onresize = () => {
rescaleDisplay(true);
setIsReady(true);
@@ -233,6 +322,7 @@ export const GuacamoleDisplay = forwardRef<
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
const adjustedX = Math.round(state.x / scale);
const adjustedY = Math.round(state.y / scale);
@@ -251,14 +341,24 @@ export const GuacamoleDisplay = forwardRef<
};
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
const keyboard = new Guacamole.Keyboard(document);
keyboard.onkeydown = (keysym: number) => {
client.sendKeyEvent(1, keysym);
const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard;
const handleDisplayFocus = () => {
hasKeyboardFocusRef.current = true;
refreshKeyboardHandlers();
};
keyboard.onkeyup = (keysym: number) => {
client.sendKeyEvent(0, keysym);
const handleDisplayBlur = () => {
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
};
displayElement.addEventListener("focus", handleDisplayFocus);
displayElement.addEventListener("blur", handleDisplayBlur);
displayElement.addEventListener("mousedown", handleDisplayFocus);
refreshKeyboardHandlers();
client.onstatechange = (state: number) => {
switch (state) {
case 0:
@@ -270,6 +370,7 @@ export const GuacamoleDisplay = forwardRef<
break;
case 3:
setIsConnecting(false);
setIsReady(true);
onConnect?.();
break;
case 4:
@@ -277,8 +378,8 @@ export const GuacamoleDisplay = forwardRef<
case 5:
setIsConnecting(false);
setIsReady(false);
keyboard.onkeydown = null;
keyboard.onkeyup = null;
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
onDisconnect?.();
break;
}
@@ -304,8 +405,19 @@ export const GuacamoleDisplay = forwardRef<
}
};
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
Guacamole.AudioPlayer.getInstance(stream, mimetype);
};
client.connect();
}, [getWebSocketUrl, onConnect, onDisconnect, onError, rescaleDisplay]);
}, [
getWebSocketUrl,
onConnect,
onDisconnect,
onError,
refreshKeyboardHandlers,
rescaleDisplay,
]);
const hasInitiatedRef = useRef(false);
const isMountedRef = useRef(false);
@@ -324,6 +436,46 @@ export const GuacamoleDisplay = forwardRef<
}
}, [isVisible, connect]);
useEffect(() => {
if (!isVisible) {
hasKeyboardFocusRef.current = false;
}
refreshKeyboardHandlers();
}, [isVisible, refreshKeyboardHandlers]);
useEffect(() => {
const handleWindowFocus = () => {
windowFocusedRef.current = true;
refreshKeyboardHandlers();
};
const handleWindowBlur = () => {
windowFocusedRef.current = false;
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
};
const handleVisibilityChange = () => {
windowFocusedRef.current =
document.visibilityState === "visible" && document.hasFocus();
if (document.visibilityState !== "visible") {
hasKeyboardFocusRef.current = false;
}
refreshKeyboardHandlers();
};
window.addEventListener("focus", handleWindowFocus);
window.addEventListener("blur", handleWindowBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleWindowFocus);
window.removeEventListener("blur", handleWindowBlur);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [refreshKeyboardHandlers]);
useEffect(() => {
return () => {
isMountedRef.current = false;
@@ -336,6 +488,7 @@ export const GuacamoleDisplay = forwardRef<
clientRef.current.disconnect();
clientRef.current = null;
}
displayElementRef.current = null;
};
}, []);
@@ -344,6 +497,14 @@ export const GuacamoleDisplay = forwardRef<
const resizeObserver = new ResizeObserver(() => {
rescaleDisplay(false);
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
resizeTimeoutRef.current = setTimeout(() => {
if (clientRef.current && containerRef.current) {
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
}
}, 200);
});
resizeObserver.observe(containerRef.current);
@@ -356,6 +517,40 @@ export const GuacamoleDisplay = forwardRef<
};
}, [rescaleDisplay]);
const syncClipboard = useCallback(() => {
const client = clientRef.current;
if (!client) return;
navigator.clipboard
.readText()
.then((text) => {
if (text) {
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (isVisible && isReady) {
syncClipboard();
}
}, [isVisible, isReady, syncClipboard]);
useEffect(() => {
const container = containerRef.current;
if (!container || !isReady) return;
const handleFocus = () => syncClipboard();
container.addEventListener("mouseenter", handleFocus);
return () => {
container.removeEventListener("mouseenter", handleFocus);
};
}, [isReady, syncClipboard]);
const connectingMessage = t("guacamole.connecting", {
type: (
connectionConfig.protocol ||
@@ -479,7 +479,7 @@ function ServerStatsInner({
if (cancelled) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
if (!cancelled && data) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
@@ -636,7 +636,9 @@ function ServerStatsInner({
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
if (data) {
setMetrics(data);
}
setShowStatsUI(true);
} catch (error: unknown) {
const err = error as {
@@ -17,16 +17,20 @@ import { getBasePath } from "@/lib/base-path";
import {
getCookie,
isElectron,
isEmbeddedMode,
logActivity,
getSnippets,
deleteCommandFromHistory,
getCommandHistory,
getHostPassword,
getServerConfig,
} from "@/ui/main-axios.ts";
import { TOTPDialog } from "@/ui/desktop/navigation/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ui/desktop/navigation/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ui/desktop/navigation/dialogs/WarpgateDialog.tsx";
import { OPKSSHDialog } from "@/ui/desktop/navigation/dialogs/OPKSSHDialog.tsx";
import { HostKeyVerificationDialog } from "@/ui/desktop/navigation/dialogs/HostKeyVerificationDialog.tsx";
import { TmuxSessionPicker } from "@/ui/desktop/navigation/dialogs/TmuxSessionPicker.tsx";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
@@ -47,6 +51,7 @@ import {
} from "@/ui/desktop/navigation/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ui/desktop/navigation/connection-log/ConnectionLog.tsx";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
interface HostConfig {
id?: number;
@@ -66,6 +71,7 @@ interface HostConfig {
interface TerminalHandle {
disconnect: () => void;
reconnect: () => void;
fit: () => void;
sendInput: (data: string) => void;
notifyResize: () => void;
@@ -82,6 +88,110 @@ interface SSHTerminalProps {
onTitleChange?: (title: string) => void;
initialPath?: string;
executeCommand?: string;
onOpenFileManager?: () => void;
previewTheme?: string | null;
}
function TerminalContextMenu({
x,
y,
hasSelection,
showCopyPaste,
showOpenFileManager,
onCopy,
onPaste,
onOpenFileManager,
onClose,
}: {
x: number;
y: number;
hasSelection: boolean;
showCopyPaste: boolean;
showOpenFileManager: boolean;
onCopy: () => void;
onPaste: () => void;
onOpenFileManager: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const menuX = x + 180 > window.innerWidth ? window.innerWidth - 190 : x;
const menuY = y + 150 > window.innerHeight ? window.innerHeight - 160 : y;
useEffect(() => {
let cleanup: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClose = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Element)) onClose();
};
const handleRightClick = (e: MouseEvent) => {
e.preventDefault();
onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClose, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKey);
window.addEventListener("blur", onClose);
cleanup = () => {
document.removeEventListener("mousedown", handleClose, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKey);
window.removeEventListener("blur", onClose);
};
}, 50);
return () => {
clearTimeout(timeoutId);
cleanup?.();
};
}, [onClose]);
const items: { label: string; action: () => void; disabled?: boolean }[] = [];
if (showCopyPaste) {
items.push(
{ label: t("terminal.copy"), action: onCopy, disabled: !hasSelection },
{ label: t("terminal.paste"), action: onPaste },
);
}
if (showOpenFileManager) {
items.push({
label: t("terminal.openFileManagerHere"),
action: onOpenFileManager,
});
}
return (
<>
<div className="fixed inset-0 z-[99990]" />
<div
ref={menuRef}
className="fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] z-[99995] overflow-hidden"
style={{ left: menuX, top: menuY }}
>
{items.map((item, i) => (
<button
key={i}
className={`w-full px-3 py-2 text-left text-sm flex items-center hover:bg-hover transition-colors first:rounded-t-lg last:rounded-b-lg ${item.disabled ? "opacity-50 cursor-not-allowed" : ""}`}
disabled={item.disabled}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
>
{item.label}
</button>
))}
</div>
</>
);
}
const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
@@ -94,6 +204,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
onTitleChange,
initialPath,
executeCommand,
onOpenFileManager,
previewTheme,
},
ref,
) {
@@ -114,21 +226,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const { theme: appTheme } = useTheme();
const { addLog, isExpanded: isConnectionLogExpanded } = useConnectionLog();
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
const savedTheme = localStorage.getItem(
`terminal_theme_host_${hostConfig.id}`,
);
const config = {
...DEFAULT_TERMINAL_CONFIG,
...hostConfig.terminalConfig,
theme:
savedTheme ||
hostConfig.terminalConfig?.theme ||
DEFAULT_TERMINAL_CONFIG.theme,
};
const isDarkMode =
appTheme === "dark" ||
appTheme === "dracula" ||
appTheme === "gentlemansChoice" ||
appTheme === "midnightEspresso" ||
appTheme === "catppuccinMocha" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
let themeColors;
if (config.theme === "termix") {
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[config.theme]?.colors ||
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const backgroundColor = themeColors.background;
@@ -137,11 +265,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const pongReceivedRef = useRef(true);
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const connectionErrorRef = useRef<string | null>(null);
const [showDisconnectedOverlay, setShowDisconnectedOverlay] =
useState(false);
const updateConnectionError = useCallback((error: string | null) => {
connectionErrorRef.current = error;
@@ -169,8 +301,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
requestId: string;
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
} | null>(null);
const opksshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
hasSelection: boolean;
} | null>(null);
const opksshFailedRef = useRef(false);
const currentHostIdRef = useRef<number | null>(null);
const currentHostConfigRef = useRef<any>(null);
@@ -183,12 +322,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const sessionIdRef = useRef<string | null>(null);
const isAttachingSessionRef = useRef<boolean>(false);
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
sessions: Array<{
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}>;
} | null>(null);
const tmuxSessionNameRef = useRef<string | null>(null);
const tmuxCopyModeHintShownRef = useRef(false);
const isVisibleRef = useRef<boolean>(false);
const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
const maxReconnectAttempts = 8;
const isUnmountingRef = useRef(false);
const shouldNotReconnectRef = useRef(false);
const isReconnectingRef = useRef(false);
@@ -211,11 +361,35 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const activityLoggedRef = useRef(false);
const keyHandlerAttachedRef = useRef(false);
const [commandHistoryTrackingEnabled, setCommandHistoryTrackingEnabled] =
useState<boolean>(
() => localStorage.getItem("commandHistoryTracking") === "true",
);
useEffect(() => {
const handleCommandHistoryTrackingChanged = () => {
setCommandHistoryTrackingEnabled(
localStorage.getItem("commandHistoryTracking") === "true",
);
};
window.addEventListener(
"commandHistoryTrackingChanged",
handleCommandHistoryTrackingChanged,
);
return () => {
window.removeEventListener(
"commandHistoryTrackingChanged",
handleCommandHistoryTrackingChanged,
);
};
}, []);
const { trackInput, getCurrentCommand, updateCurrentCommand } =
useCommandTracker({
hostId: hostConfig.id,
enabled: true,
enabled: commandHistoryTrackingEnabled,
onCommandExecuted: (command) => {
if (!autocompleteHistory.current.includes(command)) {
autocompleteHistory.current = [
@@ -558,6 +732,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current);
pongTimeoutRef.current = null;
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
@@ -584,6 +762,23 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnected(false);
setIsConnecting(false);
},
reconnect: () => {
isUnmountingRef.current = false;
shouldNotReconnectRef.current = false;
isReconnectingRef.current = false;
isConnectingRef.current = false;
reconnectAttempts.current = 0;
wasDisconnectedBySSH.current = false;
wasConnectedRef.current = false;
updateConnectionError(null);
setShowDisconnectedOverlay(false);
if (terminal) {
terminal.clear();
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
}
},
fit: () => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
@@ -628,9 +823,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (reconnectAttempts.current >= maxReconnectAttempts) {
updateConnectionError(t("terminal.maxReconnectAttemptsReached"));
setIsConnecting(false);
shouldNotReconnectRef.current = true;
setShowDisconnectedOverlay(true);
addLog({
type: "error",
stage: "connection",
@@ -706,7 +901,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, delay);
}
function connectToHost(cols: number, rows: number) {
async function connectToHost(cols: number, rows: number) {
if (isConnectingRef.current) {
return;
}
@@ -738,20 +933,52 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return;
}
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
})()
: `${getBasePath()}/ssh/websocket/`;
let baseWsUrl: string;
if (isDev) {
baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
} else if (isElectron()) {
let configuredUrl = (window as { configuredServerUrl?: string | null })
.configuredServerUrl;
if (!configuredUrl && !isEmbeddedMode()) {
try {
const serverConfig = await getServerConfig();
configuredUrl = serverConfig?.serverUrl || null;
if (configuredUrl) {
(
window as Window &
typeof globalThis & {
configuredServerUrl?: string | null;
}
).configuredServerUrl = configuredUrl;
}
} catch (error) {
console.error("Failed to resolve Electron server URL:", error);
}
}
if (isEmbeddedMode()) {
baseWsUrl = "ws://127.0.0.1:30002";
} else if (!configuredUrl) {
console.error("No configured server URL available for Electron SSH");
setIsConnected(false);
setIsConnecting(false);
updateConnectionError(t("errors.failedToLoadServer"));
isConnectingRef.current = false;
return;
} else {
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
}
} else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
}
if (
webSocketRef.current &&
@@ -769,9 +996,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
connectionTimeoutRef.current = null;
}
const wsUrl = `${baseWsUrl}?token=${encodeURIComponent(jwtToken)}`;
const ws = new WebSocket(wsUrl);
const ws = new WebSocket(baseWsUrl);
webSocketRef.current = ws;
wasDisconnectedBySSH.current = false;
updateConnectionError(null);
@@ -857,8 +1082,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
ws.send(JSON.stringify({ type: "input", data }));
});
pongReceivedRef.current = true;
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
if (!pongReceivedRef.current) {
console.warn(
"[WebSocket] Pong timeout - connection appears dead, closing",
);
ws.close();
return;
}
pongReceivedRef.current = false;
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
@@ -867,6 +1101,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
ws.addEventListener("message", (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "pong") {
pongReceivedRef.current = true;
return;
}
if (msg.type === "data") {
if (typeof msg.data === "string") {
const syntaxHighlightingEnabled =
@@ -879,19 +1117,36 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.write(outputData);
const sudoPasswordPattern =
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i;
const passwordToFill =
hostConfig.terminalConfig?.sudoPassword || hostConfig.password;
const hasSudoPw =
hostConfig.terminalConfig?.sudoPassword ||
hostConfig.password ||
hostConfig.hasSudoPassword ||
hostConfig.hasPassword;
if (
config.sudoPasswordAutoFill &&
sudoPasswordPattern.test(msg.data) &&
passwordToFill &&
hasSudoPw &&
!sudoPromptShownRef.current
) {
sudoPromptShownRef.current = true;
confirmWithToast(
t("terminal.sudoPasswordPopupTitle"),
async () => {
// Fetch password on-demand from server
let passwordToFill =
hostConfig.terminalConfig?.sudoPassword ||
hostConfig.password;
if (!passwordToFill && hostConfig.id) {
passwordToFill =
(await getHostPassword(
hostConfig.id,
"sudoPassword",
)) ||
(await getHostPassword(hostConfig.id, "password")) ||
undefined;
}
if (
passwordToFill &&
webSocketRef.current &&
webSocketRef.current.readyState === WebSocket.OPEN
) {
@@ -1052,19 +1307,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} else if (msg.type === "disconnected") {
wasDisconnectedBySSH.current = true;
setIsConnected(false);
if (terminal) {
terminal.clear();
}
setIsConnecting(false);
if (wasConnectedRef.current) {
wasConnectedRef.current = false;
if (
onClose &&
!connectionErrorRef.current &&
!opksshFailedRef.current
) {
onClose();
}
setShowDisconnectedOverlay(true);
} else if (!connectionErrorRef.current) {
updateConnectionError(
msg.message || t("terminal.connectionRejected"),
@@ -1158,6 +1404,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
authUrl: msg.url || "",
requestId: msg.requestId || "",
stage: "chooser",
providers: msg.providers,
});
if (opksshTimeoutRef.current) {
clearTimeout(opksshTimeoutRef.current);
@@ -1345,6 +1592,40 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const cols = terminal?.cols || 80;
const rows = terminal?.rows || 24;
connectToHost(cols, rows);
} else if (msg.type === "tmux_sessions_available") {
setTmuxSessionPicker({
sessions: msg.sessions,
});
} else if (
msg.type === "tmux_session_created" ||
msg.type === "tmux_session_attached"
) {
const sessionName =
typeof msg.sessionName === "string" ? msg.sessionName : "";
tmuxSessionNameRef.current = sessionName || "(active)";
addLog({
type: "info",
stage: "connection",
message:
msg.type === "tmux_session_created"
? t("terminal.tmuxSessionCreated", {
name: sessionName || "new",
})
: t("terminal.tmuxSessionAttached", {
name: sessionName,
}),
});
} else if (msg.type === "tmux_unavailable") {
setTimeout(() => {
toast.warning(t("terminal.tmuxUnavailable"), {
duration: 8000,
});
}, 500);
addLog({
type: "warning",
stage: "connection",
message: t("terminal.tmuxUnavailable"),
});
} else if (msg.type === "connection_log") {
if (msg.data) {
addLog({
@@ -1369,8 +1650,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnected(false);
isConnectingRef.current = false;
if (terminal) {
terminal.clear();
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current);
pongTimeoutRef.current = null;
}
if (totpTimeoutRef.current) {
@@ -1379,17 +1662,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (event.code === 1006) {
console.error(
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
console.warn(
"[WebSocket] Abnormal closure detected - attempting reconnection",
);
addLog({
type: "error",
type: "warning",
stage: "connection",
message: t("terminal.websocketAbnormalClose"),
});
updateConnectionError(t("terminal.websocketAbnormalClose"));
setIsConnecting(false);
shouldNotReconnectRef.current = true;
if (wasConnectedRef.current) {
attemptReconnection();
} else {
updateConnectionError(t("terminal.websocketAbnormalClose"));
setIsConnecting(false);
}
return;
}
@@ -1406,10 +1693,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
localStorage.removeItem("jwt");
setTimeout(() => {
window.location.reload();
}, 1000);
return;
}
@@ -1581,22 +1864,25 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
}, [handleDeleteCommand]);
// Separate theme and options updates to avoid terminal re-initialization flashes
useEffect(() => {
if (!terminal || !xtermRef.current) return;
if (!terminal) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...hostConfig.terminalConfig,
...(hostConfig.terminalConfig as any),
};
let themeColors;
if (config.theme === "termix") {
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[config.theme]?.colors ||
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
@@ -1605,13 +1891,98 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
// Update terminal options individually to avoid re-initialization flashes
terminal.options.cursorBlink = config.cursorBlink;
terminal.options.cursorStyle = config.cursorStyle;
terminal.options.scrollback = config.scrollback;
terminal.options.fontSize = config.fontSize;
terminal.options.fontFamily = fontFamily;
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
terminal.options.fastScrollModifier = config.fastScrollModifier;
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
terminal.options.letterSpacing = config.letterSpacing;
terminal.options.lineHeight = config.lineHeight;
terminal.options.bellStyle = config.bellStyle as
| "none"
| "sound"
| "visual"
| "both";
terminal.options.theme = {
background: themeColors.background,
foreground: themeColors.foreground,
cursor: themeColors.cursor,
cursorAccent: themeColors.cursorAccent,
selectionBackground: themeColors.selectionBackground,
selectionForeground: themeColors.selectionForeground,
black: themeColors.black,
red: themeColors.red,
green: themeColors.green,
yellow: themeColors.yellow,
blue: themeColors.blue,
magenta: themeColors.magenta,
cyan: themeColors.cyan,
white: themeColors.white,
brightBlack: themeColors.brightBlack,
brightRed: themeColors.brightRed,
brightGreen: themeColors.brightGreen,
brightYellow: themeColors.brightYellow,
brightBlue: themeColors.brightBlue,
brightMagenta: themeColors.brightMagenta,
brightCyan: themeColors.brightCyan,
brightWhite: themeColors.brightWhite,
};
// Ensure terminal is correctly fitted if font-related options change
if (fitAddonRef.current && isFitted) {
performFit();
}
// Refresh terminal to apply new theme colors to existing buffer content
hardRefresh();
}, [
terminal,
hostConfig.terminalConfig,
previewTheme,
isDarkMode,
isFitted,
]);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as any),
};
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
// Set initial options before opening the terminal
terminal.options = {
cursorBlink: config.cursorBlink,
cursorStyle: config.cursorStyle,
scrollback: config.scrollback,
fontSize: config.fontSize,
fontFamily,
allowTransparency: true,
allowTransparency: true, // MUST be set before open()
convertEol: false,
windowsMode: false,
macOptionIsMeta: false,
@@ -1624,7 +1995,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
letterSpacing: config.letterSpacing,
lineHeight: config.lineHeight,
bellStyle: config.bellStyle as "none" | "sound" | "visual" | "both",
theme: {
background: themeColors.background,
foreground: themeColors.foreground,
@@ -1678,26 +2048,71 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return;
const handleContextMenu = (e: MouseEvent) => {
if (getUseRightClickCopyPaste()) {
e.preventDefault();
e.stopPropagation();
if (terminal.hasSelection()) {
const text = terminal.getSelection();
navigator.clipboard
.writeText(text)
.then(() => terminal.clearSelection());
} else {
navigator.clipboard.readText().then((text) => {
if (text) terminal.paste(text);
});
}
return;
}
if (!onOpenFileManager) return;
e.preventDefault();
e.stopPropagation();
if (terminal.hasSelection()) {
const selection = terminal.getSelection();
if (selection) {
await writeTextToClipboard(selection);
terminal.clearSelection();
}
} else {
const text = await readTextFromClipboard();
if (text) terminal.paste(text);
}
setContextMenu({
x: e.clientX,
y: e.clientY,
hasSelection: terminal.hasSelection(),
});
};
element?.addEventListener("contextmenu", handleContextMenu);
const handlePaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text");
if (text) {
e.preventDefault();
e.stopPropagation();
terminal.paste(text);
}
};
element?.addEventListener("paste", handlePaste);
let tmuxDragTracking = false;
const handleTmuxDragStart = (e: MouseEvent) => {
if (e.button !== 0) return;
if (!tmuxSessionNameRef.current) return;
tmuxDragTracking = true;
};
const handleTmuxDragMove = () => {
if (!tmuxDragTracking) return;
tmuxDragTracking = false;
if (tmuxCopyModeHintShownRef.current) return;
tmuxCopyModeHintShownRef.current = true;
toast.info(t("terminal.tmuxCopyHint"), { duration: 5000 });
};
const handleTmuxDragEnd = () => {
tmuxDragTracking = false;
};
element?.addEventListener("mousedown", handleTmuxDragStart);
element?.addEventListener("mousemove", handleTmuxDragMove);
element?.addEventListener("mouseup", handleTmuxDragEnd);
const handleBackspaceMode = (e: KeyboardEvent) => {
if (e.key !== "Backspace") return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const config = {
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as any),
};
if (config.backspaceMode !== "control-h") return;
e.preventDefault();
@@ -1729,11 +2144,15 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
resizeObserver.disconnect();
clipboardProvider.dispose();
element?.removeEventListener("contextmenu", handleContextMenu);
element?.removeEventListener("paste", handlePaste);
element?.removeEventListener("mousedown", handleTmuxDragStart);
element?.removeEventListener("mousemove", handleTmuxDragMove);
element?.removeEventListener("mouseup", handleTmuxDragEnd);
element?.removeEventListener("keydown", handleBackspaceMode, true);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
};
}, [xtermRef, terminal, hostConfig, isDarkMode]);
}, [xtermRef, terminal]);
const isMountedRef = useRef(false);
@@ -1765,6 +2184,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current);
pongTimeoutRef.current = null;
}
const persistenceEnabled =
localStorage.getItem("enableTerminalSessionPersistence") === "true";
@@ -1838,11 +2261,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
!e.metaKey &&
e.key.toLowerCase() === "v"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard().then((text) => {
if (text) terminal.paste(text);
});
// Let the browser handle Ctrl+V natively, the paste event
// listener will intercept the result without triggering the
// clipboard permission popup
return false;
}
@@ -2145,6 +2566,40 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
{showDisconnectedOverlay && !isConnecting && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-10"
style={{ backgroundColor }}
>
<div className="flex gap-2">
<Button
onClick={() => {
setShowDisconnectedOverlay(false);
isUnmountingRef.current = false;
shouldNotReconnectRef.current = false;
isReconnectingRef.current = false;
isConnectingRef.current = false;
reconnectAttempts.current = 0;
wasDisconnectedBySSH.current = false;
wasConnectedRef.current = false;
updateConnectionError(null);
if (terminal) {
terminal.clear();
connectToHost(terminal.cols, terminal.rows);
}
}}
>
{t("terminal.reconnect")}
</Button>
{onClose && (
<Button variant="secondary" onClick={onClose}>
{t("terminal.closeTab")}
</Button>
)}
</div>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={isConnected}
@@ -2191,6 +2646,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
requestId={opksshDialog.requestId}
stage={opksshDialog.stage}
error={opksshDialog.error}
providers={opksshDialog.providers}
onCancel={() => {
if (webSocketRef.current) {
webSocketRef.current.send(
@@ -2217,6 +2673,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
);
}
}}
onSelectProvider={(alias) => {
if (!opksshDialog.authUrl) return;
const selectUrl = `${opksshDialog.authUrl}/select?op=${encodeURIComponent(alias)}`;
window.open(selectUrl, "_blank");
if (webSocketRef.current) {
webSocketRef.current.send(
JSON.stringify({
type: "opkssh_browser_opened",
data: { requestId: opksshDialog.requestId },
}),
);
}
setOpksshDialog((prev) =>
prev ? { ...prev, stage: "waiting" } : null,
);
}}
backgroundColor={backgroundColor}
/>
)}
@@ -2254,6 +2726,37 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
/>
)}
{tmuxSessionPicker && (
<TmuxSessionPicker
isOpen={true}
sessions={tmuxSessionPicker.sessions}
onSelect={(sessionName) => {
setTmuxSessionPicker(null);
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(
JSON.stringify({
type: "tmux_attach",
data: { sessionName },
}),
);
}
}}
onCreateNew={() => {
setTmuxSessionPicker(null);
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(
JSON.stringify({
type: "tmux_attach",
data: { sessionName: "" },
}),
);
}
}}
onCancel={() => setTmuxSessionPicker(null)}
backgroundColor={backgroundColor}
/>
)}
<CommandAutocomplete
visible={showAutocomplete}
suggestions={autocompleteSuggestions}
@@ -2261,6 +2764,29 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
position={autocompletePosition}
onSelect={handleAutocompleteSelect}
/>
{contextMenu && (
<TerminalContextMenu
x={contextMenu.x}
y={contextMenu.y}
hasSelection={contextMenu.hasSelection}
showCopyPaste={getUseRightClickCopyPaste()}
showOpenFileManager={!!onOpenFileManager}
onCopy={async () => {
const selection = terminal?.getSelection();
if (selection) {
await writeTextToClipboard(selection);
terminal?.clearSelection();
}
}}
onPaste={async () => {
const text = await readTextFromClipboard();
if (text) terminal?.paste(text);
}}
onOpenFileManager={() => onOpenFileManager?.()}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
},
@@ -12,6 +12,7 @@ import { CredentialsManager } from "@/ui/desktop/apps/host-manager/credentials/C
import { CredentialEditor } from "@/ui/desktop/apps/host-manager/credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next";
import { exportSSHHostWithCredentials } from "@/ui/main-axios.ts";
import type { SSHHost, HostManagerProps } from "../../../types/index";
export function HostManager({
@@ -93,9 +94,13 @@ export function HostManager({
}
if (hostConfig && hostConfig.id !== lastProcessedHostIdRef.current) {
setEditingHost(hostConfig);
setIsAddingHost(false);
lastProcessedHostIdRef.current = hostConfig.id;
setIsAddingHost(false);
exportSSHHostWithCredentials(hostConfig.id)
.then((fullHost) =>
setEditingHost({ ...hostConfig, ...fullHost } as SSHHost),
)
.catch(() => setEditingHost(hostConfig));
} else if (!hostConfig && editingHost) {
setEditingHost(null);
setIsAddingHost(false);
@@ -111,17 +116,26 @@ export function HostManager({
setActiveTab(normalizedTab);
}
if (hostConfig && hostConfig.id !== lastProcessedHostIdRef.current) {
setEditingHost(hostConfig);
setIsAddingHost(false);
lastProcessedHostIdRef.current = hostConfig.id;
setIsAddingHost(false);
exportSSHHostWithCredentials(hostConfig.id)
.then((fullHost) =>
setEditingHost({ ...hostConfig, ...fullHost } as SSHHost),
)
.catch(() => setEditingHost(hostConfig));
}
}
}, [_updateTimestamp, initialTab, hostConfig]);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
const handleEditHost = async (host: SSHHost) => {
setIsAddingHost(false);
lastProcessedHostIdRef.current = host.id;
try {
const fullHost = await exportSSHHostWithCredentials(host.id);
setEditingHost({ ...host, ...fullHost } as SSHHost);
} catch {
setEditingHost(host);
}
};
const handleAddHost = () => {
@@ -413,6 +413,7 @@ export function HostManagerEditor({
sudoPassword: z.string().optional(),
keepaliveInterval: z.number().min(0).max(300000).optional(),
keepaliveCountMax: z.number().min(0).max(100).optional(),
autoTmux: z.boolean(),
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
@@ -448,6 +449,16 @@ export function HostManagerEditor({
}),
)
.optional(),
macAddress: z.string().optional(),
portKnockSequence: z
.array(
z.object({
port: z.coerce.number().min(1).max(65535),
protocol: z.enum(["tcp", "udp"]).default("tcp"),
delay: z.coerce.number().min(0).max(60000).default(100),
}),
)
.optional(),
enableDocker: z.boolean().default(false),
domain: z.string().optional(),
security: z.string().optional(),
@@ -507,11 +518,7 @@ export function HostManagerEditor({
});
}
if (!data.keyType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.keyTypeRequired", "Key type is required"),
path: ["keyType"],
});
data.keyType = "auto";
}
} else if (data.authType === "credential") {
if (!data.credentialId) {
@@ -522,19 +529,6 @@ export function HostManagerEditor({
});
}
}
data.tunnelConnections.forEach((connection, index) => {
if (
connection.endpointHost &&
!sshConfigurations.includes(connection.endpointHost)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.mustSelectValidSshConfig"),
path: ["tunnelConnections", index, "endpointHost"],
});
}
});
});
type FormData = z.infer<typeof formSchema>;
@@ -580,6 +574,8 @@ export function HostManagerEditor({
socks5Username: "",
socks5Password: "",
socks5ProxyChain: [],
macAddress: "",
portKnockSequence: [],
enableDocker: false,
domain: "",
security: "any",
@@ -821,6 +817,10 @@ export function HostManagerEditor({
socks5ProxyChain: Array.isArray(cleanedHost.socks5ProxyChain)
? cleanedHost.socks5ProxyChain
: [],
macAddress: cleanedHost.macAddress || "",
portKnockSequence: Array.isArray(cleanedHost.portKnockSequence)
? cleanedHost.portKnockSequence
: [],
enableDocker: Boolean(cleanedHost.enableDocker),
domain: (cleanedHost as any).domain || "",
security: (cleanedHost as any).security || "any",
@@ -1086,6 +1086,7 @@ export function HostManagerEditor({
socks5Username: "general",
socks5Password: "general",
socks5ProxyChain: "general",
portKnockSequence: "general",
quickActions: "general",
enableTerminal: "terminal",
terminalConfig: "terminal",
@@ -1248,7 +1249,10 @@ export function HostManagerEditor({
};
const handleSshConfigClick = (config: string, index: number) => {
form.setValue(`tunnelConnections.${index}.endpointHost`, config);
form.setValue(`tunnelConnections.${index}.endpointHost`, config, {
shouldValidate: true,
shouldDirty: true,
});
setSshConfigDropdownOpen((prev) => ({ ...prev, [index]: false }));
};
@@ -35,14 +35,15 @@ import {
updateSSHHost,
renameFolder,
exportSSHHostWithCredentials,
exportAllSSHHosts,
getSSHFolders,
updateFolderMetadata,
deleteAllHostsInFolder,
refreshServerPolling,
isElectron,
getConfiguredServerUrl,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
getGuacamoleToken,
logActivity,
} from "@/ui/main-axios.ts";
import { useServerStatus } from "@/ui/contexts/ServerStatusContext";
@@ -89,6 +90,8 @@ import {
Monitor,
MessagesSquare,
Eye,
ChevronsDownUp,
ChevronsUpDown,
} from "lucide-react";
import type {
SSHHost,
@@ -843,6 +846,39 @@ export function HostManagerViewer({
URL.revokeObjectURL(url);
};
const [exporting, setExporting] = useState(false);
const handleExportAll = () => {
confirmWithToast(
t("hosts.exportAllSensitiveWarning"),
async () => {
setExporting(true);
try {
const data = await exportAllSSHHosts();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `termix-hosts-export-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(
t("hosts.exportedAllHosts", { count: data.hosts.length }),
);
} catch {
toast.error(t("hosts.failedToExportAllHosts"));
} finally {
setExporting(false);
}
},
"destructive",
);
};
const handleJsonImport = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
@@ -1160,6 +1196,15 @@ export function HostManagerViewer({
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
disabled={exporting || hosts.length === 0}
onClick={handleExportAll}
>
{exporting ? t("hosts.exporting") : t("hosts.exportAllJson")}
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadSample}>
{t("hosts.downloadSample")}
</Button>
@@ -1215,6 +1260,28 @@ export function HostManagerViewer({
<ListChecks className="h-4 w-4 mr-2" />
{selectionMode ? t("hosts.exitSelectMode") : t("hosts.selectMode")}
</Button>
<Button
variant="outline"
className="h-9"
onClick={() => {
if (openAccordions.length > 0) {
setOpenAccordions([]);
} else {
setOpenAccordions(folderKeys);
}
}}
title={
openAccordions.length > 0
? t("hosts.collapseAll", "Collapse All")
: t("hosts.expandAll", "Expand All")
}
>
{openAccordions.length > 0 ? (
<ChevronsDownUp className="h-4 w-4" />
) : (
<ChevronsUpDown className="h-4 w-4" />
)}
</Button>
</div>
<ScrollArea className="flex-1 min-h-0">
@@ -1921,19 +1988,9 @@ export function HostManagerViewer({
| "vnc"
| "telnet";
const result =
await getGuacamoleToken({
protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert:
host.ignoreCert,
guacamoleConfig:
host.guacamoleConfig as any,
});
await getGuacamoleTokenFromHost(
host.id,
);
addTab({
type: protocol,
@@ -1951,6 +2008,9 @@ export function HostManagerViewer({
security: host.security,
"ignore-cert":
host.ignoreCert,
dpi: getGuacamoleDpi(
host,
),
},
});
@@ -224,49 +224,73 @@ export function HostGeneralTab({
)}
/>
{connectionType !== "vnc" && (
<FormField
control={form.control}
name="username"
render={({ field }) => {
const isCredentialAuth = authTab === "credential";
const credentialId = form.watch("credentialId");
const overrideEnabled = form.watch("overrideCredentialUsername");
const selectedCredential = credentials.find(
(c) => c.id === credentialId,
);
const shouldDisable =
isCredentialAuth &&
selectedCredential?.username &&
!overrideEnabled;
return (
<FormItem className="col-span-6">
<FormLabel>{t("hosts.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
disabled={shouldDisable}
{...field}
onChange={(e) => {
field.onChange(e.target.value);
if (
isCredentialAuth &&
selectedCredential &&
!selectedCredential.username &&
e.target.value.trim() !== ""
) {
form.setValue("overrideCredentialUsername", true);
}
}}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
);
}}
/>
)}
</div>
<div className="grid grid-cols-12 gap-4 mt-3">
<FormField
control={form.control}
name="username"
render={({ field }) => {
const isCredentialAuth = authTab === "credential";
const credentialId = form.watch("credentialId");
const overrideEnabled = form.watch("overrideCredentialUsername");
const selectedCredential = credentials.find(
(c) => c.id === credentialId,
);
const shouldDisable =
isCredentialAuth &&
selectedCredential?.username &&
!overrideEnabled;
return (
<FormItem className="col-span-6">
<FormLabel>{t("hosts.username")}</FormLabel>
<FormControl>
<Input
placeholder={t("placeholders.username")}
disabled={shouldDisable}
{...field}
onChange={(e) => {
field.onChange(e.target.value);
if (
isCredentialAuth &&
selectedCredential &&
!selectedCredential.username &&
e.target.value.trim() !== ""
) {
form.setValue("overrideCredentialUsername", true);
}
}}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
</FormItem>
);
}}
name="macAddress"
render={({ field }) => (
<FormItem className="col-span-5">
<FormLabel>{t("hosts.macAddress")}</FormLabel>
<FormControl>
<Input
placeholder="AA:BB:CC:DD:EE:FF"
{...field}
onBlur={(e) => {
field.onChange(e.target.value.trim());
field.onBlur();
}}
/>
</FormControl>
<FormDescription>{t("hosts.macAddressDesc")}</FormDescription>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
@@ -1438,6 +1462,120 @@ export function HostGeneralTab({
)}
</AccordionContent>
</AccordionItem>
<AccordionItem value="port-knocking">
<AccordionTrigger>{t("hosts.portKnocking")}</AccordionTrigger>
<AccordionContent className="space-y-3 pt-2">
<p className="text-sm text-muted-foreground">
{t("hosts.portKnockingDesc")}
</p>
<Controller
control={form.control}
name="portKnockSequence"
render={({ field }) => {
const sequence = field.value || [];
return (
<div className="space-y-2">
{sequence.map(
(
knock: {
port: number;
protocol?: string;
delay?: number;
},
index: number,
) => (
<div
key={index}
className="flex items-center gap-2"
>
<Input
type="number"
placeholder={t("hosts.port")}
value={knock.port || ""}
onChange={(e) => {
const updated = [...sequence];
updated[index] = {
...updated[index],
port: parseInt(e.target.value) || 0,
};
field.onChange(updated);
}}
className="w-24"
/>
<Select
value={knock.protocol || "tcp"}
onValueChange={(v) => {
const updated = [...sequence];
updated[index] = {
...updated[index],
protocol: v,
};
field.onChange(updated);
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
placeholder={t("hosts.delayMs")}
value={knock.delay ?? 100}
onChange={(e) => {
const updated = [...sequence];
updated[index] = {
...updated[index],
delay: parseInt(e.target.value) || 0,
};
field.onChange(updated);
}}
className="w-20"
/>
<span className="text-xs text-muted-foreground">
ms
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
field.onChange(
sequence.filter(
(_: unknown, i: number) => i !== index,
),
);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
),
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange([
...sequence,
{ port: 0, protocol: "tcp", delay: 100 },
]);
}}
>
<Plus className="h-4 w-4 mr-1" />
{t("hosts.addKnock")}
</Button>
</div>
);
}}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</>
)}
@@ -47,9 +47,11 @@ import {
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
import type { HostTerminalTabProps } from "./shared/tab-types";
import React from "react";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
const [snippetPopoverOpen, setSnippetPopoverOpen] = React.useState(false);
const { setPreviewTerminalTheme } = useTabs() as any;
return (
<div className="space-y-1">
<FormField
@@ -103,9 +105,15 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
<SelectValue placeholder={t("hosts.selectTheme")} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectContent
onMouseLeave={() => setPreviewTerminalTheme(null)}
>
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
<SelectItem key={key} value={key}>
<SelectItem
key={key}
value={key}
onMouseEnter={() => setPreviewTerminalTheme(key)}
>
{theme.name}
</SelectItem>
))}
@@ -637,6 +645,25 @@ export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
/>
)}
<FormField
control={form.control}
name="terminalConfig.autoTmux"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
<div className="space-y-0.5">
<FormLabel>{t("hosts.autoTmux")}</FormLabel>
<FormDescription>{t("hosts.autoTmuxDesc")}</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="terminalConfig.sudoPasswordAutoFill"
+506 -116
View File
@@ -60,6 +60,8 @@ import {
Archive,
HardDrive,
Globe,
Share2,
Users,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
@@ -79,6 +81,13 @@ import {
renameSnippetFolder,
deleteSnippetFolder,
reorderSnippets,
getSharedSnippets,
shareSnippet,
getSnippetAccess,
revokeSnippetAccess,
getRoles,
getUserList,
type AccessRecord,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData, SnippetFolder } from "../../../../types";
@@ -180,6 +189,30 @@ export function SSHToolsSidebar({
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [snippetFolders, setSnippetFolders] = useState<SnippetFolder[]>([]);
const [sharedSnippetsList, setSharedSnippetsList] = useState<
Array<{
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
}>
>([]);
const [shareDialogSnippet, setShareDialogSnippet] = useState<Snippet | null>(
null,
);
const [shareTargetType, setShareTargetType] = useState<"user" | "role">(
"user",
);
const [shareUsers, setShareUsers] = useState<
Array<{ id: string; username: string }>
>([]);
const [shareRoles, setShareRoles] = useState<
Array<{ id: number; name: string; displayName?: string }>
>([]);
const [shareTargetId, setShareTargetId] = useState("");
const [shareAccessList, setShareAccessList] = useState<AccessRecord[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
@@ -222,6 +255,9 @@ export function SSHToolsSidebar({
const [searchQuery, setSearchQuery] = useState("");
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState<boolean>(
() => localStorage.getItem("commandHistoryTracking") === "true",
);
const [splitMode, setSplitMode] = useState<
"none" | "2" | "3" | "4" | "5" | "6"
@@ -240,6 +276,14 @@ export function SSHToolsSidebar({
const startWidthRef = React.useRef<number>(sidebarWidth);
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
useEffect(() => {
const terminalIds = new Set(terminalTabs.map((t) => t.id));
setSelectedSnippetTabIds((prev) =>
prev.filter((id) => terminalIds.has(id)),
);
}, [terminalTabs.length]);
const activeUiTab = tabs.find((tab) => tab.id === currentTab);
const activeTerminal =
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
@@ -327,6 +371,17 @@ export function SSHToolsSidebar({
}
}, [isOpen, activeTab, activeTerminalHostId]);
useEffect(() => {
const handleChange = () => {
setCommandHistoryEnabled(
localStorage.getItem("commandHistoryTracking") === "true",
);
};
window.addEventListener("commandHistoryTrackingChanged", handleChange);
return () =>
window.removeEventListener("commandHistoryTrackingChanged", handleChange);
}, []);
const filteredCommands = searchQuery
? commandHistory.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
@@ -625,16 +680,19 @@ export function SSHToolsSidebar({
const fetchSnippets = async () => {
try {
setLoading(true);
const [snippetsData, foldersData] = await Promise.all([
const [snippetsData, foldersData, sharedData] = await Promise.all([
getSnippets(),
getSnippetFolders(),
getSharedSnippets().catch(() => ({ sharedSnippets: [] })),
]);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
setSnippetFolders(Array.isArray(foldersData) ? foldersData : []);
setSharedSnippetsList(sharedData.sharedSnippets || []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
setSnippetFolders([]);
setSharedSnippetsList([]);
} finally {
setLoading(false);
}
@@ -675,6 +733,63 @@ export function SSHToolsSidebar({
);
};
const handleOpenShareDialog = async (snippet: Snippet) => {
setShareDialogSnippet(snippet);
setShareTargetId("");
setShareTargetType("user");
try {
const [usersData, rolesData, accessData] = await Promise.all([
getUserList(),
getRoles(),
getSnippetAccess(snippet.id),
]);
setShareUsers(
(usersData?.users || []).map((u: Record<string, unknown>) => ({
id: u.id as string,
username: u.username as string,
})),
);
setShareRoles(
(rolesData?.roles || []).map(
(r: { id: number; name: string; displayName?: string }) => r,
),
);
setShareAccessList(accessData.accessList || []);
} catch {
toast.error(t("snippets.failedToLoadShareData"));
}
};
const handleShare = async () => {
if (!shareDialogSnippet || !shareTargetId) return;
try {
await shareSnippet(shareDialogSnippet.id, {
targetType: shareTargetType,
targetUserId: shareTargetType === "user" ? shareTargetId : undefined,
targetRoleId:
shareTargetType === "role" ? parseInt(shareTargetId) : undefined,
});
toast.success(t("snippets.shareSuccess"));
const accessData = await getSnippetAccess(shareDialogSnippet.id);
setShareAccessList(accessData.accessList || []);
setShareTargetId("");
} catch {
toast.error(t("snippets.shareFailed"));
}
};
const handleRevokeSnippetAccess = async (accessId: number) => {
if (!shareDialogSnippet) return;
try {
await revokeSnippetAccess(shareDialogSnippet.id, accessId);
toast.success(t("snippets.revokeSuccess"));
const accessData = await getSnippetAccess(shareDialogSnippet.id);
setShareAccessList(accessData.accessList || []);
} catch {
toast.error(t("snippets.revokeFailed"));
}
};
const handleSubmit = async () => {
const errors = {
name: !formData.name.trim(),
@@ -736,7 +851,7 @@ export function SSHToolsSidebar({
selectedSnippetTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(snippet.content + "\n");
tab.terminalRef.current.sendInput(snippet.content + "\r");
}
});
toast.success(
@@ -1296,6 +1411,30 @@ export function SSHToolsSidebar({
})
: t("snippets.executeOnCurrent")}
</p>
<div className="flex gap-2 mb-1">
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() =>
setSelectedSnippetTabIds(
terminalTabs.map((t) => t.id),
)
}
>
{t("snippets.selectAll", "Select All")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-6 px-2"
onClick={() => setSelectedSnippetTabIds([])}
>
{t("snippets.deselectAll", "Deselect All")}
</Button>
</div>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto thin-scrollbar">
{terminalTabs.map((tab) => (
<Button
@@ -1375,6 +1514,118 @@ export function SSHToolsSidebar({
) : (
<TooltipProvider>
<div className="space-y-3 overflow-y-auto flex-1 min-h-0 thin-scrollbar">
{sharedSnippetsList.length > 0 &&
(() => {
const isCollapsed =
collapsedFolders.has("__shared__");
const FolderIcon = getFolderIcon("__shared__");
return (
<div key="__shared__">
<div className="flex items-center gap-2 mb-2 hover:bg-hover-alt p-2 rounded-lg transition-colors group/folder">
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => toggleFolder("__shared__")}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<FolderIcon className="h-4 w-4" />
<span className="text-sm font-semibold">
{t("snippets.sharedWithYou")}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{sharedSnippetsList.length}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 pointer-events-none">
<div className="h-6 w-6" />
<div className="h-6 w-6" />
</div>
</div>
{!isCollapsed && (
<div className="space-y-2 ml-6">
{sharedSnippetsList.map((snippet) => (
<div
key={`shared-${snippet.id}`}
className="bg-field border border-input rounded-lg hover:shadow-lg hover:border-edge-hover hover:bg-hover-alt transition-all duration-200 p-3 group"
>
<div className="mb-2 flex items-center gap-2">
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-foreground mb-1">
{snippet.name}
</h3>
{snippet.description && (
<p className="text-xs text-muted-foreground">
{snippet.description}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{t("snippets.sharedBy", {
username:
snippet.ownerUsername,
})}
</p>
</div>
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
{snippet.content}
</code>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="default"
className="flex-1"
onClick={() =>
handleExecute(
snippet as unknown as Snippet,
)
}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("snippets.runTooltip")}
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() =>
handleCopy(
snippet as unknown as Snippet,
)
}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("snippets.copyTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
);
})()}
{Array.from(groupSnippetsByFolder()).map(
([folderName, folderSnippets]) => {
const folderMetadata = snippetFolders.find(
@@ -1584,6 +1835,27 @@ export function SSHToolsSidebar({
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() =>
handleOpenShareDialog(
snippet,
)
}
>
<Share2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{t("snippets.shareTooltip")}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
@@ -1602,131 +1874,151 @@ export function SSHToolsSidebar({
value="command-history"
className="flex flex-col flex-1 overflow-hidden"
>
<div className="space-y-2 flex-shrink-0 mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("commandHistory.searchPlaceholder")}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
{!commandHistoryEnabled ? (
<div className="flex flex-col items-center justify-center flex-1 py-8 text-center px-4">
<div className="bg-muted/40 border rounded-lg p-5 space-y-2">
<p className="font-medium text-sm">
{t("commandHistory.disabledTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("commandHistory.disabledDescription")}
</p>
</div>
</div>
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
{t("commandHistory.tabHint")}
</p>
</div>
<div className="flex-1 overflow-hidden min-h-0">
{historyError ? (
<div className="text-center py-8">
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
<p className="text-destructive font-medium mb-2">
{t("commandHistory.error")}
</p>
<p className="text-sm text-muted-foreground">
{historyError}
</p>
) : (
<>
<div className="space-y-2 flex-shrink-0 mb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"commandHistory.searchPlaceholder",
)}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
}}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button
onClick={() =>
setHistoryRefreshCounter((prev) => prev + 1)
}
variant="outline"
>
{t("common.retry")}
</Button>
</div>
) : !activeTerminal ? (
<div className="text-center text-muted-foreground py-8">
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noTerminal")}{" "}
</p>
<p className="text-sm">
{t("commandHistory.noTerminalHint")}
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
{t("commandHistory.tabHint")}
</p>
</div>
) : isHistoryLoading && commandHistory.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
<p className="mb-2 font-medium">
{t("commandHistory.loading")}{" "}
</p>
</div>
) : filteredCommands.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
<div className="flex-1 overflow-hidden min-h-0">
{historyError ? (
<div className="text-center py-8">
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 mb-4">
<p className="text-destructive font-medium mb-2">
{t("commandHistory.error")}
</p>
<p className="text-sm text-muted-foreground">
{historyError}
</p>
</div>
<Button
onClick={() =>
setHistoryRefreshCounter((prev) => prev + 1)
}
variant="outline"
>
{t("common.retry")}
</Button>
</div>
) : !activeTerminal ? (
<div className="text-center text-muted-foreground py-8">
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noResults")}
{t("commandHistory.noTerminal")}{" "}
</p>
<p className="text-sm">
{t("commandHistory.noResultsHint", {
query: searchQuery,
})}
{t("commandHistory.noTerminalHint")}
</p>
</>
</div>
) : isHistoryLoading &&
commandHistory.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Loader2 className="h-12 w-12 mb-4 opacity-20 mx-auto animate-spin" />
<p className="mb-2 font-medium">
{t("commandHistory.loading")}{" "}
</p>
</div>
) : filteredCommands.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noResults")}
</p>
<p className="text-sm">
{t("commandHistory.noResultsHint", {
query: searchQuery,
})}
</p>
</>
) : (
<>
<p className="mb-2 font-medium">
{t("commandHistory.empty")}
</p>
<p className="text-sm">
{t("commandHistory.emptyHint")}
</p>
</>
)}
</div>
) : (
<>
<p className="mb-2 font-medium">
{t("commandHistory.empty")}
</p>
<p className="text-sm">
{t("commandHistory.emptyHint")}
</p>
</>
<div
ref={commandHistoryScrollRef}
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
>
{filteredCommands.map((command, index) => (
<div
key={index}
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
onClick={() =>
handleCommandSelect(command)
}
title={command}
>
{command}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCommandDelete(command);
}}
title={t("commandHistory.deleteTooltip")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
) : (
<div
ref={commandHistoryScrollRef}
className="space-y-2 overflow-y-auto h-full thin-scrollbar"
>
{filteredCommands.map((command, index) => (
<div
key={index}
className="bg-canvas border-2 border-edge rounded-md px-3 py-2.5 hover:bg-hover-alt hover:border-edge-hover transition-all duration-200 group h-12 flex items-center"
>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
<span
className="flex-1 font-mono text-sm cursor-pointer text-foreground truncate"
onClick={() => handleCommandSelect(command)}
title={command}
>
{command}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400 flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
handleCommandDelete(command);
}}
title={t("commandHistory.deleteTooltip")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</TabsContent>
<TabsContent
@@ -2238,6 +2530,104 @@ export function SSHToolsSidebar({
</div>
</div>
)}
{shareDialogSnippet && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="bg-card border-2 border-border rounded-lg p-6 w-full max-w-md space-y-4 max-h-[80vh] overflow-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{t("snippets.shareSnippet")}
</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setShareDialogSnippet(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
{shareDialogSnippet.name}
</p>
<div className="space-y-3">
<div className="flex gap-2">
<Select
value={shareTargetType}
onValueChange={(v: "user" | "role") => {
setShareTargetType(v);
setShareTargetId("");
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{t("snippets.user")}</SelectItem>
<SelectItem value="role">{t("snippets.role")}</SelectItem>
</SelectContent>
</Select>
<Select value={shareTargetId} onValueChange={setShareTargetId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={t("snippets.selectTarget")} />
</SelectTrigger>
<SelectContent>
{shareTargetType === "user"
? shareUsers.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.username}
</SelectItem>
))
: shareRoles.map((r) => (
<SelectItem key={r.id} value={String(r.id)}>
{r.displayName || r.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleShare}
disabled={!shareTargetId}
size="sm"
>
<Share2 className="w-4 h-4" />
</Button>
</div>
</div>
{shareAccessList.length > 0 && (
<div className="space-y-2">
<span className="text-sm font-semibold">
{t("snippets.currentAccess")}
</span>
{shareAccessList.map((access) => (
<div
key={access.id}
className="flex items-center justify-between rounded-md border p-2 text-sm"
>
<span>
{access.username ||
access.roleDisplayName ||
access.roleName}
</span>
<Button
size="sm"
variant="ghost"
className="hover:bg-destructive hover:text-destructive-foreground"
onClick={() => handleRevokeSnippetAccess(access.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
</>
);
}
+49 -10
View File
@@ -31,6 +31,7 @@ import {
getOIDCAuthorizeUrl,
verifyTOTPLogin,
getServerConfig,
saveServerConfig,
isElectron,
getEmbeddedServerStatus,
isEmbeddedMode,
@@ -56,6 +57,7 @@ interface AuthProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
authLoading: boolean;
setDbError: (error: string | null) => void;
dbError?: string | null;
onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
@@ -72,6 +74,7 @@ export function Auth({
loggedIn,
authLoading,
setDbError,
dbError: _dbError,
onAuthSuccess,
...props
}: AuthProps) {
@@ -80,6 +83,10 @@ export function Auth({
const isDarkMode =
theme === "dark" ||
theme === "dracula" ||
theme === "gentlemansChoice" ||
theme === "midnightEspresso" ||
theme === "catppuccinMocha" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
@@ -93,7 +100,7 @@ export function Auth({
return true;
}
} catch (_e) {
return false;
return true;
}
return false;
};
@@ -147,7 +154,24 @@ export function Auth({
const handleElectronAuthSuccess = useCallback(async () => {
try {
const meRes = await getUserInfo();
let retries = 5;
let meRes = null;
while (retries-- > 0) {
try {
meRes = await getUserInfo();
break;
} catch (err: any) {
const isNoServer =
err?.code === "NO_SERVER_CONFIGURED" ||
err?.message?.includes("no-server-configured");
if (isNoServer && retries > 0) {
await new Promise((r) => setTimeout(r, 500));
} else {
throw err;
}
}
}
if (!meRes) throw new Error("Failed to get user info");
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
@@ -255,12 +279,6 @@ export function Auth({
});
}, [setDbError, firstUserToastShown, showServerConfig, t]);
useEffect(() => {
if (!registrationAllowed && !internalLoggedIn) {
toast.warning(t("messages.registrationDisabled"));
}
}, [registrationAllowed, internalLoggedIn, t]);
useEffect(() => {
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
setTab("external");
@@ -658,6 +676,11 @@ export function Auth({
if (success) {
setOidcLoading(true);
const urlToken = urlParams.get("token");
if (urlToken && (isElectron() || isInElectronWebView())) {
localStorage.setItem("jwt", urlToken);
}
getUserInfo()
.then((meRes) => {
if (isInElectronWebView()) {
@@ -683,6 +706,13 @@ export function Auth({
}
}
if (isElectron()) {
const token = getCookie("jwt");
if (token) {
localStorage.setItem("jwt", token);
}
}
setInternalLoggedIn(true);
setLoggedIn(true);
setIsAdmin(!!meRes.is_admin);
@@ -772,7 +802,12 @@ export function Auth({
getEmbeddedServerStatus(),
]);
if (status?.embedded && status?.running && !config?.serverUrl) {
if (
status?.embedded &&
status?.running &&
config &&
!config.serverUrl
) {
setCurrentServerUrl("");
setShowServerConfig(false);
return;
@@ -816,7 +851,11 @@ export function Auth({
onServerConfigured={() => {
window.location.reload();
}}
onUseEmbedded={() => {
onUseEmbedded={async () => {
await saveServerConfig({
serverUrl: "",
lastUpdated: new Date().toISOString(),
});
setShowServerConfig(false);
setCurrentServerUrl("");
}}
@@ -1,9 +1,8 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button.tsx";
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { AlertCircle, Loader2, ArrowLeft, RefreshCw } from "lucide-react";
import { getCookie } from "@/ui/main-axios.ts";
import { setCookie } from "@/ui/main-axios.ts";
interface ElectronLoginFormProps {
serverUrl: string;
@@ -20,64 +19,75 @@ export function ElectronLoginForm({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState(false);
const isAuthenticatingRef = useRef(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
const hasAuthenticatedRef = useRef(false);
const [currentUrl, setCurrentUrl] = useState(serverUrl);
const hasLoadedOnce = useRef(false);
const onAuthSuccessRef = useRef(onAuthSuccess);
useEffect(() => {
localStorage.removeItem("jwt");
}, []);
onAuthSuccessRef.current = onAuthSuccess;
}, [onAuthSuccess]);
const handleAuthToken = useCallback(
async (token: string) => {
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
hasAuthenticatedRef.current = true;
isAuthenticatingRef.current = true;
setIsAuthenticating(true);
try {
setCookie("jwt", token);
await new Promise((resolve) => setTimeout(resolve, 300));
onAuthSuccessRef.current();
} catch (_err) {
setError(t("errors.authTokenSaveFailed"));
isAuthenticatingRef.current = false;
setIsAuthenticating(false);
hasAuthenticatedRef.current = false;
}
},
[t],
);
// postMessage from server's Auth.tsx (works if server has postMessage code)
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
try {
const serverOrigin = new URL(serverUrl).origin;
if (event.origin !== serverOrigin) {
return;
if (!event.data || typeof event.data !== "object") return;
const { type, token, platform } = event.data;
if (type === "AUTH_SUCCESS" && token && platform === "desktop") {
await handleAuthToken(token);
}
if (event.data && typeof event.data === "object") {
const data = event.data;
if (
data.type === "AUTH_SUCCESS" &&
data.token &&
!hasAuthenticatedRef.current &&
!isAuthenticating
) {
hasAuthenticatedRef.current = true;
setIsAuthenticating(true);
try {
localStorage.setItem("jwt", data.token);
const savedToken = localStorage.getItem("jwt");
if (!savedToken) {
throw new Error("Failed to save JWT to localStorage");
}
await new Promise((resolve) => setTimeout(resolve, 500));
onAuthSuccess();
} catch (err) {
setError(t("errors.authTokenSaveFailed"));
setIsAuthenticating(false);
hasAuthenticatedRef.current = false;
}
}
}
} catch (err) {
console.error("Authentication operation failed:", err);
} catch (_err) {
// ignore
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [handleAuthToken]);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [serverUrl, isAuthenticating, onAuthSuccess, t]);
// Poll iframe localStorage via main process executeJavaScriptInFrame (bypasses cross-origin)
useEffect(() => {
if (loading) return;
const electronAPI = (window as any).electronAPI;
if (!electronAPI?.invoke) return;
const poll = setInterval(async () => {
if (hasAuthenticatedRef.current || isAuthenticatingRef.current) return;
try {
const token = await electronAPI.invoke("get-iframe-jwt");
if (token && token.length > 20) {
await handleAuthToken(token);
}
} catch (_e) {
// ignore
}
}, 500);
return () => clearInterval(poll);
}, [loading, handleAuthToken]);
useEffect(() => {
const iframe = iframeRef.current;
@@ -92,116 +102,9 @@ export function ElectronLoginForm({
if (iframe.contentWindow) {
setCurrentUrl(iframe.contentWindow.location.href);
}
} catch (e) {
} catch (_e) {
setCurrentUrl(serverUrl);
}
try {
const injectedScript = `
(function() {
let hasNotified = false;
function postJWTToParent(token, source) {
if (hasNotified) {
return;
}
hasNotified = true;
try {
window.parent.postMessage({
type: 'AUTH_SUCCESS',
token: token,
source: source,
platform: 'desktop',
timestamp: Date.now()
}, '*');
} catch (e) {
}
}
function checkAuth() {
try {
const localToken = localStorage.getItem('jwt');
if (localToken && localToken.length > 20) {
postJWTToParent(localToken, 'localStorage');
return true;
}
const sessionToken = sessionStorage.getItem('jwt');
if (sessionToken && sessionToken.length > 20) {
postJWTToParent(sessionToken, 'sessionStorage');
return true;
}
const cookies = document.cookie;
if (cookies && cookies.length > 0) {
const cookieArray = cookies.split('; ');
const tokenCookie = cookieArray.find(row => row.startsWith('jwt='));
if (tokenCookie) {
const token = tokenCookie.split('=')[1];
if (token && token.length > 20) {
postJWTToParent(token, 'cookie');
return true;
}
}
}
} catch (error) {
}
return false;
}
const originalSetItem = localStorage.setItem;
localStorage.setItem = function(key, value) {
originalSetItem.apply(this, arguments);
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
setTimeout(() => checkAuth(), 100);
}
};
const originalSessionSetItem = sessionStorage.setItem;
sessionStorage.setItem = function(key, value) {
originalSessionSetItem.apply(this, arguments);
if (key === 'jwt' && value && value.length > 20 && !hasNotified) {
setTimeout(() => checkAuth(), 100);
}
};
const intervalId = setInterval(() => {
if (hasNotified) {
clearInterval(intervalId);
return;
}
if (checkAuth()) {
clearInterval(intervalId);
}
}, 500);
setTimeout(() => {
clearInterval(intervalId);
}, 300000);
setTimeout(() => checkAuth(), 500);
})();
`;
try {
if (iframe.contentWindow) {
try {
iframe.contentWindow.eval(injectedScript);
} catch (evalError) {
iframe.contentWindow.postMessage(
{ type: "INJECT_SCRIPT", script: injectedScript },
"*",
);
}
}
} catch (err) {
console.error("Authentication operation failed:", err);
}
} catch (err) {
console.error("Authentication operation failed:", err);
}
};
const handleError = () => {
@@ -213,12 +116,11 @@ export function ElectronLoginForm({
iframe.addEventListener("load", handleLoad);
iframe.addEventListener("error", handleError);
return () => {
iframe.removeEventListener("load", handleLoad);
iframe.removeEventListener("error", handleError);
};
}, [t]);
}, [serverUrl, t]);
const handleRefresh = () => {
if (iframeRef.current) {
@@ -228,44 +130,47 @@ export function ElectronLoginForm({
}
};
const handleBack = () => {
onChangeServer();
};
const displayUrl = currentUrl.replace(/^https?:\/\//, "");
const isEmbeddedServer = serverUrl.includes("localhost:30001");
return (
<div className="fixed inset-0 w-screen h-screen bg-canvas flex flex-col">
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
<button
onClick={handleBack}
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
disabled={isAuthenticating}
>
<ArrowLeft className="h-5 w-5" />
<span className="text-base font-medium">
{t("serverConfig.changeServer")}
</span>
</button>
{!isEmbeddedServer && (
<div className="flex-1 mx-4 text-center">
<span className="text-muted-foreground text-sm truncate block">
{displayUrl}
</span>
</div>
)}
{isEmbeddedServer && <div className="flex-1" />}
<button
onClick={handleRefresh}
className="p-2 text-foreground hover:text-primary transition-colors"
disabled={loading || isAuthenticating}
>
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{isAuthenticating && (
<div className="absolute inset-0 flex items-center justify-center bg-canvas z-50">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)}
{error && (
{!isAuthenticating && (
<div className="flex items-center justify-between p-4 bg-canvas border-b border-edge">
<button
onClick={onChangeServer}
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
>
<ArrowLeft className="h-5 w-5" />
<span className="text-base font-medium">
{t("serverConfig.changeServer")}
</span>
</button>
{!isEmbeddedServer && (
<div className="flex-1 mx-4 text-center">
<span className="text-muted-foreground text-sm truncate block">
{displayUrl}
</span>
</div>
)}
{isEmbeddedServer && <div className="flex-1" />}
<button
onClick={handleRefresh}
className="p-2 text-foreground hover:text-primary transition-colors"
disabled={loading}
>
<RefreshCw className={`h-5 w-5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
)}
{error && !isAuthenticating && (
<div className="absolute top-20 left-1/2 transform -translate-x-1/2 z-50 w-full max-w-md px-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
@@ -275,7 +180,7 @@ export function ElectronLoginForm({
</div>
)}
{loading && (
{loading && !isAuthenticating && (
<div
className="absolute inset-0 flex items-center justify-center bg-canvas z-40"
style={{ marginTop: "60px" }}
@@ -289,7 +194,10 @@ export function ElectronLoginForm({
</div>
)}
<div className="flex-1 overflow-hidden">
<div
className="flex-1 overflow-hidden"
style={{ visibility: isAuthenticating ? "hidden" : "visible" }}
>
<iframe
ref={iframeRef}
src={serverUrl}
@@ -39,6 +39,10 @@ export function ElectronServerConfig({
const isDarkMode =
theme === "dark" ||
theme === "dracula" ||
theme === "gentlemansChoice" ||
theme === "midnightEspresso" ||
theme === "catppuccinMocha" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
+42 -10
View File
@@ -87,20 +87,34 @@ export function AppView({
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab, updateTab } =
useTabs() as {
tabs: TabData[];
currentTab: number;
allSplitScreenTab: number[];
removeTab: (id: number) => void;
updateTab: (tabId: number, updates: Partial<Omit<TabData, "id">>) => void;
};
const {
tabs,
currentTab,
allSplitScreenTab,
removeTab,
updateTab,
addTab,
previewTerminalTheme,
} = useTabs() as {
tabs: TabData[];
currentTab: number;
allSplitScreenTab: number[];
removeTab: (id: number) => void;
updateTab: (tabId: number, updates: Partial<Omit<TabData, "id">>) => void;
addTab: (tab: Omit<TabData, "id">) => number;
previewTerminalTheme: string | null;
};
const { state: sidebarState } = useSidebar();
const { theme: appTheme } = useTheme();
const isDarkMode = useMemo(() => {
if (appTheme === "dark") return true;
if (appTheme === "dracula") return true;
if (appTheme === "gentlemansChoice") return true;
if (appTheme === "midnightEspresso") return true;
if (appTheme === "catppuccinMocha") return true;
if (appTheme === "light") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}, [appTheme]);
@@ -362,13 +376,18 @@ export function AppView({
};
let themeColors;
if (terminalConfig.theme === "termix") {
const activeTerminalTheme =
t.id === currentTab && previewTerminalTheme
? previewTerminalTheme
: terminalConfig.theme;
if (activeTerminalTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[terminalConfig.theme]?.colors ||
TERMINAL_THEMES[activeTerminalTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const backgroundColor = themeColors.background;
@@ -394,6 +413,19 @@ export function AppView({
splitScreen={allSplitScreenTab.length > 0}
onClose={() => removeTab(t.id)}
onTitleChange={(title) => updateTab(t.id, { title })}
onOpenFileManager={
(t.hostConfig as any)?.enableFileManager
? () =>
addTab({
type: "file_manager",
title: t.title,
hostConfig: t.hostConfig,
})
: undefined
}
previewTheme={
t.id === currentTab ? previewTerminalTheme : null
}
/>
) : t.type === "server_stats" ? (
<ServerView
+92 -11
View File
@@ -10,6 +10,23 @@ import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
import { useCommandHistory } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { QuickConnectDialog } from "@/ui/desktop/navigation/dialogs/QuickConnectDialog.tsx";
import { useTheme } from "@/components/theme-provider";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
} from "@/components/ui/dropdown-menu.tsx";
import {
Sun,
Moon,
Monitor,
Palette,
Terminal as TerminalIcon,
} from "lucide-react";
import { TERMINAL_THEMES } from "@/constants/terminal-themes.ts";
interface TabData {
id: number;
@@ -45,15 +62,10 @@ export function TopNavbar({
removeTab,
allSplitScreenTab,
reorderTabs,
} = useTabs() as {
tabs: TabData[];
currentTab: number;
setCurrentTab: (id: number) => void;
setSplitScreenTab: (id: number) => void;
removeTab: (id: number) => void;
allSplitScreenTab: number[];
reorderTabs: (fromIndex: number, toIndex: number) => void;
};
updateTab,
previewTerminalTheme,
setPreviewTerminalTheme,
} = useTabs() as any;
const leftPosition =
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
const { t } = useTranslation();
@@ -146,7 +158,7 @@ export function TopNavbar({
const handleSnippetExecute = (content: string) => {
const tab = tabs.find((t: TabData) => t.id === currentTab);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(content + "\n");
tab.terminalRef.current.sendInput(content + "\r");
}
};
@@ -378,6 +390,7 @@ export function TopNavbar({
const isUserProfile = tab.type === "user_profile";
const isRdp = tab.type === "rdp";
const isVnc = tab.type === "vnc";
const isTelnet = tab.type === "telnet";
const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable;
@@ -456,7 +469,6 @@ export function TopNavbar({
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
e
onMouseDown={(e) => {
if (e.button === 1 && !disableClose) {
e.preventDefault();
@@ -499,6 +511,7 @@ export function TopNavbar({
isUserProfile ||
isRdp ||
isVnc ||
isTelnet ||
tab.type === "network_graph"
? () => handleTabClose(tab.id)
: undefined
@@ -518,6 +531,7 @@ export function TopNavbar({
isUserProfile ||
isRdp ||
isVnc ||
isTelnet ||
tab.type === "network_graph"
}
disableActivate={disableActivate}
@@ -535,6 +549,73 @@ export function TopNavbar({
<div className="flex items-center justify-center gap-2 flex-1 px-2">
<TabDropdown />
{/* Terminal Theme Switcher */}
{(() => {
const activeTab = tabs.find((t: any) => t.id === currentTab);
if (activeTab?.type !== "terminal") return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[30px] h-[30px] border-edge"
title={t("hosts.selectTheme")}
>
<TerminalIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-canvas border-edge text-foreground max-h-[400px] overflow-y-auto thin-scrollbar"
onMouseLeave={() => setPreviewTerminalTheme(null)}
>
<DropdownMenuLabel className="text-xs opacity-70">
Terminal Themes
</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
<DropdownMenuItem
key={key}
onClick={() => {
const activeTab = tabs.find(
(t: any) => t.id === currentTab,
);
if (activeTab?.hostConfig) {
const updatedConfig = {
...activeTab.hostConfig.terminalConfig,
theme: key,
};
// Persist terminal theme selection to localStorage
localStorage.setItem(
`terminal_theme_host_${activeTab.hostConfig.id}`,
key,
);
updateTab(currentTab, {
hostConfig: {
...activeTab.hostConfig,
terminalConfig: updatedConfig,
},
});
}
}}
onMouseEnter={() => setPreviewTerminalTheme(key)}
className="flex items-center gap-2 cursor-pointer"
>
<div
className="w-3 h-3 rounded-full border border-edge"
style={{ backgroundColor: theme.colors.background }}
/>
<span>{theme.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
})()}
<Button
variant="outline"
onClick={() => setToolsSidebarOpen(!toolsSidebarOpen)}
@@ -1,10 +1,7 @@
import React, { useEffect } from "react";
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Shield, Copy, ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { Shield, ExternalLink, Loader2, AlertCircle } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
interface OPKSSHDialogProps {
isOpen: boolean;
@@ -12,8 +9,10 @@ interface OPKSSHDialogProps {
requestId: string;
stage: "chooser" | "waiting" | "authenticating" | "completed" | "error";
error?: string;
providers?: Array<{ alias: string; issuer: string }>;
onCancel: () => void;
onOpenUrl: () => void;
onSelectProvider?: (alias: string) => void;
backgroundColor?: string;
}
@@ -23,26 +22,15 @@ export function OPKSSHDialog({
requestId,
stage,
error,
providers,
onCancel,
onOpenUrl,
onSelectProvider,
backgroundColor,
}: OPKSSHDialogProps) {
const { t } = useTranslation();
const hasOpenedRef = React.useRef(false);
useEffect(() => {}, [isOpen, authUrl, stage, onOpenUrl]);
if (!isOpen) return null;
const handleCopyUrl = async () => {
try {
await navigator.clipboard.writeText(authUrl);
toast.success(t("common.copied"));
} catch (error) {
toast.error(t("common.copyFailed"));
}
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
@@ -58,47 +46,54 @@ export function OPKSSHDialog({
</div>
<div className="space-y-4">
{stage === "chooser" && authUrl && (
{stage === "chooser" && (
<>
<p className="text-muted-foreground">
{t("terminal.opksshAuthDescription")}
</p>
<div>
<Label htmlFor="opksshUrl" className="text-base font-semibold">
{t("terminal.opksshAuthUrl")}
</Label>
<div className="flex gap-2 mt-2">
<Input
id="opksshUrl"
type="text"
value={authUrl}
readOnly
className="flex-1 font-mono text-xs"
/>
{providers && providers.length > 0 && onSelectProvider ? (
<div className="space-y-2">
{providers.map((provider) => (
<Button
key={provider.alias}
type="button"
onClick={() => onSelectProvider(provider.alias)}
className="w-full flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4" />
{t("terminal.opksshSignInWith", {
provider:
provider.alias.charAt(0).toUpperCase() +
provider.alias.slice(1),
})}
</Button>
))}
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyUrl}
title={t("common.copy")}
className="w-full"
onClick={onCancel}
>
<Copy className="w-4 h-4" />
{t("common.cancel")}
</Button>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
type="button"
onClick={onOpenUrl}
className="flex-1 flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4" />
{t("terminal.opksshOpenBrowser")}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.cancel")}
</Button>
</div>
) : authUrl ? (
<div>
<div className="flex gap-2 pt-2">
<Button
type="button"
onClick={onOpenUrl}
className="flex-1 flex items-center justify-center gap-2"
>
<ExternalLink className="w-4 h-4" />
{t("terminal.opksshOpenBrowser")}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
{t("common.cancel")}
</Button>
</div>
</div>
) : null}
</>
)}
@@ -121,7 +116,9 @@ export function OPKSSHDialog({
<p className="text-sm font-medium text-destructive">
{t("common.error")}
</p>
<p className="text-sm text-destructive/90 mt-1">{error}</p>
<p className="text-sm text-destructive/90 mt-1 whitespace-pre-wrap break-words">
{error}
</p>
</div>
</div>
<div className="flex justify-end pt-2">
@@ -0,0 +1,122 @@
import { Button } from "@/components/ui/button.tsx";
import { Terminal, Monitor, Users, Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
interface TmuxSessionInfo {
name: string;
created: number;
lastActivity: number;
windows: number;
attachedClients: number;
}
interface TmuxSessionPickerProps {
isOpen: boolean;
sessions: TmuxSessionInfo[];
onSelect: (sessionName: string) => void;
onCreateNew: () => void;
onCancel: () => void;
backgroundColor?: string;
}
function formatTimestamp(
unix: number,
t: (key: string, opts?: Record<string, unknown>) => string,
): string {
if (!unix) return "---";
const date = new Date(unix * 1000);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHr / 24);
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
return date.toLocaleDateString();
}
export function TmuxSessionPicker({
isOpen,
sessions,
onSelect,
onCreateNew,
onCancel,
backgroundColor,
}: TmuxSessionPickerProps) {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-elevated border-2 border-edge rounded-lg p-6 max-w-md w-full mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="mb-4">
<div className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
<h3 className="text-lg font-semibold">
{t("terminal.tmuxSessionPickerTitle")}
</h3>
</div>
<p className="text-sm text-muted-foreground mt-1">
{t("terminal.tmuxSessionPickerDesc")}
</p>
</div>
<div className="space-y-2 mb-4 max-h-60 overflow-y-auto">
{sessions.map((session) => (
<button
key={session.name}
onClick={() => onSelect(session.name)}
className="w-full text-left px-3 py-3 rounded-md border border-edge hover:bg-muted transition-colors"
>
<div className="font-mono text-sm font-medium">
{session.name}
</div>
<div className="flex gap-3 mt-1 text-xs text-muted-foreground">
<span
className="flex items-center gap-1"
title={t("terminal.tmuxWindows")}
>
<Monitor className="w-3 h-3" />
{t("terminal.tmuxWindowCount", { count: session.windows })}
</span>
{session.attachedClients > 0 && (
<span
className="flex items-center gap-1"
title={t("terminal.tmuxAttached")}
>
<Users className="w-3 h-3" />
{t("terminal.tmuxAttachedCount", {
count: session.attachedClients,
})}
</span>
)}
<span
className="flex items-center gap-1"
title={t("terminal.tmuxLastActivity")}
>
<Clock className="w-3 h-3" />
{formatTimestamp(session.lastActivity, t)}
</span>
</div>
</button>
))}
</div>
<div className="flex gap-2">
<Button onClick={onCreateNew} variant="outline" className="flex-1">
{t("terminal.tmuxCreateNew")}
</Button>
<Button onClick={onCancel} variant="outline" className="flex-1">
{t("common.cancel")}
</Button>
</div>
</div>
</div>
);
}
+37 -13
View File
@@ -13,6 +13,7 @@ import {
Pencil,
ArrowDownUp,
Container,
Power,
} from "lucide-react";
import {
DropdownMenu,
@@ -21,12 +22,20 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
import { getSSHHosts, getGuacamoleToken, logActivity } from "@/ui/main-axios";
import {
getSSHHosts,
getGuacamoleToken,
getGuacamoleDpi,
getGuacamoleTokenFromHost,
logActivity,
wakeOnLan,
} from "@/ui/main-axios";
import type { HostProps } from "../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { useTranslation } from "react-i18next";
import { useHostStatus } from "@/ui/contexts/ServerStatusContext";
import { cn } from "@/lib/utils.ts";
import { toast } from "sonner";
export function Host({ host: initialHost }: HostProps): React.ReactElement {
const { addTab } = useTabs();
@@ -118,17 +127,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
) {
try {
const protocol = host.connectionType as "rdp" | "vnc" | "telnet";
const result = await getGuacamoleToken({
protocol,
hostname: host.ip,
port: host.port,
username: host.username,
password: host.password,
domain: host.domain,
security: host.security,
ignoreCert: host.ignoreCert,
guacamoleConfig: host.guacamoleConfig as any,
});
const result = await getGuacamoleTokenFromHost(host.id);
addTab({
type: protocol,
title,
@@ -144,6 +143,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
domain: host.domain,
security: host.security,
"ignore-cert": host.ignoreCert,
dpi: getGuacamoleDpi(host),
},
});
@@ -297,7 +297,15 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
) : (
<Terminal className="h-4 w-4" />
)}
<span className="flex-1">{t("hosts.openTerminal")}</span>
<span className="flex-1">
{host.connectionType === "rdp"
? t("hosts.openRdp")
: host.connectionType === "vnc"
? t("hosts.openVnc")
: host.connectionType === "telnet"
? t("hosts.openTelnet")
: t("hosts.openTerminal")}
</span>
</DropdownMenuItem>
)}
{isSSH &&
@@ -353,6 +361,22 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<span className="flex-1">{t("hosts.openDocker")}</span>
</DropdownMenuItem>
)}
{host.macAddress && (
<DropdownMenuItem
onClick={async () => {
try {
await wakeOnLan(host.id);
toast.success(t("hosts.wolSent"));
} catch {
toast.error(t("hosts.wolFailed"));
}
}}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-hover text-foreground-secondary"
>
<Power className="h-4 w-4" />
<span className="flex-1">{t("hosts.wakeOnLan")}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() =>
addTab({
+41 -20
View File
@@ -1,6 +1,7 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { useTranslation } from "react-i18next";
import { getHostPassword } from "@/ui/main-axios.ts";
import { cn } from "@/lib/utils";
import {
Home,
@@ -18,6 +19,7 @@ import {
Container as DockerIcon,
Key,
} from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
interface TabProps {
@@ -66,42 +68,61 @@ export function Tab({
if (!hostConfig) return;
const hasSshPassword =
hostConfig.authType === "password" && hostConfig.password;
const hasSudoPassword = hostConfig.sudoPassword;
const hasSshPw =
hostConfig.authType === "password" &&
(hostConfig.hasPassword || hostConfig.password);
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
if (!hasSshPassword && !hasSudoPassword) {
return;
if (!hasSshPw && !hasSudoPw) return;
let passwordToCopy = "";
if (hasSshPw) {
passwordToCopy = hostConfig.password || "";
} else if (hasSudoPw) {
passwordToCopy = hostConfig.sudoPassword;
}
if (!passwordToCopy) return;
try {
let passwordToCopy = "";
if (hasSshPassword) {
passwordToCopy = hostConfig.password || "";
} else if (hasSudoPassword) {
passwordToCopy = hostConfig.sudoPassword;
}
await navigator.clipboard.writeText(passwordToCopy);
} catch {}
toast.success(t("nav.passwordCopied"));
} catch {
try {
const textarea = document.createElement("textarea");
textarea.value = passwordToCopy;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
toast.success(t("nav.passwordCopied"));
} catch {
toast.error(t("nav.failedToCopyPassword"));
}
}
};
const hasPassword =
hostConfig &&
((hostConfig.authType === "password" && hostConfig.password) ||
((hostConfig.authType === "password" &&
(hostConfig.hasPassword || hostConfig.password)) ||
hostConfig.hasSudoPassword ||
hostConfig.sudoPassword);
const getPasswordButtonTitle = () => {
if (!hostConfig) return "";
const hasSshPassword =
hostConfig.authType === "password" && hostConfig.password;
const hasSudoPassword = hostConfig.sudoPassword;
const hasSshPw =
hostConfig.authType === "password" &&
(hostConfig.hasPassword || hostConfig.password);
const hasSudoPw = hostConfig.hasSudoPassword || hostConfig.sudoPassword;
if (hasSshPassword) {
if (hasSshPw) {
return t("nav.copyPassword");
} else if (hasSudoPassword) {
} else if (hasSudoPw) {
return t("nav.copySudoPassword");
}
return t("nav.noPasswordAvailable");
+40 -20
View File
@@ -34,6 +34,8 @@ interface TabContextType {
},
) => void;
updateTab: (tabId: number, updates: Partial<Omit<Tab, "id">>) => void;
previewTerminalTheme: string | null;
setPreviewTerminalTheme: (theme: string | null) => void;
}
const TabContext = createContext<TabContextType | undefined>(undefined);
@@ -122,6 +124,9 @@ export function TabProvider({ children }: TabProviderProps) {
return 1;
});
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
const [previewTerminalTheme, setPreviewTerminalTheme] = useState<
string | null
>(null);
const [initialMaxId] = useState(() => {
let maxId = 1;
tabs.forEach((tab) => {
@@ -151,6 +156,13 @@ export function TabProvider({ children }: TabProviderProps) {
}
}, [tabs, currentTab]);
// Safety net: if currentTab points to a tab that no longer exists, fall back to home
useEffect(() => {
if (tabs.length > 0 && !tabs.some((t) => t.id === currentTab)) {
setCurrentTab(1);
}
}, [tabs, currentTab]);
React.useEffect(() => {
setTabs((prev) =>
prev.map((tab) =>
@@ -264,6 +276,8 @@ export function TabProvider({ children }: TabProviderProps) {
return id;
};
const pendingCurrentTabRef = useRef<number | null>(null);
const removeTab = (tabId: number) => {
const tab = tabs.find((t) => t.id === tabId);
if (
@@ -274,31 +288,33 @@ export function TabProvider({ children }: TabProviderProps) {
tab.terminalRef.current.disconnect();
}
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
setTabs((prev) => {
const closedIndex = prev.findIndex((t) => t.id === tabId);
const filtered = prev.filter((t) => t.id !== tabId);
if (filtered.length === 0) {
pendingCurrentTabRef.current = 1;
return [{ id: 1, type: "home", title: t("nav.home") }];
}
// If the closed tab was active, compute the next tab to activate
// using the latest prev so rapid closes don't use stale data
const nextIndex =
closedIndex < filtered.length ? closedIndex : filtered.length - 1;
pendingCurrentTabRef.current = filtered[Math.max(0, nextIndex)]?.id ?? 1;
return filtered;
});
setAllSplitScreenTab((prev) => {
const newSplits = prev.filter((id) => id !== tabId);
if (newSplits.length <= 1) {
return [];
}
return newSplits;
return newSplits.length <= 1 ? [] : newSplits;
});
if (currentTab === tabId) {
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
if (remainingTabs.length > 0) {
const remainingSplitTabs = allSplitScreenTab.filter(
(id) => id !== tabId,
);
if (remainingSplitTabs.length > 0) {
setCurrentTab(remainingSplitTabs[0]);
} else {
setCurrentTab(remainingTabs[0].id);
}
} else {
setCurrentTab(1);
}
}
setCurrentTab((prevCurrentTab) => {
if (prevCurrentTab !== tabId) return prevCurrentTab;
return pendingCurrentTabRef.current ?? 1;
});
};
const setSplitScreenTab = (tabId: number) => {
@@ -412,6 +428,8 @@ export function TabProvider({ children }: TabProviderProps) {
reorderTabs,
updateHostConfig,
updateTab,
previewTerminalTheme,
setPreviewTerminalTheme,
}),
[
tabs,
@@ -424,6 +442,8 @@ export function TabProvider({ children }: TabProviderProps) {
reorderTabs,
updateHostConfig,
updateTab,
previewTerminalTheme,
setPreviewTerminalTheme,
],
);
@@ -25,6 +25,10 @@ export function ElectronVersionCheck({
const isDarkMode =
theme === "dark" ||
theme === "dracula" ||
theme === "gentlemansChoice" ||
theme === "midnightEspresso" ||
theme === "catppuccinMocha" ||
(theme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const lineColor = isDarkMode ? "#151517" : "#f9f9f9";
+82 -6
View File
@@ -97,7 +97,7 @@ export function UserProfile({
}: UserProfileProps) {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const { theme, setTheme } = useTheme();
const { theme, setTheme, setThemePreview } = useTheme();
const [userInfo, setUserInfo] = useState<{
username: string;
is_admin: boolean;
@@ -121,6 +121,9 @@ export function UserProfile({
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
localStorage.getItem("commandAutocomplete") === "true",
);
const [commandHistoryTracking, setCommandHistoryTracking] = useState<boolean>(
() => localStorage.getItem("commandHistoryTracking") === "true",
);
const [terminalSyntaxHighlighting, setTerminalSyntaxHighlighting] =
useState<boolean>(
() => localStorage.getItem("terminalSyntaxHighlighting") === "true",
@@ -162,7 +165,7 @@ export function UserProfile({
const fetchVersion = async () => {
try {
const info = await getVersionInfo();
const info = await getVersionInfo(!disableUpdateCheck);
setVersionInfo({ version: info.localVersion });
} catch {
toast.error(t("user.failedToLoadVersionInfo"));
@@ -214,6 +217,12 @@ export function UserProfile({
localStorage.setItem("commandAutocomplete", enabled.toString());
};
const handleCommandHistoryTrackingToggle = (enabled: boolean) => {
setCommandHistoryTracking(enabled);
localStorage.setItem("commandHistoryTracking", enabled.toString());
window.dispatchEvent(new Event("commandHistoryTrackingChanged"));
};
const handleTerminalSyntaxHighlightingToggle = (enabled: boolean) => {
setTerminalSyntaxHighlighting(enabled);
localStorage.setItem("terminalSyntaxHighlighting", enabled.toString());
@@ -525,20 +534,73 @@ export function UserProfile({
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">
<SelectContent
onMouseLeave={() => setThemePreview(null)}
>
<SelectItem
value="light"
onMouseEnter={() => setThemePreview("light")}
>
<div className="flex items-center gap-2">
<Sun className="w-4 h-4" />
{t("profile.themeLight")}
</div>
</SelectItem>
<SelectItem value="dark">
<SelectItem
value="dark"
onMouseEnter={() => setThemePreview("dark")}
>
<div className="flex items-center gap-2">
<Moon className="w-4 h-4" />
{t("profile.themeDark")}
</div>
</SelectItem>
<SelectItem value="system">
<SelectItem
value="dracula"
onMouseEnter={() => setThemePreview("dracula")}
>
<div className="flex items-center gap-2">
<Palette className="w-4 h-4" />
Dracula
</div>
</SelectItem>
<SelectItem
value="gentlemansChoice"
onMouseEnter={() =>
setThemePreview("gentlemansChoice")
}
>
<div className="flex items-center gap-2">
<Palette className="w-4 h-4" />
Gentleman's Choice
</div>
</SelectItem>
<SelectItem
value="midnightEspresso"
onMouseEnter={() =>
setThemePreview("midnightEspresso")
}
>
<div className="flex items-center gap-2">
<Palette className="w-4 h-4" />
Midnight Espresso
</div>
</SelectItem>
<SelectItem
value="catppuccinMocha"
onMouseEnter={() =>
setThemePreview("catppuccinMocha")
}
>
<div className="flex items-center gap-2">
<Palette className="w-4 h-4" />
Catppuccin Mocha
</div>
</SelectItem>
<SelectItem
value="system"
onMouseEnter={() => setThemePreview("system")}
>
<div className="flex items-center gap-2">
<Monitor className="w-4 h-4" />
{t("profile.themeSystem")}
@@ -591,6 +653,20 @@ export function UserProfile({
onCheckedChange={handleCommandAutocompleteToggle}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-foreground-secondary">
{t("profile.commandHistoryTracking")}
</Label>
<p className="text-sm text-muted-foreground mt-1">
{t("profile.commandHistoryTrackingDesc")}
</p>
</div>
<Switch
checked={commandHistoryTracking}
onCheckedChange={handleCommandHistoryTrackingToggle}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label className="text-foreground-secondary">
+22 -3
View File
@@ -1,6 +1,21 @@
import { useRef, useCallback } from "react";
import { saveCommandToHistory } from "@/ui/main-axios.ts";
const SENSITIVE_PATTERNS = [
/\bpassw(or)?d\b/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/\bPASS(WORD)?=/i,
/\bAWS_SECRET/i,
/\bmysql\b.*-p/i,
/\bsudo\s+-S\b/,
/\bhtpasswd\b/i,
/\bsshpass\b/i,
/\bcurl\b.*-u\s/i,
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
interface UseCommandTrackerOptions {
hostId?: number;
enabled?: boolean;
@@ -52,9 +67,13 @@ export function useCommandTracker({
const command = currentCommandRef.current.trim();
if (command.length > 0) {
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
if (!isSensitive) {
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
}
if (onCommandExecuted) {
onCommandExecuted(command);
+389 -63
View File
@@ -149,28 +149,12 @@ interface OIDCAuthorize {
// ============================================================================
export function isElectron(): boolean {
const hasISElectron =
(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).IS_ELECTRON === true;
const win = window as any;
const hasISElectron = win.IS_ELECTRON === true;
const hasElectronAPI = !!win.electronAPI;
const isElectronProp = win.electronAPI?.isElectron === true;
const hasElectronAPI = !!(
window as Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: unknown;
configuredServerUrl?: string;
}
).electronAPI;
const result = hasISElectron || hasElectronAPI;
return result;
return hasISElectron || hasElectronAPI || isElectronProp;
}
function getLoggerForService(serviceName: string) {
@@ -199,20 +183,22 @@ const electronSettingsCache = new Map<string, string>();
if (isElectron()) {
(async () => {
try {
const electronAPI = (
window as Window &
typeof globalThis & {
electronAPI?: any;
}
).electronAPI;
const electronAPI = (window as any).electronAPI;
if (electronAPI?.getSetting) {
const settingsToLoad = ["rightClickCopyPaste", "jwt"];
for (const key of settingsToLoad) {
const value = await electronAPI.getSetting(key);
if (value !== null && value !== undefined) {
electronSettingsCache.set(key, value);
localStorage.setItem(key, value);
// Only populate if not already set to prevent overwriting new values during login
if (!localStorage.getItem(key)) {
electronSettingsCache.set(key, value);
localStorage.setItem(key, value);
console.log(`[Electron] Loaded setting ${key} from main process`);
} else {
// Even if we don't overwrite localStorage, update the cache
electronSettingsCache.set(key, localStorage.getItem(key)!);
}
}
}
}
@@ -312,16 +298,27 @@ function createApiInstance(
const logger = getLoggerForService(serviceName);
if (process.env.NODE_ENV === "development") {
const requestBaseURL = config.baseURL || "";
const isDevMode = process.env.NODE_ENV === "development";
if (isDevMode) {
logger.requestStart(method, fullUrl, context);
}
if (isElectron()) {
config.headers["X-Electron-App"] = "true";
if (config.headers.set) {
config.headers.set("X-Electron-App", "true");
} else {
config.headers["X-Electron-App"] = "true";
}
const token = localStorage.getItem("jwt");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
if (config.headers.set) {
config.headers.set("Authorization", `Bearer ${token}`);
} else {
config.headers["Authorization"] = `Bearer ${token}`;
}
userWasAuthenticated = true;
}
}
@@ -339,15 +336,42 @@ function createApiInstance(
platform = "iOS";
}
}
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
if (config.headers.set) {
config.headers.set("User-Agent", `Termix-Mobile/${platform}`);
} else {
config.headers["User-Agent"] = `Termix-Mobile/${platform}`;
}
}
if (!isElectron()) {
const token = document.cookie
const tokenCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("jwt="));
if (token) {
userWasAuthenticated = true;
if (tokenCookie) {
const tokenValue = tokenCookie.split("=")[1];
if (tokenValue) {
// Always add Authorization header as fallback if token is present,
// especially important for cross-origin requests where cookies might be blocked
const decodedToken = decodeURIComponent(tokenValue);
if (config.headers.set) {
config.headers.set("Authorization", `Bearer ${decodedToken}`);
} else {
config.headers["Authorization"] = `Bearer ${decodedToken}`;
}
userWasAuthenticated = true;
}
} else {
// Check localStorage as fallback even in browser mode
const localToken = localStorage.getItem("jwt");
if (localToken) {
if (config.headers.set) {
config.headers.set("Authorization", `Bearer ${localToken}`);
} else {
config.headers["Authorization"] = `Bearer ${localToken}`;
}
userWasAuthenticated = true;
}
}
}
@@ -426,8 +450,11 @@ function createApiInstance(
};
const logger = getLoggerForService(serviceName);
// A caller can mark a request as a silent retry (see progressive /status
// retry) so we don't spam error logs / health events on each attempt.
const isSilentRetry = !!(error.config as any)?.__silentRetry;
if (process.env.NODE_ENV === "development") {
if (process.env.NODE_ENV === "development" && !isSilentRetry) {
if (status === 401) {
logger.authError(method, fullUrl, context);
} else if (status === 0 || !status) {
@@ -457,7 +484,24 @@ function createApiInstance(
errorMessage === "Authentication required" ||
errorMessage === "Missing authentication token";
if (isSessionExpired || isSessionNotFound || isInvalidToken) {
const headers = error.config?.headers;
let hasAuthHeader = false;
if (headers) {
if (typeof headers.get === "function") {
hasAuthHeader = !!(
headers.get("Authorization") || headers.get("authorization")
);
} else {
hasAuthHeader = !!(
headers["Authorization"] || headers["authorization"]
);
}
}
if (
(isSessionExpired || isSessionNotFound || isInvalidToken) &&
hasAuthHeader
) {
const wasAuthenticated = userWasAuthenticated;
localStorage.removeItem("jwt");
@@ -488,7 +532,7 @@ function createApiInstance(
userWasAuthenticated = false;
}
} else {
} else if (!isSilentRetry) {
const wasAuthenticated = !!localStorage.getItem("jwt");
dbHealthMonitor.reportDatabaseError(error, wasAuthenticated);
}
@@ -786,6 +830,9 @@ export let rbacApi: AxiosInstance;
// Docker Management API (port 30007)
export let dockerApi: AxiosInstance;
// Pre-initialize with default values to avoid undefined errors during early mounting
initializeApiInstances();
function initializeApp() {
if (isElectron()) {
Promise.all([getServerConfig(), getEmbeddedServerStatus()])
@@ -1006,10 +1053,16 @@ function handleApiError(error: unknown, operation: string): never {
export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
try {
const hostsResponse = await sshHostApi.get("/db/host");
const hosts: SSHHost[] = hostsResponse.data;
const hosts: SSHHost[] = Array.isArray(hostsResponse.data)
? hostsResponse.data
: [];
const statusesResponse = await getAllServerStatuses();
const statuses = statusesResponse || {};
let statuses: Record<number, ServerStatus> = {};
try {
statuses = (await getAllServerStatuses()) || {};
} catch {
// Status fetch failure should not prevent host list from loading
}
return hosts.map((host) => ({
...host,
@@ -1073,6 +1126,8 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain || null,
macAddress: hostData.macAddress || null,
portKnockSequence: hostData.portKnockSequence || null,
};
if (!submitData.enableTunnel) {
@@ -1160,6 +1215,8 @@ export async function updateSSHHost(
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain || null,
macAddress: hostData.macAddress || null,
portKnockSequence: hostData.portKnockSequence || null,
};
if (!submitData.enableTunnel) {
@@ -1190,6 +1247,15 @@ export async function updateSSHHost(
}
}
export async function wakeOnLan(hostId: number): Promise<{ success: boolean }> {
try {
const response = await sshHostApi.post(`/db/host/${hostId}/wake`);
return response.data;
} catch (error) {
throw handleApiError(error, "wake on LAN");
}
}
export async function bulkImportSSHHosts(
hosts: SSHHostData[],
overwrite = false,
@@ -1258,6 +1324,17 @@ export async function exportSSHHostWithCredentials(
}
}
export async function exportAllSSHHosts(): Promise<{
hosts: SSHHost[];
}> {
try {
const response = await sshHostApi.get("/db/hosts/export");
return response.data;
} catch (error) {
handleApiError(error, "export all SSH hosts");
}
}
// ============================================================================
// SSH AUTOSTART MANAGEMENT
// ============================================================================
@@ -2299,15 +2376,81 @@ export async function removeFolderShortcut(
// SERVER STATISTICS
// ============================================================================
/**
* Progressive retry schedule for the background /status poll.
*
* Each entry describes one attempt's per-request timeout and the pause to
* observe before the next attempt. The pause on the last entry is `null`:
* after that final failure we surface the network error, which flows
* through the response interceptor + dbHealthMonitor (which decides
* between the degraded toast and the full-outage overlay based on whether
* any WebSocket is still alive).
*
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
* poll cadence, so the next tick acts as the next retry without overlap.
*/
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
timeoutMs: number;
pauseAfterMs: number | null;
}> = [
{ timeoutMs: 2000, pauseAfterMs: 3000 },
{ timeoutMs: 5000, pauseAfterMs: 5000 },
{ timeoutMs: 8000, pauseAfterMs: null },
];
function isTransientStatusError(error: unknown): boolean {
if (!axios.isAxiosError(error)) return false;
if (error.response) {
// Definitive server response (even 5xx) is not something more retries
// will fix in a useful timeframe; bail out and report it normally.
return false;
}
const code = error.code;
if (!code) {
// No code + no response means classic network error (offline / DNS / TCP)
return true;
}
return (
code === "ECONNABORTED" ||
code === "ETIMEDOUT" ||
code === "ERR_NETWORK" ||
code === "ECONNREFUSED" ||
code === "ECONNRESET"
);
}
export async function getAllServerStatuses(): Promise<
Record<number, ServerStatus>
> {
try {
const response = await statsApi.get("/status");
return response.data || {};
} catch (error) {
handleApiError(error, "fetch server statuses");
let lastError: unknown = null;
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
try {
const response = await statsApi.get("/status", {
timeout: timeoutMs,
// Silence per-attempt interceptor logging & health-monitor side
// effects on all attempts except the final one, so background
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
return response.data || {};
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
break;
}
if (pauseAfterMs === null) {
break;
}
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
}
}
handleApiError(lastError, "fetch server statuses");
}
export async function getServerStatusById(id: number): Promise<ServerStatus> {
@@ -2320,11 +2463,25 @@ export async function getServerStatusById(id: number): Promise<ServerStatus> {
}
}
export async function getServerMetricsById(id: number): Promise<ServerMetrics> {
export async function getServerMetricsById(
id: number,
): Promise<ServerMetrics | null> {
try {
const response = await statsApi.get(`/metrics/${id}`);
const response = await statsApi.get(`/metrics/${id}`, {
// Treat 404 as an expected "no metrics yet / disabled" signal rather
// than an error so we don't spam warn logs on the client.
validateStatus: (status) => status === 200 || status === 404,
});
if (response.status === 404) {
return null;
}
return response.data;
} catch (error) {
// If a 404 still slips through (e.g. intercepted before reaching here),
// swallow it quietly; everything else still flows through handleApiError.
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
handleApiError(error, "fetch server metrics");
throw error;
}
@@ -2378,9 +2535,12 @@ export async function sendMetricsHeartbeat(
}
}
export async function registerMetricsViewer(
hostId: number,
): Promise<{ success: boolean; viewerSessionId: string }> {
export async function registerMetricsViewer(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
skipped?: boolean;
reason?: string;
}> {
try {
const response = await statsApi.post("/metrics/register-viewer", {
hostId,
@@ -2471,6 +2631,50 @@ export async function updateGlobalMonitoringSettings(settings: {
}
}
// ============================================================================
// LOG LEVEL SETTINGS
// ============================================================================
export async function getLogLevel(): Promise<{ level: string }> {
try {
const response = await authApi.get("/users/log-level");
return response.data;
} catch (error) {
handleApiError(error, "fetch log level");
}
}
export async function updateLogLevel(level: string): Promise<void> {
try {
await authApi.patch("/users/log-level", { level });
} catch (error) {
handleApiError(error, "update log level");
}
}
// ============================================================================
// SESSION TIMEOUT SETTINGS
// ============================================================================
export async function getSessionTimeout(): Promise<{ timeoutHours: number }> {
try {
const response = await authApi.get("/users/session-timeout");
return response.data;
} catch (error) {
handleApiError(error, "fetch session timeout");
}
}
export async function updateSessionTimeout(
timeoutHours: number,
): Promise<void> {
try {
await authApi.patch("/users/session-timeout", { timeoutHours });
} catch (error) {
handleApiError(error, "update session timeout");
}
}
// ============================================================================
// GUACAMOLE SETTINGS
// ============================================================================
@@ -2538,7 +2742,7 @@ export async function loginUser(
const isInIframe =
typeof window !== "undefined" && window.self !== window.top;
if (isInIframe && hasToken) {
if (isInIframe && isElectron() && hasToken) {
localStorage.setItem("jwt", response.data.token);
try {
@@ -2550,7 +2754,7 @@ export async function loginUser(
platform: "desktop",
timestamp: Date.now(),
},
"*",
window.location.origin,
);
} catch (e) {
console.error("[main-axios] Error posting message to parent:", e);
@@ -2831,10 +3035,10 @@ export async function revokeAllUserSessions(
}
export async function makeUserAdmin(
username: string,
userId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/users/make-admin", { username });
const response = await authApi.post("/users/make-admin", { userId });
return response.data;
} catch (error) {
handleApiError(error, "make user admin");
@@ -2842,10 +3046,10 @@ export async function makeUserAdmin(
}
export async function removeAdminStatus(
username: string,
userId: string,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.post("/users/remove-admin", { username });
const response = await authApi.post("/users/remove-admin", { userId });
return response.data;
} catch (error) {
handleApiError(error, "remove admin status");
@@ -3012,7 +3216,7 @@ export async function verifyTOTPLogin(
const isInIframe =
typeof window !== "undefined" && window.self !== window.top;
if (isInIframe && hasToken) {
if (isInIframe && isElectron() && hasToken) {
localStorage.setItem("jwt", response.data.token);
try {
@@ -3024,7 +3228,7 @@ export async function verifyTOTPLogin(
platform: "desktop",
timestamp: Date.now(),
},
"*",
window.location.origin,
);
} catch (e) {
console.error("[main-axios] Error posting message to parent:", e);
@@ -3091,9 +3295,13 @@ export async function getReleasesRSS(
}
}
export async function getVersionInfo(): Promise<Record<string, unknown>> {
export async function getVersionInfo(
checkRemote = true,
): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/version");
const response = await authApi.get(
`/version${checkRemote ? "" : "?checkRemote=false"}`,
);
return response.data;
} catch (error) {
handleApiError(error, "fetch version info");
@@ -3207,6 +3415,20 @@ export async function getSSHHostWithCredentials(
}
}
export async function getHostPassword(
hostId: number,
field: "password" | "sudoPassword" = "password",
): Promise<string | null> {
try {
const response = await sshHostApi.get(
`/db/host/${hostId}/password?field=${field}`,
);
return response.data?.value || null;
} catch {
return null;
}
}
export async function applyCredentialToHost(
hostId: number,
credentialId: number,
@@ -3629,7 +3851,9 @@ export async function reorderSnippets(
updates: Array<{ id: number; order: number; folder?: string }>,
): Promise<{ success: boolean }> {
try {
const response = await authApi.post("/snippets/reorder", { updates });
const response = await authApi.post("/snippets/reorder", {
snippets: updates,
});
return response.data;
} catch (error) {
throw handleApiError(error, "reorder snippets");
@@ -3886,6 +4110,39 @@ export interface GuacamoleTokenResponse {
token: string;
}
type GuacamoleConfigSource = {
guacamoleConfig?: string | Record<string, unknown> | null;
};
export function getGuacamoleDpi(
source?: GuacamoleConfigSource,
): number | undefined {
const config = source?.guacamoleConfig;
if (!config) return undefined;
let dpi: unknown;
if (typeof config === "string") {
try {
dpi = JSON.parse(config).dpi;
} catch {
return undefined;
}
} else {
dpi = config.dpi;
}
const parsedDpi = typeof dpi === "string" ? Number(dpi) : dpi;
if (
typeof parsedDpi !== "number" ||
!Number.isFinite(parsedDpi) ||
parsedDpi <= 0
) {
return undefined;
}
return Math.trunc(parsedDpi);
}
function toGuacamoleParams(
config: GuacamoleTokenRequest["guacamoleConfig"],
): Record<string, unknown> {
@@ -4132,6 +4389,75 @@ export async function revokeHostAccess(
}
}
// ============================================================================
// SNIPPET SHARING
// ============================================================================
export async function shareSnippet(
snippetId: number,
shareData: {
targetType: "user" | "role";
targetUserId?: string;
targetRoleId?: number;
durationHours?: number;
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(
`/rbac/snippet/${snippetId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share snippet");
}
}
export async function getSnippetAccess(
snippetId: number,
): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippet access");
}
}
export async function revokeSnippetAccess(
snippetId: number,
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(
`/rbac/snippet/${snippetId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke snippet access");
}
}
export async function getSharedSnippets(): Promise<{
sharedSnippets: Array<{
id: number;
name: string;
content: string;
description: string | null;
folder: string | null;
ownerUsername: string;
permissionLevel: string;
expiresAt: string | null;
}>;
}> {
try {
const response = await rbacApi.get("/rbac/shared-snippets");
return response.data;
} catch (error) {
handleApiError(error, "fetch shared snippets");
}
}
// ============================================================================
// DOCKER MANAGEMENT API
// ============================================================================
+81 -18
View File
@@ -13,7 +13,13 @@ import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { isElectron, getCookie, getSnippets } from "@/ui/main-axios.ts";
import { toast } from "sonner";
import {
isElectron,
isEmbeddedMode,
getCookie,
getSnippets,
} from "@/ui/main-axios.ts";
import { getBasePath } from "@/lib/base-path";
import { useTheme } from "@/components/theme-provider";
import {
@@ -121,7 +127,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const isFittingRef = useRef(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 3;
const maxReconnectAttempts = 8;
const isUnmountingRef = useRef(false);
const shouldNotReconnectRef = useRef(false);
const isReconnectingRef = useRef(false);
@@ -506,13 +512,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
const configuredUrl = (window as { configuredServerUrl?: string })
.configuredServerUrl;
if (isEmbeddedMode() || !configuredUrl) {
return "ws://127.0.0.1:30002";
}
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/ssh/websocket/`;
@@ -709,7 +719,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
ws.send(
JSON.stringify({
type: "input",
data: snippet.content + "\n",
data: snippet.content + "\r",
}),
);
}
@@ -800,6 +810,46 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
} else if (msg.type === "tmux_sessions_available") {
// On mobile, auto-attach to the first available session
const sessions = msg.sessions as Array<{ name: string }>;
if (sessions.length > 0 && ws.readyState === 1) {
ws.send(
JSON.stringify({
type: "tmux_attach",
data: { sessionName: sessions[0].name },
}),
);
}
} else if (
msg.type === "tmux_session_created" ||
msg.type === "tmux_session_attached"
) {
const sessionName =
typeof msg.sessionName === "string" ? msg.sessionName : "";
addLog({
type: "info",
stage: "connection",
message:
msg.type === "tmux_session_created"
? t("terminal.tmuxSessionCreated", {
name: sessionName || "new",
})
: t("terminal.tmuxSessionAttached", {
name: sessionName,
}),
});
} else if (msg.type === "tmux_unavailable") {
setTimeout(() => {
toast.warning(t("terminal.tmuxUnavailable"), {
duration: 8000,
});
}, 500);
addLog({
type: "warning",
stage: "connection",
message: t("terminal.tmuxUnavailable"),
});
} else if (msg.type === "connection_log") {
if (msg.data) {
addLog({
@@ -834,17 +884,21 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (event.code === 1006) {
console.error(
"[WebSocket] Abnormal closure detected - possible HTTPS/proxy issue",
console.warn(
"[WebSocket] Abnormal closure detected - attempting reconnection",
);
addLog({
type: "error",
type: "warning",
stage: "connection",
message: t("terminal.websocketAbnormalClose"),
});
updateConnectionError(t("terminal.websocketAbnormalClose"));
setIsConnecting(false);
shouldNotReconnectRef.current = true;
if (wasConnectedRef.current) {
attemptReconnection();
} else {
updateConnectionError(t("terminal.websocketAbnormalClose"));
setIsConnecting(false);
}
return;
}
@@ -861,10 +915,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
localStorage.removeItem("jwt");
setTimeout(() => {
window.location.reload();
}, 1000);
return;
}
@@ -994,6 +1044,16 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.open(xtermRef.current);
const handlePaste = (e: ClipboardEvent) => {
const text = e.clipboardData?.getData("text");
if (text) {
e.preventDefault();
e.stopPropagation();
terminal.paste(text);
}
};
xtermRef.current.addEventListener("paste", handlePaste);
const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => {
@@ -1046,9 +1106,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
});
});
const currentElement = xtermRef.current;
return () => {
resizeObserver.disconnect();
clipboardProvider.dispose();
currentElement?.removeEventListener("paste", handlePaste);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (pingIntervalRef.current) {
+5 -6
View File
@@ -165,12 +165,6 @@ export function Auth({
});
}, []);
useEffect(() => {
if (!registrationAllowed && !internalLoggedIn) {
toast.warning(t("messages.registrationDisabled"));
}
}, [registrationAllowed, internalLoggedIn, t]);
useEffect(() => {
if (!passwordLoginAllowed && oidcConfigured && tab !== "external") {
setTab("external");
@@ -595,6 +589,11 @@ export function Auth({
setOidcLoading(true);
setError(null);
const urlToken = urlParams.get("token");
if (urlToken && (isElectron() || isReactNativeWebView())) {
localStorage.setItem("jwt", urlToken);
}
window.history.replaceState({}, document.title, window.location.pathname);
setTimeout(() => {