diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml
index 4f189677..9abbddc3 100644
--- a/.github/workflows/beta-release.yml
+++ b/.github/workflows/beta-release.yml
@@ -28,7 +28,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
@@ -67,7 +67,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -121,7 +121,7 @@ jobs:
CHANGES=$(git log --oneline --no-merges "${{ steps.prev.outputs.sha }}..${{ needs.prep.outputs.sha }}" -- . ':!package-lock.json' | sed 's/^/- /')
fi
if [ -z "$CHANGES" ]; then
- CHANGES="- No new commits since the last beta (or this is the first beta build)."
+ CHANGES="- No new commits since the last beta."
fi
cat > BETA_RELEASE_BODY.md << EOF
diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml
new file mode 100644
index 00000000..8ab185cd
--- /dev/null
+++ b/.github/workflows/crowdin-sync.yml
@@ -0,0 +1,83 @@
+name: Crowdin Sync
+
+on:
+ schedule:
+ - cron: "0 6 * * *"
+ workflow_dispatch:
+ inputs:
+ branch:
+ description: "Branch to sync translations into"
+ required: false
+ type: string
+
+permissions:
+ contents: write
+
+jobs:
+ crowdin:
+ runs-on: blacksmith-2vcpu-ubuntu-2404
+ steps:
+ - name: Resolve target branch
+ id: branch
+ run: |
+ BRANCH="${{ inputs.branch }}"
+ if [ -z "$BRANCH" ]; then
+ BRANCH="${{ github.event.repository.default_branch }}"
+ fi
+ echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout branch
+ uses: actions/checkout@v7
+ with:
+ ref: ${{ steps.branch.outputs.name }}
+ fetch-depth: 0
+ token: ${{ secrets.GHCR_TOKEN }}
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version-file: ".nvmrc"
+
+ - name: Upload sources to Crowdin
+ uses: crowdin/github-action@v2
+ with:
+ upload_sources: true
+ upload_translations: false
+ download_translations: false
+ create_pull_request: false
+ push_translations: false
+ token: ${{ secrets.CROWDIN_API_KEY }}
+ project_id: "858252"
+ env:
+ CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
+
+ - name: Machine pre-translate untranslated strings
+ env:
+ CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
+ run: node scripts/crowdin-pretranslate.cjs
+
+ - name: Download translations from Crowdin
+ uses: crowdin/github-action@v2
+ with:
+ upload_sources: false
+ upload_translations: false
+ download_translations: true
+ create_pull_request: false
+ push_translations: false
+ token: ${{ secrets.CROWDIN_API_KEY }}
+ project_id: "858252"
+ env:
+ CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}
+
+ - name: Commit translations
+ run: |
+ git config user.name "LukeGus"
+ git config user.email "bugattiguy527@gmail.com"
+
+ git add src/ui/locales/translated
+ if git diff --cached --quiet; then
+ echo "No translation changes to commit."
+ exit 0
+ fi
+ git commit -m "chore: sync Crowdin translations"
+ git push origin HEAD:"${{ steps.branch.outputs.name }}"
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 212f62cc..6a465bce 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -43,6 +43,7 @@ on:
jobs:
build:
runs-on: blacksmith-8vcpu-ubuntu-2404
+ timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml
index 324cc50f..3b9d1c82 100644
--- a/.github/workflows/electron.yml
+++ b/.github/workflows/electron.yml
@@ -72,7 +72,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -166,7 +166,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -380,7 +380,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -966,7 +966,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml
index c76952da..54f1486f 100644
--- a/.github/workflows/openapi.yml
+++ b/.github/workflows/openapi.yml
@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml
index 2e48e167..123f4dcb 100644
--- a/.github/workflows/pr-check.yml
+++ b/.github/workflows/pr-check.yml
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cf89c5a1..d4b51618 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -44,7 +44,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
@@ -92,7 +92,7 @@ jobs:
token: ${{ secrets.GHCR_TOKEN }}
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
cache: "npm"
@@ -144,7 +144,7 @@ jobs:
token: ${{ secrets.GHCR_TOKEN }}
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
@@ -225,7 +225,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
@@ -304,7 +304,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
@@ -420,7 +420,7 @@ jobs:
path: termix
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: "termix/.nvmrc"
cache: "npm"
@@ -513,7 +513,7 @@ jobs:
fetch-depth: 1
- name: Setup Node.js
- uses: actions/setup-node@v6
+ uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
diff --git a/README.md b/README.md
index 6ec99960..b92f6c78 100644
--- a/README.md
+++ b/README.md
@@ -189,6 +189,20 @@ SSH sessions and tabs stay open across devices/refreshes if enabled in user prof
**Languages:**
Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Session Sharing:**
+Share a live terminal, RDP, VNC, or Telnet session with others in real time. Share via a link (joined anonymously, no account needed) or with a specific Termix user, and choose read-only or read-write access. Shares can expire automatically or be revoked at any time, and session sharing can be toggled globally or per-host.
+
+
+
+
+**Desktop Standalone + 2-Way Sync:**
+The Electron desktop app runs fully standalone with its own local backend and database, no server required. Optionally connect it to a remote Termix server for automatic two-way sync of hosts, credentials, snippets, and more, and choose whether SSH connections are started locally or through the remote server.
+
@@ -293,6 +307,14 @@ networks:
+## Telemetry
+
+Termix sends a small anonymous usage ping once every 24 hours to help understand how many instances are running and which features are actually used. This only includes a randomly generated instance ID, a count of users and hosts, the app version, and whether certain features (terminal, file manager, tunnels, docker, etc.) were used in the last 24 hours. It never includes usernames, hostnames, IP addresses, credentials, or any other identifying or connection data.
+
+This is opt-out and enabled by default. You can disable it at any time in Admin Settings under **General**.
+
+
+
## Donate
Termix is free and open source with no subscriptions or paid plans. If you find it useful, consider donating to help cover server costs, domains, and development time. Donations also help fund the time to research and learn what's needed to build features like SAML, Kubernetes, and Agent support. Track progress and donate below.
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index e4ab722e..caaa91f2 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,70 +1,73 @@
-Revamped RBAC/sharing, session recording & replay, Vault auth for monitors, API key host enrollment, Proxmox guest auto sync, database refactor, plus 30+ bug fixes across terminal, file manager, RDP/VNC, and auth. DO NOT DOWNGRADE FROM THIS VERSION.
+Standalone-first Electron desktop app with optional remote sync, shared/multiplayer terminal and remote desktop sessions, improved SSH MFA support, custom key shortcuts, bug fixes across terminal, RDP/VNC, mobile, and auth.
-https://youtu.be/c3UD4q2jW_8
+https://youtu.be/g0QjNdV3YYY
-- Revamped RBAC/sharing system (new UI, all auth types and host protocols now supported)
-- Complete admin control over user information (manage all users hosts, credentials, and snippets)
-- Support Vault auth for monitors
-- API key host enrollment endpoint
-- Allow pinned hosts with name sorting
-- Session recording and replay
-- Terminal font size shortcuts (ctrl + / -)
-- Open File Manager to tab right-click menu
-- Proxmox guest auto sync
-- Complete database refactor
-- 30-day donation reminder and new donation milestones that support research: (donate.termix.site)
-- Improve site performance with cache and poll pauses
-- Save quick connect sessions as hosts
+- Added simple telemetrics to PostHog (user count, total hosts across users, and version metrics).
+- Reworked Electron desktop app to run standalone-first with a now optional sync to a remote Termix server.
+- Added support for starting connections locally or from the remote server on desktop app.
+- Added support for custom key shortcuts.
+- Added support for more MFA types (SSH-only).
+- Added multiplayer/shared sessions for terminals and remote desktop (share via link or user).
+- Added support for logging into SSH hosts that require multi-factor authentication (like Duo or JumpCloud push/TOTP prompts).
+- Added the option to convert a Quick Connect session into a saved host after connecting.
+- Added an export option for sharing host entries without credentials, so a host list can be shared without leaking passwords or keys.
+- Unified the folder picker across hosts, credentials, and snippets so they all use the same searchable, create-in-place selector.
+- Allowed pasting into the key recording field from the clipboard.
+- Allowed sharing hosts that use authentication type "none".
+- Allowed setting authentication type "none" on RDP hosts.
+- Added the option to show two or more sidebar panels open at the same time.
+- Added global custom themes that can be applied across all hosts instead of per host.
+- Added a button to quickly create a Credentials entry from a host's existing authentication details.
+- Added persistent split screen, so your layout and assigned tabs are restored after closing and reopening the app.
+- Added tag matching to host search, so searching now matches tags as well as hostnames.
+- Brought back the ability to collapse snippets.
+- Added the ability to assign login credentials to an entire folder of hosts instead of one at a time.
+- Added the ability to share terminal, VNC, and RDP sessions with other users, including read-only and read-write modes.
+- Added the ability to share entire folders of hosts with other users instead of sharing hosts one by one.
+- Added the ability to make folders of hosts available to specific users instead of everyone recreating them.
+- Reworked SSH credentials to support both a password and an SSH key on the same credential, with an option to auto fill the password when prompted.
+- Added a custom group claim option for OIDC login, useful for identity providers like Zitadel that don't use a plain "groups" claim.
-- Syntax highlighting artifacts
-- Filter dashboard status hosts
-- Persist dashboard service link changes
-- Snippet text overflow
-- Persist remote desktop credential auth
-- Guard language switching failures
-- Resolve tunnel source credentials
-- Windows file delete command
-- Artifact release checkout ref
-- Command palette escape in fullscreen
-- Alerts and audit log normalization
-- macOS VNC protocol negotiation
-- Port knocking before SSH connect
-- Allow escape to close link confirmation
-- Prevent Electron modifier wheel zoom
-- Credential auth optional password
-- Retry transient terminal DNS lookups
-- OIDC redirect forwarded port handling
-- Preserve recent open tabs on startup
-- Terminal font selection
-- Poor font legibility in multiple places
-- File manager uploads failing
-- Tmux detection for non-POSTIX shells
-- OPKSSH js-yaml ESM import
-- Android Vietnamese IME input
-- Firefox RDP clipboard paste
-- Proxmox discovery over HTTPS
-- External editor actions in file preview
-- Firefox desktop OIDC callback
-- Status checks through jump hosts
-- Restore sudo password auto fill settings
-- Preserve file editor position on save
-- Sync cloud preference storage mode
-- Render RDP sessions at native pixel density
-- Restore database import in embedded desktop mode
-- Command autocomplete dropdown poor contrast
-- Allow clipboard paste in key recording field
-- Fix GitHub/google SSO "not defined" errors
+- Fixed the Add Channel dialog failing with "config is required" when adding Webhook or ntfy alert channels.
+- Fixed font size and UI scaling being too small even at the largest setting on high resolution displays.
+- Fixed the Docker integration not working on hosts using authentication type "none".
+- Fixed the latest Russian translation updates from Crowdin not being included in the app.
+- Fixed missing Nerd Font symbol support in the Android app.
+- Fixed credential changes on RDP hosts not saving properly.
+- Fixed credential folders not showing up in the folder dropdown when editing a credential.
+- Fixed RDP hosts not using their stored credential and falling back to a direct connection.
+- Fixed Cmd + scroll on Mac resizing the terminal instead of scrolling.
+- Fixed text repeating itself in the terminal when typing with a wireless keyboard on Android.
+- Fixed RDP connections failing when going through a jump host.
+- Fixed SSH connections failing when going through a jump host in some setups.
+- Fixed OIDC login failing with a database error when the identity provider didn't return a client ID.
+- Fixed SSH client-to-server tunnels failing with an authentication error.
+- Fixed Host Metrics disk usage only showing the root filesystem and ignoring other mounted disks.
+- Fixed Android navigation buttons covering the terminal's top bar keys.
+- Fixed Proxmox discovery importing DHCP LXC containers with an IP of 0.0.0.0 instead of their real address.
+- Fixed VNC connections to macOS Screen Sharing hanging after the handshake instead of connecting.
+- Fixed File Manager delete still failing on Windows hosts running PowerShell 5.1.
+- Fixed the terminal dropping characters while typing on iOS.
+- Fixed SSH lines like "[username@host]" being wrongly highlighted as a log level and breaking output formatting.
+- Fixed RDP touch mode on Android not registering taps as clicks.
+- Fixed VNC connections still failing due to a guacd protocol version mismatch.
+- Guacamole tab showing "connecting" instead of rendering the desktop.
+- Fixed tmux not using Tailscale when starting connections.
+- Fixed an invalid websocket frame from causing code 10006 crash triggering restart loop.
+- Remove chacha20-poly1305 without native ssh2 binding.
+- Corrected SSRF blocklist from false-positive on all IPv4.
+- Fixed terminal background image incorrectly displaying.
diff --git a/docker/Dockerfile b/docker/Dockerfile
index f6ba5f51..f4028795 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -58,7 +58,8 @@ WORKDIR /app
ENV DATA_DIR=/app/data \
PORT=8080 \
- NODE_ENV=production
+ NODE_ENV=production \
+ POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd
RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget certbot python3-certbot-dns-cloudflare && \
update-ca-certificates && \
diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf
index 9449028a..ce22530d 100644
--- a/docker/nginx-https.conf
+++ b/docker/nginx-https.conf
@@ -226,6 +226,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/sync(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
+ }
+
location ~ ^/termix-id(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -467,6 +476,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/session-sharing(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
+ }
+
location /host/tunnel/ {
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
diff --git a/docker/nginx.conf b/docker/nginx.conf
index 68cff5a6..235218b9 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -215,6 +215,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/sync(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
+ }
+
location ~ ^/termix-id(/.*)?$ {
proxy_pass http://127.0.0.1:30001;
proxy_http_version 1.1;
@@ -456,6 +465,15 @@ http {
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
}
+ location ~ ^/session-sharing(/.*)?$ {
+ proxy_pass http://127.0.0.1:30001;
+ proxy_http_version 1.1;
+ proxy_set_header Host $http_host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
+ }
+
location /host/tunnel/ {
proxy_pass http://127.0.0.1:30003;
proxy_http_version 1.1;
diff --git a/docs/readme/README-AR.md b/docs/readme/README-AR.md
index 9d208394..3aa71869 100644
--- a/docs/readme/README-AR.md
+++ b/docs/readme/README-AR.md
@@ -189,6 +189,20 @@ Termix هي منصة مفتوحة المصدر ومجانية للأبد وذا
**اللغات:**
دعم مدمج لحوالي 30 لغة (تُدار بواسطة [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**مشاركة الجلسة:**
+شارك جلسة طرفية أو RDP أو VNC أو Telnet مباشرة مع الآخرين في الوقت الفعلي. شارك عبر رابط (الانضمام بشكل مجهول، بدون الحاجة لحساب) أو مع مستخدم Termix محدد، واختر الوصول للقراءة فقط أو للقراءة والكتابة. يمكن أن تنتهي صلاحية المشاركات تلقائيًا أو يتم إلغاؤها في أي وقت، ويمكن تبديل مشاركة الجلسة عالميًا أو لكل مضيف على حدة.
+
+
+
+
+**تطبيق سطح مكتب مستقل + مزامنة ثنائية الاتجاه:**
+يعمل تطبيق سطح المكتب Electron بشكل مستقل تمامًا مع خلفية وقاعدة بيانات محلية خاصة به، دون الحاجة لخادم. يمكن اختياريًا توصيله بخادم Termix عن بُعد للمزامنة التلقائية ثنائية الاتجاه للمضيفين وبيانات الاعتماد والمقتطفات والمزيد، واختيار ما إذا كانت اتصالات SSH تبدأ محليًا أو عبر الخادم البعيد.
+
diff --git a/docs/readme/README-CN.md b/docs/readme/README-CN.md
index 2290b99b..079026f8 100644
--- a/docs/readme/README-CN.md
+++ b/docs/readme/README-CN.md
@@ -189,6 +189,20 @@ Termix 是一个开源、永久免费、自托管的一体化服务器管理平
**语言:**
内置支持约 30 种语言(由 [Crowdin](https://docs.termix.site/translations) 管理)。
+
+
+
+
+
+**会话共享:**
+与他人实时共享终端、RDP、VNC 或 Telnet 会话。通过链接分享(匿名加入,无需帐户)或与特定的 Termix 用户分享,并选择只读或读写权限。共享可以自动过期或随时撤销,会话共享可以全局或按主机切换。
+
+
+
+
+**桌面独立运行 + 双向同步:**
+Electron 桌面应用可完全独立运行,拥有自己的本地后端和数据库,无需服务器。也可以选择连接到远程 Termix 服务器,实现主机、凭据、代码片段等的自动双向同步,并选择 SSH 连接是在本地启动还是通过远程服务器启动。
+
diff --git a/docs/readme/README-DE.md b/docs/readme/README-DE.md
index 6ca2f9af..be5b9961 100644
--- a/docs/readme/README-DE.md
+++ b/docs/readme/README-DE.md
@@ -189,6 +189,20 @@ SSH-Sitzungen und Tabs bleiben uber Gerate/Aktualisierungen hinweg offen, wenn i
**Sprachen:**
Integrierte Unterstutzung fur ca. 30 Sprachen (verwaltet uber [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Sitzungsfreigabe:**
+Teilen Sie eine Live-Terminal-, RDP-, VNC- oder Telnet-Sitzung in Echtzeit mit anderen. Freigabe uber einen Link (anonymer Beitritt, kein Konto erforderlich) oder mit einem bestimmten Termix-Benutzer, mit Wahl zwischen Nur-Lese- oder Lese-/Schreibzugriff. Freigaben konnen automatisch ablaufen oder jederzeit widerrufen werden, und die Sitzungsfreigabe kann global oder pro Host umgeschaltet werden.
+
+
+
+
+**Eigenstandiger Desktop + bidirektionale Synchronisierung:**
+Die Electron-Desktop-App lauft vollstandig eigenstandig mit eigenem lokalem Backend und eigener Datenbank, kein Server erforderlich. Optional mit einem entfernten Termix-Server verbinden fur automatische bidirektionale Synchronisierung von Hosts, Zugangsdaten, Snippets und mehr, mit der Wahl, ob SSH-Verbindungen lokal oder uber den entfernten Server gestartet werden.
+
diff --git a/docs/readme/README-ES.md b/docs/readme/README-ES.md
index b2689408..9006dfcd 100644
--- a/docs/readme/README-ES.md
+++ b/docs/readme/README-ES.md
@@ -189,6 +189,20 @@ Las sesiones SSH y pestanas permanecen abiertas entre dispositivos/actualizacion
**Idiomas:**
Soporte integrado para aproximadamente 30 idiomas (gestionado por [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Uso compartido de sesion:**
+Comparte una sesion en vivo de terminal, RDP, VNC o Telnet con otras personas en tiempo real. Comparte mediante un enlace (se une de forma anonima, sin necesidad de cuenta) o con un usuario especifico de Termix, y elige acceso de solo lectura o de lectura y escritura. Las comparticiones pueden expirar automaticamente o revocarse en cualquier momento, y el uso compartido de sesiones se puede activar globalmente o por host.
+
+
+
+
+**Aplicacion de escritorio independiente + sincronizacion bidireccional:**
+La aplicacion de escritorio Electron funciona de forma totalmente independiente con su propio backend y base de datos locales, sin necesidad de servidor. Opcionalmente, conectala a un servidor Termix remoto para sincronizacion bidireccional automatica de hosts, credenciales, fragmentos y mas, y elige si las conexiones SSH se inician localmente o a traves del servidor remoto.
+
diff --git a/docs/readme/README-FR.md b/docs/readme/README-FR.md
index 0813165a..48d81313 100644
--- a/docs/readme/README-FR.md
+++ b/docs/readme/README-FR.md
@@ -189,6 +189,20 @@ Les sessions SSH et les onglets restent ouverts sur tous les appareils/actualisa
**Langues:**
Support integre d'environ 30 langues (gere par [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Partage de session:**
+Partagez une session de terminal, RDP, VNC ou Telnet en direct avec d'autres personnes en temps reel. Partagez via un lien (rejoint anonymement, sans compte necessaire) ou avec un utilisateur Termix specifique, et choisissez un acces en lecture seule ou en lecture-ecriture. Les partages peuvent expirer automatiquement ou etre revoques a tout moment, et le partage de session peut etre active globalement ou par hote.
+
+
+
+
+**Application de bureau autonome + synchronisation bidirectionnelle:**
+L'application de bureau Electron fonctionne de maniere totalement autonome avec son propre backend et sa propre base de donnees locale, sans serveur requis. Connectez-la eventuellement a un serveur Termix distant pour une synchronisation bidirectionnelle automatique des hotes, des identifiants, des extraits de code et plus encore, et choisissez si les connexions SSH sont demarrees localement ou via le serveur distant.
+
diff --git a/docs/readme/README-HI.md b/docs/readme/README-HI.md
index 17a1e63f..ecf024d2 100644
--- a/docs/readme/README-HI.md
+++ b/docs/readme/README-HI.md
@@ -189,6 +189,20 @@ Termix एक ओपन-सोर्स, हमेशा के लिए मु
**भाषाएँ:**
लगभग 30 भाषाओं का बिल्ट-इन सपोर्ट ([Crowdin](https://docs.termix.site/translations) द्वारा प्रबंधित)।
+
+
+
+
+
+**सेशन शेयरिंग:**
+लाइव टर्मिनल, RDP, VNC, या Telnet सेशन को दूसरों के साथ रीयल टाइम में शेयर करें। लिंक के जरिए शेयर करें (गुमनाम रूप से जुड़ें, अकाउंट की जरूरत नहीं) या किसी खास Termix यूजर के साथ, और रीड-ओनली या रीड-राइट एक्सेस चुनें। शेयर अपने आप एक्सपायर हो सकते हैं या कभी भी रद्द किए जा सकते हैं, और सेशन शेयरिंग को ग्लोबली या प्रति होस्ट टॉगल किया जा सकता है।
+
+
+
+
+**डेस्कटॉप स्टैंडअलोन + 2-वे सिंक:**
+Electron डेस्कटॉप ऐप अपने खुद के लोकल बैकएंड और डेटाबेस के साथ पूरी तरह से स्टैंडअलोन चलता है, किसी सर्वर की जरूरत नहीं। चाहें तो इसे किसी रिमोट Termix सर्वर से कनेक्ट करें ताकि होस्ट्स, क्रेडेंशियल्स, स्निपेट्स और अन्य चीज़ों का ऑटोमैटिक 2-वे सिंक हो सके, और चुनें कि SSH कनेक्शन लोकली शुरू हों या रिमोट सर्वर के जरिए।
+
diff --git a/docs/readme/README-IT.md b/docs/readme/README-IT.md
index 91d6884b..97e90f2d 100644
--- a/docs/readme/README-IT.md
+++ b/docs/readme/README-IT.md
@@ -189,6 +189,20 @@ Le sessioni SSH e le schede rimangono aperte tra dispositivi/aggiornamenti se ab
**Lingue:**
Supporto integrato per circa 30 lingue (gestito da [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Condivisione Sessione:**
+Condividi una sessione di terminale, RDP, VNC o Telnet dal vivo con altri in tempo reale. Condividi tramite un link (accesso anonimo, nessun account necessario) o con un utente Termix specifico, e scegli l'accesso in sola lettura o lettura/scrittura. Le condivisioni possono scadere automaticamente o essere revocate in qualsiasi momento, e la condivisione della sessione puo essere attivata globalmente o per singolo host.
+
+
+
+
+**App Desktop Standalone + Sincronizzazione Bidirezionale:**
+L'app desktop Electron funziona in modo completamente autonomo con il proprio backend e database locali, senza bisogno di un server. Facoltativamente, collegala a un server Termix remoto per la sincronizzazione bidirezionale automatica di host, credenziali, snippet e altro, scegliendo se le connessioni SSH vengono avviate localmente o tramite il server remoto.
+
diff --git a/docs/readme/README-JA.md b/docs/readme/README-JA.md
index dbde0588..45c153ed 100644
--- a/docs/readme/README-JA.md
+++ b/docs/readme/README-JA.md
@@ -189,6 +189,20 @@ Tailnetのデバイスをリストしてホストとしてすばやく追加し
**多言語対応:**
約30言語の組み込みサポート([Crowdin](https://docs.termix.site/translations)で管理されています)。
+
+
+
+
+
+**セッション共有:**
+ターミナル、RDP、VNC、Telnetのライブセッションを他のユーザーとリアルタイムで共有できます。リンクで共有(匿名参加、アカウント不要)するか、特定のTermixユーザーと共有し、読み取り専用または読み取り/書き込みアクセスを選択できます。共有は自動的に期限切れになるか、いつでも取り消すことができ、セッション共有はグローバルまたはホストごとに切り替えられます。
+
+
+
+
+**デスクトップスタンドアロン + 双方向同期:**
+Electronデスクトップアプリは、独自のローカルバックエンドとデータベースを使用して完全にスタンドアロンで動作し、サーバーは不要です。オプションでリモートのTermixサーバーに接続し、ホスト、認証情報、スニペットなどの自動双方向同期を行い、SSH接続をローカルで開始するかリモートサーバー経由で開始するかを選択できます。
+
diff --git a/docs/readme/README-KO.md b/docs/readme/README-KO.md
index 8382f83b..834dc87b 100644
--- a/docs/readme/README-KO.md
+++ b/docs/readme/README-KO.md
@@ -189,6 +189,20 @@ Tailscale 네트워크의 기기를 나열하여 호스트로 빠르게 추가
**다국어 지원:**
약 30개 언어 내장 지원([Crowdin](https://docs.termix.site/translations)으로 관리).
+
+
+
+
+
+**세션 공유:**
+터미널, RDP, VNC, Telnet 세션을 다른 사람과 실시간으로 공유하세요. 링크를 통해 공유(계정 없이 익명으로 참여)하거나 특정 Termix 사용자와 공유할 수 있으며, 읽기 전용 또는 읽기/쓰기 권한을 선택할 수 있습니다. 공유는 자동으로 만료되거나 언제든지 취소될 수 있으며, 세션 공유는 전역 또는 호스트별로 전환할 수 있습니다.
+
+
+
+
+**데스크톱 독립 실행 + 양방향 동기화:**
+Electron 데스크톱 앱은 자체 로컬 백엔드와 데이터베이스를 사용하여 서버 없이 완전히 독립적으로 실행됩니다. 선택적으로 원격 Termix 서버에 연결하여 호스트, 자격 증명, 스니펫 등을 자동으로 양방향 동기화하고, SSH 연결을 로컬에서 시작할지 원격 서버를 통해 시작할지 선택할 수 있습니다.
+
diff --git a/docs/readme/README-PT.md b/docs/readme/README-PT.md
index c46606cc..052dd2e6 100644
--- a/docs/readme/README-PT.md
+++ b/docs/readme/README-PT.md
@@ -189,6 +189,20 @@ Sessoes SSH e abas permanecem abertas entre dispositivos/atualizacoes se habilit
**Idiomas:**
Suporte integrado para aproximadamente 30 idiomas (gerenciado pelo [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Compartilhamento de sessao:**
+Compartilhe uma sessao de terminal, RDP, VNC ou Telnet ao vivo com outras pessoas em tempo real. Compartilhe por meio de um link (entrada anonima, sem necessidade de conta) ou com um usuario especifico do Termix, e escolha acesso somente leitura ou leitura/gravacao. Os compartilhamentos podem expirar automaticamente ou ser revogados a qualquer momento, e o compartilhamento de sessao pode ser ativado globalmente ou por host.
+
+
+
+
+**Aplicativo de desktop autonomo + sincronizacao bidirecional:**
+O aplicativo de desktop Electron funciona de forma totalmente autonoma com seu proprio backend e banco de dados locais, sem necessidade de servidor. Opcionalmente, conecte-o a um servidor Termix remoto para sincronizacao bidirecional automatica de hosts, credenciais, snippets e muito mais, e escolha se as conexoes SSH sao iniciadas localmente ou por meio do servidor remoto.
+
diff --git a/docs/readme/README-RU.md b/docs/readme/README-RU.md
index 7db61e9f..f6774717 100644
--- a/docs/readme/README-RU.md
+++ b/docs/readme/README-RU.md
@@ -189,6 +189,20 @@ SSH-сессии и вкладки остаются открытыми на вс
**Языки:**
Встроенная поддержка около 30 языков (управляется через [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Общий доступ к сеансу:**
+Делитесь сеансом терминала, RDP, VNC или Telnet с другими в режиме реального времени. Делитесь по ссылке (анонимное присоединение, учетная запись не требуется) или с конкретным пользователем Termix, выбирая доступ только для чтения или для чтения и записи. Общий доступ может автоматически истекать или быть отозван в любое время, а общий доступ к сеансам можно включать глобально или для отдельного хоста.
+
+
+
+
+**Автономное настольное приложение + двусторонняя синхронизация:**
+Настольное приложение на Electron полностью автономно, с собственным локальным бэкендом и базой данных, сервер не требуется. При желании подключите его к удаленному серверу Termix для автоматической двусторонней синхронизации хостов, учетных данных, сниппетов и прочего, и выберите, запускаются ли SSH-соединения локально или через удаленный сервер.
+
diff --git a/docs/readme/README-TR.md b/docs/readme/README-TR.md
index 1ee719b0..b75269f3 100644
--- a/docs/readme/README-TR.md
+++ b/docs/readme/README-TR.md
@@ -189,6 +189,20 @@ Kullanici profilinde etkinlestirilmisse SSH oturumlari ve sekmeler cihazlar/yeni
**Diller:**
Yaklasik 30 dil icin yerlesik destek ([Crowdin](https://docs.termix.site/translations) tarafindan yonetilir).
+
+
+
+
+
+**Oturum Paylasimi:**
+Canli bir terminal, RDP, VNC veya Telnet oturumunu baskalariyla gercek zamanli olarak paylasin. Bir baglanti uzerinden (anonim olarak katilir, hesap gerekmez) veya belirli bir Termix kullanicisiyla paylasin ve salt okunur veya okuma/yazma erisimi secin. Paylasimlar otomatik olarak sona erebilir veya istediginiz zaman iptal edilebilir; oturum paylasimi genel olarak veya sunucu bazinda acilip kapatilabilir.
+
+
+
+
+**Bagimsiz Masaustu + Cift Yonlu Senkronizasyon:**
+Electron masaustu uygulamasi, kendi yerel arka ucu ve veritabaniyla tamamen bagimsiz calisir, sunucu gerekmez. Istege bagli olarak sunucular, kimlik bilgileri, kod parcaciklari ve daha fazlasinin otomatik cift yonlu senkronizasyonu icin uzak bir Termix sunucusuna baglayin ve SSH baglantilarinin yerel olarak mi yoksa uzak sunucu uzerinden mi baslatilacagini secin.
+
diff --git a/docs/readme/README-VI.md b/docs/readme/README-VI.md
index 106b48c5..d7de3ac1 100644
--- a/docs/readme/README-VI.md
+++ b/docs/readme/README-VI.md
@@ -189,6 +189,20 @@ Cac phien SSH va tab van mo tren cac thiet bi/lan lam moi neu duoc bat trong ho
**Ngon Ngu:**
Ho tro tich hop khoang 30 ngon ngu (duoc quan ly boi [Crowdin](https://docs.termix.site/translations)).
+
+
+
+
+
+**Chia Se Phien:**
+Chia se mot phien terminal, RDP, VNC, hoac Telnet truc tiep voi nguoi khac theo thoi gian thuc. Chia se qua lien ket (tham gia an danh, khong can tai khoan) hoac voi mot nguoi dung Termix cu the, va chon quyen truy cap chi doc hoac doc/ghi. Cac lien ket chia se co the tu dong het han hoac bi thu hoi bat cu luc nao, va tinh nang chia se phien co the duoc bat/tat toan cuc hoac theo tung host.
+
+
+
+
+**Ung Dung Desktop Doc Lap + Dong Bo 2 Chieu:**
+Ung dung desktop Electron chay hoan toan doc lap voi backend va co so du lieu cuc bo rieng, khong can may chu. Tuy chon ket noi voi may chu Termix tu xa de tu dong dong bo 2 chieu cac host, thong tin dang nhap, doan ma va nhieu hon nua, va chon xem cac ket noi SSH duoc khoi tao cuc bo hay thong qua may chu tu xa.
+
diff --git a/electron/main.cjs b/electron/main.cjs
index 13963207..2e20608e 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -20,6 +20,7 @@ const net = require("net");
const { URL } = require("url");
const { fork, spawn } = require("child_process");
const WebSocket = require("ws");
+const remoteSync = require("./remote-sync.cjs");
// Portable mode: if a `.portable` marker exists next to the executable,
// store all data in a `data` folder beside the exe instead of %APPDATA%.
@@ -441,7 +442,10 @@ function isInvalidCertificateAllowedForUrl(url) {
// fall through
}
- const config = getServerConfigSync();
+ // The only remaining "connected remote server" a self-signed/invalid
+ // certificate could legitimately apply to is the Remote Sync server
+ // (also used for C2S tunnel relaying, see getC2SRelayUrl).
+ const config = remoteSync.getRemoteSyncConfig();
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
return getOrigin(url) === getOrigin(config.serverUrl);
@@ -816,7 +820,62 @@ function getBackendDataDir() {
return dataDir;
}
+function getBackendPidFilePath() {
+ return path.join(app.getPath("userData"), "backend.pid");
+}
+
+// If the app was previously killed abnormally (crash, force-quit, Task
+// Manager) rather than through the normal quit flow, will-quit never fires
+// and stopBackendServer() never runs -- the forked backend child is a
+// genuinely separate OS process on Windows/mac/Linux, so it keeps running
+// and holding every port the backend binds (30001, 30003-30008, 30010,
+// 30012...). Every subsequent launch's own backend then fails outright
+// with EADDRINUSE and the app is stuck until something manually kills the
+// orphan. Reap any such leftover process, identified by PID file, before
+// spawning a new one.
+function reapOrphanedBackendProcess() {
+ const pidFilePath = getBackendPidFilePath();
+ let recordedPid;
+ try {
+ recordedPid = parseInt(fs.readFileSync(pidFilePath, "utf8").trim(), 10);
+ } catch {
+ return;
+ }
+ if (!Number.isInteger(recordedPid) || recordedPid <= 0) return;
+
+ try {
+ // Signal 0 does not kill the process -- it only checks whether a
+ // process with this PID exists and is signalable, throwing ESRCH if
+ // not. This avoids killing an unrelated process that happens to have
+ // reused the same PID since the last run.
+ process.kill(recordedPid, 0);
+ } catch {
+ // No live process at that PID; nothing to reap.
+ try {
+ fs.unlinkSync(pidFilePath);
+ } catch {
+ // already absent
+ }
+ return;
+ }
+
+ logToFile(
+ `Found orphaned backend process from a previous session (pid ${recordedPid}), terminating it before starting a new one`,
+ );
+ try {
+ process.kill(recordedPid, "SIGKILL");
+ } catch {
+ // already gone
+ }
+ try {
+ fs.unlinkSync(pidFilePath);
+ } catch {
+ // already absent
+ }
+}
+
function startBackendServer() {
+ reapOrphanedBackendProcess();
return new Promise((resolve) => {
const { entryPath, backendCwd } = getBackendPaths();
@@ -852,11 +911,17 @@ function startBackendServer() {
NODE_ENV: "production",
ELECTRON_EMBEDDED: "true",
PORT: "30001",
+ VERSION: app.getVersion(),
},
stdio: ["pipe", "pipe", "pipe", "ipc"],
});
logToFile("Backend process spawned, pid:", backendProcess.pid);
+ try {
+ fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid));
+ } catch {
+ // Non-fatal: only means a future crash won't self-heal via reap.
+ }
let resolved = false;
const readyTimeout = setTimeout(() => {
@@ -888,6 +953,7 @@ function startBackendServer() {
backendStartFailed = true;
}
backendProcess = null;
+ clearBackendPidFile();
if (!resolved) {
resolved = true;
clearTimeout(readyTimeout);
@@ -907,6 +973,14 @@ function startBackendServer() {
});
}
+function clearBackendPidFile() {
+ try {
+ fs.unlinkSync(getBackendPidFilePath());
+ } catch {
+ // already absent
+ }
+}
+
function stopBackendServer() {
if (!backendProcess) return;
@@ -929,6 +1003,7 @@ function stopBackendServer() {
backendProcess.on("exit", () => {
clearTimeout(forceKillTimeout);
backendProcess = null;
+ clearBackendPidFile();
});
}
@@ -1335,7 +1410,6 @@ ipcMain.handle("get-embedded-server-status", () => {
return {
running:
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
- embedded: !isDev,
dataDir: isDev ? null : getBackendDataDir(),
};
});
@@ -1442,6 +1516,80 @@ ipcMain.handle("save-server-config", (event, config) => {
}
});
+// --- Remote sync (optional desktop <-> self-hosted server sync) ---
+
+// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
+// ever set in it) so the renderer can prompt upgraded installs to set up
+// Remote Sync -- their hosts live on that old server and won't appear
+// locally until sync is enabled. A fresh install never had this file, so
+// this is naturally false for anyone who never used the old architecture.
+ipcMain.handle("get-legacy-server-config", () => {
+ const config = getServerConfigSync();
+ return { serverUrl: config?.serverUrl || null };
+});
+
+ipcMain.handle("get-desktop-settings", () => {
+ return remoteSync.getDesktopSettings();
+});
+
+ipcMain.handle("save-desktop-settings", (_event, settings) => {
+ return remoteSync.saveDesktopSettings(settings);
+});
+
+ipcMain.handle("get-remote-sync-config", () => {
+ return remoteSync.getRemoteSyncConfig();
+});
+
+ipcMain.handle("save-remote-sync-config", (_event, config) => {
+ return remoteSync.saveRemoteSyncConfig(config);
+});
+
+ipcMain.handle("clear-remote-sync-config", async () => {
+ const result = remoteSync.clearRemoteSyncConfig();
+ remoteSync.clearRemoteSyncJwt();
+ remoteSync.getRemoteSyncEngine()?.updateStatus({
+ connected: false,
+ syncing: false,
+ needsReauth: false,
+ lastError: null,
+ });
+ return result;
+});
+
+ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
+ const result = remoteSync.saveRemoteSyncJwt(token);
+ if (result.success) {
+ remoteSync.getRemoteSyncEngine()?.updateStatus({
+ connected: true,
+ needsReauth: false,
+ lastError: null,
+ });
+ remoteSync.getRemoteSyncEngine()?.syncNow();
+ }
+ return result;
+});
+
+ipcMain.handle("get-remote-sync-jwt", () => {
+ return remoteSync.getRemoteSyncJwt();
+});
+
+ipcMain.handle("clear-remote-sync-jwt", () => {
+ return remoteSync.clearRemoteSyncJwt();
+});
+
+ipcMain.handle("get-remote-sync-status", () => {
+ return remoteSync.getRemoteSyncEngine()?.status || null;
+});
+
+ipcMain.handle("remote-sync-now", async () => {
+ return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
+});
+
+ipcMain.handle("notify-local-login", (_event, token) => {
+ remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
+ return { success: true };
+});
+
function getC2STunnelConfigPath() {
return path.join(app.getPath("userData"), "c2s-tunnels.json");
}
@@ -1577,36 +1725,33 @@ const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
const C2S_WS_LOW_WATERMARK = 256 * 1024;
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
+// C2S (client-to-server) tunnels relay through a connected, self-hosted
+// Termix server -- the same "remote server" concept Remote Sync connects
+// to, not the always-local embedded backend. There's no separate C2S
+// server-URL setting in the UI; it has always shared whatever remote
+// server the rest of the app was pointed at. Before the standalone-first
+// rework that was server-config.json; now it's remote-sync-config.json,
+// since that's the only remaining notion of "a connected remote server."
function getC2SRelayUrl() {
- const config = getServerConfigSync();
- const serverUrl =
- config?.serverUrl || (!isDev ? "http://127.0.0.1:30003" : null);
+ const config = remoteSync.getRemoteSyncConfig();
+ const serverUrl = config?.serverUrl;
if (!serverUrl) {
- throw new Error("No Termix server configured");
+ throw new Error(
+ "No remote Termix server connected -- enable Remote Sync first",
+ );
}
const base = serverUrl.replace(/\/$/, "");
- const relayHttpUrl = base.endsWith(":30003")
- ? `${base}/ssh/tunnel/c2s/stream`
- : `${base}/ssh/tunnel/c2s/stream`;
+ const relayHttpUrl = `${base}/ssh/tunnel/c2s/stream`;
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}
-async function getC2SRelayHeaders(relayUrl) {
- if (!mainWindow?.webContents?.session) return {};
-
- const cookieUrl = relayUrl
- .replace(/^ws:/, "http:")
- .replace(/^wss:/, "https:");
- const cookies = await mainWindow.webContents.session.cookies.get({
- url: cookieUrl,
- name: "jwt",
- });
- const jwt = cookies[0]?.value;
+async function getC2SRelayHeaders() {
+ const jwt = remoteSync.getRemoteSyncJwt();
if (!jwt) return {};
return {
- Cookie: `jwt=${encodeURIComponent(jwt)}`,
+ Authorization: `Bearer ${jwt}`,
};
}
@@ -1706,7 +1851,7 @@ async function openC2SRelay(
) {
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
const relayUrl = getC2SRelayUrl();
- const headers = await getC2SRelayHeaders(relayUrl);
+ const headers = await getC2SRelayHeaders();
logToFile(`[c2s] opening relay for ${tunnelName}`, {
relayUrl,
targetHost,
@@ -1811,7 +1956,7 @@ async function openC2SRelay(
async function testC2SRelay(tunnel, targetHost, targetPort) {
const relayUrl = getC2SRelayUrl();
- const headers = await getC2SRelayHeaders(relayUrl);
+ const headers = await getC2SRelayHeaders();
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
@@ -2090,7 +2235,7 @@ async function startC2SRemoteTunnel(tunnel, index = 0) {
}
const relayUrl = getC2SRelayUrl();
- const headers = await getC2SRelayHeaders(relayUrl);
+ const headers = await getC2SRelayHeaders();
const ws = new WebSocket(
relayUrl,
getWebSocketOptions(relayUrl, { headers }),
@@ -2772,31 +2917,33 @@ ipcMain.handle("close-external-editor", (_event, editId) => {
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
try {
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
-
const healthUrl = `${normalizedServerUrl}/health`;
+ // This is a best-effort reachability probe, not a hard gate: a reverse
+ // proxy doing SSO in front of the real server (Pangolin, Authelia,
+ // Cloudflare Access, etc.) intercepts this unauthenticated request
+ // before it ever reaches Termix's own /health route, and returns its
+ // own login page (HTML, or a redirect) instead of {"status":"ok"}.
+ // That's a legitimate, working setup -- the login iframe shown right
+ // after this check is what actually proves the server is real, by
+ // completing an authenticated round-trip. So any response at all here
+ // (any status code, any body) means "something is there, let the user
+ // proceed"; only a network-level failure (nothing answered at all)
+ // blocks continuing.
try {
const response = await httpFetch(healthUrl, {
method: "GET",
timeout: 10000,
});
- if (response.ok) {
- const data = await response.text();
-
- if (
- data.includes("") ||
- data.includes("")
- ) {
- return {
- success: false,
- error:
- "Server returned HTML instead of JSON. This does not appear to be a Termix server.",
- };
- }
+ const data = await response.text();
+ const looksLikeHtml =
+ data.includes("") ||
+ data.includes("");
+ if (response.ok && !looksLikeHtml) {
try {
const healthData = JSON.parse(data);
if (
@@ -2816,64 +2963,27 @@ ipcMain.handle("test-server-connection", async (event, serverUrl) => {
console.log("Health endpoint did not return valid JSON");
}
}
+
+ // Reachable, but not a recognized Termix health response -- likely a
+ // proxy/SSO login page in front of the real server. Let the user
+ // proceed; the login step next will fail clearly if this really
+ // isn't a Termix server.
+ return {
+ success: true,
+ status: response.status,
+ testedUrl: healthUrl,
+ warning: looksLikeHtml
+ ? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
+ : "Server responded, but not with the expected health check format. Continuing anyway.",
+ };
} catch (urlError) {
console.error("Health check failed:", urlError);
+ return {
+ success: false,
+ error:
+ "Server is not responding. Please ensure the server is running and accessible.",
+ };
}
-
- try {
- const versionUrl = `${normalizedServerUrl}/version`;
- const response = await httpFetch(versionUrl, {
- method: "GET",
- timeout: 10000,
- });
-
- if (response.ok) {
- const data = await response.text();
-
- if (
- data.includes("") ||
- data.includes("")
- ) {
- return {
- success: false,
- error:
- "Server returned HTML instead of JSON. This does not appear to be a Termix server.",
- };
- }
-
- try {
- const versionData = JSON.parse(data);
- if (
- versionData &&
- (versionData.status === "up_to_date" ||
- versionData.status === "requires_update" ||
- (versionData.localVersion &&
- versionData.version &&
- versionData.latest_release))
- ) {
- return {
- success: true,
- status: response.status,
- testedUrl: versionUrl,
- warning:
- "Health endpoint not available, but server appears to be running",
- };
- }
- } catch (parseError) {
- console.log("Version endpoint did not return valid JSON");
- }
- }
- } catch (versionError) {
- console.error("Version check failed:", versionError);
- }
-
- return {
- success: false,
- error:
- "Server is not responding or does not appear to be a valid Termix server. Please ensure the server is running and accessible.",
- };
} catch (error) {
return { success: false, error: error.message };
}
@@ -2967,6 +3077,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
+ remoteSync.initRemoteSync(() => mainWindow);
logToFile("=== Startup complete ===");
});
diff --git a/electron/preload.js b/electron/preload.js
index 6b9cf1c0..43d84eff 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -31,6 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
+ onRemoteSyncStatusChanged: (callback) => {
+ const listener = (_event, status) => callback(status);
+ ipcRenderer.on("remote-sync-status-changed", listener);
+ return () =>
+ ipcRenderer.removeListener("remote-sync-status-changed", listener);
+ },
+
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
diff --git a/electron/remote-sync.cjs b/electron/remote-sync.cjs
new file mode 100644
index 00000000..b34dd037
--- /dev/null
+++ b/electron/remote-sync.cjs
@@ -0,0 +1,516 @@
+// Remote sync engine for the desktop app's optional connection to a
+// self-hosted Termix server. Runs entirely in the Electron main process:
+// - Holds the remote JWT (safeStorage-encrypted on disk, never exposed to
+// the renderer's localStorage) and the local embedded backend's JWT
+// (cached in memory only, handed over by the renderer at local-login
+// time via notify-local-login).
+// - On a timer, pulls + pushes each synced entity type between the
+// embedded backend (always localhost:30001) and the configured remote
+// server, reconciling by syncId with last-write-wins on updatedAt, and
+// propagating tombstones (deletions) in both directions.
+// - Pushes connection/sync status to the renderer via IPC so the Settings
+// UI and a global banner can reflect it without polling.
+
+const { app, safeStorage } = require("electron");
+const fs = require("fs");
+const path = require("path");
+
+const SYNCED_ENTITY_TYPES = [
+ "hosts",
+ "sshCredentials",
+ "sshFolders",
+ "snippets",
+ "snippetFolders",
+ "vaultProfiles",
+ "dashboardServiceLinks",
+ "homepageItems",
+];
+
+const SYNC_INTERVAL_MS = 90 * 1000;
+const EMBEDDED_BASE_URL = "http://127.0.0.1:30001";
+
+function dataPath(filename) {
+ return path.join(app.getPath("userData"), filename);
+}
+
+function readJson(filePath, fallback) {
+ try {
+ if (!fs.existsSync(filePath)) return fallback;
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
+ } catch {
+ return fallback;
+ }
+}
+
+function writeJson(filePath, value) {
+ const userDataPath = app.getPath("userData");
+ if (!fs.existsSync(userDataPath)) {
+ fs.mkdirSync(userDataPath, { recursive: true });
+ }
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
+}
+
+function getDesktopSettingsPath() {
+ return dataPath("desktop-settings.json");
+}
+
+function getRemoteSyncConfigPath() {
+ return dataPath("remote-sync-config.json");
+}
+
+function getRemoteSyncCredentialPath() {
+ return dataPath("remote-sync-credential.json");
+}
+
+function getRemoteSyncStatePath() {
+ return dataPath("remote-sync-state.json");
+}
+
+function getDesktopSettings() {
+ return readJson(getDesktopSettingsPath(), {
+ defaultConnectionOrigin: "local",
+ migrationNoticeAcknowledged: false,
+ });
+}
+
+function saveDesktopSettings(settings) {
+ writeJson(getDesktopSettingsPath(), settings);
+ return { success: true };
+}
+
+function getRemoteSyncConfig() {
+ return readJson(getRemoteSyncConfigPath(), null);
+}
+
+function saveRemoteSyncConfig(config) {
+ writeJson(getRemoteSyncConfigPath(), config);
+ return { success: true };
+}
+
+function clearRemoteSyncConfig() {
+ try {
+ fs.unlinkSync(getRemoteSyncConfigPath());
+ } catch {
+ // already absent
+ }
+ return { success: true };
+}
+
+function getSafeStorageAvailable() {
+ try {
+ return safeStorage.isEncryptionAvailable();
+ } catch {
+ return false;
+ }
+}
+
+function saveRemoteSyncJwt(token) {
+ if (!getSafeStorageAvailable()) {
+ return { success: false, error: "Encryption unavailable on this system" };
+ }
+ writeJson(getRemoteSyncCredentialPath(), {
+ encrypted: true,
+ value: safeStorage.encryptString(token).toString("base64"),
+ obtainedAt: new Date().toISOString(),
+ });
+ return { success: true };
+}
+
+function getRemoteSyncJwt() {
+ const record = readJson(getRemoteSyncCredentialPath(), null);
+ if (!record?.encrypted || !getSafeStorageAvailable()) return null;
+ try {
+ return safeStorage.decryptString(Buffer.from(record.value, "base64"));
+ } catch {
+ return null;
+ }
+}
+
+function clearRemoteSyncJwt() {
+ try {
+ fs.unlinkSync(getRemoteSyncCredentialPath());
+ } catch {
+ // already absent
+ }
+ return { success: true };
+}
+
+function decodeJwtExpiry(token) {
+ try {
+ const payloadB64 = token.split(".")[1];
+ const payload = JSON.parse(
+ Buffer.from(payloadB64, "base64").toString("utf8"),
+ );
+ return typeof payload.exp === "number" ? payload.exp * 1000 : null;
+ } catch {
+ return null;
+ }
+}
+
+function isJwtExpiredOrExpiringSoon(token, marginMs = 60 * 1000) {
+ const expiresAt = decodeJwtExpiry(token);
+ if (expiresAt === null) return false;
+ return Date.now() + marginMs >= expiresAt;
+}
+
+class RemoteSyncEngine {
+ constructor(getMainWindow) {
+ this.getMainWindow = getMainWindow;
+ this.localJwt = null;
+ this.timer = null;
+ this.syncing = false;
+ this.status = {
+ connected: false,
+ syncing: false,
+ lastSyncedAt: null,
+ lastError: null,
+ needsReauth: false,
+ };
+ }
+
+ setLocalJwt(token) {
+ this.localJwt = token || null;
+ }
+
+ emitStatus() {
+ const win = this.getMainWindow?.();
+ if (!win || win.isDestroyed()) return;
+ win.webContents.send("remote-sync-status-changed", this.status);
+ }
+
+ updateStatus(patch) {
+ this.status = { ...this.status, ...patch };
+ this.emitStatus();
+ }
+
+ start() {
+ const config = getRemoteSyncConfig();
+ this.status.connected = !!config?.serverUrl;
+ if (this.timer) clearInterval(this.timer);
+ this.timer = setInterval(() => this.syncNow(), SYNC_INTERVAL_MS);
+ if (config?.serverUrl) {
+ // Fire an initial sync shortly after startup rather than waiting a
+ // full interval, but don't block app boot on it.
+ setTimeout(() => this.syncNow(), 5000);
+ }
+ }
+
+ stop() {
+ if (this.timer) {
+ clearInterval(this.timer);
+ this.timer = null;
+ }
+ }
+
+ async syncNow() {
+ if (this.syncing) return this.status;
+ const config = getRemoteSyncConfig();
+ if (!config?.serverUrl) {
+ this.updateStatus({ connected: false, syncing: false });
+ return this.status;
+ }
+
+ const remoteJwt = getRemoteSyncJwt();
+ if (!remoteJwt) {
+ this.updateStatus({
+ connected: true,
+ syncing: false,
+ needsReauth: true,
+ lastError: "Not signed in to remote server",
+ });
+ return this.status;
+ }
+ if (isJwtExpiredOrExpiringSoon(remoteJwt)) {
+ this.updateStatus({
+ connected: true,
+ syncing: false,
+ needsReauth: true,
+ lastError: "Remote session expired",
+ });
+ return this.status;
+ }
+ if (!this.localJwt) {
+ // Local login hasn't handed us a token yet -- this is expected for the
+ // first tick or two right after a cold boot (renderer hasn't finished
+ // its own session check yet), but if it never arrives (e.g. a gap in
+ // whichever code path establishes the local session), sync would
+ // otherwise silently no-op forever with no visible error. Surface it
+ // as a normal, non-alarming "not synced yet" status rather than
+ // leaving lastSyncedAt/lastError untouched.
+ this.updateStatus({
+ connected: true,
+ syncing: false,
+ lastError: "Waiting for local session",
+ });
+ return this.status;
+ }
+
+ this.syncing = true;
+ this.updateStatus({ connected: true, syncing: true, lastError: null });
+
+ try {
+ const state = readJson(getRemoteSyncStatePath(), { entities: {} });
+ let sawAuthFailure = false;
+
+ for (const entityType of SYNCED_ENTITY_TYPES) {
+ const entityState = state.entities[entityType] || {
+ lastPulledAt: null,
+ lastPushedAt: null,
+ };
+
+ const result = await this.syncEntity({
+ entityType,
+ remoteBaseUrl: config.serverUrl.replace(/\/$/, ""),
+ remoteJwt,
+ since: entityState.lastPulledAt,
+ });
+
+ if (result.authFailure) {
+ sawAuthFailure = true;
+ break;
+ }
+
+ state.entities[entityType] = {
+ lastPulledAt: result.syncedAt,
+ lastPushedAt: result.syncedAt,
+ };
+ }
+
+ if (sawAuthFailure) {
+ this.updateStatus({
+ syncing: false,
+ needsReauth: true,
+ lastError: "Remote server rejected the session",
+ });
+ return this.status;
+ }
+
+ writeJson(getRemoteSyncStatePath(), state);
+ writeJson(getRemoteSyncConfigPath(), {
+ ...config,
+ lastSyncedAt: new Date().toISOString(),
+ lastSyncStatus: "ok",
+ lastSyncError: null,
+ });
+
+ this.updateStatus({
+ connected: true,
+ syncing: false,
+ needsReauth: false,
+ lastSyncedAt: new Date().toISOString(),
+ lastError: null,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ writeJson(getRemoteSyncConfigPath(), {
+ ...config,
+ lastSyncStatus: "error",
+ lastSyncError: message,
+ });
+ this.updateStatus({ syncing: false, lastError: message });
+ } finally {
+ this.syncing = false;
+ }
+
+ return this.status;
+ }
+
+ async fetchJson(url, token, options = {}) {
+ const res = await fetch(url, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ ...(options.headers || {}),
+ },
+ });
+ if (res.status === 401 || res.status === 403) {
+ const err = new Error(`Auth failed (${res.status})`);
+ err.authFailure = true;
+ throw err;
+ }
+ if (!res.ok) {
+ throw new Error(`Request failed (${res.status}): ${url}`);
+ }
+
+ const text = await res.text();
+ // A reverse-proxy SSO in front of the remote server (Pangolin, Authelia,
+ // etc.) can intercept even an authenticated, Bearer-token'd request and
+ // serve its own login page instead of forwarding to Termix -- that comes
+ // back as a normal 200 OK, so the status checks above don't catch it.
+ // This is NOT the same as needsReauth/a bad Termix JWT: sync runs as a
+ // plain server-to-server fetch() in this main process, with no browser
+ // cookie jar at all, so re-authenticating through the login iframe (which
+ // only affects the renderer's browser session) can never fix this --
+ // reconnecting would tell the user to do something that doesn't help.
+ // The proxy has to allow this traffic through some other way (an API
+ // bypass rule, a separate hostname/port that isn't proxy-gated, etc.),
+ // so this gets its own distinct, honest error rather than piggybacking
+ // on needsReauth or a raw JSON.parse crash.
+ const looksLikeHtml =
+ text.includes("") ||
+ text.includes("");
+ if (looksLikeHtml) {
+ const err = new Error(
+ "The reverse proxy in front of this server is blocking sync traffic with its own login page. Reconnecting won't fix this -- the proxy needs to let Termix's API requests through (e.g. an SSO bypass rule for the sync API, or a non-proxied hostname/port for it).",
+ );
+ err.proxyBlocked = true;
+ throw err;
+ }
+
+ try {
+ return JSON.parse(text);
+ } catch {
+ throw new Error(`Server returned invalid JSON: ${url}`);
+ }
+ }
+
+ async pullSide(baseUrl, token, entityType, since) {
+ const url = `${baseUrl}/sync/${entityType}${since ? `?since=${encodeURIComponent(since)}` : ""}`;
+ const data = await this.fetchJson(url, token);
+ return data.rows || [];
+ }
+
+ async pullTombstones(baseUrl, token, entityType, since) {
+ const url = `${baseUrl}/sync/${entityType}/tombstones${since ? `?since=${encodeURIComponent(since)}` : ""}`;
+ const data = await this.fetchJson(url, token);
+ return data.tombstones || [];
+ }
+
+ async pushRow(baseUrl, token, entityType, row) {
+ await this.fetchJson(`${baseUrl}/sync/${entityType}`, token, {
+ method: "POST",
+ body: JSON.stringify({ row }),
+ });
+ }
+
+ async pushTombstone(baseUrl, token, entityType, syncId) {
+ await this.fetchJson(`${baseUrl}/sync/tombstones`, token, {
+ method: "POST",
+ body: JSON.stringify({ entityType, syncId }),
+ });
+ }
+
+ async syncEntity({ entityType, remoteBaseUrl, remoteJwt, since }) {
+ const syncedAt = new Date().toISOString();
+ try {
+ const [localRows, remoteRows, localTombstones, remoteTombstones] =
+ await Promise.all([
+ this.pullSide(EMBEDDED_BASE_URL, this.localJwt, entityType, since),
+ this.pullSide(remoteBaseUrl, remoteJwt, entityType, since),
+ this.pullTombstones(
+ EMBEDDED_BASE_URL,
+ this.localJwt,
+ entityType,
+ since,
+ ),
+ this.pullTombstones(remoteBaseUrl, remoteJwt, entityType, since),
+ ]);
+
+ const tombstonedSyncIds = new Set([
+ ...localTombstones.map((t) => t.syncId),
+ ...remoteTombstones.map((t) => t.syncId),
+ ]);
+
+ const localBySyncId = new Map(
+ localRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
+ );
+ const remoteBySyncId = new Map(
+ remoteRows.filter((r) => r.syncId).map((r) => [r.syncId, r]),
+ );
+ const allSyncIds = new Set([
+ ...localBySyncId.keys(),
+ ...remoteBySyncId.keys(),
+ ]);
+
+ for (const syncId of allSyncIds) {
+ if (tombstonedSyncIds.has(syncId)) continue;
+
+ const localRow = localBySyncId.get(syncId);
+ const remoteRow = remoteBySyncId.get(syncId);
+
+ if (localRow && !remoteRow) {
+ await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
+ } else if (remoteRow && !localRow) {
+ await this.pushRow(
+ EMBEDDED_BASE_URL,
+ this.localJwt,
+ entityType,
+ remoteRow,
+ );
+ } else if (localRow && remoteRow) {
+ const localUpdatedAt = new Date(localRow.updatedAt || 0).getTime();
+ const remoteUpdatedAt = new Date(remoteRow.updatedAt || 0).getTime();
+ if (localUpdatedAt > remoteUpdatedAt) {
+ await this.pushRow(remoteBaseUrl, remoteJwt, entityType, localRow);
+ } else if (remoteUpdatedAt > localUpdatedAt) {
+ await this.pushRow(
+ EMBEDDED_BASE_URL,
+ this.localJwt,
+ entityType,
+ remoteRow,
+ );
+ }
+ }
+ }
+
+ // Apply tombstones to whichever side hasn't already deleted the row.
+ for (const tombstone of localTombstones) {
+ if (remoteBySyncId.has(tombstone.syncId)) {
+ await this.pushTombstone(
+ remoteBaseUrl,
+ remoteJwt,
+ entityType,
+ tombstone.syncId,
+ );
+ }
+ }
+ for (const tombstone of remoteTombstones) {
+ if (localBySyncId.has(tombstone.syncId)) {
+ await this.pushTombstone(
+ EMBEDDED_BASE_URL,
+ this.localJwt,
+ entityType,
+ tombstone.syncId,
+ );
+ }
+ }
+
+ return { syncedAt };
+ } catch (error) {
+ if (error?.authFailure) {
+ return { syncedAt, authFailure: true };
+ }
+ throw error;
+ }
+ }
+}
+
+let engine = null;
+
+function initRemoteSync(getMainWindow) {
+ engine = new RemoteSyncEngine(getMainWindow);
+ engine.start();
+ return engine;
+}
+
+function getRemoteSyncEngine() {
+ return engine;
+}
+
+module.exports = {
+ initRemoteSync,
+ getRemoteSyncEngine,
+ getDesktopSettings,
+ saveDesktopSettings,
+ getRemoteSyncConfig,
+ saveRemoteSyncConfig,
+ clearRemoteSyncConfig,
+ saveRemoteSyncJwt,
+ getRemoteSyncJwt,
+ clearRemoteSyncJwt,
+ isJwtExpiredOrExpiringSoon,
+ decodeJwtExpiry,
+};
diff --git a/index.html b/index.html
index da71ea34..79c6e4f5 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,10 @@
-
+
diff --git a/package-lock.json b/package-lock.json
index 4e9b87b5..d9b4c673 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "termix",
- "version": "2.5.1",
+ "version": "2.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
- "version": "2.5.1",
+ "version": "2.6.0",
"hasInstallScript": true,
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
@@ -31,22 +31,22 @@
"ldapjs": "^3.0.7",
"motion": "^12.42.2",
"multer": "^2.2.0",
- "nanoid": "^5.1.16",
+ "nanoid": "^6.0.0",
"qrcode": "^1.5.4",
"serialport": "^13.0.0",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.7.0",
- "ws": "^8.20.0"
+ "ws": "^8.21.1"
},
"devDependencies": {
- "@biomejs/biome": "2.5.2",
+ "@biomejs/biome": "2.5.4",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.43.5",
+ "@codemirror/view": "^6.43.6",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -58,23 +58,23 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"@monaco-editor/react": "^4.7.0",
- "@radix-ui/react-accordion": "^1.2.15",
- "@radix-ui/react-alert-dialog": "^1.1.18",
- "@radix-ui/react-checkbox": "^1.3.6",
- "@radix-ui/react-dialog": "^1.1.18",
- "@radix-ui/react-dropdown-menu": "^2.1.19",
- "@radix-ui/react-label": "^2.1.11",
- "@radix-ui/react-popover": "^1.1.18",
- "@radix-ui/react-progress": "^1.1.11",
- "@radix-ui/react-scroll-area": "^1.2.13",
- "@radix-ui/react-select": "^2.3.2",
- "@radix-ui/react-separator": "^1.1.11",
- "@radix-ui/react-slider": "^1.4.2",
+ "@radix-ui/react-accordion": "^1.2.17",
+ "@radix-ui/react-alert-dialog": "^1.1.20",
+ "@radix-ui/react-checkbox": "^1.3.8",
+ "@radix-ui/react-dialog": "^1.1.20",
+ "@radix-ui/react-dropdown-menu": "^2.1.21",
+ "@radix-ui/react-label": "^2.1.12",
+ "@radix-ui/react-popover": "^1.1.20",
+ "@radix-ui/react-progress": "^1.1.13",
+ "@radix-ui/react-scroll-area": "^1.2.15",
+ "@radix-ui/react-select": "^2.3.4",
+ "@radix-ui/react-separator": "^1.1.12",
+ "@radix-ui/react-slider": "^1.4.4",
"@radix-ui/react-slot": "^1.3.0",
- "@radix-ui/react-switch": "^1.3.2",
- "@radix-ui/react-tabs": "^1.1.16",
- "@radix-ui/react-tooltip": "^1.2.11",
- "@tailwindcss/vite": "^4.3.2",
+ "@radix-ui/react-switch": "^1.3.4",
+ "@radix-ui/react-tabs": "^1.1.18",
+ "@radix-ui/react-tooltip": "^1.2.13",
+ "@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -94,12 +94,12 @@
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
- "@uiw/codemirror-extensions-langs": "^4.25.9",
- "@uiw/codemirror-theme-github": "^4.25.9",
- "@uiw/react-codemirror": "^4.25.9",
+ "@uiw/codemirror-extensions-langs": "^4.25.11",
+ "@uiw/codemirror-theme-github": "^4.25.11",
+ "@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.3",
- "@vitest/coverage-v8": "^4.1.9",
- "@vitest/ui": "^4.1.9",
+ "@vitest/coverage-v8": "^4.1.10",
+ "@vitest/ui": "^4.1.10",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -119,19 +119,19 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
- "i18next": "^26.3.4",
+ "i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"lucide-react": "^1.20.0",
"prettier": "3.8.4",
- "radix-ui": "^1.6.1",
+ "radix-ui": "^1.6.3",
"react": "^19.2.7",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.7",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.79.0",
- "react-i18next": "^17.0.4",
+ "react-i18next": "^17.0.10",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
@@ -148,7 +148,7 @@
"typescript-eslint": "^8.61.1",
"vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0",
- "vitest": "^4.1.9"
+ "vitest": "^4.1.10"
},
"engines": {
"node": ">=22.12.0",
@@ -569,9 +569,9 @@
}
},
"node_modules/@biomejs/biome": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.2.tgz",
- "integrity": "sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.4.tgz",
+ "integrity": "sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -585,20 +585,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
- "@biomejs/cli-darwin-arm64": "2.5.2",
- "@biomejs/cli-darwin-x64": "2.5.2",
- "@biomejs/cli-linux-arm64": "2.5.2",
- "@biomejs/cli-linux-arm64-musl": "2.5.2",
- "@biomejs/cli-linux-x64": "2.5.2",
- "@biomejs/cli-linux-x64-musl": "2.5.2",
- "@biomejs/cli-win32-arm64": "2.5.2",
- "@biomejs/cli-win32-x64": "2.5.2"
+ "@biomejs/cli-darwin-arm64": "2.5.4",
+ "@biomejs/cli-darwin-x64": "2.5.4",
+ "@biomejs/cli-linux-arm64": "2.5.4",
+ "@biomejs/cli-linux-arm64-musl": "2.5.4",
+ "@biomejs/cli-linux-x64": "2.5.4",
+ "@biomejs/cli-linux-x64-musl": "2.5.4",
+ "@biomejs/cli-win32-arm64": "2.5.4",
+ "@biomejs/cli-win32-x64": "2.5.4"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.2.tgz",
- "integrity": "sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.4.tgz",
+ "integrity": "sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==",
"cpu": [
"arm64"
],
@@ -613,9 +613,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.2.tgz",
- "integrity": "sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.4.tgz",
+ "integrity": "sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==",
"cpu": [
"x64"
],
@@ -630,9 +630,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.2.tgz",
- "integrity": "sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.4.tgz",
+ "integrity": "sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==",
"cpu": [
"arm64"
],
@@ -647,9 +647,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.2.tgz",
- "integrity": "sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.4.tgz",
+ "integrity": "sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==",
"cpu": [
"arm64"
],
@@ -664,9 +664,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.2.tgz",
- "integrity": "sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.4.tgz",
+ "integrity": "sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==",
"cpu": [
"x64"
],
@@ -681,9 +681,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.2.tgz",
- "integrity": "sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.4.tgz",
+ "integrity": "sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==",
"cpu": [
"x64"
],
@@ -698,9 +698,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.2.tgz",
- "integrity": "sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.4.tgz",
+ "integrity": "sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==",
"cpu": [
"arm64"
],
@@ -715,9 +715,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
- "version": "2.5.2",
- "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.2.tgz",
- "integrity": "sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==",
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.4.tgz",
+ "integrity": "sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==",
"cpu": [
"x64"
],
@@ -1129,9 +1129,9 @@
}
},
"node_modules/@codemirror/view": {
- "version": "6.43.5",
- "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.5.tgz",
- "integrity": "sha512-7uT/vUgH6dfXWn3WqOe23KneILMvGy5wQjNMEcRXLKzziJ9NOktpW6tGoyQpwVkBgE5Gj6hKkCcsddbnkaWrOQ==",
+ "version": "6.43.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
+ "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3829,20 +3829,20 @@
"license": "MIT"
},
"node_modules/@radix-ui/primitive": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz",
- "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==",
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz",
+ "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==",
"dev": true,
"license": "MIT"
},
"node_modules/@radix-ui/react-accessible-icon": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.11.tgz",
- "integrity": "sha512-HQDOFTKwSnmUij6l54wYJJtxTAnxI71+YJLOrjm2ladFB8HAV5Jt7hwaZPhWTGBkYoW4+ZAOfNZrLDh/qvxSYA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.12.tgz",
+ "integrity": "sha512-Y0zhCQ/XUdTom5hAxvE8RlXqR4hZmKGK6g2//LfgHmb88PJFOpXSh9B/7FlfYXezVY5FKGjRYWCYz5FXxZ9WZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-visually-hidden": "1.2.7"
+ "@radix-ui/react-visually-hidden": "1.2.8"
},
"peerDependencies": {
"@types/react": "*",
@@ -3860,21 +3860,21 @@
}
},
"node_modules/@radix-ui/react-accordion": {
- "version": "1.2.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.15.tgz",
- "integrity": "sha512-24Zz/0SYx8F2bSVThBnQrdJs2VbKelyuJordcFRRdA0fRAhrq/wSegGCqaQz34VQoiWqSMGYCYXEhynLSlyQlg==",
+ "version": "1.2.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.17.tgz",
+ "integrity": "sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collapsible": "1.1.15",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collapsible": "1.1.17",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -3892,16 +3892,16 @@
}
},
"node_modules/@radix-ui/react-alert-dialog": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.18.tgz",
- "integrity": "sha512-6c2cXpNlAgHDhKguK24XcWHHayMpK+lk7/WwBXBco+ZJ4Dv7xP++GBM280KgTD/HCRu3jSdfe8WQiZssonYaIA==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.20.tgz",
+ "integrity": "sha512-Ft1W+jPqSh5BKfSTe4dpq6UYQKKQJ5Tvq3wfux+WVlg7nPwFK/3pIlHTb3Rbe+b/tNurx8YGXD9em91ujmgwuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dialog": "1.1.18",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
@@ -3920,9 +3920,9 @@
}
},
"node_modules/@radix-ui/react-arrow": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
- "integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.12.tgz",
+ "integrity": "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3944,9 +3944,9 @@
}
},
"node_modules/@radix-ui/react-aspect-ratio": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.11.tgz",
- "integrity": "sha512-IUAhIVpBUvP5NNICjlaB1OFmtRLGqQqTF3ZOSGPoq3XeLXRFtHiWTRxSVEULgOd9GQR2c7tsYqDnhUennapZnw==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.12.tgz",
+ "integrity": "sha512-Sok2IBJxA1XO4pU3ldzZMwUBMumIt64EY8zOUlVq5CdS+i0FrEbajVslfDB+YGWLMsrjY2kZQB0DgkrZXLZvcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3968,13 +3968,14 @@
}
},
"node_modules/@radix-ui/react-avatar": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.1.tgz",
- "integrity": "sha512-+8PWoLLZv3AVb5m0pvoiOca/bQGzc9vPVb+982HB2x3Un0DpYEPM3zLMl4oqRpBsocJuNqLkiv/HXTnTrlwr4g==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.3.tgz",
+ "integrity": "sha512-peavtnApRB1tABx42tHw+rPU83GSg5tXicMYO/Xi1/lqNcRsF6jkr6L7Njo7gj4q/xtDRDKBkqJvbMtoOMYWtA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-is-hydrated": "0.1.1",
@@ -3996,19 +3997,18 @@
}
},
"node_modules/@radix-ui/react-checkbox": {
- "version": "1.3.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.6.tgz",
- "integrity": "sha512-eUEUoGMDpfkgHWSE97ZZaUJtzR1M7EKnNIpD1Q16+8JR9NWghcaqMulx9PuCQ720w0UclfYn6FEbCdd5Hx087g==",
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.8.tgz",
+ "integrity": "sha512-wfN60IGuxynWK7rP4Ks2p7u9G7gqirzkAiFptuzVbsR1ot2/K+PavNUAtxiKxyRfLOvSbVfvvm9m3rFqLEXz7A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3",
- "@radix-ui/react-use-previous": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-size": "1.1.2"
},
"peerDependencies": {
@@ -4027,19 +4027,19 @@
}
},
"node_modules/@radix-ui/react-collapsible": {
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.15.tgz",
- "integrity": "sha512-8A1zibu5skAQ+UVbaeNH5hVMibiFCRJzgMuM14LTWGttnTZKQL9jwYnhAbHRuxrtCqPXa4JvvnVUq1pTNgyZYw==",
+ "version": "1.1.17",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.17.tgz",
+ "integrity": "sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
@@ -4058,14 +4058,14 @@
}
},
"node_modules/@radix-ui/react-collection": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz",
- "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz",
+ "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0"
},
@@ -4101,9 +4101,9 @@
}
},
"node_modules/@radix-ui/react-context": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz",
- "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==",
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz",
+ "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -4117,17 +4117,17 @@
}
},
"node_modules/@radix-ui/react-context-menu": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.2.tgz",
- "integrity": "sha512-qzsA/ZPhF6yMxBOTIk1nlCkoy2mswSbwYL+ErBa2iP0s4WWrlxmczArYqMcpVfEjmM7KJj/ADPXky0yZfbSxtQ==",
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.4.tgz",
+ "integrity": "sha512-eO9tkvHvo4dNwb+lytEcKWjy8c8To+ttLwNt0f9XzzsVFIaspqt3i1/c0JaaksxBB5G//zPo9CCgn39huWQyBA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-menu": "2.1.21",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -4145,24 +4145,25 @@
}
},
"node_modules/@radix-ui/react-dialog": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz",
- "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz",
+ "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-focus-scope": "1.1.13",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
+ "@radix-ui/react-use-layout-effect": "1.1.2",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
@@ -4198,13 +4199,13 @@
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz",
- "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz",
+ "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
@@ -4226,19 +4227,19 @@
}
},
"node_modules/@radix-ui/react-dropdown-menu": {
- "version": "2.1.19",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.19.tgz",
- "integrity": "sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==",
+ "version": "2.1.21",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.21.tgz",
+ "integrity": "sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-menu": "2.1.21",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -4272,9 +4273,9 @@
}
},
"node_modules/@radix-ui/react-focus-scope": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz",
- "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz",
+ "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4298,17 +4299,17 @@
}
},
"node_modules/@radix-ui/react-form": {
- "version": "0.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.11.tgz",
- "integrity": "sha512-0mTMJHv1gQAuEQoq5VDpTD3MRgmfUFdXAVFhpqR7wBeUr+tyRsof0wv/4XdPHLwQrefhoH2FiGHCggrCJhalIw==",
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.13.tgz",
+ "integrity": "sha512-PopvWqiutoZh5TJXk9EV9Wh+khbp+LQ+A0H4uHocIjVcKIi6gMlBy4sAaW15thwUSc6PrR8J62nB2uM+htqrcg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-label": "2.1.11",
+ "@radix-ui/react-label": "2.1.12",
"@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
@@ -4327,21 +4328,21 @@
}
},
"node_modules/@radix-ui/react-hover-card": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.18.tgz",
- "integrity": "sha512-rt+Fx4HoCeEwFL2IdoV2QaPltqDLlzxN77i9nwB3Y70scFlfAHh1QCdE2TXKuFJtA1TNygb0oivnFBZifgtZOw==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.20.tgz",
+ "integrity": "sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.14",
- "@radix-ui/react-popper": "1.3.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
+ "@radix-ui/react-popper": "1.3.4",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -4378,9 +4379,9 @@
}
},
"node_modules/@radix-ui/react-label": {
- "version": "2.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz",
- "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==",
+ "version": "2.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.12.tgz",
+ "integrity": "sha512-dxioNQ7VOrYKKWJIxMRmJPDSWQN0gNCUy3zaqUSBwsuFAiFzI0yLGJr2q3ml07k/HlOk55N8KEfwa1ZgfprJ3w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4402,26 +4403,26 @@
}
},
"node_modules/@radix-ui/react-menu": {
- "version": "2.1.19",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.19.tgz",
- "integrity": "sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==",
+ "version": "2.1.21",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.21.tgz",
+ "integrity": "sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-focus-scope": "1.1.13",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-popper": "1.3.4",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
+ "@radix-ui/react-roving-focus": "1.1.16",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"aria-hidden": "^1.2.4",
@@ -4443,22 +4444,22 @@
}
},
"node_modules/@radix-ui/react-menubar": {
- "version": "1.1.19",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.19.tgz",
- "integrity": "sha512-Glt6mebxcgQvLeVkH3HiqV5bgQubE+31ELxLs7q0GlYI5k0XYkOkeuPrhXoylxK8eufvIt9CJjzY1TfFMXK3qw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.21.tgz",
+ "integrity": "sha512-uQONG1qM4D8FSEt0xRs5yDpzeSWggf8lOKqHa84NvqoVoc1qJ6XN+gdkrJuQCyEY55793gLlQI3wjgWO5A/Oqg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-menu": "2.1.19",
+ "@radix-ui/react-menu": "2.1.21",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -4476,26 +4477,26 @@
}
},
"node_modules/@radix-ui/react-navigation-menu": {
- "version": "1.2.17",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.17.tgz",
- "integrity": "sha512-fYeYQvbeNn5AQk2RBbpO7koLm2YbS00UYxC/IL2sgLlninEH5UNIv+X3E0KJ1Vy4WIo+dhN9w8GNqSHhbHWCIg==",
+ "version": "1.2.19",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.19.tgz",
+ "integrity": "sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.7"
+ "@radix-ui/react-visually-hidden": "1.2.8"
},
"peerDependencies": {
"@types/react": "*",
@@ -4513,21 +4514,21 @@
}
},
"node_modules/@radix-ui/react-one-time-password-field": {
- "version": "0.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.11.tgz",
- "integrity": "sha512-Rsgab65u73E5kPVh8OS6PgPwJgPyf08GFfJDGAbMdF4DL7CgDhFOaDnXuk/DiMEVF6kgQwl0oJmFklvipmiOLg==",
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.13.tgz",
+ "integrity": "sha512-reLtbZtEBsMcqXkjd/wOga4e8t9uxzFHdX9W/j/ZfGznTNJxLGjRrDNGnGOOWcBazMH1BI/b7Cx+hblSWSD7aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-is-hydrated": "0.1.1",
"@radix-ui/react-use-layout-effect": "1.1.2"
@@ -4548,18 +4549,18 @@
}
},
"node_modules/@radix-ui/react-password-toggle-field": {
- "version": "0.1.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.6.tgz",
- "integrity": "sha512-pQ3xGp/uemomASPH97Eb3shfXX8QlG11bBJyEvRBV+vwtO4HvQlS06Yj9f31Ao7XepvF98SFrRgVDQ7jv+2xjQ==",
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.8.tgz",
+ "integrity": "sha512-NH9puF7Es5Loh8vFELm+SyayzV27nyBw8kiP/uD9wbkwgq359FfbkKEvccrNk75z0LiSqC4REWk1iL9xdeWJkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-is-hydrated": "0.1.1"
},
@@ -4579,25 +4580,25 @@
}
},
"node_modules/@radix-ui/react-popover": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.18.tgz",
- "integrity": "sha512-qdXDes+eHlnMUGlBAAAe5EG7oOQvqsXuq4mq585diMudg80iB+jHbsSeG3+Q4eWNsogNyhqU2p/3i+Y0iEepqg==",
+ "version": "1.1.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.20.tgz",
+ "integrity": "sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-focus-scope": "1.1.13",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-popper": "1.3.4",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
@@ -4617,16 +4618,16 @@
}
},
"node_modules/@radix-ui/react-popper": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.2.tgz",
- "integrity": "sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.4.tgz",
+ "integrity": "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==",
"dev": true,
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
- "@radix-ui/react-arrow": "1.1.11",
+ "@radix-ui/react-arrow": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
@@ -4650,9 +4651,9 @@
}
},
"node_modules/@radix-ui/react-portal": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
- "integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
+ "version": "1.1.14",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz",
+ "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4675,9 +4676,9 @@
}
},
"node_modules/@radix-ui/react-presence": {
- "version": "1.1.6",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz",
- "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==",
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz",
+ "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4723,13 +4724,13 @@
}
},
"node_modules/@radix-ui/react-progress": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.11.tgz",
- "integrity": "sha512-KqiGJcFaZDc+BvveAgU3ZhACg2MvSUDrCBx4lRR/ZVRNal0bvt8lBpvnSkep9heeOuF8Qfw3fszLDX4OpQ2NVw==",
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.13.tgz",
+ "integrity": "sha512-1dUdKDd63Tz9FfbTw20MVr28ohG4v7HOJ1dsavGBBPBS3KGzLOyLKiMJAC1OdgiY18nTSHpD4fULGK5gsLY/ww==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
@@ -4748,21 +4749,20 @@
}
},
"node_modules/@radix-ui/react-radio-group": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.2.tgz",
- "integrity": "sha512-W8Uo9riHnlzLLWy+r2mVHUyuEWqD/+be4PZzbEvaGoFSBDHkm+GYWjtcE6u3AmPKNyfanWpnVfpZ2GqPCdzzsw==",
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.4.tgz",
+ "integrity": "sha512-OpbUmp/korY+tjEQmHwGyQ+QQ3LBlCPC70z03Q/NSqGaHf2EijuwpjQPnswrH6cZLWyT2J6FmB+kzRoMUtPBig==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-use-controllable-state": "1.2.3",
- "@radix-ui/react-use-previous": "1.1.2",
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-size": "1.1.2"
},
"peerDependencies": {
@@ -4781,21 +4781,23 @@
}
},
"node_modules/@radix-ui/react-roving-focus": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz",
- "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.16.tgz",
+ "integrity": "sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-use-controllable-state": "1.2.4",
+ "@radix-ui/react-use-is-hydrated": "0.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
@@ -4813,18 +4815,18 @@
}
},
"node_modules/@radix-ui/react-scroll-area": {
- "version": "1.2.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.13.tgz",
- "integrity": "sha512-7tncSubo2G0UY1e8rk+72qe3XRzrGnOLtZQ1PL1KoBfRUNX0NrJT5akb+0kfwSCc3gVR4wdHqyhAQBDpDNOwDw==",
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.15.tgz",
+ "integrity": "sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2"
@@ -4845,32 +4847,32 @@
}
},
"node_modules/@radix-ui/react-select": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.2.tgz",
- "integrity": "sha512-brXD6C/V0fVK0DDbscLVw6LsXrjQ+ay8jdOBaN+tLb4vsHsAMm6Gt6eT77wHX1Eq8GPtD5rJ+RxFtfDozsb4+Q==",
+ "version": "2.3.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.4.tgz",
+ "integrity": "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.11",
+ "@radix-ui/react-focus-scope": "1.1.13",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-popper": "1.3.4",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.7",
+ "@radix-ui/react-visually-hidden": "1.2.8",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
@@ -4890,9 +4892,9 @@
}
},
"node_modules/@radix-ui/react-separator": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.11.tgz",
- "integrity": "sha512-jRhe86+8PF7VZ1u14eOWVOuh2BuAhALg/FT1VcMC4OHedMTRUazDnDlKTt+yxo5cRNKHMfmvZ4sSQtWDeMV4CQ==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.12.tgz",
+ "integrity": "sha512-2hezgFBBR5jU3S9L9bIZ9Uag6LnvxuFBNsLCfTR8qx+NshuvFmpL4C72+5zMS3Z6UgHNSU1thOw2UaBBPEDpsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4914,20 +4916,20 @@
}
},
"node_modules/@radix-ui/react-slider": {
- "version": "1.4.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.2.tgz",
- "integrity": "sha512-qt5C1ppJz66aUDrH1VccjPrq7aFchK0wBrn6xsxlCHNUyE57dRRQ7lp1QFpF7OscMexZF8MCGBTVBlENHPkNiA==",
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.4.tgz",
+ "integrity": "sha512-8dUytW34KoJaB22ctfP7hqUCuyYa8xn2w7H8kCneeOtS5oM7UBivcnZtR8P4kPYMgdoAZlkMhE9/qkYZ5MlRzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.2",
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "1.1.2"
@@ -4967,18 +4969,17 @@
}
},
"node_modules/@radix-ui/react-switch": {
- "version": "1.3.2",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.2.tgz",
- "integrity": "sha512-tgRBI3DdNwAJYE4BBZyZcz/HRRCvAsPkRvG1wvKc+41tBGMxPn/a87T/wikXAvyDypNQ9kaZwHbeZe+veHCGpA==",
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.4.tgz",
+ "integrity": "sha512-7iGMj1SfZBAc6xRiy0Y3Wr/v52viQeDhOmaM3fNRyNf2nbYooZA3kKoEDGLPtrDv8JitpzcueqdZLusVMojLdQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3",
- "@radix-ui/react-use-previous": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-size": "1.1.2"
},
"peerDependencies": {
@@ -4997,20 +4998,20 @@
}
},
"node_modules/@radix-ui/react-tabs": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz",
- "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.18.tgz",
+ "integrity": "sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -5028,24 +5029,24 @@
}
},
"node_modules/@radix-ui/react-toast": {
- "version": "1.2.18",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.18.tgz",
- "integrity": "sha512-YNEnTHV47hPep+U0QvVM02OJNka9uygREc+k4Nh5VSZBg4MmE+myI442x3hCGfRpX7N2WSSYSJKws4gE+Z8lgg==",
+ "version": "1.2.20",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.20.tgz",
+ "integrity": "sha512-S28OtO1IvYSpWfaUBtiYCTTwRLF8doafj+a+uQw8rc8dLINS52uuG3CIPCeZc3Jfdb/S7o7HhlQxLoXlIYRu6g==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.14",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-layout-effect": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.7"
+ "@radix-ui/react-visually-hidden": "1.2.8"
},
"peerDependencies": {
"@types/react": "*",
@@ -5063,15 +5064,15 @@
}
},
"node_modules/@radix-ui/react-toggle": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.13.tgz",
- "integrity": "sha512-bI2ILJrzwgmAsH05TsJ9pVrzqQwAip7OM2/krqAdYn0R16bl86UPWbe5VPHsALat0EnqpV01cGtkleaUKPNdNg==",
+ "version": "1.1.15",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.15.tgz",
+ "integrity": "sha512-tyCejFjhJ51UKFVIG8jh9nTdRIsFPxrgrI4IdlxuJeP+AKTfTko+0gBueyBFLHqsyE71Aj9PKHjMnG+YRPyKhA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -5089,19 +5090,19 @@
}
},
"node_modules/@radix-ui/react-toggle-group": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.14.tgz",
- "integrity": "sha512-TK1vusNKb8IRhF23FTbRgUNZ9zfs5rGIyI7LfR3h26p9LrQ060i0uW9QWeD8baZMddaaP0DBGlIa6pbZG+mitg==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.16.tgz",
+ "integrity": "sha512-uil+A0Um3LaZQJkMap4nIg0VgqWc0j3iNU4AXf9a/zHOgPHNYWfVk5WVsG2296Y8HLv1bxiN7uQJblHc1+00tw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-toggle": "1.1.13",
- "@radix-ui/react-use-controllable-state": "1.2.3"
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-toggle": "1.1.15",
+ "@radix-ui/react-use-controllable-state": "1.2.4"
},
"peerDependencies": {
"@types/react": "*",
@@ -5119,19 +5120,19 @@
}
},
"node_modules/@radix-ui/react-toolbar": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.14.tgz",
- "integrity": "sha512-L/EkWVqlnj3lL2toHh4C7PwH2jxfa7OCq6lGfXSCii99ve2S4Ux5rc9HnOa7LN9exHa/Nl9kmCAmP9BuDPy5UA==",
+ "version": "1.1.16",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.16.tgz",
+ "integrity": "sha512-ZnvUAH+ftoRYzUzFQ8gqKnQ1lUFYb3amguGu+BXpfjvLIkjmXCcHCJlQeBLBlJCOtGNVtP+wHrZaUCC/zYKQMg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-context": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-separator": "1.1.11",
- "@radix-ui/react-toggle-group": "1.1.14"
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-separator": "1.1.12",
+ "@radix-ui/react-toggle-group": "1.1.16"
},
"peerDependencies": {
"@types/react": "*",
@@ -5149,24 +5150,25 @@
}
},
"node_modules/@radix-ui/react-tooltip": {
- "version": "1.2.11",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.11.tgz",
- "integrity": "sha512-8XZ6Py3y3W2nEzAUGCN5cfVKaUi+CVApcz1d6lrNVVf2hvYEixMRkq8k9ggPKnQUpRRuOV5avt8uvxViH2jLwA==",
+ "version": "1.2.13",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.13.tgz",
+ "integrity": "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-dismissable-layer": "1.1.14",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
"@radix-ui/react-id": "1.1.2",
- "@radix-ui/react-popper": "1.3.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-popper": "1.3.4",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
- "@radix-ui/react-use-controllable-state": "1.2.3",
- "@radix-ui/react-visually-hidden": "1.2.7"
+ "@radix-ui/react-use-controllable-state": "1.2.4",
+ "@radix-ui/react-use-layout-effect": "1.1.2",
+ "@radix-ui/react-visually-hidden": "1.2.8"
},
"peerDependencies": {
"@types/react": "*",
@@ -5200,12 +5202,13 @@
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
- "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz",
+ "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@radix-ui/primitive": "1.1.6",
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
@@ -5344,9 +5347,9 @@
}
},
"node_modules/@radix-ui/react-visually-hidden": {
- "version": "1.2.7",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz",
- "integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==",
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.8.tgz",
+ "integrity": "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6286,49 +6289,49 @@
}
},
"node_modules/@tailwindcss/node": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
- "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
- "enhanced-resolve": "5.21.6",
+ "enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
- "tailwindcss": "4.3.2"
+ "tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
- "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
- "@tailwindcss/oxide-android-arm64": "4.3.2",
- "@tailwindcss/oxide-darwin-arm64": "4.3.2",
- "@tailwindcss/oxide-darwin-x64": "4.3.2",
- "@tailwindcss/oxide-freebsd-x64": "4.3.2",
- "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
- "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
- "@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
- "@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
- "@tailwindcss/oxide-linux-x64-musl": "4.3.2",
- "@tailwindcss/oxide-wasm32-wasi": "4.3.2",
- "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
- "@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
- "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
"cpu": [
"arm64"
],
@@ -6343,9 +6346,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
- "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
"cpu": [
"arm64"
],
@@ -6360,9 +6363,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
- "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
"cpu": [
"x64"
],
@@ -6377,9 +6380,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
- "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
"cpu": [
"x64"
],
@@ -6394,9 +6397,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
- "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
"cpu": [
"arm"
],
@@ -6411,9 +6414,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
- "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
"cpu": [
"arm64"
],
@@ -6428,9 +6431,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
- "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
"cpu": [
"arm64"
],
@@ -6445,9 +6448,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
- "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
@@ -6462,9 +6465,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
- "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
@@ -6479,9 +6482,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
- "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@@ -6575,9 +6578,9 @@
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
- "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
"cpu": [
"arm64"
],
@@ -6592,9 +6595,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
- "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
"cpu": [
"x64"
],
@@ -6609,15 +6612,15 @@
}
},
"node_modules/@tailwindcss/vite": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
- "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@tailwindcss/node": "4.3.2",
- "@tailwindcss/oxide": "4.3.2",
- "tailwindcss": "4.3.2"
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
@@ -7356,9 +7359,9 @@
}
},
"node_modules/@uiw/codemirror-extensions-basic-setup": {
- "version": "4.25.10",
- "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.10.tgz",
- "integrity": "sha512-P3vytLlpE62KYSWrMUnwDCv2lvaQDuDZzyj03mHntuHo5bSl34fRZpjTY3kQTPGuXHxkGSYpoPFFj+hMTqaaMQ==",
+ "version": "4.25.11",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz",
+ "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7384,9 +7387,9 @@
}
},
"node_modules/@uiw/codemirror-extensions-langs": {
- "version": "4.25.10",
- "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.25.10.tgz",
- "integrity": "sha512-VsfENMb23HrcKG2z0n0RB0KY7d11k+8qzUlH6i36099QXa07D/FEfeffoSnP+QdghhvISNnuMTJsWKqYQq6qlA==",
+ "version": "4.25.11",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-langs/-/codemirror-extensions-langs-4.25.11.tgz",
+ "integrity": "sha512-RJ6MQTGInT+HBnzEs6PoUmPBgLFbWH3e9ZiF0+H1bdWJXrqN0Kfkiopumz1OCcbWFJ8aVJCD6KPK9mXV/LQ4yA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7425,22 +7428,22 @@
}
},
"node_modules/@uiw/codemirror-theme-github": {
- "version": "4.25.10",
- "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.10.tgz",
- "integrity": "sha512-iMM2QT4FaebJMO4W7lXmxNkRPIjKzgY26wL0QG0Ugy0gzsnxoNz4zgNeFIblPA8rvrN3vOIhNNh4nk9UOlFKxA==",
+ "version": "4.25.11",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-theme-github/-/codemirror-theme-github-4.25.11.tgz",
+ "integrity": "sha512-3s0LK3gX2mvGI996z3G0tEHZqshbeJRly+QMRsKGAI1Tfr1V475bfaW9NvnoDdINuQHt+RB+ri6q57+WWF8d+A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@uiw/codemirror-themes": "4.25.10"
+ "@uiw/codemirror-themes": "4.25.11"
},
"funding": {
"url": "https://jaywcjlove.github.io/#/sponsor"
}
},
"node_modules/@uiw/codemirror-themes": {
- "version": "4.25.10",
- "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.10.tgz",
- "integrity": "sha512-Fqiz1HIuDlDftcL+/O53V333UOH6MqQ84VbiQB5egn6u+uDwAqACp1FrdAoi4wgpR3b3TGW4Gr0wIYcrJSSz1A==",
+ "version": "4.25.11",
+ "resolved": "https://registry.npmjs.org/@uiw/codemirror-themes/-/codemirror-themes-4.25.11.tgz",
+ "integrity": "sha512-SBNCOgRsCtewGNocRbmjbCkltGXlFcPJsvhxQ351VynQjnWUiPbUrFcEU/haQ3HanROdAAjWXZJPk5bMBxl2jw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7458,9 +7461,9 @@
}
},
"node_modules/@uiw/react-codemirror": {
- "version": "4.25.10",
- "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.10.tgz",
- "integrity": "sha512-DzgSMwM5qzB7v1FIb4gEeriYt67iiay756/HIOM9mAbeOVK0MO7rqefHf0O5c0269pJKMW7AH9FjclExD23V9w==",
+ "version": "4.25.11",
+ "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz",
+ "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7468,7 +7471,7 @@
"@codemirror/commands": "^6.1.0",
"@codemirror/state": "^6.1.1",
"@codemirror/theme-one-dark": "^6.0.0",
- "@uiw/codemirror-extensions-basic-setup": "4.25.10",
+ "@uiw/codemirror-extensions-basic-setup": "4.25.11",
"codemirror": "^6.0.0"
},
"funding": {
@@ -7518,14 +7521,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
- "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
+ "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
- "@vitest/utils": "4.1.9",
+ "@vitest/utils": "4.1.10",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -7539,8 +7542,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "4.1.9",
- "vitest": "4.1.9"
+ "@vitest/browser": "4.1.10",
+ "vitest": "4.1.10"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -7549,16 +7552,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
- "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.9",
- "@vitest/utils": "4.1.9",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -7567,13 +7570,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
- "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.9",
+ "@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -7604,9 +7607,9 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
- "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7617,13 +7620,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
- "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.9",
+ "@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
@@ -7631,14 +7634,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
- "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.9",
- "@vitest/utils": "4.1.9",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -7647,9 +7650,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
- "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -7657,13 +7660,13 @@
}
},
"node_modules/@vitest/ui": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz",
- "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.10.tgz",
+ "integrity": "sha512-EOUqfXHTXtpSHsyLHH40ts3Ue+hRhSGwzwzMlK0dTEOLSDYyOXLyr5JDGmHQWhN2DYI30gw6dVx3cdgM9FZl+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.9",
+ "@vitest/utils": "4.1.10",
"fflate": "^0.8.2",
"flatted": "^3.4.2",
"pathe": "^2.0.3",
@@ -7675,17 +7678,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "vitest": "4.1.9"
+ "vitest": "4.1.10"
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
- "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.9",
+ "@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -10210,9 +10213,9 @@
}
},
"node_modules/enhanced-resolve": {
- "version": "5.21.6",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
- "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
+ "version": "5.24.2",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
+ "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11601,9 +11604,9 @@
}
},
"node_modules/i18next": {
- "version": "26.3.4",
- "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
- "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
+ "version": "26.3.6",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
+ "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"dev": true,
"funding": [
{
@@ -11621,7 +11624,7 @@
],
"license": "MIT",
"peerDependencies": {
- "typescript": "^5 || ^6"
+ "typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
@@ -14235,9 +14238,9 @@
"optional": true
},
"node_modules/nanoid": {
- "version": "5.1.16",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
- "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-6.0.0.tgz",
+ "integrity": "sha512-mkUH+rPkwU2qPadJ0oJZOjeZ5Mxn8Q1UhevwkTRWNuUZzyia3h4rhzK39hxaHTk0o2OxB8W2SQ6A8k23ZDi1pQ==",
"funding": [
{
"type": "github",
@@ -14249,7 +14252,7 @@
"nanoid": "bin/nanoid.js"
},
"engines": {
- "node": "^18 || >=20"
+ "node": "^22 || ^24 || >=26"
}
},
"node_modules/napi-build-utils": {
@@ -15338,67 +15341,67 @@
}
},
"node_modules/radix-ui": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.1.tgz",
- "integrity": "sha512-QXDXJtB6sK83mLASONYUZCauatcWb+knFviFpN1EhtdbbmlsRmzCLrbZSKztnNiem2KOHIBbiDbauVB7SORXMw==",
+ "version": "1.6.3",
+ "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.3.tgz",
+ "integrity": "sha512-KmhSq0NfxIwN9q6ZpEaZ+J0hiVFQcGyrPYYhbxg34q9B8CIrQoccLJ3mJ9znLRslLoaogsP2ml8JKOVoKMXgvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@radix-ui/primitive": "1.1.4",
- "@radix-ui/react-accessible-icon": "1.1.11",
- "@radix-ui/react-accordion": "1.2.15",
- "@radix-ui/react-alert-dialog": "1.1.18",
- "@radix-ui/react-arrow": "1.1.11",
- "@radix-ui/react-aspect-ratio": "1.1.11",
- "@radix-ui/react-avatar": "1.2.1",
- "@radix-ui/react-checkbox": "1.3.6",
- "@radix-ui/react-collapsible": "1.1.15",
- "@radix-ui/react-collection": "1.1.11",
+ "@radix-ui/primitive": "1.1.6",
+ "@radix-ui/react-accessible-icon": "1.1.12",
+ "@radix-ui/react-accordion": "1.2.17",
+ "@radix-ui/react-alert-dialog": "1.1.20",
+ "@radix-ui/react-arrow": "1.1.12",
+ "@radix-ui/react-aspect-ratio": "1.1.12",
+ "@radix-ui/react-avatar": "1.2.3",
+ "@radix-ui/react-checkbox": "1.3.8",
+ "@radix-ui/react-collapsible": "1.1.17",
+ "@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
- "@radix-ui/react-context": "1.1.4",
- "@radix-ui/react-context-menu": "2.3.2",
- "@radix-ui/react-dialog": "1.1.18",
+ "@radix-ui/react-context": "1.2.0",
+ "@radix-ui/react-context-menu": "2.3.4",
+ "@radix-ui/react-dialog": "1.1.20",
"@radix-ui/react-direction": "1.1.2",
- "@radix-ui/react-dismissable-layer": "1.1.14",
- "@radix-ui/react-dropdown-menu": "2.1.19",
+ "@radix-ui/react-dismissable-layer": "1.1.16",
+ "@radix-ui/react-dropdown-menu": "2.1.21",
"@radix-ui/react-focus-guards": "1.1.4",
- "@radix-ui/react-focus-scope": "1.1.11",
- "@radix-ui/react-form": "0.1.11",
- "@radix-ui/react-hover-card": "1.1.18",
- "@radix-ui/react-label": "2.1.11",
- "@radix-ui/react-menu": "2.1.19",
- "@radix-ui/react-menubar": "1.1.19",
- "@radix-ui/react-navigation-menu": "1.2.17",
- "@radix-ui/react-one-time-password-field": "0.1.11",
- "@radix-ui/react-password-toggle-field": "0.1.6",
- "@radix-ui/react-popover": "1.1.18",
- "@radix-ui/react-popper": "1.3.2",
- "@radix-ui/react-portal": "1.1.13",
- "@radix-ui/react-presence": "1.1.6",
+ "@radix-ui/react-focus-scope": "1.1.13",
+ "@radix-ui/react-form": "0.1.13",
+ "@radix-ui/react-hover-card": "1.1.20",
+ "@radix-ui/react-label": "2.1.12",
+ "@radix-ui/react-menu": "2.1.21",
+ "@radix-ui/react-menubar": "1.1.21",
+ "@radix-ui/react-navigation-menu": "1.2.19",
+ "@radix-ui/react-one-time-password-field": "0.1.13",
+ "@radix-ui/react-password-toggle-field": "0.1.8",
+ "@radix-ui/react-popover": "1.1.20",
+ "@radix-ui/react-popper": "1.3.4",
+ "@radix-ui/react-portal": "1.1.14",
+ "@radix-ui/react-presence": "1.1.8",
"@radix-ui/react-primitive": "2.1.7",
- "@radix-ui/react-progress": "1.1.11",
- "@radix-ui/react-radio-group": "1.4.2",
- "@radix-ui/react-roving-focus": "1.1.14",
- "@radix-ui/react-scroll-area": "1.2.13",
- "@radix-ui/react-select": "2.3.2",
- "@radix-ui/react-separator": "1.1.11",
- "@radix-ui/react-slider": "1.4.2",
+ "@radix-ui/react-progress": "1.1.13",
+ "@radix-ui/react-radio-group": "1.4.4",
+ "@radix-ui/react-roving-focus": "1.1.16",
+ "@radix-ui/react-scroll-area": "1.2.15",
+ "@radix-ui/react-select": "2.3.4",
+ "@radix-ui/react-separator": "1.1.12",
+ "@radix-ui/react-slider": "1.4.4",
"@radix-ui/react-slot": "1.3.0",
- "@radix-ui/react-switch": "1.3.2",
- "@radix-ui/react-tabs": "1.1.16",
- "@radix-ui/react-toast": "1.2.18",
- "@radix-ui/react-toggle": "1.1.13",
- "@radix-ui/react-toggle-group": "1.1.14",
- "@radix-ui/react-toolbar": "1.1.14",
- "@radix-ui/react-tooltip": "1.2.11",
+ "@radix-ui/react-switch": "1.3.4",
+ "@radix-ui/react-tabs": "1.1.18",
+ "@radix-ui/react-toast": "1.2.20",
+ "@radix-ui/react-toggle": "1.1.15",
+ "@radix-ui/react-toggle-group": "1.1.16",
+ "@radix-ui/react-toolbar": "1.1.16",
+ "@radix-ui/react-tooltip": "1.2.13",
"@radix-ui/react-use-callback-ref": "1.1.2",
- "@radix-ui/react-use-controllable-state": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.4",
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-escape-keydown": "1.1.3",
"@radix-ui/react-use-is-hydrated": "0.1.1",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
- "@radix-ui/react-visually-hidden": "1.2.7"
+ "@radix-ui/react-visually-hidden": "1.2.8"
},
"peerDependencies": {
"@types/react": "*",
@@ -15542,9 +15545,9 @@
}
},
"node_modules/react-i18next": {
- "version": "17.0.8",
- "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
- "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
+ "version": "17.0.10",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
+ "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -15555,7 +15558,7 @@
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
- "typescript": "^5 || ^6"
+ "typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
@@ -16842,9 +16845,9 @@
}
},
"node_modules/tailwindcss": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
- "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true,
"license": "MIT"
},
@@ -17745,19 +17748,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.9",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
- "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.9",
- "@vitest/mocker": "4.1.9",
- "@vitest/pretty-format": "4.1.9",
- "@vitest/runner": "4.1.9",
- "@vitest/snapshot": "4.1.9",
- "@vitest/spy": "4.1.9",
- "@vitest/utils": "4.1.9",
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -17785,12 +17788,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.9",
- "@vitest/browser-preview": "4.1.9",
- "@vitest/browser-webdriverio": "4.1.9",
- "@vitest/coverage-istanbul": "4.1.9",
- "@vitest/coverage-v8": "4.1.9",
- "@vitest/ui": "4.1.9",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -18027,9 +18030,9 @@
"license": "ISC"
},
"node_modules/ws": {
- "version": "8.21.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
- "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
diff --git a/package.json b/package.json
index 72160103..f66d52a0 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "termix",
"private": true,
- "version": "2.5.1",
+ "version": "2.6.0",
"description": "Self-hosted SSH and remote desktop management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -14,7 +14,7 @@
"format:check": "prettier --check .",
"biome:check": "biome check biome.json package.json",
"biome:fix": "biome check --write biome.json package.json",
- "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
+ "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-guacamole-lite.cjs && node scripts/patch-guacamole-common-js.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-xterm-android-ime.cjs",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
@@ -65,22 +65,22 @@
"ldapjs": "^3.0.7",
"motion": "^12.42.2",
"multer": "^2.2.0",
- "nanoid": "^5.1.16",
+ "nanoid": "^6.0.0",
"qrcode": "^1.5.4",
"serialport": "^13.0.0",
"socks": "^2.8.7",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.7.0",
- "ws": "^8.20.0"
+ "ws": "^8.21.1"
},
"devDependencies": {
- "@biomejs/biome": "2.5.2",
+ "@biomejs/biome": "2.5.4",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/search": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
- "@codemirror/view": "^6.43.5",
+ "@codemirror/view": "^6.43.6",
"@commitlint/cli": "^21.0.2",
"@commitlint/config-conventional": "^21.0.2",
"@deadendjs/swagger-jsdoc": "^8.1.2",
@@ -92,23 +92,23 @@
"@fontsource/jetbrains-mono": "^5.2.8",
"@fontsource/source-code-pro": "^5.2.7",
"@monaco-editor/react": "^4.7.0",
- "@radix-ui/react-accordion": "^1.2.15",
- "@radix-ui/react-alert-dialog": "^1.1.18",
- "@radix-ui/react-checkbox": "^1.3.6",
- "@radix-ui/react-dialog": "^1.1.18",
- "@radix-ui/react-dropdown-menu": "^2.1.19",
- "@radix-ui/react-label": "^2.1.11",
- "@radix-ui/react-popover": "^1.1.18",
- "@radix-ui/react-progress": "^1.1.11",
- "@radix-ui/react-scroll-area": "^1.2.13",
- "@radix-ui/react-select": "^2.3.2",
- "@radix-ui/react-separator": "^1.1.11",
- "@radix-ui/react-slider": "^1.4.2",
+ "@radix-ui/react-accordion": "^1.2.17",
+ "@radix-ui/react-alert-dialog": "^1.1.20",
+ "@radix-ui/react-checkbox": "^1.3.8",
+ "@radix-ui/react-dialog": "^1.1.20",
+ "@radix-ui/react-dropdown-menu": "^2.1.21",
+ "@radix-ui/react-label": "^2.1.12",
+ "@radix-ui/react-popover": "^1.1.20",
+ "@radix-ui/react-progress": "^1.1.13",
+ "@radix-ui/react-scroll-area": "^1.2.15",
+ "@radix-ui/react-select": "^2.3.4",
+ "@radix-ui/react-separator": "^1.1.12",
+ "@radix-ui/react-slider": "^1.4.4",
"@radix-ui/react-slot": "^1.3.0",
- "@radix-ui/react-switch": "^1.3.2",
- "@radix-ui/react-tabs": "^1.1.16",
- "@radix-ui/react-tooltip": "^1.2.11",
- "@tailwindcss/vite": "^4.3.2",
+ "@radix-ui/react-switch": "^1.3.4",
+ "@radix-ui/react-tabs": "^1.1.18",
+ "@radix-ui/react-tooltip": "^1.2.13",
+ "@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -128,12 +128,12 @@
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
- "@uiw/codemirror-extensions-langs": "^4.25.9",
- "@uiw/codemirror-theme-github": "^4.25.9",
- "@uiw/react-codemirror": "^4.25.9",
+ "@uiw/codemirror-extensions-langs": "^4.25.11",
+ "@uiw/codemirror-theme-github": "^4.25.11",
+ "@uiw/react-codemirror": "^4.25.11",
"@vitejs/plugin-react": "^6.0.3",
- "@vitest/coverage-v8": "^4.1.9",
- "@vitest/ui": "^4.1.9",
+ "@vitest/coverage-v8": "^4.1.10",
+ "@vitest/ui": "^4.1.10",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -153,19 +153,19 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"husky": "^9.1.7",
- "i18next": "^26.3.4",
+ "i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.8",
"lucide-react": "^1.20.0",
"prettier": "3.8.4",
- "radix-ui": "^1.6.1",
+ "radix-ui": "^1.6.3",
"react": "^19.2.7",
"react-cytoscapejs": "^2.0.0",
"react-dom": "^19.2.7",
"react-h5-audio-player": "^3.10.2",
"react-hook-form": "^7.79.0",
- "react-i18next": "^17.0.4",
+ "react-i18next": "^17.0.10",
"react-icons": "^5.6.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.4.1",
@@ -182,7 +182,7 @@
"typescript-eslint": "^8.61.1",
"vite": "^8.0.16",
"vite-plugin-svgr": "^5.2.0",
- "vitest": "^4.1.9"
+ "vitest": "^4.1.10"
},
"lint-staged": {
"*.{ts,tsx}": [
diff --git a/scripts/patch-guacamole-common-js.cjs b/scripts/patch-guacamole-common-js.cjs
new file mode 100644
index 00000000..039071de
--- /dev/null
+++ b/scripts/patch-guacamole-common-js.cjs
@@ -0,0 +1,68 @@
+const fs = require("fs");
+const path = require("path");
+
+const packageRoot = path.join(
+ __dirname,
+ "..",
+ "node_modules",
+ "guacamole-common-js",
+);
+
+const bundlePaths = [
+ path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
+ path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
+];
+
+const oldFlushBlock =
+ " if (window.requestAnimationFrame && document.hasFocus())\n" +
+ " asyncFlush();\n" +
+ " else\n" +
+ " syncFlush();";
+
+const newFlushBlock =
+ " // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
+ " // windows/tabs even while guacd is still sending display frames. Flush\n" +
+ " // synchronously so Guacamole connections do not stall while waiting for\n" +
+ " // a frame callback that may never run.\n" +
+ " syncFlush();";
+
+let patched = false;
+let foundBundle = false;
+
+for (const bundlePath of bundlePaths) {
+ if (!fs.existsSync(bundlePath)) {
+ console.log(
+ `[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
+ );
+ continue;
+ }
+
+ foundBundle = true;
+ let content = fs.readFileSync(bundlePath, "utf8");
+ if (content.includes(newFlushBlock)) continue;
+
+ if (!content.includes(oldFlushBlock)) {
+ console.log(
+ `[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
+ );
+ continue;
+ }
+
+ content = content.replace(oldFlushBlock, newFlushBlock);
+ fs.writeFileSync(bundlePath, content);
+ patched = true;
+}
+
+if (!foundBundle) {
+ console.log("[patch-guacamole-common-js] File not found, skipping");
+ process.exit(0);
+}
+
+if (!patched) {
+ console.log("[patch-guacamole-common-js] Already patched");
+ process.exit(0);
+}
+
+console.log(
+ "[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
+);
diff --git a/scripts/patch-guacamole-lite.cjs b/scripts/patch-guacamole-lite.cjs
index 67cdca65..b9a76efe 100644
--- a/scripts/patch-guacamole-lite.cjs
+++ b/scripts/patch-guacamole-lite.cjs
@@ -17,14 +17,27 @@ const cryptPath = path.join(
"lib",
"Crypt.js",
);
+const clientConnectionPath = path.join(
+ __dirname,
+ "..",
+ "node_modules",
+ "guacamole-lite",
+ "lib",
+ "ClientConnection.js",
+);
-if (!fs.existsSync(guacdClientPath) || !fs.existsSync(cryptPath)) {
+if (
+ !fs.existsSync(guacdClientPath) ||
+ !fs.existsSync(cryptPath) ||
+ !fs.existsSync(clientConnectionPath)
+) {
console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0);
}
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
+let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
// Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
@@ -56,20 +69,26 @@ const newVersionBlock =
const oldTimezone = "if (protocolVersion === '1_1_0') {";
const newTimezone = "if (protocolVersion !== '1_0_0') {";
-// Patch 3: send the `name` handshake instruction for protocol >= 1.3.0.
-// The Guacamole protocol added the `name` instruction in 1.3.0 (an optional
-// human-readable identifier for the joining user). guacd 1.6.0 began requiring
-// it during the VNC handshake even when negotiating older protocol versions,
-// causing connections to silently drop right after "User joined". See
+// Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0.
+// The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it
+// during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to
+// silently drop right after "User joined". Sending it for all non-1.0.0 sessions is
+// harmless (guacd ignores unknown handshake instructions for older versions). See
// Termix-SSH/Support#567 and #734.
const oldConnect =
" this.sendInstruction(['connect'].concat(connectArgs));";
-const newConnect =
+const oldNameConnect =
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
" }\n" +
"\n" +
" this.sendInstruction(['connect'].concat(connectArgs));";
+const newConnect =
+ " if (protocolVersion !== '1_0_0') {\n" +
+ " this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
+ " }\n" +
+ "\n" +
+ " this.sendInstruction(['connect'].concat(connectArgs));";
// Patch 4: answer guacd's dynamic argument requests locally.
// macOS Screen Sharing can request VNC username/password through the
@@ -156,13 +175,16 @@ if (!guacdClientContent.includes(newTimezone)) {
}
if (!guacdClientContent.includes(newConnect)) {
- if (!guacdClientContent.includes(oldConnect)) {
+ if (guacdClientContent.includes(oldNameConnect)) {
+ guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect);
+ } else if (guacdClientContent.includes(oldConnect)) {
+ guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
+ } else {
console.log(
"[patch-guacamole-lite] Connect target not found, skipping name patch",
);
process.exit(0);
}
- guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
patched = true;
}
@@ -259,6 +281,94 @@ if (!cryptContent.includes(newDecryptBlock)) {
patched = true;
}
+// Patch 7: drop client-to-guacd input instructions from read-only session-share
+// joins. guacd has no native read-only enforcement in the versions this project
+// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
+// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
+// clipboard streams) than a new input vector, so failing open is the safer
+// default for a client we already control.
+const oldSendMessageToGuacd =
+ " sendMessageToGuacd(message) {\n" +
+ " this.lastActivity = Date.now();\n" +
+ " this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
+ "\n" +
+ " if (this.guacdClient) {\n" +
+ " this.guacdClient.send(message, true);\n" +
+ " }\n" +
+ " }";
+const newSendMessageToGuacd =
+ " sendMessageToGuacd(message) {\n" +
+ " this.lastActivity = Date.now();\n" +
+ " this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
+ "\n" +
+ " if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
+ " return;\n" +
+ " }\n" +
+ "\n" +
+ " if (this.guacdClient) {\n" +
+ " this.guacdClient.send(message, true);\n" +
+ " }\n" +
+ " }\n" +
+ "\n" +
+ " isReadOnlyJoin() {\n" +
+ " const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
+ " return !!(connection && connection.join && connection.readOnly === true);\n" +
+ " }\n" +
+ "\n" +
+ " // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
+ " // the leading opcode from a raw '.,...;' instruction without the\n" +
+ " // overhead of a full stateful parse.\n" +
+ " isInputInstruction(message) {\n" +
+ " const dot = message.indexOf('.');\n" +
+ " if (dot === -1) return false;\n" +
+ " const len = parseInt(message.substring(0, dot), 10);\n" +
+ " if (isNaN(len)) return false;\n" +
+ " const opcode = message.substring(dot + 1, dot + 1 + len);\n" +
+ " return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" +
+ " }";
+
+if (!clientConnectionContent.includes("isReadOnlyJoin()")) {
+ if (!clientConnectionContent.includes(oldSendMessageToGuacd)) {
+ console.log(
+ "[patch-guacamole-lite] sendMessageToGuacd target not found, skipping read-only patch",
+ );
+ process.exit(0);
+ }
+ clientConnectionContent = clientConnectionContent.replace(
+ oldSendMessageToGuacd,
+ newSendMessageToGuacd,
+ );
+ patched = true;
+}
+
+// Patch 8: mergeConnectionOptions only preserves `join` across the settings
+// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it.
+const oldPreserveJoin =
+ " // For join connections, preserve the join property\n" +
+ " if (this.connectionSettings.connection.join) {\n" +
+ " compiledSettings.join = this.connectionSettings.connection.join;\n" +
+ " }";
+const newPreserveJoin =
+ " // For join connections, preserve the join property\n" +
+ " if (this.connectionSettings.connection.join) {\n" +
+ " compiledSettings.join = this.connectionSettings.connection.join;\n" +
+ " compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" +
+ " }";
+
+if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
+ if (!clientConnectionContent.includes(oldPreserveJoin)) {
+ console.log(
+ "[patch-guacamole-lite] join-preserve target not found, skipping readOnly propagation patch",
+ );
+ process.exit(0);
+ }
+ clientConnectionContent = clientConnectionContent.replace(
+ oldPreserveJoin,
+ newPreserveJoin,
+ );
+ patched = true;
+}
+
if (!patched) {
console.log("[patch-guacamole-lite] Already patched");
process.exit(0);
@@ -266,6 +376,7 @@ if (!patched) {
fs.writeFileSync(guacdClientPath, guacdClientContent);
fs.writeFileSync(cryptPath, cryptContent);
+fs.writeFileSync(clientConnectionPath, clientConnectionContent);
console.log(
- "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, and UTF-8 token decrypt",
+ "[patch-guacamole-lite] Patched protocol VERSION_1_3_0/1_5_0 support, name handshake, required arguments, UTF-8 token decrypt, and read-only join input filtering",
);
diff --git a/scripts/patch-guacamole-lite.test.ts b/scripts/patch-guacamole-lite.test.ts
index d6b8c9eb..70e3ad33 100644
--- a/scripts/patch-guacamole-lite.test.ts
+++ b/scripts/patch-guacamole-lite.test.ts
@@ -69,6 +69,31 @@ describe("patch-guacamole-lite", () => {
]);
});
+ it("sends name instruction for VERSION_1_1_0 to fix guacd 1.6.0 VNC drops", () => {
+ const client = createPatchedClient({
+ hostname: "192.0.2.10",
+ port: 5900,
+ password: "secret",
+ width: 1280,
+ height: 720,
+ dpi: 96,
+ });
+
+ client.sendHandshakeReply(["VERSION_1_1_0", "hostname", "port"]);
+
+ expect(client.sendInstruction).toHaveBeenCalledWith(["timezone"]);
+ expect(client.sendInstruction).toHaveBeenCalledWith([
+ "name",
+ "guacamole-lite",
+ ]);
+ expect(client.sendInstruction).toHaveBeenCalledWith([
+ "connect",
+ "VERSION_1_1_0",
+ "192.0.2.10",
+ 5900,
+ ]);
+ });
+
it("answers required credentials through argument value streams", () => {
const client = createPatchedClient({
username: "",
diff --git a/scripts/patch-nan.cjs b/scripts/patch-nan.cjs
index 5fec17f9..43aad385 100644
--- a/scripts/patch-nan.cjs
+++ b/scripts/patch-nan.cjs
@@ -39,6 +39,19 @@ const nanHeaderPatched = patchFile(path.join(nanDir, "nan.h"), [
# define __builtin_frame_address(level) _AddressOfReturnAddress()
#endif
+// v8::External::New()/->Value() gained a mandatory ExternalPointerTypeTag
+// argument in V8 15 (Electron 43+). Plain Node (V8 <= 13.x as of Node 24)
+// still uses the old 2-arg signatures, so this must be conditional rather
+// than assumed - a build can target either header set.
+#include
+#if defined(V8_MAJOR_VERSION) && V8_MAJOR_VERSION >= 15
+# define NAN_EXTERNAL_TAG_ARG , static_cast(0)
+# define NAN_EXTERNAL_TAG_PARAM static_cast(0)
+#else
+# define NAN_EXTERNAL_TAG_ARG
+# define NAN_EXTERNAL_TAG_PARAM
+#endif
+
#define NODE_0_10_MODULE_VERSION 11`,
},
]);
@@ -63,23 +76,24 @@ const bindingPatched = patchFile(bindingPath, [
},
]);
-// 2. nan_implementation_12_inl.h: replace v8::External::New() with the 3-arg form.
-// Electron 42 / V8 13+ requires an ExternalPointerTypeTag as the third argument.
+// 2. nan_implementation_12_inl.h: replace v8::External::New() with a form that
+// passes NAN_EXTERNAL_TAG_ARG - a macro (defined in the nan.h patch above)
+// that expands to the ExternalPointerTypeTag argument only when the target
+// V8 headers actually declare it (V8 15+ / Electron 43+).
const implPath = path.join(nanDir, "nan_implementation_12_inl.h");
let implPatched = false;
if (fs.existsSync(implPath)) {
let src = fs.readFileSync(implPath, "utf8");
const before = src;
- const TAG = "static_cast(0)";
- if (!src.includes(TAG)) {
+ if (!src.includes("NAN_EXTERNAL_TAG_ARG")) {
src = src.replace(
- /v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value\)/g,
- `v8::External::New(v8::Isolate::GetCurrent(), value, ${TAG})`,
+ /v8::External::New\(v8::Isolate::GetCurrent\(\),\s*value(?:,\s*static_cast\(0\))?\)/g,
+ `v8::External::New(v8::Isolate::GetCurrent(), value NAN_EXTERNAL_TAG_ARG)`,
);
src = src.replace(
- /v8::External::New\(isolate,\s*reinterpret_cast\(callback\)\)/g,
- `v8::External::New(isolate, reinterpret_cast(callback), ${TAG})`,
+ /v8::External::New\(isolate,\s*reinterpret_cast\(callback\)(?:,\s*static_cast\(0\))?\)/g,
+ `v8::External::New(isolate, reinterpret_cast(callback) NAN_EXTERNAL_TAG_ARG)`,
);
}
@@ -89,20 +103,19 @@ if (fs.existsSync(implPath)) {
}
}
-// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(tag) on v8::External.
-// The new API requires an ExternalPointerTypeTag argument.
+// 3. nan_callbacks_12_inl.h: replace ->Value() with ->Value(NAN_EXTERNAL_TAG_PARAM)
+// on v8::External, same conditional-tag reasoning as above.
const callbacksPath = path.join(nanDir, "nan_callbacks_12_inl.h");
let callbacksPatched = false;
if (fs.existsSync(callbacksPath)) {
let src = fs.readFileSync(callbacksPath, "utf8");
const before = src;
- const TAG = "static_cast(0)";
- if (!src.includes(TAG)) {
- // Pattern: .As()->Value()) — always followed by ))
+ if (!src.includes("NAN_EXTERNAL_TAG_PARAM")) {
+ // Pattern: .As()->Value()) or ->Value())
src = src.replace(
- /\.As\(\)->Value\(\)\)/g,
- `.As()->Value(${TAG}))`,
+ /\.As\(\)->Value\((?:static_cast\(0\))?\)\)/g,
+ `.As()->Value(NAN_EXTERNAL_TAG_PARAM))`,
);
}
diff --git a/scripts/patch-xterm-android-ime.cjs b/scripts/patch-xterm-android-ime.cjs
index e7738b32..4bbff4a3 100644
--- a/scripts/patch-xterm-android-ime.cjs
+++ b/scripts/patch-xterm-android-ime.cjs
@@ -14,6 +14,17 @@ const xtermDir = path.join(
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
// composition on the previous word and replace it with a shorter value (for
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
+//
+// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
+// for ordinary typing (it reports keyCode 229 for all software-keyboard
+// input, not just IME composition). That handler diffs the textarea value
+// via `newValue.replace(oldValue, "")`, a literal substring removal. When
+// keystrokes arrive faster than the function's setTimeout(0) callback runs,
+// several overlapping callbacks each capture a stale oldValue, so the
+// literal-substring search fails to match and the diff silently comes back
+// empty - characters are dropped instead of sent. Swap in the same
+// common-prefix diff used for composition-end above so a stale oldValue
+// still yields the correct delta.
const patches = [
{
file: "xterm.mjs",
@@ -34,6 +45,10 @@ const patches = [
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length0&&",
],
+ [
+ '_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
+ ],
],
},
{
@@ -55,6 +70,10 @@ const patches = [
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length0&&",
],
+ [
+ '_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
+ ],
],
},
];
@@ -66,18 +85,24 @@ for (const { file, replacements } of patches) {
}
let source = fs.readFileSync(filePath, "utf8");
- if (source.includes("_preCompositionValue")) {
- console.log(`[patch-xterm-android-ime] ${file} already patched`);
- continue;
- }
+ let changed = false;
for (const [original, patched] of replacements) {
+ if (source.includes(patched)) {
+ continue;
+ }
if (!source.includes(original)) {
throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`,
);
}
source = source.replace(original, patched);
+ changed = true;
+ }
+
+ if (!changed) {
+ console.log(`[patch-xterm-android-ime] ${file} already patched`);
+ continue;
}
fs.writeFileSync(filePath, source);
diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts
index 66914a15..28e5d051 100644
--- a/src/backend/database/database.ts
+++ b/src/backend/database/database.ts
@@ -12,6 +12,7 @@ import c2sTunnelPresetRoutes from "./routes/c2s-tunnel-presets.js";
import terminalRoutes from "./routes/terminal.js";
import sessionLogRoutes from "./routes/session-log-routes.js";
import guacamoleRoutes from "../hosts/guacamole/routes.js";
+import sessionSharingRoutes from "../hosts/session-sharing/routes.js";
import networkTopologyRoutes from "./routes/network-topology.js";
import rbacRoutes from "./routes/rbac.js";
import openTabsRoutes from "./routes/open-tabs.js";
@@ -22,6 +23,7 @@ import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
import { registerTailscaleRoutes } from "./routes/tailscale-routes.js";
import vaultRoutes from "./routes/vault.js";
import alertRulesRoutes from "./routes/alert-rules-routes.js";
+import syncRoutes from "./routes/sync.js";
import { createCorsMiddleware } from "../utils/cors-config.js";
import fs from "fs";
import path from "path";
@@ -1737,6 +1739,7 @@ app.use("/c2s-tunnel-presets", c2sTunnelPresetRoutes);
app.use("/terminal", terminalRoutes);
app.use("/session_logs", sessionLogRoutes);
app.use("/guacamole", guacamoleRoutes);
+app.use("/session-sharing", sessionSharingRoutes);
app.use("/network-topology", networkTopologyRoutes);
app.use("/rbac", rbacRoutes);
app.use("/open-tabs", openTabsRoutes);
@@ -1747,6 +1750,7 @@ registerAuditLogRoutes(app, authenticateJWT);
registerTailscaleRoutes(app, authenticateJWT);
app.use("/vault", vaultRoutes);
app.use("/", alertRulesRoutes);
+app.use("/sync", syncRoutes);
const frontendDistPaths = [
path.join(__dirname, "../../../dist"),
diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts
index cb9c358c..b3f0f97b 100644
--- a/src/backend/database/db/index.ts
+++ b/src/backend/database/db/index.ts
@@ -390,9 +390,11 @@ async function initializeCompleteDatabase(): Promise {
name TEXT NOT NULL,
color TEXT,
icon TEXT,
+ credential_id INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
+ FOREIGN KEY (credential_id) REFERENCES ssh_credentials (id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS recent_activity (
@@ -493,6 +495,38 @@ async function initializeCompleteDatabase(): Promise {
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
+ CREATE TABLE IF NOT EXISTS session_shares (
+ id TEXT PRIMARY KEY,
+ host_id INTEGER NOT NULL,
+ owner_user_id TEXT NOT NULL,
+ protocol TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ tab_instance_id TEXT,
+ share_type TEXT NOT NULL,
+ target_user_id TEXT,
+ link_token TEXT UNIQUE,
+ permission_level TEXT NOT NULL DEFAULT 'read-only',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL,
+ revoked_at TEXT,
+ last_joined_at TEXT,
+ join_count INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
+ FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
+ FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+
+ CREATE TABLE IF NOT EXISTS session_share_participants (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ share_id TEXT NOT NULL,
+ user_id TEXT,
+ guest_label TEXT,
+ joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ left_at TEXT,
+ FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -692,12 +726,16 @@ const addColumnIfNotExists = (
sqlite.exec(`ALTER TABLE ${table}
ADD COLUMN "${column}" ${definition};`);
} catch (alterError) {
- databaseLogger.warn(`Failed to add column ${column} to ${table}`, {
- operation: "schema_migration",
- table,
- column,
- error: alterError,
- });
+ const message =
+ alterError instanceof Error ? alterError.message : String(alterError);
+ databaseLogger.warn(
+ `Failed to add column ${column} to ${table}: ${message}`,
+ {
+ operation: "schema_migration",
+ table,
+ column,
+ },
+ );
}
}
};
@@ -736,6 +774,8 @@ const migrateSchema = () => {
addColumnIfNotExists("user_preferences", "hidden_rail_tabs", "TEXT");
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
+ addColumnIfNotExists("user_preferences", "custom_themes", "TEXT");
+ addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
sqlite.exec(`
CREATE TABLE IF NOT EXISTS dashboard_service_links (
@@ -1378,6 +1418,19 @@ const migrateSchema = () => {
}
}
+ try {
+ sqlite.prepare("SELECT credential_id FROM ssh_folders LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec("ALTER TABLE ssh_folders ADD COLUMN credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL");
+ } catch (alterError) {
+ databaseLogger.warn("Failed to add credential_id column to ssh_folders", {
+ operation: "schema_migration",
+ error: alterError,
+ });
+ }
+ }
+
try {
sqlite.prepare("SELECT sudo_password FROM ssh_data LIMIT 1").get();
} catch {
@@ -1440,6 +1493,8 @@ const migrateSchema = () => {
{ column: "rdp_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN rdp_auth_type TEXT" },
{ column: "vnc_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN vnc_auth_type TEXT" },
{ column: "telnet_auth_type", sql: "ALTER TABLE ssh_data ADD COLUMN telnet_auth_type TEXT" },
+ { column: "allow_session_sharing", sql: "ALTER TABLE ssh_data ADD COLUMN allow_session_sharing INTEGER NOT NULL DEFAULT 1" },
+ { column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
];
for (const migration of sshDataMigrations) {
@@ -1985,6 +2040,74 @@ const migrateSchema = () => {
addColumnIfNotExists("users", "sso_provider_id", "INTEGER");
+ try {
+ const usersTableInfo = sqlite.prepare("PRAGMA table_info(users)").all() as Array<{
+ cid: number;
+ name: string;
+ type: string;
+ notnull: number;
+ dflt_value: string | null;
+ pk: number;
+ }>;
+ const legacyNotNullColumns = new Set([
+ "client_id",
+ "client_secret",
+ "issuer_url",
+ "authorization_url",
+ "token_url",
+ "identifier_path",
+ "name_path",
+ "scopes",
+ ]);
+ const hasStaleNotNull = usersTableInfo.some(
+ (col) => legacyNotNullColumns.has(col.name) && col.notnull === 1,
+ );
+
+ if (hasStaleNotNull) {
+ const tempTableName = "users_temp_migration";
+ const columnDefs = usersTableInfo
+ .map((col) => {
+ const parts = [`"${col.name}"`, col.type || "TEXT"];
+ if (col.pk === 1) parts.push("PRIMARY KEY");
+ if (col.notnull === 1 && !legacyNotNullColumns.has(col.name)) {
+ parts.push("NOT NULL");
+ }
+ if (col.dflt_value !== null) {
+ parts.push(`DEFAULT ${col.dflt_value}`);
+ }
+ return parts.join(" ");
+ })
+ .join(",\n ");
+ const allColumns = usersTableInfo.map((col) => `"${col.name}"`).join(", ");
+
+ sqlite.exec(`PRAGMA foreign_keys = OFF`);
+ sqlite.exec(`
+ CREATE TABLE ${tempTableName} (
+ ${columnDefs}
+ );
+
+ INSERT INTO ${tempTableName} SELECT ${allColumns} FROM users;
+
+ DROP TABLE users;
+
+ ALTER TABLE ${tempTableName} RENAME TO users;
+ `);
+ sqlite.exec(`PRAGMA foreign_keys = ON`);
+
+ databaseLogger.info(
+ "Successfully migrated users table to remove legacy OIDC NOT NULL constraints",
+ {
+ operation: "schema_migration_users_oidc_nullable",
+ },
+ );
+ }
+ } catch (migrationError) {
+ databaseLogger.warn("Failed to migrate users table legacy OIDC columns", {
+ operation: "schema_migration",
+ error: migrationError,
+ });
+ }
+
// Migrate legacy single oidc_config settings blob into sso_providers table
try {
const migrationDone = getRawSettingValue("sso_migration_v1");
@@ -2206,6 +2329,174 @@ const migrateSchema = () => {
}
// --- homepage end ---
+ try {
+ sqlite.prepare("SELECT id FROM session_shares LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS session_shares (
+ id TEXT PRIMARY KEY,
+ host_id INTEGER NOT NULL,
+ owner_user_id TEXT NOT NULL,
+ protocol TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ tab_instance_id TEXT,
+ share_type TEXT NOT NULL,
+ target_user_id TEXT,
+ link_token TEXT UNIQUE,
+ permission_level TEXT NOT NULL DEFAULT 'read-only',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL,
+ revoked_at TEXT,
+ last_joined_at TEXT,
+ join_count INTEGER NOT NULL DEFAULT 0,
+ FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
+ FOREIGN KEY (owner_user_id) REFERENCES users (id) ON DELETE CASCADE,
+ FOREIGN KEY (target_user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+ `);
+ sqlite.exec(
+ "CREATE INDEX IF NOT EXISTS idx_session_shares_link_token ON session_shares(link_token)",
+ );
+ sqlite.exec(
+ "CREATE INDEX IF NOT EXISTS idx_session_shares_target_user ON session_shares(target_user_id)",
+ );
+ sqlite.exec(
+ "CREATE INDEX IF NOT EXISTS idx_session_shares_host ON session_shares(host_id)",
+ );
+ } catch (createError) {
+ databaseLogger.warn("Failed to create session_shares table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM session_share_participants LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS session_share_participants (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ share_id TEXT NOT NULL,
+ user_id TEXT,
+ guest_label TEXT,
+ joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ left_at TEXT,
+ FOREIGN KEY (share_id) REFERENCES session_shares (id) ON DELETE CASCADE,
+ FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
+ );
+ `);
+ sqlite.exec(
+ "CREATE INDEX IF NOT EXISTS idx_session_share_participants_share ON session_share_participants(share_id)",
+ );
+ } catch (createError) {
+ databaseLogger.warn("Failed to create session_share_participants table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+
+ // --- sync begin ---
+ // Stable per-row identity used to match rows across two independently-
+ // seeded databases (the embedded desktop backend and a connected remote
+ // server) during sync. Local autoincrement ids collide across instances,
+ // so a randomly-generated id is the join key instead. SQLite refuses a
+ // non-constant DEFAULT (e.g. randomblob()) on ALTER TABLE ADD COLUMN for
+ // tables with existing constraints ("Cannot add a column with
+ // non-constant default"), so the column is added as plain nullable TEXT;
+ // repositories set syncId explicitly on insert going forward, and
+ // existing rows are backfilled by the UPDATE loop below.
+ addColumnIfNotExists("ssh_data", "sync_id", "TEXT");
+ addColumnIfNotExists("ssh_credentials", "sync_id", "TEXT");
+ addColumnIfNotExists("ssh_folders", "sync_id", "TEXT");
+ addColumnIfNotExists("snippets", "sync_id", "TEXT");
+ addColumnIfNotExists("snippet_folders", "sync_id", "TEXT");
+ addColumnIfNotExists("vault_profiles", "sync_id", "TEXT");
+ addColumnIfNotExists("dashboard_service_links", "sync_id", "TEXT");
+ // SQLite also rejects NOT NULL DEFAULT CURRENT_TIMESTAMP here for the same
+ // "non-constant default" reason -- add nullable, then backfill from
+ // created_at below and rely on the repository layer to keep it current.
+ addColumnIfNotExists("dashboard_service_links", "updated_at", "TEXT");
+ try {
+ sqlite.exec(
+ "UPDATE dashboard_service_links SET updated_at = created_at WHERE updated_at IS NULL",
+ );
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ databaseLogger.warn(
+ `Failed to backfill dashboard_service_links.updated_at: ${message}`,
+ { operation: "schema_migration", table: "dashboard_service_links" },
+ );
+ }
+ addColumnIfNotExists("homepage_items", "sync_id", "TEXT");
+
+ const syncIdTables = [
+ "ssh_data",
+ "ssh_credentials",
+ "ssh_folders",
+ "snippets",
+ "snippet_folders",
+ "vault_profiles",
+ "dashboard_service_links",
+ "homepage_items",
+ ];
+
+ for (const table of syncIdTables) {
+ try {
+ const result = sqlite
+ .prepare(
+ `UPDATE ${table} SET sync_id = lower(hex(randomblob(16))) WHERE sync_id IS NULL`,
+ )
+ .run();
+ if (result.changes > 0) {
+ databaseLogger.info(
+ `Backfilled sync_id for ${result.changes} row(s) in ${table}`,
+ { operation: "sync_id_backfill", table },
+ );
+ }
+ sqlite.exec(
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_${table}_sync_id ON ${table}(sync_id)`,
+ );
+ } catch (e) {
+ const message = e instanceof Error ? e.message : String(e);
+ databaseLogger.warn(
+ `Failed to backfill sync_id for ${table}: ${message}`,
+ {
+ operation: "sync_id_backfill",
+ table,
+ },
+ );
+ }
+ }
+
+ try {
+ sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
+ } catch {
+ try {
+ sqlite.exec(`
+ CREATE TABLE IF NOT EXISTS sync_tombstones (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ entity_type TEXT NOT NULL,
+ sync_id TEXT NOT NULL,
+ deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+ sqlite.exec(
+ "CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
+ );
+ } catch (createError) {
+ databaseLogger.warn("Failed to create sync_tombstones table", {
+ operation: "schema_migration",
+ error: createError,
+ });
+ }
+ }
+ // --- sync end ---
+
databaseLogger.success("Schema migration completed", {
operation: "schema_migration",
});
diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts
index 6e0a3050..423154b4 100644
--- a/src/backend/database/db/schema.ts
+++ b/src/backend/database/db/schema.ts
@@ -153,6 +153,9 @@ export const hosts = sqliteTable("ssh_data", {
enableSessionLogging: integer("enable_session_logging", { mode: "boolean" })
.notNull()
.default(true),
+ allowSessionSharing: integer("allow_session_sharing", { mode: "boolean" })
+ .notNull()
+ .default(true),
enableCommandHistory: integer("enable_command_history", { mode: "boolean" })
.notNull()
.default(true),
@@ -237,6 +240,12 @@ export const hosts = sqliteTable("ssh_data", {
socks5Password: text("socks5_password"),
socks5ProxyChain: text("socks5_proxy_chain"),
+ // null = use the desktop app's global default; "local" | "remote" pins
+ // this specific host's SSH/Docker-console/Serial connections to originate
+ // from the embedded local backend or a connected remote sync server.
+ // Ignored for rdp/vnc/telnet, which always require the remote server.
+ connectionOrigin: text("connection_origin"),
+
macAddress: text("mac_address"),
wolBroadcastAddress: text("wol_broadcast_address"),
portKnockSequence: text("port_knock_sequence"),
@@ -248,6 +257,11 @@ export const hosts = sqliteTable("ssh_data", {
hostKeyLastVerified: text("host_key_last_verified"),
hostKeyChangedCount: integer("host_key_changed_count").default(0),
+ // Stable identity used to match this row across two independently-seeded
+ // databases (the embedded backend and a connected remote server) during
+ // sync -- local autoincrement ids collide across instances.
+ syncId: text("sync_id").unique(),
+
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -354,6 +368,7 @@ export const sshCredentials = sqliteTable("ssh_credentials", {
usageCount: integer("usage_count").notNull().default(0),
lastUsed: text("last_used"),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -388,6 +403,7 @@ export const snippets = sqliteTable("snippets", {
description: text("description"),
folder: text("folder"),
order: integer("order").notNull().default(0),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -405,6 +421,7 @@ export const snippetFolders = sqliteTable("snippet_folders", {
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -462,6 +479,10 @@ export const sshFolders = sqliteTable("ssh_folders", {
name: text("name").notNull(),
color: text("color"),
icon: text("icon"),
+ credentialId: integer("credential_id").references(() => sshCredentials.id, {
+ onDelete: "set null",
+ }),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -673,6 +694,62 @@ export const sessionRecordings = sqliteTable("session_recordings", {
terminationReason: text("termination_reason"),
});
+export const sessionShares = sqliteTable("session_shares", {
+ id: text("id").primaryKey(),
+
+ hostId: integer("host_id")
+ .notNull()
+ .references(() => hosts.id, { onDelete: "cascade" }),
+ ownerUserId: text("owner_user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+
+ protocol: text("protocol").notNull(),
+
+ // Live-session binding: TerminalSessionManager's session.id for SSH, or
+ // guacd's own guacamoleConnectionId for rdp/vnc/telnet. Neither is a DB
+ // row (process-local, in-memory) so this intentionally has no FK.
+ sessionId: text("session_id").notNull(),
+ tabInstanceId: text("tab_instance_id"),
+
+ shareType: text("share_type").notNull(), // "link" | "user"
+ targetUserId: text("target_user_id").references(() => users.id, {
+ onDelete: "cascade",
+ }),
+ linkToken: text("link_token").unique(),
+
+ permissionLevel: text("permission_level").notNull().default("read-only"),
+
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ expiresAt: text("expires_at").notNull(),
+ revokedAt: text("revoked_at"),
+
+ lastJoinedAt: text("last_joined_at"),
+ joinCount: integer("join_count").notNull().default(0),
+});
+
+export const sessionShareParticipants = sqliteTable(
+ "session_share_participants",
+ {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ shareId: text("share_id")
+ .notNull()
+ .references(() => sessionShares.id, { onDelete: "cascade" }),
+
+ userId: text("user_id").references(() => users.id, {
+ onDelete: "cascade",
+ }),
+ guestLabel: text("guest_label"),
+
+ joinedAt: text("joined_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+ leftAt: text("left_at"),
+ },
+);
+
export const opksshTokens = sqliteTable("opkssh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -724,6 +801,7 @@ export const vaultProfiles = sqliteTable("vault_profiles", {
keyType: text("key_type"),
// When true the profile is visible/usable by all users on the server
shared: integer("shared", { mode: "boolean" }).notNull().default(false),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -813,6 +891,8 @@ export const userPreferences = sqliteTable("user_preferences", {
hiddenRailTabs: text("hidden_rail_tabs"),
compactHostView: integer("compact_host_view", { mode: "boolean" }),
statusColorScheme: text("status_color_scheme"),
+ customThemes: text("custom_themes"),
+ customKeybindings: text("custom_keybindings"),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -879,9 +959,13 @@ export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
label: text("label").notNull(),
url: text("url").notNull(),
order: integer("order").notNull().default(0),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
});
// --- termix-id begin ---
@@ -1067,6 +1151,7 @@ export const homepageItems = sqliteTable("homepage_items", {
title: text("title"),
config: text("config").notNull().default("{}"),
folderId: integer("folder_id"),
+ syncId: text("sync_id").unique(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
@@ -1088,3 +1173,20 @@ export const homepageLayouts = sqliteTable("homepage_layouts", {
.default(sql`CURRENT_TIMESTAMP`),
});
// --- homepage end ---
+
+// --- sync begin ---
+// Records a delete for a synced entity type so the other side of a sync
+// pair (embedded desktop backend <-> connected remote server) learns about
+// the deletion instead of re-creating the row on its next pull.
+export const syncTombstones = sqliteTable("sync_tombstones", {
+ id: integer("id").primaryKey({ autoIncrement: true }),
+ userId: text("user_id")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ entityType: text("entity_type").notNull(),
+ syncId: text("sync_id").notNull(),
+ deletedAt: text("deleted_at")
+ .notNull()
+ .default(sql`CURRENT_TIMESTAMP`),
+});
+// --- sync end ---
diff --git a/src/backend/database/repositories/credential-repository.ts b/src/backend/database/repositories/credential-repository.ts
index 82d0af36..5ec291be 100644
--- a/src/backend/database/repositories/credential-repository.ts
+++ b/src/backend/database/repositories/credential-repository.ts
@@ -1,4 +1,5 @@
import { and, desc, eq, sql } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { sshCredentials, sshCredentialUsage } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -18,7 +19,7 @@ export class CredentialRepository {
async create(credential: NewCredentialRecord): Promise {
const rows = await this.context.drizzle
.insert(sshCredentials)
- .values(credential)
+ .values({ syncId: randomUUID(), ...credential })
.returning();
await this.afterWrite();
return rows[0];
@@ -30,7 +31,11 @@ export class CredentialRepository {
): Promise {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = credential.id ?? Date.now();
- const dataWithTempId = { ...credential, id: tempId };
+ const dataWithTempId = {
+ syncId: randomUUID(),
+ ...credential,
+ id: tempId,
+ };
const encryptedCredential = this.encryptCredentialRecordForWrite(
dataWithTempId,
userId,
@@ -140,7 +145,7 @@ export class CredentialRepository {
): Promise {
const rows = await this.context.drizzle
.update(sshCredentials)
- .set({ folder: newName })
+ .set({ folder: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.userId, userId),
@@ -163,7 +168,7 @@ export class CredentialRepository {
): Promise {
const rows = await this.context.drizzle
.update(sshCredentials)
- .set(update)
+ .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.id, credentialId),
@@ -190,7 +195,7 @@ export class CredentialRepository {
const rows = await this.context.drizzle
.update(sshCredentials)
- .set(encryptedUpdate)
+ .set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(
and(
eq(sshCredentials.id, credentialId),
@@ -203,7 +208,10 @@ export class CredentialRepository {
return this.decryptOne(rows[0] ?? null, userId);
}
- async deleteForUser(userId: string, credentialId: number): Promise {
+ async deleteForUser(
+ userId: string,
+ credentialId: number,
+ ): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(sshCredentials)
.where(
@@ -212,10 +220,10 @@ export class CredentialRepository {
eq(sshCredentials.userId, userId),
),
)
- .returning({ id: sshCredentials.id });
+ .returning({ syncId: sshCredentials.syncId });
await this.afterWrite();
- return rows.length > 0;
+ return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise {
diff --git a/src/backend/database/repositories/dashboard-service-link-repository.ts b/src/backend/database/repositories/dashboard-service-link-repository.ts
index a12079e0..b06c2100 100644
--- a/src/backend/database/repositories/dashboard-service-link-repository.ts
+++ b/src/backend/database/repositories/dashboard-service-link-repository.ts
@@ -1,4 +1,5 @@
import { and, asc, eq } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { dashboardServiceLinks } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -40,11 +41,13 @@ export class DashboardServiceLinkRepository {
const [created] = await this.context.drizzle
.insert(dashboardServiceLinks)
.values({
+ syncId: randomUUID(),
userId,
label: input.label,
url: input.url,
order: nextOrder,
createdAt,
+ updatedAt: createdAt,
})
.returning();
await this.afterWrite();
@@ -76,7 +79,7 @@ export class DashboardServiceLinkRepository {
): Promise {
const [updated] = await this.context.drizzle
.update(dashboardServiceLinks)
- .set(updates)
+ .set({ ...updates, updatedAt: new Date().toISOString() })
.where(
and(
eq(dashboardServiceLinks.id, id),
@@ -92,7 +95,10 @@ export class DashboardServiceLinkRepository {
return updated ?? null;
}
- async deleteForUser(userId: string, id: number): Promise {
+ async deleteForUser(
+ userId: string,
+ id: number,
+ ): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(dashboardServiceLinks)
.where(
@@ -101,13 +107,11 @@ export class DashboardServiceLinkRepository {
eq(dashboardServiceLinks.userId, userId),
),
)
- .returning({ id: dashboardServiceLinks.id });
+ .returning({ syncId: dashboardServiceLinks.syncId });
- if (rows.length > 0) {
- await this.afterWrite();
- }
-
- return rows.length > 0;
+ if (rows.length === 0) return null;
+ await this.afterWrite();
+ return rows[0];
}
async deleteByUserId(userId: string): Promise {
diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts
index 0db7fea1..42dd08d8 100644
--- a/src/backend/database/repositories/factory.ts
+++ b/src/backend/database/repositories/factory.ts
@@ -27,10 +27,12 @@ import { RecentActivityRepository } from "./recent-activity-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRecordingRepository } from "./session-recording-repository.js";
import { SessionRepository } from "./session-repository.js";
+import { SessionShareRepository } from "./session-share-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SnippetRepository } from "./snippet-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
+import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
import { SsoProviderRepository } from "./sso-provider-repository.js";
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
import { TermixIdentityRepository } from "./termix-identity-repository.js";
@@ -125,6 +127,13 @@ export function createCurrentDashboardServiceLinkRepository(): DashboardServiceL
);
}
+export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
+ return new SyncTombstoneRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
+ );
+}
+
export function createCurrentDismissedAlertRepository(): DismissedAlertRepository {
return new DismissedAlertRepository(
createCurrentRepositoryContext(),
@@ -253,6 +262,13 @@ export function createCurrentSessionRepository(): SessionRepository {
);
}
+export function createCurrentSessionShareRepository(): SessionShareRepository {
+ return new SessionShareRepository(
+ createCurrentRepositoryContext(),
+ createCurrentRepositoryWriteHook("session_share_repository_write"),
+ );
+}
+
export function createCurrentSettingsRepository(): SettingsRepository {
return new SettingsRepository(
createCurrentRepositoryContext(),
diff --git a/src/backend/database/repositories/homepage-item-repository.ts b/src/backend/database/repositories/homepage-item-repository.ts
index 1dc8efea..2be7ebed 100644
--- a/src/backend/database/repositories/homepage-item-repository.ts
+++ b/src/backend/database/repositories/homepage-item-repository.ts
@@ -1,4 +1,5 @@
import { and, asc, eq } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { homepageItems } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -37,6 +38,7 @@ export class HomepageItemRepository {
const [created] = await this.context.drizzle
.insert(homepageItems)
.values({
+ syncId: randomUUID(),
userId,
typeId: input.typeId,
title: input.title,
@@ -82,17 +84,18 @@ export class HomepageItemRepository {
return updated ?? null;
}
- async deleteForUser(userId: string, id: number): Promise {
+ async deleteForUser(
+ userId: string,
+ id: number,
+ ): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(homepageItems)
.where(and(eq(homepageItems.id, id), eq(homepageItems.userId, userId)))
- .returning({ id: homepageItems.id });
+ .returning({ syncId: homepageItems.syncId });
- if (rows.length > 0) {
- await this.afterWrite();
- }
-
- return rows.length > 0;
+ if (rows.length === 0) return null;
+ await this.afterWrite();
+ return rows[0];
}
async deleteByUserId(userId: string): Promise {
diff --git a/src/backend/database/repositories/host-folder-repository.ts b/src/backend/database/repositories/host-folder-repository.ts
index 50d0b169..d83df5a3 100644
--- a/src/backend/database/repositories/host-folder-repository.ts
+++ b/src/backend/database/repositories/host-folder-repository.ts
@@ -1,4 +1,5 @@
import { and, eq, like, or, sql } from "drizzle-orm";
+import { randomUUID } from "crypto";
import type { SQLiteColumn } from "drizzle-orm/sqlite-core";
import { hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -72,13 +73,20 @@ export class HostFolderRepository {
name: string,
color: string | null | undefined,
icon: string | null | undefined,
+ credentialId?: number | null,
now = new Date().toISOString(),
): Promise<{ folder: HostFolderRecord; created: boolean }> {
const existing = await this.findFolder(userId, name);
if (existing) {
const [updated] = await this.context.drizzle
.update(sshFolders)
- .set({ color, icon, updatedAt: now })
+ .set({
+ color,
+ icon,
+ credentialId:
+ credentialId === undefined ? existing.credentialId : credentialId,
+ updatedAt: now,
+ })
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
.returning();
@@ -89,10 +97,12 @@ export class HostFolderRepository {
const [created] = await this.context.drizzle
.insert(sshFolders)
.values({
+ syncId: randomUUID(),
userId,
name,
color,
icon,
+ credentialId: credentialId ?? null,
createdAt: now,
updatedAt: now,
})
@@ -118,7 +128,7 @@ export class HostFolderRepository {
async deleteHostsAndFolderRecords(
userId: string,
folderName: string,
- ): Promise {
+ ): Promise<{ hostSyncIds: string[]; folderSyncIds: string[] }> {
const folderMatch = (col: SQLiteColumn) =>
or(eq(col, folderName), like(col, `${folderName} / %`));
@@ -129,11 +139,21 @@ export class HostFolderRepository {
.where(and(eq(hosts.userId, userId), folderMatch(hosts.folder)));
}
- await this.context.drizzle
+ const deletedFolders = await this.context.drizzle
.delete(sshFolders)
- .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)));
+ .where(and(eq(sshFolders.userId, userId), folderMatch(sshFolders.name)))
+ .returning({ syncId: sshFolders.syncId });
await this.afterWrite();
+
+ return {
+ hostSyncIds: hostsToDelete
+ .map((h) => h.syncId)
+ .filter((id): id is string => !!id),
+ folderSyncIds: deletedFolders
+ .map((f) => f.syncId)
+ .filter((id): id is string => !!id),
+ };
}
async deleteByUserId(userId: string): Promise {
diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts
index 0cb7a4db..c7d2cf72 100644
--- a/src/backend/database/repositories/host-repository.ts
+++ b/src/backend/database/repositories/host-repository.ts
@@ -1,4 +1,5 @@
-import { and, eq, inArray } from "drizzle-orm";
+import { and, eq, inArray, sql } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { hostAccess, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -22,7 +23,7 @@ export class HostRepository {
async create(host: NewHostRecord): Promise {
const rows = await this.context.drizzle
.insert(hosts)
- .values(host)
+ .values({ syncId: randomUUID(), ...host })
.returning();
await this.afterWrite();
return rows[0];
@@ -34,7 +35,11 @@ export class HostRepository {
): Promise {
const userDataKey = DataCrypto.validateUserAccess(userId);
const tempId = host.id ?? Date.now();
- const dataWithTempId = { ...host, id: tempId };
+ const dataWithTempId = {
+ syncId: randomUUID(),
+ ...host,
+ id: tempId,
+ };
const encryptedHost = DataCrypto.encryptRecord(
"ssh_data",
dataWithTempId,
@@ -147,7 +152,7 @@ export class HostRepository {
): Promise {
const rows = await this.context.drizzle
.update(hosts)
- .set(update)
+ .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning();
@@ -170,7 +175,7 @@ export class HostRepository {
const rows = await this.context.drizzle
.update(hosts)
- .set(encryptedUpdate)
+ .set({ ...encryptedUpdate, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
.returning();
@@ -210,7 +215,7 @@ export class HostRepository {
const rows = await this.context.drizzle
.update(hosts)
- .set(update)
+ .set({ ...update, updatedAt: sql`CURRENT_TIMESTAMP` })
.where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId)))
.returning({ id: hosts.id });
@@ -221,16 +226,19 @@ export class HostRepository {
return rows.length;
}
- async deleteForUser(userId: string, hostId: number): Promise {
+ async deleteForUser(
+ userId: string,
+ hostId: number,
+ ): Promise<{ syncId: string | null } | null> {
await this.deleteAccessForHost(hostId);
const rows = await this.context.drizzle
.delete(hosts)
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)))
- .returning({ id: hosts.id });
+ .returning({ syncId: hosts.syncId });
await this.afterWrite();
- return rows.length > 0;
+ return rows[0] ?? null;
}
async deleteByUserId(userId: string): Promise {
diff --git a/src/backend/database/repositories/host-resolution-repository.ts b/src/backend/database/repositories/host-resolution-repository.ts
index 61288d58..31926f0b 100644
--- a/src/backend/database/repositories/host-resolution-repository.ts
+++ b/src/backend/database/repositories/host-resolution-repository.ts
@@ -1,5 +1,5 @@
import { and, eq, inArray, isNotNull } from "drizzle-orm";
-import { hostAccess, hosts, sshCredentials } from "../db/schema.js";
+import { hostAccess, hosts, sshCredentials, sshFolders } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { DataCrypto } from "../../utils/data-crypto.js";
@@ -315,6 +315,34 @@ export class HostResolutionRepository {
return rows[0]?.overrideCredentialId ?? null;
}
+ /**
+ * Resolve the nearest assigned credential for a folder path, walking up
+ * through parent folders (e.g. "Switches / Floor1" falls back to
+ * "Switches" if the child folder has no credential of its own).
+ */
+ async findFolderCredentialId(
+ userId: string,
+ folderPath: string,
+ ): Promise {
+ const segments = folderPath.split(" / ").filter(Boolean);
+ if (segments.length === 0) return null;
+
+ const paths = segments.map((_, i) => segments.slice(0, i + 1).join(" / "));
+ const rows = await this.context.drizzle
+ .select({ name: sshFolders.name, credentialId: sshFolders.credentialId })
+ .from(sshFolders)
+ .where(
+ and(eq(sshFolders.userId, userId), inArray(sshFolders.name, paths)),
+ );
+
+ const byName = new Map(rows.map((row) => [row.name, row.credentialId]));
+ for (let i = paths.length - 1; i >= 0; i--) {
+ const credentialId = byName.get(paths[i]);
+ if (credentialId) return credentialId;
+ }
+ return null;
+ }
+
private decryptOne>(
tableName: "ssh_data" | "ssh_credentials",
record: T | undefined,
diff --git a/src/backend/database/repositories/session-recording-repository.ts b/src/backend/database/repositories/session-recording-repository.ts
index 678a24a1..fa3efc10 100644
--- a/src/backend/database/repositories/session-recording-repository.ts
+++ b/src/backend/database/repositories/session-recording-repository.ts
@@ -58,7 +58,12 @@ export class SessionRecordingRepository {
async updateEnded(
id: number,
- input: { endedAt: string; duration: number | null },
+ input: {
+ endedAt: string;
+ duration: number | null;
+ terminatedByOwner?: boolean;
+ terminationReason?: string;
+ },
): Promise {
await this.context.drizzle
.update(sessionRecordings)
diff --git a/src/backend/database/repositories/session-share-repository.ts b/src/backend/database/repositories/session-share-repository.ts
new file mode 100644
index 00000000..016c44f1
--- /dev/null
+++ b/src/backend/database/repositories/session-share-repository.ts
@@ -0,0 +1,247 @@
+import { and, eq, gt, isNull, lt } from "drizzle-orm";
+import {
+ hosts,
+ sessionShareParticipants,
+ sessionShares,
+ users,
+} from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type SessionShareRecord = typeof sessionShares.$inferSelect;
+export type SessionShareParticipantRecord =
+ typeof sessionShareParticipants.$inferSelect;
+
+export type SessionShareType = "link" | "user";
+export type SessionSharePermissionLevel = "read-only" | "read-write";
+
+export interface SessionShareCreateInput {
+ id: string;
+ hostId: number;
+ ownerUserId: string;
+ protocol: string;
+ sessionId: string;
+ tabInstanceId?: string | null;
+ shareType: SessionShareType;
+ targetUserId?: string | null;
+ linkToken?: string | null;
+ permissionLevel: SessionSharePermissionLevel;
+ expiresAt: string;
+}
+
+export interface SessionShareWithHost extends SessionShareRecord {
+ hostName: string | null;
+ ownerUsername: string | null;
+}
+
+export interface SharedWithMeRecord extends SessionShareRecord {
+ hostName: string | null;
+ ownerUsername: string | null;
+}
+
+function activeShareFilter(now: string) {
+ return and(isNull(sessionShares.revokedAt), gt(sessionShares.expiresAt, now));
+}
+
+export class SessionShareRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async create(input: SessionShareCreateInput): Promise {
+ const [created] = await this.context.drizzle
+ .insert(sessionShares)
+ .values({
+ id: input.id,
+ hostId: input.hostId,
+ ownerUserId: input.ownerUserId,
+ protocol: input.protocol,
+ sessionId: input.sessionId,
+ tabInstanceId: input.tabInstanceId ?? null,
+ shareType: input.shareType,
+ targetUserId: input.targetUserId ?? null,
+ linkToken: input.linkToken ?? null,
+ permissionLevel: input.permissionLevel,
+ expiresAt: input.expiresAt,
+ })
+ .returning();
+
+ await this.afterWrite();
+ return created;
+ }
+
+ async findById(id: string): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sessionShares)
+ .where(eq(sessionShares.id, id))
+ .limit(1);
+ return rows[0] ?? null;
+ }
+
+ async findActiveById(
+ id: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sessionShares)
+ .where(and(eq(sessionShares.id, id), activeShareFilter(now)))
+ .limit(1);
+ return rows[0] ?? null;
+ }
+
+ async findByLinkToken(
+ linkToken: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select()
+ .from(sessionShares)
+ .where(
+ and(eq(sessionShares.linkToken, linkToken), activeShareFilter(now)),
+ )
+ .limit(1);
+ return rows[0] ?? null;
+ }
+
+ async findActiveSharesForHost(
+ hostId: number,
+ ownerUserId: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ return this.context.drizzle
+ .select()
+ .from(sessionShares)
+ .where(
+ and(
+ eq(sessionShares.hostId, hostId),
+ eq(sessionShares.ownerUserId, ownerUserId),
+ activeShareFilter(now),
+ ),
+ );
+ }
+
+ async findSharesTargetingUser(
+ userId: string,
+ now = new Date().toISOString(),
+ ): Promise {
+ const rows = await this.context.drizzle
+ .select({
+ share: sessionShares,
+ hostName: hosts.name,
+ ownerUsername: users.username,
+ })
+ .from(sessionShares)
+ .leftJoin(hosts, eq(sessionShares.hostId, hosts.id))
+ .leftJoin(users, eq(sessionShares.ownerUserId, users.id))
+ .where(
+ and(
+ eq(sessionShares.shareType, "user"),
+ eq(sessionShares.targetUserId, userId),
+ activeShareFilter(now),
+ ),
+ );
+
+ return rows.map((row) => ({
+ ...row.share,
+ hostName: row.hostName,
+ ownerUsername: row.ownerUsername,
+ }));
+ }
+
+ async revoke(shareId: string, requestingUserId: string): Promise {
+ const rows = await this.context.drizzle
+ .update(sessionShares)
+ .set({ revokedAt: new Date().toISOString() })
+ .where(
+ and(
+ eq(sessionShares.id, shareId),
+ eq(sessionShares.ownerUserId, requestingUserId),
+ ),
+ )
+ .returning({ id: sessionShares.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+ return rows.length > 0;
+ }
+
+ async revokeAsAdmin(shareId: string): Promise {
+ const rows = await this.context.drizzle
+ .update(sessionShares)
+ .set({ revokedAt: new Date().toISOString() })
+ .where(eq(sessionShares.id, shareId))
+ .returning({ id: sessionShares.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+ return rows.length > 0;
+ }
+
+ async deleteExpiredShares(now = new Date().toISOString()): Promise {
+ const rows = await this.context.drizzle
+ .delete(sessionShares)
+ .where(lt(sessionShares.expiresAt, now))
+ .returning({ id: sessionShares.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+ return rows.length;
+ }
+
+ async touchShareUsage(
+ shareId: string,
+ lastJoinedAt = new Date().toISOString(),
+ ): Promise {
+ const current = await this.findById(shareId);
+ await this.context.drizzle
+ .update(sessionShares)
+ .set({
+ lastJoinedAt,
+ joinCount: (current?.joinCount ?? 0) + 1,
+ })
+ .where(eq(sessionShares.id, shareId));
+ await this.afterWrite();
+ }
+
+ async recordParticipantJoin(
+ shareId: string,
+ userId: string | null,
+ guestLabel: string | null,
+ ): Promise {
+ const [created] = await this.context.drizzle
+ .insert(sessionShareParticipants)
+ .values({ shareId, userId, guestLabel })
+ .returning();
+ await this.afterWrite();
+ return created;
+ }
+
+ async recordParticipantLeave(participantId: number): Promise {
+ await this.context.drizzle
+ .update(sessionShareParticipants)
+ .set({ leftAt: new Date().toISOString() })
+ .where(eq(sessionShareParticipants.id, participantId));
+ await this.afterWrite();
+ }
+
+ async deleteSharesForHost(hostId: number): Promise {
+ const rows = await this.context.drizzle
+ .delete(sessionShares)
+ .where(eq(sessionShares.hostId, hostId))
+ .returning({ id: sessionShares.id });
+
+ if (rows.length > 0) {
+ await this.afterWrite();
+ }
+ return rows.length;
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/snippet-repository.ts b/src/backend/database/repositories/snippet-repository.ts
index 5196ff38..60e6bf17 100644
--- a/src/backend/database/repositories/snippet-repository.ts
+++ b/src/backend/database/repositories/snippet-repository.ts
@@ -1,4 +1,5 @@
import { and, asc, eq, sql } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { snippetFolders, snippets } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -151,6 +152,7 @@ export class SnippetRepository {
const rows = await this.context.drizzle
.insert(snippets)
.values({
+ syncId: randomUUID(),
userId,
name: input.name.trim(),
content: input.content.trim(),
@@ -343,6 +345,7 @@ export class SnippetRepository {
const maxOrder = await this.maxOrderForFolder(userId, folderVal);
await this.context.drizzle.insert(snippets).values({
+ syncId: randomUUID(),
userId,
name: snippet.name.trim(),
content: snippet.content.trim(),
@@ -377,6 +380,7 @@ export class SnippetRepository {
const rows = await this.context.drizzle
.insert(snippetFolders)
.values({
+ syncId: randomUUID(),
userId,
name: name.trim(),
color: color?.trim() || null,
@@ -452,19 +456,24 @@ export class SnippetRepository {
return { status: "renamed" };
}
- async deleteFolder(userId: string, name: string): Promise {
+ async deleteFolder(
+ userId: string,
+ name: string,
+ ): Promise<{ syncId: string | null } | null> {
await this.context.drizzle
.update(snippets)
.set({ folder: null })
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
- await this.context.drizzle
+ const rows = await this.context.drizzle
.delete(snippetFolders)
.where(
and(eq(snippetFolders.userId, userId), eq(snippetFolders.name, name)),
- );
+ )
+ .returning({ syncId: snippetFolders.syncId });
await this.afterWrite();
+ return rows[0] ?? null;
}
private async findFolderByName(
diff --git a/src/backend/database/repositories/sync-tombstone-repository.ts b/src/backend/database/repositories/sync-tombstone-repository.ts
new file mode 100644
index 00000000..1fe4953f
--- /dev/null
+++ b/src/backend/database/repositories/sync-tombstone-repository.ts
@@ -0,0 +1,70 @@
+import { and, eq, gt } from "drizzle-orm";
+import { syncTombstones } from "../db/schema.js";
+import type { DatabaseContext } from "./database-context.js";
+
+export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
+
+export type SyncEntityType =
+ | "hosts"
+ | "sshCredentials"
+ | "sshFolders"
+ | "snippets"
+ | "snippetFolders"
+ | "vaultProfiles"
+ | "dashboardServiceLinks"
+ | "homepageItems";
+
+export class SyncTombstoneRepository {
+ constructor(
+ private readonly context: DatabaseContext,
+ private readonly onWrite?: () => void | Promise,
+ ) {}
+
+ async record(
+ userId: string,
+ entityType: SyncEntityType,
+ syncId: string,
+ ): Promise {
+ if (!syncId) return;
+ await this.context.drizzle.insert(syncTombstones).values({
+ userId,
+ entityType,
+ syncId,
+ });
+ await this.afterWrite();
+ }
+
+ async recordMany(
+ userId: string,
+ entityType: SyncEntityType,
+ syncIds: string[],
+ ): Promise {
+ const rows = syncIds.filter(Boolean);
+ if (rows.length === 0) return;
+ await this.context.drizzle
+ .insert(syncTombstones)
+ .values(rows.map((syncId) => ({ userId, entityType, syncId })));
+ await this.afterWrite();
+ }
+
+ async listSince(
+ userId: string,
+ entityType: SyncEntityType,
+ since: string | null,
+ ): Promise {
+ const conditions = [
+ eq(syncTombstones.userId, userId),
+ eq(syncTombstones.entityType, entityType),
+ ];
+ if (since) conditions.push(gt(syncTombstones.deletedAt, since));
+
+ return this.context.drizzle
+ .select()
+ .from(syncTombstones)
+ .where(and(...conditions));
+ }
+
+ private async afterWrite(): Promise {
+ await this.onWrite?.();
+ }
+}
diff --git a/src/backend/database/repositories/vault-profile-repository.ts b/src/backend/database/repositories/vault-profile-repository.ts
index 319a7b4d..64ff3706 100644
--- a/src/backend/database/repositories/vault-profile-repository.ts
+++ b/src/backend/database/repositories/vault-profile-repository.ts
@@ -1,4 +1,5 @@
import { desc, eq, or } from "drizzle-orm";
+import { randomUUID } from "crypto";
import { vaultProfiles } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
@@ -47,6 +48,7 @@ export class VaultProfileRepository {
const [created] = await this.context.drizzle
.insert(vaultProfiles)
.values({
+ syncId: randomUUID(),
userId: input.userId,
name: input.name,
description: input.description,
@@ -98,17 +100,15 @@ export class VaultProfileRepository {
return updated ?? null;
}
- async deleteById(id: number): Promise {
+ async deleteById(id: number): Promise<{ syncId: string | null } | null> {
const rows = await this.context.drizzle
.delete(vaultProfiles)
.where(eq(vaultProfiles.id, id))
- .returning({ id: vaultProfiles.id });
+ .returning({ syncId: vaultProfiles.syncId });
- if (rows.length > 0) {
- await this.afterWrite();
- }
-
- return rows.length > 0;
+ if (rows.length === 0) return null;
+ await this.afterWrite();
+ return rows[0];
}
async deleteByUserId(userId: string): Promise {
diff --git a/src/backend/database/routes/acme-ssl-routes.ts b/src/backend/database/routes/acme-ssl-routes.ts
index 317a08a9..5f3fa681 100644
--- a/src/backend/database/routes/acme-ssl-routes.ts
+++ b/src/backend/database/routes/acme-ssl-routes.ts
@@ -28,7 +28,7 @@ export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
- challengeType: "http-webroot" | "dns-cloudflare";
+ challengeType: "http-webroot" | "dns-cloudflare" | "manual";
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
@@ -166,7 +166,7 @@ export function registerAcmeSSLRoutes(
* type: string
* challengeType:
* type: string
- * enum: [http-webroot, dns-cloudflare]
+ * enum: [http-webroot, dns-cloudflare, manual]
* cloudflareToken:
* type: string
* responses:
@@ -414,4 +414,159 @@ export function registerAcmeSSLRoutes(
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
+
+ /**
+ * @openapi
+ * /users/manual-ssl-upload:
+ * post:
+ * summary: Upload a manual/custom SSL certificate and key (admin only)
+ * description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
+ * tags:
+ * - Users
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * certificate:
+ * type: string
+ * privateKey:
+ * type: string
+ * responses:
+ * 200:
+ * description: Certificate uploaded and installed successfully.
+ * 400:
+ * description: Invalid or missing certificate/key.
+ * 403:
+ * description: Not authorized.
+ * 500:
+ * description: Certificate installation failed.
+ */
+ router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const actor = await getAdminActor(userId);
+ try {
+ if (!actor) {
+ return res.status(403).json({ error: "Not authorized" });
+ }
+
+ const { certificate, privateKey } = req.body;
+
+ if (
+ typeof certificate !== "string" ||
+ typeof privateKey !== "string" ||
+ !certificate.includes("BEGIN CERTIFICATE") ||
+ !privateKey.includes("PRIVATE KEY")
+ ) {
+ return res.status(400).json({
+ error: "A valid PEM certificate and private key are required",
+ });
+ }
+
+ await fs.mkdir(SSL_DIR, { recursive: true });
+
+ const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
+ const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
+
+ try {
+ await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
+ await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
+
+ try {
+ execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
+ stdio: "pipe",
+ });
+ execFileSync(
+ "openssl",
+ ["pkey", "-in", tmpKeyFile, "-noout", "-check"],
+ { stdio: "pipe" },
+ );
+ } catch {
+ return res.status(400).json({
+ error:
+ "The provided certificate or private key is not valid PEM data",
+ });
+ }
+
+ const certPubkey = execFileSync(
+ "openssl",
+ ["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
+ { stdio: "pipe" },
+ );
+ const keyPubkey = execFileSync(
+ "openssl",
+ ["pkey", "-in", tmpKeyFile, "-pubout"],
+ { stdio: "pipe" },
+ );
+
+ if (!certPubkey.equals(keyPubkey)) {
+ return res
+ .status(400)
+ .json({ error: "The certificate and private key do not match" });
+ }
+
+ const certDest = path.join(SSL_DIR, "termix.crt");
+ const keyDest = path.join(SSL_DIR, "termix.key");
+ await fs.rename(tmpCertFile, certDest);
+ await fs.rename(tmpKeyFile, keyDest);
+ await fs.chmod(keyDest, 0o600);
+ await fs.chmod(certDest, 0o644);
+ } finally {
+ await fs.rm(tmpCertFile, { force: true });
+ await fs.rm(tmpKeyFile, { force: true });
+ }
+
+ const settingsRepository = createCurrentSettingsRepository();
+ const existing = await settingsRepository.get("acme_ssl_settings");
+ const current = existing ? JSON.parse(existing) : {};
+ const updated = {
+ ...current,
+ challengeType: "manual",
+ lastIssuedAt: new Date().toISOString(),
+ };
+ await settingsRepository.set(
+ "acme_ssl_settings",
+ JSON.stringify(updated),
+ );
+
+ authLogger.info("Manual SSL certificate installed", {
+ operation: "manual_ssl_installed",
+ });
+
+ const { ipAddress, userAgent } = getRequestMeta(req);
+ await logAudit({
+ userId,
+ username: actor.username ?? userId,
+ action: "manual_ssl_upload",
+ resourceType: "setting",
+ details: JSON.stringify({ success: true }),
+ ipAddress,
+ userAgent,
+ success: true,
+ });
+
+ res.json({ success: true, ...(await getAcmeSettings()) });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : "Unknown error";
+ authLogger.error("Manual SSL certificate upload failed", err);
+
+ const { ipAddress, userAgent } = getRequestMeta(req);
+ await logAudit({
+ userId,
+ username: actor?.username ?? userId,
+ action: "manual_ssl_upload",
+ resourceType: "setting",
+ details: JSON.stringify({ error: message }),
+ ipAddress,
+ userAgent,
+ success: false,
+ });
+
+ res
+ .status(500)
+ .json({ error: `Certificate installation failed: ${message}` });
+ }
+ });
}
diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts
index a467426a..bd5d49b3 100644
--- a/src/backend/database/routes/credentials.ts
+++ b/src/backend/database/routes/credentials.ts
@@ -12,6 +12,7 @@ import {
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
+ createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -642,6 +643,13 @@ router.delete(
userId,
credentialId,
);
+ if (credentialToDelete.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "sshCredentials",
+ credentialToDelete.syncId,
+ );
+ }
// Shares stay in place; re-snapshot so recipients fall back to whatever
// auth the host still has (or lose the stale credential copy).
diff --git a/src/backend/database/routes/dashboard-service-links-routes.ts b/src/backend/database/routes/dashboard-service-links-routes.ts
index 34829356..9e0c2123 100644
--- a/src/backend/database/routes/dashboard-service-links-routes.ts
+++ b/src/backend/database/routes/dashboard-service-links-routes.ts
@@ -4,7 +4,10 @@ import { dashboardLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { isNonEmptyString } from "./host-normalizers.js";
import express from "express";
-import { createCurrentDashboardServiceLinkRepository } from "../repositories/factory.js";
+import {
+ createCurrentDashboardServiceLinkRepository,
+ createCurrentSyncTombstoneRepository,
+} from "../repositories/factory.js";
export const dashboardServiceLinksRouter = express.Router();
@@ -152,10 +155,18 @@ dashboardServiceLinksRouter.delete(
return res.status(404).json({ error: "Not found" });
}
- await createCurrentDashboardServiceLinkRepository().deleteForUser(
- userId,
- id,
- );
+ const deleted =
+ await createCurrentDashboardServiceLinkRepository().deleteForUser(
+ userId,
+ id,
+ );
+ if (deleted?.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "dashboardServiceLinks",
+ deleted.syncId,
+ );
+ }
DatabaseSaveTrigger.triggerSave("dashboard_service_link_deleted");
res.json({ message: "Service link deleted" });
diff --git a/src/backend/database/routes/desktop-auto-session.ts b/src/backend/database/routes/desktop-auto-session.ts
new file mode 100644
index 00000000..e6f54160
--- /dev/null
+++ b/src/backend/database/routes/desktop-auto-session.ts
@@ -0,0 +1,54 @@
+import type { Request } from "express";
+import type { UserRecord } from "../repositories/user-repository.js";
+
+export function isLoopbackRequest(req: Request): boolean {
+ const ip = req.ip || req.socket?.remoteAddress || "";
+ return (
+ ip === "127.0.0.1" ||
+ ip === "::1" ||
+ ip === "::ffff:127.0.0.1" ||
+ ip.endsWith(":127.0.0.1")
+ );
+}
+
+export function extractBearerOrCookieToken(req: Request): string | undefined {
+ const cookieToken = (req as Request & { cookies?: Record })
+ .cookies?.jwt;
+ if (cookieToken) return cookieToken;
+
+ const authHeader = req.headers["authorization"];
+ if (authHeader?.startsWith("Bearer ")) {
+ return authHeader.slice("Bearer ".length);
+ }
+ return undefined;
+}
+
+/**
+ * Decides who the desktop auto-session endpoint should silently log in as.
+ *
+ * The local embedded backend's trust boundary is machine access (loopback),
+ * not any individual account's credentials -- anyone who can reach loopback
+ * already has full filesystem access to the local, encrypted-at-rest
+ * database and its keys. A login form must never appear for the local
+ * backend, under any circumstance, including a local database that ended
+ * up with more than one user (e.g. from repeated manual registration
+ * during testing, or a household sharing one install) -- a user in that
+ * state deserves to get into the app they installed, not a confusing,
+ * unexplained dead end. So this always returns a single, deterministic
+ * user: the admin account if one exists, else the earliest-registered
+ * account. It never returns null.
+ */
+export function resolveDesktopAutoSessionUser(
+ allUsers: UserRecord[],
+): UserRecord | null {
+ if (allUsers.length === 0) return null;
+ if (allUsers.length === 1) return allUsers[0];
+
+ const admin = allUsers.find((user) => user.isAdmin);
+ if (admin) return admin;
+
+ return [...allUsers].sort(
+ (a, b) =>
+ new Date(a.registeredAt).getTime() - new Date(b.registeredAt).getTime(),
+ )[0];
+}
diff --git a/src/backend/database/routes/homepage-items-routes.ts b/src/backend/database/routes/homepage-items-routes.ts
index 5ea49791..e953e63a 100644
--- a/src/backend/database/routes/homepage-items-routes.ts
+++ b/src/backend/database/routes/homepage-items-routes.ts
@@ -1,7 +1,10 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, Response } from "express";
import { homepageLogger } from "../../utils/logger.js";
-import { createCurrentHomepageItemRepository } from "../repositories/factory.js";
+import {
+ createCurrentHomepageItemRepository,
+ createCurrentSyncTombstoneRepository,
+} from "../repositories/factory.js";
import express from "express";
export const homepageItemsRouter = express.Router();
@@ -184,7 +187,14 @@ homepageItemsRouter.delete("/:id", async (req: Request, res: Response) => {
return res.status(404).json({ error: "Not found" });
}
- await itemRepository.deleteForUser(userId, id);
+ const deleted = await itemRepository.deleteForUser(userId, id);
+ if (deleted?.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "homepageItems",
+ deleted.syncId,
+ );
+ }
res.json({ message: "Homepage item deleted" });
} catch (err) {
homepageLogger.error("Failed to delete homepage item", err);
diff --git a/src/backend/database/routes/host-folder-routes.ts b/src/backend/database/routes/host-folder-routes.ts
index dfde1491..933fda99 100644
--- a/src/backend/database/routes/host-folder-routes.ts
+++ b/src/backend/database/routes/host-folder-routes.ts
@@ -3,6 +3,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import { databaseLogger, sshLogger } from "../../utils/logger.js";
import {
createCurrentCommandHistoryRepository,
+ createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentHostFolderRepository,
createCurrentRecentActivityRepository,
@@ -10,6 +11,7 @@ import {
createCurrentSshCredentialUsageRepository,
createCurrentSessionRecordingRepository,
createCurrentTransferRecentRepository,
+ createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import { isNonEmptyString } from "./host-normalizers.js";
@@ -138,7 +140,7 @@ export function registerHostFolderRoutes(
* /host/folders/metadata:
* put:
* summary: Update folder metadata
- * description: Updates the metadata (color, icon) of a folder.
+ * description: Updates the metadata (color, icon, assigned credential) of a folder.
* tags:
* - SSH
* requestBody:
@@ -154,6 +156,9 @@ export function registerHostFolderRoutes(
* type: string
* icon:
* type: string
+ * credentialId:
+ * type: integer
+ * nullable: true
* responses:
* 200:
* description: Folder metadata updated successfully.
@@ -167,19 +172,46 @@ export function registerHostFolderRoutes(
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
- const { name, color, icon } = req.body;
+ const { name, color, icon, credentialId } = req.body;
if (!isNonEmptyString(userId) || !name) {
return res.status(400).json({ error: "Folder name is required" });
}
+ const normalizedCredentialId =
+ credentialId === undefined
+ ? undefined
+ : credentialId === null || credentialId === ""
+ ? null
+ : Number(credentialId);
+
+ if (
+ normalizedCredentialId !== undefined &&
+ normalizedCredentialId !== null &&
+ !Number.isInteger(normalizedCredentialId)
+ ) {
+ return res.status(400).json({ error: "Invalid credential ID" });
+ }
+
try {
+ if (normalizedCredentialId) {
+ const credential =
+ await createCurrentCredentialRepository().findByIdForUser(
+ userId,
+ normalizedCredentialId,
+ );
+ if (!credential) {
+ return res.status(404).json({ error: "Credential not found" });
+ }
+ }
+
const { folder, created } =
await createCurrentHostFolderRepository().upsertMetadata(
userId,
name,
color,
icon,
+ normalizedCredentialId,
);
if (!created) {
@@ -287,9 +319,17 @@ export function registerHostFolderRoutes(
);
}
- await hostFolderRepository.deleteHostsAndFolderRecords(
+ const { hostSyncIds, folderSyncIds } =
+ await hostFolderRepository.deleteHostsAndFolderRecords(
+ userId,
+ folderName,
+ );
+ const tombstoneRepository = createCurrentSyncTombstoneRepository();
+ await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
+ await tombstoneRepository.recordMany(
userId,
- folderName,
+ "sshFolders",
+ folderSyncIds,
);
try {
diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts
index fd406e54..9c17ac07 100644
--- a/src/backend/database/routes/host-normalizers.ts
+++ b/src/backend/database/routes/host-normalizers.ts
@@ -225,6 +225,9 @@ export function stripSensitiveFields(
result.hasKeyPassword = !!host.keyPassword;
result.hasPassword = !!host.password;
result.hasSudoPassword = !!host.sudoPassword;
+ result.hasRdpPassword = !!host.rdpPassword;
+ result.hasVncPassword = !!host.vncPassword;
+ result.hasTelnetPassword = !!host.telnetPassword;
for (const field of SENSITIVE_FIELDS) {
delete result[field];
}
diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts
index e4a80ddd..c216c3f1 100644
--- a/src/backend/database/routes/host.ts
+++ b/src/backend/database/routes/host.ts
@@ -14,6 +14,7 @@ import {
} from "../../hosts/credential-username.js";
import {
createCurrentCommandHistoryRepository,
+ createCurrentCredentialRepository,
createCurrentFileManagerBookmarkRepository,
createCurrentOpksshTokenRepository,
createCurrentRecentActivityRepository,
@@ -25,6 +26,7 @@ import {
createCurrentHostResolutionRepository,
createCurrentHostRepository,
createCurrentUserRepository,
+ createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import {
isNonEmptyString,
@@ -174,6 +176,7 @@ router.post(
enableDocker,
enableProxmox,
enableTmuxMonitor,
+ allowSessionSharing,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
@@ -199,6 +202,7 @@ router.post(
socks5Username,
socks5Password,
socks5ProxyChain,
+ connectionOrigin,
portKnockSequence,
overrideCredentialUsername,
macAddress,
@@ -287,6 +291,7 @@ router.post(
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
+ allowSessionSharing: allowSessionSharing === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -328,6 +333,10 @@ router.post(
socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
+ connectionOrigin:
+ connectionOrigin === "local" || connectionOrigin === "remote"
+ ? connectionOrigin
+ : null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
@@ -814,6 +823,7 @@ router.put(
enableDocker,
enableProxmox,
enableTmuxMonitor,
+ allowSessionSharing,
showTerminalInSidebar,
showFileManagerInSidebar,
showTunnelInSidebar,
@@ -839,6 +849,7 @@ router.put(
socks5Username,
socks5Password,
socks5ProxyChain,
+ connectionOrigin,
portKnockSequence,
overrideCredentialUsername,
macAddress,
@@ -924,6 +935,7 @@ router.put(
enableDocker: enableDocker ? 1 : 0,
enableProxmox: enableProxmox ? 1 : 0,
enableTmuxMonitor: enableTmuxMonitor ? 1 : 0,
+ allowSessionSharing: allowSessionSharing === false ? 0 : 1,
showTerminalInSidebar: showTerminalInSidebar ? 1 : 0,
showFileManagerInSidebar: showFileManagerInSidebar ? 1 : 0,
showTunnelInSidebar: showTunnelInSidebar ? 1 : 0,
@@ -965,6 +977,10 @@ router.put(
socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
+ connectionOrigin:
+ connectionOrigin === "local" || connectionOrigin === "remote"
+ ? connectionOrigin
+ : null,
macAddress: macAddress || null,
wolBroadcastAddress: wolBroadcastAddress || null,
portKnockSequence: portKnockSequence
@@ -1482,7 +1498,7 @@ router.get(
* name: field
* schema:
* type: string
- * enum: [password, sudoPassword, vncPassword]
+ * enum: [password, sudoPassword, rdpPassword, vncPassword, telnetPassword, key, keyPassword]
* responses:
* 200:
* description: The requested password value.
@@ -1498,7 +1514,17 @@ router.get(
const userId = (req as AuthenticatedRequest).userId;
const field = (req.query.field as string) || "password";
- if (!["password", "sudoPassword", "vncPassword"].includes(field)) {
+ if (
+ ![
+ "password",
+ "sudoPassword",
+ "rdpPassword",
+ "vncPassword",
+ "telnetPassword",
+ "key",
+ "keyPassword",
+ ].includes(field)
+ ) {
return res.status(400).json({ error: "Invalid field" });
}
@@ -1726,9 +1752,16 @@ router.get(
* /host/db/hosts/export:
* get:
* summary: Export all SSH hosts
- * description: Exports all SSH hosts for the current user with decrypted credentials.
+ * description: Exports all SSH hosts for the current user. By default credentials are decrypted and embedded. With `share=1`, secrets are omitted and credential-authenticated hosts instead reference a scrubbed `credentials` array by alias, suitable for handing off to another user.
* tags:
* - SSH
+ * parameters:
+ * - in: query
+ * name: share
+ * required: false
+ * schema:
+ * type: string
+ * description: Set to "1" to export without embedded secrets.
* responses:
* 200:
* description: All exported SSH hosts.
@@ -1743,6 +1776,7 @@ router.get(
requireDataAccess,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
+ const shareMode = req.query.share === "1" || req.query.share === "true";
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
@@ -1753,10 +1787,12 @@ router.get(
await createCurrentHostResolutionRepository().findHostsByUserId(userId);
const exportedHosts = [];
+ const usedCredentialIds = new Set();
for (const host of allHosts) {
- const resolvedHost =
- (await resolveHostCredentials(host, userId)) || host;
+ const resolvedHost = shareMode
+ ? host
+ : (await resolveHostCredentials(host, userId)) || host;
const exportedConnectionType =
(resolvedHost.connectionType as string) || "ssh";
@@ -1770,7 +1806,7 @@ router.get(
ip: resolvedHost.ip,
port: resolvedHost.port,
username: resolvedHost.username,
- password: resolvedHost.password || null,
+ password: shareMode ? null : resolvedHost.password || null,
folder: resolvedHost.folder,
tags:
typeof resolvedHost.tags === "string"
@@ -1793,8 +1829,8 @@ router.get(
: {
...baseExportData,
authType: resolvedHost.authType,
- key: resolvedHost.key || null,
- keyPassword: resolvedHost.keyPassword || null,
+ key: shareMode ? null : resolvedHost.key || null,
+ keyPassword: shareMode ? null : resolvedHost.keyPassword || null,
keyType: resolvedHost.keyType || null,
credentialId: resolvedHost.credentialId || null,
overrideCredentialUsername:
@@ -1811,7 +1847,9 @@ router.get(
showDockerInSidebar: !!resolvedHost.showDockerInSidebar,
showServerStatsInSidebar: !!resolvedHost.showServerStatsInSidebar,
defaultPath: resolvedHost.defaultPath,
- sudoPassword: resolvedHost.sudoPassword || null,
+ sudoPassword: shareMode
+ ? null
+ : resolvedHost.sudoPassword || null,
tunnelConnections: resolvedHost.tunnelConnections
? JSON.parse(resolvedHost.tunnelConnections as string)
: [],
@@ -1839,22 +1877,92 @@ router.get(
socks5Host: resolvedHost.socks5Host || null,
socks5Port: resolvedHost.socks5Port || null,
socks5Username: resolvedHost.socks5Username || null,
- socks5Password: resolvedHost.socks5Password || null,
+ socks5Password: shareMode
+ ? null
+ : resolvedHost.socks5Password || null,
socks5ProxyChain: resolvedHost.socks5ProxyChain
? JSON.parse(resolvedHost.socks5ProxyChain as string)
: null,
};
+ if (
+ shareMode &&
+ !isRemoteDesktop &&
+ resolvedHost.authType === "credential" &&
+ resolvedHost.credentialId
+ ) {
+ usedCredentialIds.add(resolvedHost.credentialId as number);
+ }
+
exportedHosts.push(exportData);
}
- sshLogger.success("All hosts exported with decrypted credentials", {
- operation: "hosts_export_all",
+ if (!shareMode) {
+ sshLogger.success("All hosts exported with decrypted credentials", {
+ operation: "hosts_export_all",
+ count: exportedHosts.length,
+ userId,
+ });
+
+ return res.json({ hosts: exportedHosts });
+ }
+
+ const exportedCredentials: Record[] = [];
+ if (usedCredentialIds.size > 0) {
+ const credentialRepository = createCurrentCredentialRepository();
+ const ownedCredentials =
+ await credentialRepository.listDecryptedByUserId(userId);
+ const credentialById = new Map(
+ ownedCredentials.map((credential) => [credential.id, credential]),
+ );
+
+ for (const host of exportedHosts as Record[]) {
+ const credentialId = host.credentialId as number | null;
+ if (!credentialId) continue;
+ const credential = credentialById.get(credentialId);
+ if (!credential) continue;
+
+ host.credentialAlias = credential.name;
+
+ if (
+ !exportedCredentials.some(
+ (entry) => entry.alias === credential.name,
+ )
+ ) {
+ exportedCredentials.push({
+ alias: credential.name,
+ name: credential.name,
+ description: credential.description || null,
+ folder: credential.folder || null,
+ tags:
+ typeof credential.tags === "string"
+ ? credential.tags.split(",").filter(Boolean)
+ : [],
+ authType: credential.authType,
+ username: credential.username || null,
+ keyType: credential.keyType || null,
+ });
+ }
+ }
+ }
+
+ for (const host of exportedHosts as Record[]) {
+ delete host.credentialId;
+ }
+
+ sshLogger.success("All hosts exported for sharing without secrets", {
+ operation: "hosts_export_all_share",
count: exportedHosts.length,
+ credentialCount: exportedCredentials.length,
userId,
});
- res.json({ hosts: exportedHosts });
+ res.json({
+ version: "1",
+ exportedAt: new Date().toISOString(),
+ credentials: exportedCredentials,
+ hosts: exportedHosts,
+ });
} catch (err) {
sshLogger.error("Failed to export all SSH hosts", err, {
operation: "hosts_export_all",
@@ -1959,6 +2067,13 @@ router.delete(
);
await createCurrentHostRepository().deleteForUser(userId, numericHostId);
+ if (hostToDelete.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "hosts",
+ hostToDelete.syncId,
+ );
+ }
databaseLogger.success("SSH host deleted", {
operation: "host_delete_success",
diff --git a/src/backend/database/routes/keybinding-validation.ts b/src/backend/database/routes/keybinding-validation.ts
new file mode 100644
index 00000000..1890c328
--- /dev/null
+++ b/src/backend/database/routes/keybinding-validation.ts
@@ -0,0 +1,49 @@
+const VALID_ACTION_TYPES = [
+ "copy",
+ "paste",
+ "sendControlCode",
+ "sendText",
+ "runSnippet",
+];
+
+export function isValidKeyCombo(combo: unknown): boolean {
+ return (
+ !!combo &&
+ typeof combo === "object" &&
+ typeof (combo as { key?: unknown }).key === "string" &&
+ typeof (combo as { isCode?: unknown }).isCode === "boolean" &&
+ typeof (combo as { ctrl?: unknown }).ctrl === "boolean" &&
+ typeof (combo as { alt?: unknown }).alt === "boolean" &&
+ typeof (combo as { shift?: unknown }).shift === "boolean" &&
+ typeof (combo as { meta?: unknown }).meta === "boolean"
+ );
+}
+
+export function isValidKeybindingAction(action: unknown): boolean {
+ if (!action || typeof action !== "object") return false;
+ const type = (action as { type?: unknown }).type;
+ if (typeof type !== "string" || !VALID_ACTION_TYPES.includes(type))
+ return false;
+ if (type === "sendText") {
+ return typeof (action as { text?: unknown }).text === "string";
+ }
+ if (type === "sendControlCode") {
+ const code = (action as { controlCode?: unknown }).controlCode;
+ return typeof code === "string" && /^[a-zA-Z]$/.test(code);
+ }
+ if (type === "runSnippet") {
+ return typeof (action as { snippetId?: unknown }).snippetId === "string";
+ }
+ return true;
+}
+
+export function isValidKeybinding(entry: unknown): boolean {
+ return (
+ !!entry &&
+ typeof entry === "object" &&
+ typeof (entry as { id?: unknown }).id === "string" &&
+ typeof (entry as { enabled?: unknown }).enabled === "boolean" &&
+ isValidKeyCombo((entry as { combo?: unknown }).combo) &&
+ isValidKeybindingAction((entry as { action?: unknown }).action)
+ );
+}
diff --git a/src/backend/database/routes/open-tabs.ts b/src/backend/database/routes/open-tabs.ts
index 6ea7c22e..0247a47a 100644
--- a/src/backend/database/routes/open-tabs.ts
+++ b/src/backend/database/routes/open-tabs.ts
@@ -7,6 +7,7 @@ import { sessionManager } from "../../hosts/terminal/session-manager.js";
import {
getCurrentSettingValue,
createCurrentOpenTabRepository,
+ createCurrentSessionShareRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -277,12 +278,15 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* /open-tabs/active-sessions:
* get:
* summary: Get all active backend sessions for the current user
- * description: Returns live terminal sessions from the session manager. Used by the Active Connections panel and tab restore logic.
+ * description: >
+ * Returns live terminal sessions from the session manager, both sessions the
+ * caller owns and SSH sessions shared to the caller by another user (via
+ * an in-app session share). Used by the Active Connections panel and tab restore logic.
* tags:
* - Open Tabs
* responses:
* 200:
- * description: List of active sessions.
+ * description: List of active sessions (own and shared-with-me).
* content:
* application/json:
* schema:
@@ -302,6 +306,17 @@ router.delete("/:id", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* createdAt:
* type: number
+ * isOwnSession:
+ * type: boolean
+ * sharedByUsername:
+ * type: string
+ * nullable: true
+ * permissionLevel:
+ * type: string
+ * nullable: true
+ * shareId:
+ * type: string
+ * nullable: true
*/
router.get(
"/active-sessions",
@@ -309,17 +324,46 @@ router.get(
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
try {
- const sessions = sessionManager.getUserSessions(userId);
- return res.json(
- sessions.map((s) => ({
- sessionId: s.id,
- hostId: s.hostId,
- hostName: s.hostName,
- tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
- isConnected: s.isConnected,
- createdAt: s.createdAt,
- })),
- );
+ const ownSessions = sessionManager.getUserSessions(userId);
+ const result = ownSessions.map((s) => ({
+ sessionId: s.id,
+ hostId: s.hostId,
+ hostName: s.hostName,
+ tabInstanceId: s.attachedTabInstanceId ?? s.tabInstanceId ?? null,
+ isConnected: s.isConnected,
+ createdAt: s.createdAt,
+ isOwnSession: true,
+ sharedByUsername: null as string | null,
+ permissionLevel: null as string | null,
+ shareId: null as string | null,
+ }));
+
+ const sharedWithMe =
+ await createCurrentSessionShareRepository().findSharesTargetingUser(
+ userId,
+ );
+ for (const share of sharedWithMe) {
+ if (share.protocol !== "ssh") continue;
+ const sharedSession = sessionManager.getSession(share.sessionId);
+ if (!sharedSession || !sharedSession.isConnected) continue;
+ result.push({
+ sessionId: sharedSession.id,
+ hostId: sharedSession.hostId,
+ hostName: sharedSession.hostName,
+ tabInstanceId:
+ sharedSession.attachedTabInstanceId ??
+ sharedSession.tabInstanceId ??
+ null,
+ isConnected: sharedSession.isConnected,
+ createdAt: sharedSession.createdAt,
+ isOwnSession: false,
+ sharedByUsername: share.ownerUsername,
+ permissionLevel: share.permissionLevel,
+ shareId: share.id,
+ });
+ }
+
+ return res.json(result);
} catch (e) {
databaseLogger.error("Failed to get active sessions", e, {
operation: "get_active_sessions",
diff --git a/src/backend/database/routes/proxmox.ts b/src/backend/database/routes/proxmox.ts
index c3a4b8fa..553c1b20 100644
--- a/src/backend/database/routes/proxmox.ts
+++ b/src/backend/database/routes/proxmox.ts
@@ -488,16 +488,51 @@ async function discoverProxmoxGuestsForHost(
async function resolveIp(g: GuestBase): Promise {
if (g.type === "lxc") {
+ let configIp: string | null = null;
try {
const cfgJson = await execCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
8000,
);
- return parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
+ configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
} catch {
- return null;
+ configIp = null;
}
+ if (configIp) return configIp;
+ // Static config parsing found nothing (e.g. net0 uses ip=dhcp).
+ // Fall back to the live interface list for running containers.
+ if (g.status === "running") {
+ try {
+ const ifRaw = await execCommand(
+ client,
+ `pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
+ 5000,
+ );
+ const data = JSON.parse(ifRaw);
+ const entries: Array> = Array.isArray(data)
+ ? data
+ : [];
+ const allIps: string[] = [];
+ for (const entry of entries) {
+ if (entry.name === "lo") continue;
+ const inet = entry.inet;
+ if (typeof inet !== "string") continue;
+ const m = inet.match(/^(\d{1,3}(?:\.\d{1,3}){3})\/\d+$/);
+ if (m && !m[1].startsWith("127.")) allIps.push(m[1]);
+ }
+ if (allIps.length) {
+ for (const prefix of config.preferredPrefixes) {
+ const match = allIps.find((ip) => ip.startsWith(prefix));
+ if (match) return match;
+ }
+ return allIps[0];
+ }
+ } catch {
+ // Guest not running or interfaces unavailable
+ }
+ }
+ return null;
}
if (g.type === "qemu" && g.status === "running") {
try {
diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts
index 06fbb12e..8be290c4 100644
--- a/src/backend/database/routes/rbac.ts
+++ b/src/backend/database/routes/rbac.ts
@@ -14,6 +14,7 @@ import {
} from "../../utils/permission-catalog.js";
import {
createCurrentCredentialRepository,
+ createCurrentHostFolderRepository,
createCurrentHostResolutionRepository,
createCurrentRbacAccessRepository,
createCurrentRoleRepository,
@@ -311,6 +312,225 @@ router.post(
},
);
+/**
+ * @openapi
+ * /rbac/folder/share:
+ * post:
+ * summary: Share all hosts in a folder
+ * description: Shares every host within a folder (and its subfolders) with one or more users and/or roles at a permission level. Only hosts owned by the caller are shared; skips hosts the caller may not share.
+ * tags:
+ * - RBAC
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required: [folder, targets]
+ * properties:
+ * folder:
+ * type: string
+ * targets:
+ * type: array
+ * items:
+ * type: object
+ * properties:
+ * type:
+ * type: string
+ * enum: [user, role]
+ * id:
+ * oneOf:
+ * - type: string
+ * - type: integer
+ * permissionLevel:
+ * type: string
+ * enum: [connect, view, edit, manage]
+ * durationHours:
+ * type: number
+ * responses:
+ * 200:
+ * description: Folder shared successfully.
+ * 400:
+ * description: Invalid request body.
+ * 404:
+ * description: Folder has no hosts.
+ * 500:
+ * description: Failed to share folder.
+ */
+router.post(
+ "/folder/share",
+ authenticateJWT,
+ async (req: AuthenticatedRequest, res: Response) => {
+ const userId = req.userId!;
+ const { folder } = req.body ?? {};
+
+ if (!isNonEmptyString(folder)) {
+ return res.status(400).json({ error: "Folder name is required" });
+ }
+
+ try {
+ const targets = parseShareTargets(req.body ?? {});
+ if (!targets) {
+ return res.status(400).json({
+ error:
+ "targets must be a non-empty array of { type: 'user'|'role', id } entries",
+ });
+ }
+
+ const { durationHours, permissionLevel = "connect" } = req.body;
+
+ if (!isSharePermissionLevel(permissionLevel)) {
+ return res.status(400).json({
+ error: "Invalid permission level",
+ validLevels: SHARE_PERMISSION_LEVELS,
+ });
+ }
+
+ const userRepository = createCurrentUserRepository();
+ const roleRepository = createCurrentRoleRepository();
+ for (const target of targets) {
+ if (target.type === "user") {
+ const targetUser = await userRepository.findById(target.id as string);
+ if (!targetUser) {
+ return res.status(404).json({
+ error: "Target user not found",
+ targetId: target.id,
+ });
+ }
+ } else {
+ const targetRole = await roleRepository.findRoleById(
+ target.id as number,
+ );
+ if (!targetRole) {
+ return res.status(404).json({
+ error: "Target role not found",
+ targetId: target.id,
+ });
+ }
+ }
+ }
+
+ const hostsInFolder =
+ await createCurrentHostFolderRepository().listHostsInFolder(
+ userId,
+ folder,
+ );
+ if (hostsInFolder.length === 0) {
+ return res.status(404).json({ error: "Folder has no hosts" });
+ }
+
+ const expiresAt = expiryFromDuration(durationHours);
+ const rbacAccessRepository = createCurrentRbacAccessRepository();
+ const { SharedHostSecretsManager } =
+ await import("../../utils/shared-host-secrets-manager.js");
+ const secretsManager = SharedHostSecretsManager.getInstance();
+
+ const hostResults: Array<{
+ hostId: number;
+ shared: boolean;
+ reason?: string;
+ }> = [];
+
+ for (const host of hostsInFolder) {
+ if (targets.some((t) => t.type === "user" && t.id === host.userId)) {
+ hostResults.push({
+ hostId: host.id,
+ shared: false,
+ reason: "owner",
+ });
+ continue;
+ }
+
+ const sharing = await canManageHostSharing(userId, host.id);
+ if (!sharing.allowed) {
+ hostResults.push({
+ hostId: host.id,
+ shared: false,
+ reason: "forbidden",
+ });
+ continue;
+ }
+
+ for (const target of targets) {
+ const accessGrant = await rbacAccessRepository.upsertHostAccess({
+ hostId: host.id,
+ grantedBy: userId,
+ permissionLevel,
+ expiresAt,
+ ...(target.type === "user"
+ ? {
+ targetType: "user" as const,
+ targetUserId: target.id as string,
+ }
+ : {
+ targetType: "role" as const,
+ targetRoleId: target.id as number,
+ }),
+ });
+
+ try {
+ if (target.type === "user") {
+ await secretsManager.snapshotForUser(
+ accessGrant.id,
+ host.id,
+ target.id as string,
+ host.userId,
+ );
+ } else {
+ await secretsManager.snapshotForRole(
+ accessGrant.id,
+ host.id,
+ target.id as number,
+ host.userId,
+ );
+ }
+ } catch (snapshotError) {
+ databaseLogger.warn("Share created but secret snapshot failed", {
+ operation: "rbac_folder_share_snapshot_failed",
+ hostId: host.id,
+ accessId: accessGrant.id,
+ error:
+ snapshotError instanceof Error
+ ? snapshotError.message
+ : "Unknown error",
+ });
+ }
+ }
+
+ hostResults.push({ hostId: host.id, shared: true });
+ }
+
+ const sharedCount = hostResults.filter((r) => r.shared).length;
+
+ databaseLogger.success("Folder shared successfully", {
+ operation: "rbac_folder_share_success",
+ userId,
+ folder,
+ hostsShared: sharedCount,
+ targets: targets.length,
+ permissionLevel,
+ });
+
+ res.json({
+ success: true,
+ message: "Folder shared successfully",
+ permissionLevel,
+ expiresAt,
+ hostsShared: sharedCount,
+ hostsTotal: hostsInFolder.length,
+ hostResults,
+ });
+ } catch (error) {
+ databaseLogger.error("Failed to share folder", error, {
+ operation: "share_folder",
+ folder,
+ userId,
+ });
+ res.status(500).json({ error: "Failed to share folder" });
+ }
+ },
+);
+
/**
* @openapi
* /rbac/host/{id}/access/{accessId}:
diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts
index 2fbc4068..0680ebc7 100644
--- a/src/backend/database/routes/snippets.ts
+++ b/src/backend/database/routes/snippets.ts
@@ -12,6 +12,7 @@ import {
createCurrentRoleRepository,
createCurrentSnippetRepository,
createCurrentUserRepository,
+ createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
const router = express.Router();
@@ -400,7 +401,17 @@ router.delete(
try {
const folderName = decodeURIComponent(name);
- await createCurrentSnippetRepository().deleteFolder(userId, folderName);
+ const deletedFolder = await createCurrentSnippetRepository().deleteFolder(
+ userId,
+ folderName,
+ );
+ if (deletedFolder?.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "snippetFolders",
+ deletedFolder.syncId,
+ );
+ }
authLogger.success(
`Snippet folder deleted: ${folderName} by user ${userId}`,
@@ -1241,6 +1252,14 @@ router.delete(
return res.status(404).json({ error: "Snippet not found" });
}
+ if (existing.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "snippets",
+ existing.syncId,
+ );
+ }
+
databaseLogger.info("Command snippet deleted", {
operation: "snippet_delete",
userId,
diff --git a/src/backend/database/routes/sync.ts b/src/backend/database/routes/sync.ts
new file mode 100644
index 00000000..3f1198c4
--- /dev/null
+++ b/src/backend/database/routes/sync.ts
@@ -0,0 +1,415 @@
+import type { Request, Response } from "express";
+import express from "express";
+import { and, eq, gt } from "drizzle-orm";
+import {
+ hosts,
+ sshCredentials,
+ sshFolders,
+ snippets,
+ snippetFolders,
+ vaultProfiles,
+ dashboardServiceLinks,
+ homepageItems,
+} from "../db/schema.js";
+import { AuthManager } from "../../utils/auth-manager.js";
+import { DataCrypto } from "../../utils/data-crypto.js";
+import { databaseLogger } from "../../utils/logger.js";
+import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
+import type { AuthenticatedRequest } from "../../../types/index.js";
+import {
+ createCurrentRepositoryContext,
+ createCurrentSyncTombstoneRepository,
+} from "../repositories/factory.js";
+import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
+
+const router = express.Router();
+const authManager = AuthManager.getInstance();
+const authenticateJWT = authManager.createAuthMiddleware();
+
+// Encrypted tables need DataCrypto to translate between the wire payload
+// (plaintext) and the stored row (encrypted). Everything else is stored
+// and synced as-is.
+const ENCRYPTED_ENTITY_TABLES: Partial> = {
+ hosts: "ssh_data",
+ sshCredentials: "ssh_credentials",
+};
+
+interface EntityConfig {
+ table:
+ | typeof hosts
+ | typeof sshCredentials
+ | typeof sshFolders
+ | typeof snippets
+ | typeof snippetFolders
+ | typeof vaultProfiles
+ | typeof dashboardServiceLinks
+ | typeof homepageItems;
+ // Fields that only make sense on the device that created the row, or
+ // that are managed elsewhere and must never be overwritten by a sync
+ // payload from the other side.
+ readOnlyFields: string[];
+}
+
+const ENTITY_CONFIG: Record = {
+ hosts: {
+ table: hosts,
+ readOnlyFields: ["connectionOrigin"],
+ },
+ sshCredentials: { table: sshCredentials, readOnlyFields: [] },
+ sshFolders: { table: sshFolders, readOnlyFields: [] },
+ snippets: { table: snippets, readOnlyFields: [] },
+ snippetFolders: { table: snippetFolders, readOnlyFields: [] },
+ vaultProfiles: { table: vaultProfiles, readOnlyFields: [] },
+ dashboardServiceLinks: { table: dashboardServiceLinks, readOnlyFields: [] },
+ homepageItems: { table: homepageItems, readOnlyFields: [] },
+};
+
+const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
+
+export function isValidEntityType(value: unknown): value is SyncEntityType {
+ return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
+}
+
+function requireUserDataKey(userId: string): Buffer {
+ return DataCrypto.validateUserAccess(userId);
+}
+
+function decryptIfNeeded(
+ entityType: SyncEntityType,
+ row: Record,
+ userId: string,
+): Record {
+ const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
+ if (!tableName) return row;
+ const userDataKey = DataCrypto.getUserDataKey(userId);
+ if (!userDataKey) return row;
+ return DataCrypto.decryptRecord(
+ tableName,
+ row,
+ userId,
+ userDataKey,
+ ) as Record;
+}
+
+function encryptIfNeeded(
+ entityType: SyncEntityType,
+ row: Record,
+ userId: string,
+): Record {
+ const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
+ if (!tableName) return row;
+ const userDataKey = requireUserDataKey(userId);
+ return DataCrypto.encryptRecord(
+ tableName,
+ row,
+ userId,
+ userDataKey,
+ ) as Record;
+}
+
+export function stripWritePayload(
+ entityType: SyncEntityType,
+ payload: Record,
+): Record {
+ const { readOnlyFields } = ENTITY_CONFIG[entityType];
+ const clean = { ...payload };
+ delete clean.id;
+ delete clean.userId;
+ delete clean.syncId;
+ for (const field of readOnlyFields) delete clean[field];
+ return clean;
+}
+
+/**
+ * @openapi
+ * /sync/{entityType}:
+ * get:
+ * summary: Pull synced rows for an entity type
+ * description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
+ * tags:
+ * - Sync
+ * parameters:
+ * - in: path
+ * name: entityType
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: since
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Rows updated since the given timestamp.
+ * 400:
+ * description: Unknown entity type.
+ * 500:
+ * description: Failed to fetch rows.
+ */
+router.get(
+ "/:entityType",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const entityType = req.params.entityType;
+ if (!isValidEntityType(entityType)) {
+ return res.status(400).json({ error: "Unknown entity type" });
+ }
+ const since =
+ typeof req.query.since === "string" && req.query.since
+ ? req.query.since
+ : null;
+
+ try {
+ const { table } = ENTITY_CONFIG[entityType];
+ const context = createCurrentRepositoryContext();
+ const conditions = [eq(table.userId, userId)];
+ if (since && "updatedAt" in table) {
+ conditions.push(gt((table as typeof hosts).updatedAt, since));
+ }
+
+ const rows = await context.drizzle
+ .select()
+ .from(table as typeof hosts)
+ .where(and(...conditions));
+
+ const decrypted = rows.map((row) =>
+ decryptIfNeeded(entityType, row as Record, userId),
+ );
+
+ res.json({ rows: decrypted });
+ } catch (err) {
+ databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
+ operation: "sync_pull",
+ entityType,
+ userId,
+ });
+ res.status(500).json({ error: "Failed to fetch rows" });
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /sync/{entityType}:
+ * post:
+ * summary: Upsert a synced row by syncId
+ * description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
+ * tags:
+ * - Sync
+ * parameters:
+ * - in: path
+ * name: entityType
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Row upserted.
+ * 400:
+ * description: Unknown entity type or missing syncId.
+ * 500:
+ * description: Failed to upsert row.
+ */
+router.post(
+ "/:entityType",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const entityType = req.params.entityType;
+ if (!isValidEntityType(entityType)) {
+ return res.status(400).json({ error: "Unknown entity type" });
+ }
+ const payload = req.body?.row;
+ const syncId = payload?.syncId;
+ if (!payload || typeof syncId !== "string" || !syncId) {
+ return res.status(400).json({ error: "Missing row.syncId" });
+ }
+
+ try {
+ const { table } = ENTITY_CONFIG[entityType];
+ const context = createCurrentRepositoryContext();
+
+ const existingRows = await context.drizzle
+ .select()
+ .from(table as typeof hosts)
+ .where(
+ and(
+ eq((table as typeof hosts).syncId, syncId),
+ eq(table.userId, userId),
+ ),
+ )
+ .limit(1);
+ const existing = existingRows[0] as Record | undefined;
+
+ const writePayload = stripWritePayload(entityType, payload);
+ const encryptedPayload = encryptIfNeeded(
+ entityType,
+ writePayload,
+ userId,
+ );
+
+ let resultRow: Record;
+ if (existing) {
+ const updatedRows = await context.drizzle
+ .update(table as typeof hosts)
+ .set(encryptedPayload)
+ .where(
+ and(
+ eq((table as typeof hosts).id, existing.id as number),
+ eq(table.userId, userId),
+ ),
+ )
+ .returning();
+ resultRow = updatedRows[0] as Record;
+ } else {
+ const insertedRows = await context.drizzle
+ .insert(table as typeof hosts)
+ .values({
+ ...encryptedPayload,
+ userId,
+ syncId,
+ } as typeof hosts.$inferInsert)
+ .returning();
+ resultRow = insertedRows[0] as Record;
+ }
+
+ await DatabaseSaveTrigger.forceSave("sync_upsert");
+
+ res.json({
+ row: decryptIfNeeded(entityType, resultRow, userId),
+ created: !existing,
+ });
+ } catch (err) {
+ databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
+ operation: "sync_upsert",
+ entityType,
+ userId,
+ });
+ res.status(500).json({ error: "Failed to upsert row" });
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /sync/{entityType}/tombstones:
+ * get:
+ * summary: Pull deletion tombstones for an entity type
+ * description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
+ * tags:
+ * - Sync
+ * parameters:
+ * - in: path
+ * name: entityType
+ * required: true
+ * schema:
+ * type: string
+ * - in: query
+ * name: since
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Tombstones recorded since the given timestamp.
+ * 400:
+ * description: Unknown entity type.
+ * 500:
+ * description: Failed to fetch tombstones.
+ */
+router.get(
+ "/:entityType/tombstones",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const entityType = req.params.entityType;
+ if (!isValidEntityType(entityType)) {
+ return res.status(400).json({ error: "Unknown entity type" });
+ }
+ const since =
+ typeof req.query.since === "string" && req.query.since
+ ? req.query.since
+ : null;
+
+ try {
+ const tombstones = await createCurrentSyncTombstoneRepository().listSince(
+ userId,
+ entityType,
+ since,
+ );
+ res.json({ tombstones });
+ } catch (err) {
+ databaseLogger.error(
+ `Failed to fetch sync tombstones for ${entityType}`,
+ err,
+ { operation: "sync_tombstones_pull", entityType, userId },
+ );
+ res.status(500).json({ error: "Failed to fetch tombstones" });
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /sync/tombstones:
+ * post:
+ * summary: Report a deletion from the other side of a sync pair
+ * description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
+ * tags:
+ * - Sync
+ * responses:
+ * 200:
+ * description: Deletion applied (or row already absent).
+ * 400:
+ * description: Unknown entity type or missing syncId.
+ * 500:
+ * description: Failed to apply deletion.
+ */
+router.post(
+ "/tombstones",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ const entityType = req.body?.entityType;
+ const syncId = req.body?.syncId;
+ if (
+ !isValidEntityType(entityType) ||
+ typeof syncId !== "string" ||
+ !syncId
+ ) {
+ return res.status(400).json({ error: "Missing entityType or syncId" });
+ }
+
+ try {
+ const { table } = ENTITY_CONFIG[entityType];
+ const context = createCurrentRepositoryContext();
+
+ await context.drizzle
+ .delete(table as typeof hosts)
+ .where(
+ and(
+ eq((table as typeof hosts).syncId, syncId),
+ eq(table.userId, userId),
+ ),
+ );
+
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ entityType,
+ syncId,
+ );
+ await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
+
+ res.json({ success: true });
+ } catch (err) {
+ databaseLogger.error("Failed to apply sync tombstone", err, {
+ operation: "sync_tombstone_apply",
+ entityType,
+ userId,
+ });
+ res.status(500).json({ error: "Failed to apply deletion" });
+ }
+ },
+);
+
+export default router;
diff --git a/src/backend/database/routes/user-preferences.ts b/src/backend/database/routes/user-preferences.ts
index 87b5ae73..498b92b3 100644
--- a/src/backend/database/routes/user-preferences.ts
+++ b/src/backend/database/routes/user-preferences.ts
@@ -8,6 +8,7 @@ import type {
UserPreferenceRecord,
UserPreferenceUpdate,
} from "../repositories/user-preference-repository.js";
+import { isValidKeybinding } from "./keybinding-validation.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -33,6 +34,8 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
hiddenRailTabs: row?.hiddenRailTabs ?? null,
compactHostView: row?.compactHostView ?? null,
statusColorScheme: row?.statusColorScheme ?? null,
+ customThemes: row?.customThemes ?? null,
+ customKeybindings: row?.customKeybindings ?? null,
});
/**
@@ -106,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
* statusColorScheme:
* type: string
* nullable: true
+ * customThemes:
+ * type: string
+ * nullable: true
+ * description: JSON-encoded array of the user's saved global custom terminal themes.
+ * customKeybindings:
+ * type: string
+ * nullable: true
+ * description: JSON-encoded array of the user's custom terminal keybindings.
*/
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
@@ -175,6 +186,12 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* type: boolean
* statusColorScheme:
* type: string
+ * customThemes:
+ * type: string
+ * description: JSON-encoded array of the user's saved global custom terminal themes.
+ * customKeybindings:
+ * type: string
+ * description: JSON-encoded array of the user's custom terminal keybindings.
* responses:
* 200:
* description: Preferences updated successfully.
@@ -201,6 +218,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
hiddenRailTabs,
compactHostView,
statusColorScheme,
+ customThemes,
+ customKeybindings,
} = req.body as {
reopenTabsOnLogin?: boolean;
theme?: string | null;
@@ -221,6 +240,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
hiddenRailTabs?: string | null;
compactHostView?: boolean | null;
statusColorScheme?: string | null;
+ customThemes?: string | null;
+ customKeybindings?: string | null;
};
const updates: UserPreferenceUpdate = {
@@ -244,12 +265,64 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
storageMode,
hiddenRailTabs,
statusColorScheme,
+ customThemes,
+ customKeybindings,
})) {
if (value !== undefined && value !== null && typeof value !== "string") {
return res.status(400).json({ error: `${key} must be a string` });
}
}
+ if (customThemes !== undefined && customThemes !== null) {
+ let parsedThemes: unknown;
+ try {
+ parsedThemes = JSON.parse(customThemes);
+ } catch {
+ return res
+ .status(400)
+ .json({ error: "customThemes must be a JSON-encoded array" });
+ }
+ if (!Array.isArray(parsedThemes) || parsedThemes.length > 100) {
+ return res.status(400).json({
+ error: "customThemes must be a JSON array of at most 100 themes",
+ });
+ }
+ const isValidTheme = (entry: unknown): boolean =>
+ !!entry &&
+ typeof entry === "object" &&
+ typeof (entry as { id?: unknown }).id === "string" &&
+ typeof (entry as { name?: unknown }).name === "string" &&
+ !!(entry as { colors?: unknown }).colors &&
+ typeof (entry as { colors?: unknown }).colors === "object";
+ if (!parsedThemes.every(isValidTheme)) {
+ return res.status(400).json({
+ error: "Each custom theme must have an id, name, and colors object",
+ });
+ }
+ }
+
+ if (customKeybindings !== undefined && customKeybindings !== null) {
+ let parsedKeybindings: unknown;
+ try {
+ parsedKeybindings = JSON.parse(customKeybindings);
+ } catch {
+ return res
+ .status(400)
+ .json({ error: "customKeybindings must be a JSON-encoded array" });
+ }
+ if (!Array.isArray(parsedKeybindings) || parsedKeybindings.length > 200) {
+ return res.status(400).json({
+ error: "customKeybindings must be a JSON array of at most 200 bindings",
+ });
+ }
+ if (!parsedKeybindings.every(isValidKeybinding)) {
+ return res.status(400).json({
+ error:
+ "Each custom keybinding must have an id, enabled flag, valid combo, and valid action",
+ });
+ }
+ }
+
const boolFields: Record = {
commandAutocomplete,
commandPaletteEnabled,
@@ -294,6 +367,9 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
if (compactHostView !== undefined) updates.compactHostView = compactHostView;
if (statusColorScheme !== undefined)
updates.statusColorScheme = statusColorScheme;
+ if (customThemes !== undefined) updates.customThemes = customThemes;
+ if (customKeybindings !== undefined)
+ updates.customKeybindings = customKeybindings;
if (Object.keys(updates).length === 1) {
return res.status(400).json({ error: "No preferences provided" });
diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts
index 2c23c107..27819bff 100644
--- a/src/backend/database/routes/user-settings-routes.ts
+++ b/src/backend/database/routes/user-settings-routes.ts
@@ -519,6 +519,207 @@ export function registerUserSettingsRoutes(
},
);
+ /**
+ * @openapi
+ * /users/analytics-enabled:
+ * get:
+ * summary: Get analytics enabled setting
+ * description: Returns whether anonymous usage telemetry is enabled.
+ * tags:
+ * - Users
+ * responses:
+ * 200:
+ * description: Analytics enabled status.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * enabled:
+ * type: boolean
+ */
+ router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
+ try {
+ res.json({
+ enabled: await createCurrentSettingsRepository().getBoolean(
+ "analytics_enabled",
+ true,
+ ),
+ });
+ } catch (err) {
+ authLogger.error("Failed to get analytics enabled setting", err);
+ res
+ .status(500)
+ .json({ error: "Failed to get analytics enabled setting" });
+ }
+ });
+
+ /**
+ * @openapi
+ * /users/analytics-enabled:
+ * patch:
+ * summary: Update analytics enabled setting (admin only)
+ * description: Enables or disables the daily anonymous usage telemetry heartbeat.
+ * tags:
+ * - Users
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * enabled:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: Setting updated.
+ * 403:
+ * description: Not authorized.
+ * 500:
+ * description: Failed to update setting.
+ */
+ router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const actor = await getAdminActor(userId);
+ if (!actor) {
+ return res.status(403).json({ error: "Not authorized" });
+ }
+ const { enabled } = req.body;
+ if (typeof enabled !== "boolean") {
+ return res.status(400).json({ error: "enabled must be a boolean" });
+ }
+ await createCurrentSettingsRepository().set(
+ "analytics_enabled",
+ enabled ? "true" : "false",
+ );
+
+ const { ipAddress, userAgent } = getRequestMeta(req);
+ await logAudit({
+ userId,
+ username: actor.username ?? userId,
+ action: "update_analytics_enabled",
+ resourceType: "setting",
+ details: JSON.stringify({ enabled }),
+ ipAddress,
+ userAgent,
+ success: true,
+ });
+
+ res.json({ enabled });
+ } catch (err) {
+ authLogger.error("Failed to update analytics enabled setting", err);
+ res
+ .status(500)
+ .json({ error: "Failed to update analytics enabled setting" });
+ }
+ });
+
+ /**
+ * @openapi
+ * /users/session-sharing-enabled:
+ * get:
+ * summary: Get session sharing globally enabled setting
+ * description: Returns whether live session sharing (terminal/RDP/VNC/Telnet share links and in-app joins) is allowed instance-wide. Overrides every per-host toggle when false.
+ * tags:
+ * - Users
+ * responses:
+ * 200:
+ * description: Session sharing enabled status.
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * enabled:
+ * type: boolean
+ */
+ router.get("/session-sharing-enabled", authenticateJWT, async (_req, res) => {
+ try {
+ res.json({
+ enabled: await createCurrentSettingsRepository().getBoolean(
+ "session_sharing_globally_enabled",
+ true,
+ ),
+ });
+ } catch (err) {
+ authLogger.error("Failed to get session sharing enabled setting", err);
+ res
+ .status(500)
+ .json({ error: "Failed to get session sharing enabled setting" });
+ }
+ });
+
+ /**
+ * @openapi
+ * /users/session-sharing-enabled:
+ * patch:
+ * summary: Update session sharing globally enabled setting (admin only)
+ * description: Enables or disables live session sharing instance-wide, overriding every per-host allowSessionSharing toggle.
+ * tags:
+ * - Users
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * properties:
+ * enabled:
+ * type: boolean
+ * responses:
+ * 200:
+ * description: Setting updated.
+ * 403:
+ * description: Not authorized.
+ * 500:
+ * description: Failed to update setting.
+ */
+ router.patch(
+ "/session-sharing-enabled",
+ authenticateJWT,
+ async (req, res) => {
+ const userId = (req as AuthenticatedRequest).userId;
+ try {
+ const actor = await getAdminActor(userId);
+ if (!actor) {
+ return res.status(403).json({ error: "Not authorized" });
+ }
+ const { enabled } = req.body;
+ if (typeof enabled !== "boolean") {
+ return res.status(400).json({ error: "enabled must be a boolean" });
+ }
+ await createCurrentSettingsRepository().set(
+ "session_sharing_globally_enabled",
+ enabled ? "true" : "false",
+ );
+
+ const { ipAddress, userAgent } = getRequestMeta(req);
+ await logAudit({
+ userId,
+ username: actor.username ?? userId,
+ action: "update_session_sharing_enabled",
+ resourceType: "setting",
+ details: JSON.stringify({ enabled }),
+ ipAddress,
+ userAgent,
+ success: true,
+ });
+
+ res.json({ enabled });
+ } catch (err) {
+ authLogger.error(
+ "Failed to update session sharing enabled setting",
+ err,
+ );
+ res
+ .status(500)
+ .json({ error: "Failed to update session sharing enabled setting" });
+ }
+ },
+ );
+
/**
* @openapi
* /users/host-defaults:
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index f58f34b8..51de8958 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -18,6 +18,11 @@ import {
isOidcTokenCallback,
} from "../../utils/oidc-desktop-callback.js";
import { deleteUserAndRelatedData } from "./delete-user-data.js";
+import {
+ isLoopbackRequest,
+ extractBearerOrCookieToken,
+ resolveDesktopAutoSessionUser,
+} from "./desktop-auto-session.js";
import { shouldShowDonationModal } from "./donation-modal-utils.js";
import {
getOIDCConfigFromEnv,
@@ -1848,9 +1853,13 @@ router.post(
* description: Not authenticated.
*/
router.get("/me/token", authenticateJWT, (req: Request, res: Response) => {
- const token = (req as Request & { cookies: Record }).cookies
- ?.jwt;
- res.json({ token: token || null });
+ // authenticateJWT accepts either the jwt cookie or an Authorization:
+ // Bearer header (see auth-manager.ts's createAuthMiddleware) -- this must
+ // check both too, or a request that only carried the header (e.g. the
+ // Electron renderer's own axios interceptor, which always attaches a
+ // stored localStorage JWT as a Bearer header) would pass authentication
+ // here but still get back a null token.
+ res.json({ token: extractBearerOrCookieToken(req) ?? null });
});
/**
@@ -1880,6 +1889,80 @@ router.get("/setup-required", async (req, res) => {
}
});
+/**
+ * @openapi
+ * /users/internal/auto-session:
+ * post:
+ * summary: Mint a session for the sole local desktop user
+ * description: Used by the Electron desktop app to skip the login form entirely when running standalone against the embedded local backend. Only available over loopback. Logs in as the sole local user regardless of its credentials; if the local database has more than one user (e.g. repeated manual registration), deterministically logs in as the admin account, or the earliest-registered account if none is admin -- a login form must never appear for the local backend under any circumstance. Only declines if zero local users exist at all, which normal desktop provisioning never produces. Provisions the resolved user's data-encryption key if missing before minting the session, matching every other login path -- self-heals an account that previously ended up with a valid session but no usable encryption key.
+ * tags:
+ * - Users
+ * responses:
+ * 200:
+ * description: Session created.
+ * 403:
+ * description: Forbidden, or no local users exist.
+ * 500:
+ * description: Failed to create session.
+ */
+router.post("/internal/auto-session", async (req, res) => {
+ try {
+ if (!isLoopbackRequest(req)) {
+ authLogger.warn(
+ "Rejected non-loopback attempt to access auto-session endpoint",
+ { source: req.ip },
+ );
+ return res.status(403).json({ error: "Forbidden" });
+ }
+
+ const userRepository = createCurrentUserRepository();
+ const allUsers = await userRepository.listAll();
+ const userRecord = resolveDesktopAutoSessionUser(allUsers);
+ if (!userRecord) {
+ return res.status(403).json({
+ error: "No local users exist",
+ });
+ }
+ await authManager.registerUser(userRecord.id);
+ const existingToken = extractBearerOrCookieToken(req);
+ if (existingToken) {
+ const existingPayload = await authManager.verifyJWTToken(existingToken);
+ if (existingPayload?.userId === userRecord.id) {
+ return res.json({
+ success: true,
+ is_admin: !!userRecord.isAdmin,
+ username: userRecord.username,
+ token: existingToken,
+ });
+ }
+ }
+
+ const token = await authManager.generateJWTToken(userRecord.id, {
+ deviceType: "desktop",
+ deviceInfo: "Termix Desktop (local)",
+ rememberMe: true,
+ });
+
+ const response = {
+ success: true,
+ is_admin: !!userRecord.isAdmin,
+ username: userRecord.username,
+ token,
+ };
+
+ return res
+ .cookie(
+ "jwt",
+ token,
+ authManager.getSecureCookieOptions(req, 30 * 24 * 60 * 60 * 1000),
+ )
+ .json(response);
+ } catch (err) {
+ authLogger.error("Failed to create auto-session", err);
+ res.status(500).json({ error: "Failed to create auto-session" });
+ }
+});
+
/**
* @openapi
* /users/count:
diff --git a/src/backend/database/routes/vault.ts b/src/backend/database/routes/vault.ts
index d70b3050..0616b73f 100644
--- a/src/backend/database/routes/vault.ts
+++ b/src/backend/database/routes/vault.ts
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
import {
createCurrentVaultProfileRepository,
createCurrentUserRepository,
+ createCurrentSyncTombstoneRepository,
} from "../repositories/factory.js";
import type { VaultProfileUpdateInput } from "../repositories/vault-profile-repository.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
@@ -421,7 +422,14 @@ router.delete(
.status(403)
.json({ error: "Only the owner can delete this profile" });
}
- await repository.deleteById(id);
+ const deleted = await repository.deleteById(id);
+ if (deleted?.syncId) {
+ await createCurrentSyncTombstoneRepository().record(
+ userId,
+ "vaultProfiles",
+ deleted.syncId,
+ );
+ }
res.json({ success: true });
} catch (err) {
authLogger.error("Failed to delete vault profile", err);
diff --git a/src/backend/hosts/auth-manager.ts b/src/backend/hosts/auth-manager.ts
index 9aceab71..a1dfe43a 100644
--- a/src/backend/hosts/auth-manager.ts
+++ b/src/backend/hosts/auth-manager.ts
@@ -117,6 +117,24 @@ export class SSHAuthManager {
return;
}
+ // JumpCloud Protect / DUO-style push MFA: a menu choice ("Choose [1] Push,
+ // or [2] TOTP:") followed by an empty-answerable confirm ("Press enter to
+ // send Push request:"). Checked before the TOTP regex because the menu
+ // prompt's own text ("...or [2] TOTP:") would otherwise match it and get
+ // misrouted into the numeric-code flow.
+ const pushPromptPattern =
+ /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
+ const isPushPrompt = promptTexts.some((p) => pushPromptPattern.test(p));
+
+ if (isPushPrompt) {
+ sshLogger.info("Push/menu MFA prompt detected", {
+ operation: "ssh_keyboard_interactive_push",
+ hostId: this.context.hostId,
+ });
+ this.handlePasswordAuth(prompts, finish, resolvedCredentials, hostConfig);
+ return;
+ }
+
const totpPromptIndex = prompts.findIndex((p) =>
/verification code|verification_code|token|otp|2fa|authenticator|google.*auth/i.test(
p.prompt,
@@ -313,6 +331,10 @@ export class SSHAuthManager {
? passwordPromptIndex
: firstUnansweredIndex;
+ const pushPromptPattern =
+ /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
+ const isPushPrompt = pushPromptPattern.test(prompts[promptIndex].prompt);
+
this.context.keyboardInteractiveFinish = (userResponses: string[]) => {
const userInput = (userResponses[0] || "").trim();
@@ -333,22 +355,25 @@ export class SSHAuthManager {
clearTimeout(this.context.totpTimeout);
}
- this.context.totpTimeout = setTimeout(() => {
- if (this.context.keyboardInteractiveFinish) {
- this.context.keyboardInteractiveFinish = null;
- this.context.keyboardInteractiveResponded = false;
- sshLogger.warn("Password prompt timeout", {
- operation: "password_timeout",
- hostId: this.context.hostId,
- });
- this.context.ws.send(
- JSON.stringify({
- type: "error",
- message: "Password verification timeout. Please reconnect.",
- }),
- );
- }
- }, 180000);
+ this.context.totpTimeout = setTimeout(
+ () => {
+ if (this.context.keyboardInteractiveFinish) {
+ this.context.keyboardInteractiveFinish = null;
+ this.context.keyboardInteractiveResponded = false;
+ sshLogger.warn("Password prompt timeout", {
+ operation: "password_timeout",
+ hostId: this.context.hostId,
+ });
+ this.context.ws.send(
+ JSON.stringify({
+ type: "error",
+ message: "Password verification timeout. Please reconnect.",
+ }),
+ );
+ }
+ },
+ isPushPrompt ? 300000 : 180000,
+ );
this.sendLog("auth", "info", "Password authentication required");
@@ -356,6 +381,7 @@ export class SSHAuthManager {
JSON.stringify({
type: "password_required",
prompt: prompts[promptIndex].prompt,
+ echo: prompts[promptIndex].echo,
}),
);
return;
diff --git a/src/backend/hosts/docker/console.ts b/src/backend/hosts/docker/console.ts
index 46a3c9bf..7fb1d003 100644
--- a/src/backend/hosts/docker/console.ts
+++ b/src/backend/hosts/docker/console.ts
@@ -32,6 +32,12 @@ const wss = new WebSocketServer({
port: 30009,
});
+wss.on("error", (error) => {
+ sshLogger.error("Docker console WebSocket server error", error, {
+ operation: "wss_error",
+ });
+});
+
async function detectShell(
session: SSHSession,
containerId: string,
diff --git a/src/backend/hosts/docker/routes.ts b/src/backend/hosts/docker/routes.ts
index 2cebf272..68e7d741 100644
--- a/src/backend/hosts/docker/routes.ts
+++ b/src/backend/hosts/docker/routes.ts
@@ -280,6 +280,7 @@ export function registerDockerSshRoutes(app: express.Express): void {
if (userProvidedPassword) {
resolvedCredentials.password = userProvidedPassword;
+ resolvedCredentials.authType = "password";
}
if (userProvidedSshKey) {
resolvedCredentials.sshKey = userProvidedSshKey;
diff --git a/src/backend/hosts/guacamole/guacamole-server.ts b/src/backend/hosts/guacamole/guacamole-server.ts
index 1d142e21..efc8654d 100644
--- a/src/backend/hosts/guacamole/guacamole-server.ts
+++ b/src/backend/hosts/guacamole/guacamole-server.ts
@@ -27,12 +27,64 @@ const GUACAMOLE_RECORDINGS_DIR =
path.join(DATA_DIR, "session_recordings", "guacamole");
type GuacamoleClientConnection = {
+ guacamoleConnectionId?: string;
connectionSettings?: {
- connection?: { type?: string };
+ connection?: { type?: string; join?: string; readOnly?: boolean };
recording?: GuacamoleRecordingMetadata;
+ termixMeta?: {
+ termixConnectId: string;
+ hostId: number;
+ ownerUserId: string;
+ protocol: string;
+ };
};
};
+export interface GuacSessionInfo {
+ guacamoleConnectionId: string;
+ hostId: number;
+ ownerUserId: string;
+ protocol: string;
+ openedAt: number;
+}
+
+// Keyed by termixConnectId (routes.ts's correlation id), populated once the
+// primary connection's guacd handshake completes.
+const guacSessionByConnectId = new Map();
+// Keyed by guacd's own guacamoleConnectionId, for join-time lookups.
+const guacSessionByGuacamoleId = new Map();
+const pendingConnectResolvers = new Map<
+ string,
+ (info: GuacSessionInfo | null) => void
+>();
+
+export function waitForGuacdOpen(
+ termixConnectId: string,
+ timeoutMs = 10000,
+): Promise {
+ const existing = guacSessionByConnectId.get(termixConnectId);
+ if (existing) return Promise.resolve(existing);
+
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = (info: GuacSessionInfo | null) => {
+ if (settled) return;
+ settled = true;
+ pendingConnectResolvers.delete(termixConnectId);
+ resolve(info);
+ };
+
+ pendingConnectResolvers.set(termixConnectId, finish);
+ setTimeout(() => finish(null), timeoutMs);
+ });
+}
+
+export function getGuacSessionInfo(
+ guacamoleConnectionId: string,
+): GuacSessionInfo | null {
+ return guacSessionByGuacamoleId.get(guacamoleConnectionId) ?? null;
+}
+
async function persistGuacamoleRecording(
clientConnection: GuacamoleClientConnection,
): Promise {
@@ -118,7 +170,6 @@ const clientOptions = {
vnc: {
"swap-red-blue": false,
cursor: "remote",
- security: "any",
width: 1280,
height: 720,
},
@@ -149,6 +200,25 @@ function createGuacServer(): GuacamoleLite {
operation: "guac_connection_open",
type: clientConnection.connectionSettings?.connection?.type,
});
+
+ const termixMeta = clientConnection.connectionSettings?.termixMeta;
+ const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
+ const isJoin = !!clientConnection.connectionSettings?.connection?.join;
+
+ if (!isJoin && termixMeta && guacamoleConnectionId) {
+ const info: GuacSessionInfo = {
+ guacamoleConnectionId,
+ hostId: termixMeta.hostId,
+ ownerUserId: termixMeta.ownerUserId,
+ protocol: termixMeta.protocol,
+ openedAt: Date.now(),
+ };
+ guacSessionByConnectId.set(termixMeta.termixConnectId, info);
+ guacSessionByGuacamoleId.set(guacamoleConnectionId, info);
+
+ const resolver = pendingConnectResolvers.get(termixMeta.termixConnectId);
+ if (resolver) resolver(info);
+ }
});
server.on("close", (clientConnection: GuacamoleClientConnection) => {
@@ -156,6 +226,15 @@ function createGuacServer(): GuacamoleLite {
operation: "guac_connection_close",
type: clientConnection.connectionSettings?.connection?.type,
});
+
+ const isJoin = !!clientConnection.connectionSettings?.connection?.join;
+ const termixMeta = clientConnection.connectionSettings?.termixMeta;
+ const guacamoleConnectionId = clientConnection.guacamoleConnectionId;
+ if (!isJoin && termixMeta && guacamoleConnectionId) {
+ guacSessionByConnectId.delete(termixMeta.termixConnectId);
+ guacSessionByGuacamoleId.delete(guacamoleConnectionId);
+ }
+
persistGuacamoleRecording(clientConnection).catch((error) => {
guacLogger.error("Failed to persist Guacamole recording", error, {
operation: "guac_recording_persist_error",
diff --git a/src/backend/hosts/guacamole/routes.ts b/src/backend/hosts/guacamole/routes.ts
index 0e47c2f9..f5b07d5d 100644
--- a/src/backend/hosts/guacamole/routes.ts
+++ b/src/backend/hosts/guacamole/routes.ts
@@ -3,16 +3,18 @@ import { GuacamoleTokenService } from "./token-service.js";
import { guacLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { PermissionManager } from "../../utils/permission-manager.js";
-import { Client } from "ssh2";
import net from "net";
import crypto from "crypto";
import path from "path";
-import type { AuthenticatedRequest } from "../../../types/index.js";
+import type { AuthenticatedRequest, ProxyNode } from "../../../types/index.js";
import {
createCurrentHostResolutionRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { resolveGuacdOptions } from "../../utils/guacd-config.js";
+import { createJumpHostChain } from "../jump-host-chain.js";
+import type { SOCKS5Config } from "../../utils/socks5-helper.js";
+import { waitForGuacdOpen } from "./guacamole-server.js";
const router = express.Router();
const tokenService = GuacamoleTokenService.getInstance();
@@ -165,6 +167,12 @@ router.post("/token", async (req, res) => {
* type: string
* enum: [rdp, vnc, telnet]
* description: Override the host's default connection type
+ * promptedUsername:
+ * type: string
+ * description: Username for this connection only, used when the host's RDP auth type is "none". Not persisted.
+ * promptedPassword:
+ * type: string
+ * description: Password for this connection only, used when the host's RDP auth type is "none". Not persisted.
* responses:
* 200:
* description: Connection token generated successfully
@@ -176,6 +184,10 @@ router.post("/token", async (req, res) => {
* token:
* type: string
* description: Encrypted connection token
+ * guacamoleConnectionId:
+ * type: string
+ * nullable: true
+ * description: guacd's own connection id for this session, once the handshake completes. Used to mint session-share join tokens.
* 400:
* description: Invalid request or unsupported connection type
* 403:
@@ -421,12 +433,22 @@ router.post(
let username: string;
let password: string;
+ const rdpAuthTypeForConnect = isSharedConnection
+ ? null
+ : (host.rdpAuthType as string) ||
+ (host.rdpCredentialId ? "credential" : "direct");
+
switch (connectionType) {
case "rdp":
- username =
- (host.rdpUser as string) || (host.username as string) || "";
- password =
- (host.rdpPassword as string) || (host.password as string) || "";
+ if (rdpAuthTypeForConnect === "none") {
+ username = String(req.body?.promptedUsername || "");
+ password = String(req.body?.promptedPassword || "");
+ } else {
+ username =
+ (host.rdpUser as string) || (host.username as string) || "";
+ password =
+ (host.rdpPassword as string) || (host.password as string) || "";
+ }
port = (host.rdpPort as number) || port || 3389;
break;
case "vnc":
@@ -463,65 +485,91 @@ router.post(
if (jumpHosts.length > 0) {
try {
- const { resolveHostById } = await import("../host-resolver.js");
- const jumpHost = await resolveHostById(jumpHosts[0].hostId, userId);
- if (jumpHost) {
- const tunnelPort = await new Promise((resolve, reject) => {
- const sshClient = new Client();
- sshClient.on("ready", () => {
- const server = net.createServer((sock) => {
- sshClient.forwardOut(
- "127.0.0.1",
- 0,
- hostname,
- port,
- (err, stream) => {
- if (err) {
- sock.destroy();
- return;
- }
- sock.pipe(stream).pipe(sock);
- },
- );
- });
- server.listen(0, "127.0.0.1", () => {
- const addr = server.address() as net.AddressInfo;
- // Auto-cleanup after 1 hour
- setTimeout(
- () => {
- server.close();
- sshClient.end();
- },
- 60 * 60 * 1000,
- );
- resolve(addr.port);
- });
- });
- sshClient.on("error", reject);
+ let socks5ProxyChain: ProxyNode[] = [];
+ if (hostRecord.socks5ProxyChain) {
+ try {
+ socks5ProxyChain =
+ typeof hostRecord.socks5ProxyChain === "string"
+ ? JSON.parse(hostRecord.socks5ProxyChain as string)
+ : (hostRecord.socks5ProxyChain as ProxyNode[]);
+ } catch {
+ socks5ProxyChain = [];
+ }
+ }
- const connectOpts: Record = {
- host: jumpHost.ip,
- port: jumpHost.port || 22,
- username: jumpHost.username,
- readyTimeout: 30000,
- };
- if (jumpHost.key) {
- connectOpts.privateKey = jumpHost.key;
- if (jumpHost.keyPassword)
- connectOpts.passphrase = jumpHost.keyPassword;
- } else if (jumpHost.password) {
- connectOpts.password = jumpHost.password;
- }
- sshClient.connect(connectOpts);
- });
- hostname = "127.0.0.1";
- port = tunnelPort;
- guacLogger.info("SSH tunnel established for guacamole", {
- operation: "guac_ssh_tunnel",
- hostId,
- tunnelPort,
+ const proxyConfig: SOCKS5Config | null =
+ hostRecord.useSocks5 &&
+ (hostRecord.socks5Host || socks5ProxyChain.length > 0)
+ ? {
+ useSocks5: hostRecord.useSocks5 as boolean,
+ socks5Host: hostRecord.socks5Host as string | undefined,
+ socks5Port: hostRecord.socks5Port as number | undefined,
+ socks5Username: hostRecord.socks5Username as
+ | string
+ | undefined,
+ socks5Password: hostRecord.socks5Password as
+ | string
+ | undefined,
+ socks5ProxyChain,
+ }
+ : null;
+
+ const jumpClient = await createJumpHostChain(
+ jumpHosts,
+ userId,
+ proxyConfig,
+ );
+
+ if (!jumpClient) {
+ guacLogger.error(
+ "Failed to establish jump host chain for guacamole",
+ undefined,
+ { operation: "guac_ssh_tunnel_error", hostId },
+ );
+ return res.status(500).json({
+ error: "Failed to establish SSH tunnel to remote host",
});
}
+
+ const targetHostname = hostname;
+ const targetPort = port;
+ const tunnelPort = await new Promise((resolve, reject) => {
+ const server = net.createServer((sock) => {
+ jumpClient.forwardOut(
+ "127.0.0.1",
+ 0,
+ targetHostname,
+ targetPort,
+ (err, stream) => {
+ if (err) {
+ sock.destroy();
+ return;
+ }
+ sock.pipe(stream).pipe(sock);
+ },
+ );
+ });
+ server.on("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ const addr = server.address() as net.AddressInfo;
+ // Auto-cleanup after 1 hour
+ setTimeout(
+ () => {
+ server.close();
+ jumpClient.end();
+ },
+ 60 * 60 * 1000,
+ );
+ resolve(addr.port);
+ });
+ });
+ hostname = "127.0.0.1";
+ port = tunnelPort;
+ guacLogger.info("SSH tunnel established for guacamole", {
+ operation: "guac_ssh_tunnel",
+ hostId,
+ tunnelPort,
+ });
} catch (tunnelError) {
guacLogger.error("Failed to establish SSH tunnel", tunnelError, {
operation: "guac_ssh_tunnel_error",
@@ -541,7 +589,8 @@ router.post(
? { guacdPort: perConnectionGuacdPort }
: {}),
};
- const recordingEnabled = host.enableSessionLogging !== false;
+ const recordingEnabled =
+ connectionType !== "vnc" && host.enableSessionLogging !== false;
const recordingName = `${crypto.randomUUID()}.guac`;
const recordingPath =
process.env.GUACD_RECORDING_PATH ||
@@ -564,6 +613,14 @@ router.post(
guacConfig["recording-include-keys"] = true;
}
+ const termixConnectId = crypto.randomUUID();
+ const termixMeta = {
+ termixConnectId,
+ hostId,
+ ownerUserId: userId,
+ protocol: connectionType as "rdp" | "vnc" | "telnet",
+ };
+
switch (connectionType) {
case "rdp":
if (guacConfig["enable-drive"] && !guacConfig["drive-path"]) {
@@ -591,6 +648,7 @@ router.post(
...guacdOverrides,
},
recordingMetadata,
+ termixMeta,
);
break;
case "vnc":
@@ -600,11 +658,11 @@ router.post(
password,
{
port,
- security: "any",
...guacConfig,
...guacdOverrides,
},
recordingMetadata,
+ termixMeta,
);
break;
case "telnet":
@@ -618,13 +676,19 @@ router.post(
...guacdOverrides,
},
recordingMetadata,
+ termixMeta,
);
break;
default:
return res.status(400).json({ error: "Invalid connection type" });
}
- res.json({ token });
+ const sessionInfo = await waitForGuacdOpen(termixConnectId, 10000);
+
+ res.json({
+ token,
+ guacamoleConnectionId: sessionInfo?.guacamoleConnectionId ?? null,
+ });
} catch (error) {
guacLogger.error("Failed to generate guacamole token for host", error, {
operation: "guac_host_token_error",
diff --git a/src/backend/hosts/guacamole/token-service.ts b/src/backend/hosts/guacamole/token-service.ts
index d2fe23da..a77e6203 100644
--- a/src/backend/hosts/guacamole/token-service.ts
+++ b/src/backend/hosts/guacamole/token-service.ts
@@ -2,11 +2,13 @@ import crypto from "crypto";
import { guacLogger } from "../../utils/logger.js";
export interface GuacamoleConnectionSettings {
- type: "rdp" | "vnc" | "telnet";
+ type?: "rdp" | "vnc" | "telnet";
+ join?: string;
+ readOnly?: boolean;
guacdHost?: string;
guacdPort?: number;
settings: {
- hostname: string;
+ hostname?: string;
port?: number;
username?: string;
password?: string;
@@ -28,9 +30,17 @@ export interface GuacamoleConnectionSettings {
};
}
+export interface TermixGuacMeta {
+ termixConnectId: string;
+ hostId: number;
+ ownerUserId: string;
+ protocol: "rdp" | "vnc" | "telnet";
+}
+
export interface GuacamoleToken {
connection: GuacamoleConnectionSettings;
recording?: GuacamoleRecordingMetadata;
+ termixMeta?: TermixGuacMeta;
}
export interface GuacamoleRecordingMetadata {
@@ -137,6 +147,7 @@ export class GuacamoleTokenService {
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
+ termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
@@ -155,6 +166,7 @@ export class GuacamoleTokenService {
},
},
recording,
+ termixMeta,
};
return this.encryptToken(token);
}
@@ -168,6 +180,7 @@ export class GuacamoleTokenService {
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
+ termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
@@ -184,6 +197,7 @@ export class GuacamoleTokenService {
},
},
recording,
+ termixMeta,
};
return this.encryptToken(token);
}
@@ -197,6 +211,7 @@ export class GuacamoleTokenService {
guacdPort?: number;
} = {},
recording?: GuacamoleRecordingMetadata,
+ termixMeta?: TermixGuacMeta,
): string {
const { guacdHost, guacdPort, ...settingsOptions } = options;
const token: GuacamoleToken = {
@@ -213,6 +228,20 @@ export class GuacamoleTokenService {
},
},
recording,
+ termixMeta,
+ };
+ return this.encryptToken(token);
+ }
+
+ // join tokens never carry recording params - only the primary connection's
+ // token should write recording-path/recording-name to guacd.
+ createJoinToken(guacamoleConnectionId: string, readOnly: boolean): string {
+ const token: GuacamoleToken = {
+ connection: {
+ join: guacamoleConnectionId,
+ readOnly,
+ settings: {},
+ },
};
return this.encryptToken(token);
}
diff --git a/src/backend/hosts/host-resolver.ts b/src/backend/hosts/host-resolver.ts
index 859d8e09..df5d8bd4 100644
--- a/src/backend/hosts/host-resolver.ts
+++ b/src/backend/hosts/host-resolver.ts
@@ -122,35 +122,61 @@ export async function resolveHostById(
repository,
);
if (!resolved) return null;
- } else if (host.credentialId) {
- try {
- const cred = (await repository.findCredentialByIdForUser(
- host.credentialId as number,
- ownerId,
- )) as Record | null;
-
- if (cred) {
- host.password = pickResolvedPassword(host.password, cred.password);
- // Prefer the normalised private key; fall back to raw key field
- host.key = (cred.privateKey || cred.key) as string | null;
- host.keyPassword = cred.keyPassword;
- host.keyType = cred.keyType;
- // CA-signed certificate for cert-based auth
- (host as Record).certPublicKey =
- cred.certPublicKey || null;
- host.username = pickResolvedUsername(
- host.username,
- cred.username,
- host.overrideCredentialUsername,
+ } else {
+ let effectiveCredentialId = host.credentialId as number | null | undefined;
+ if (
+ !effectiveCredentialId &&
+ host.authType === "credential" &&
+ host.folder
+ ) {
+ try {
+ effectiveCredentialId = await repository.findFolderCredentialId(
+ ownerId,
+ host.folder as string,
);
- host.authType = host.key ? "key" : host.password ? "password" : "none";
+ } catch (e) {
+ sshLogger.warn("Failed to resolve folder credential for host", {
+ operation: "host_resolver_folder_credential",
+ hostId,
+ error: e instanceof Error ? e.message : "Unknown",
+ });
+ }
+ }
+
+ if (effectiveCredentialId) {
+ try {
+ const cred = (await repository.findCredentialByIdForUser(
+ effectiveCredentialId,
+ ownerId,
+ )) as Record | null;
+
+ if (cred) {
+ host.password = pickResolvedPassword(host.password, cred.password);
+ // Prefer the normalised private key; fall back to raw key field
+ host.key = (cred.privateKey || cred.key) as string | null;
+ host.keyPassword = cred.keyPassword;
+ host.keyType = cred.keyType;
+ // CA-signed certificate for cert-based auth
+ (host as Record).certPublicKey =
+ cred.certPublicKey || null;
+ host.username = pickResolvedUsername(
+ host.username,
+ cred.username,
+ host.overrideCredentialUsername,
+ );
+ host.authType = host.key
+ ? "key"
+ : host.password
+ ? "password"
+ : "none";
+ }
+ } catch (e) {
+ sshLogger.warn("Failed to resolve credential for host", {
+ operation: "host_resolver_credential",
+ hostId,
+ error: e instanceof Error ? e.message : "Unknown",
+ });
}
- } catch (e) {
- sshLogger.warn("Failed to resolve credential for host", {
- operation: "host_resolver_credential",
- hostId,
- error: e instanceof Error ? e.message : "Unknown",
- });
}
}
diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts
index b934a34d..8f71f1d9 100644
--- a/src/backend/hosts/metrics/index.ts
+++ b/src/backend/hosts/metrics/index.ts
@@ -32,6 +32,7 @@ import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
+import { collectTemperatureMetrics } from "./widgets/temperature-collector.js";
import {
createSocks5Connection,
type SOCKS5Config,
@@ -146,6 +147,7 @@ const DEFAULT_STATS_CONFIG: StatsConfig = {
"processes",
"ports",
"firewall",
+ "temperature",
],
statusCheckEnabled: true,
statusCheckInterval: 60,
@@ -1582,6 +1584,21 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
// expected
}
+ let temperature: {
+ source: "sysfs" | "sensors" | "none";
+ highestCelsius: number | null;
+ sensors: Array<{ label: string; celsius: number }>;
+ } = {
+ source: "none",
+ highestCelsius: null,
+ sensors: [],
+ };
+ try {
+ temperature = await collectTemperatureMetrics(client);
+ } catch {
+ // expected
+ }
+
const result = {
cpu,
memory,
@@ -1593,6 +1610,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
login_stats,
ports,
firewall,
+ temperature,
};
metricsCache.set(host.id, result);
diff --git a/src/backend/hosts/metrics/widgets/disk-collector.ts b/src/backend/hosts/metrics/widgets/disk-collector.ts
index 79ae76c2..eec042d3 100644
--- a/src/backend/hosts/metrics/widgets/disk-collector.ts
+++ b/src/backend/hosts/metrics/widgets/disk-collector.ts
@@ -1,6 +1,67 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
+const PSEUDO_FS_RE = /^(tmpfs|devtmpfs|overlay|udev|none|shm)$/;
+
+export interface DfRow {
+ filesystem: string;
+ mount: string;
+ parts: string[];
+}
+
+export function parseDfLines(output: string): DfRow[] {
+ return output
+ .split("\n")
+ .map((l) => l.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const parts = line.split(/\s+/);
+ return { filesystem: parts[0] || "", mount: parts[5] || "", parts };
+ })
+ .filter(
+ (row) => row.parts.length >= 6 && !PSEUDO_FS_RE.test(row.filesystem),
+ );
+}
+
+// Finds the index of the most-utilized real filesystem in a `df -B1`-style
+// row set (parts[1] = total bytes, parts[2] = used bytes), so a nearly-full
+// secondary mount (e.g. /data) isn't hidden behind a healthy root filesystem.
+export function findWorstMountIndex(bytesRows: DfRow[]): {
+ index: number;
+ usedBytes: number;
+ totalBytes: number;
+} {
+ let worstIndex = -1;
+ let worstUsedBytes = -1;
+ let worstTotalBytes = 0;
+
+ bytesRows.forEach((row, index) => {
+ const totalBytes = Number(row.parts[1]);
+ const usedBytes = Number(row.parts[2]);
+ if (
+ !Number.isFinite(totalBytes) ||
+ !Number.isFinite(usedBytes) ||
+ totalBytes <= 0
+ ) {
+ return;
+ }
+ const usedRatio = usedBytes / totalBytes;
+ const worstRatio =
+ worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
+ if (usedRatio > worstRatio) {
+ worstIndex = index;
+ worstUsedBytes = usedBytes;
+ worstTotalBytes = totalBytes;
+ }
+ });
+
+ return {
+ index: worstIndex,
+ usedBytes: worstUsedBytes,
+ totalBytes: worstTotalBytes,
+ };
+}
+
export async function collectDiskMetrics(client: Client): Promise<{
percent: number | null;
usedHuman: string | null;
@@ -14,41 +75,28 @@ export async function collectDiskMetrics(client: Client): Promise<{
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
- execCommand(client, "df -h -P / | tail -n +2"),
- execCommand(client, "df -B1 -P / | tail -n +2"),
+ execCommand(client, "df -h -P | tail -n +2"),
+ execCommand(client, "df -B1 -P | tail -n +2"),
]);
- const humanLine =
- diskOutHuman.stdout
- .split("\n")
- .map((l) => l.trim())
- .filter(Boolean)[0] || "";
- const bytesLine =
- diskOutBytes.stdout
- .split("\n")
- .map((l) => l.trim())
- .filter(Boolean)[0] || "";
+ const humanRows = parseDfLines(diskOutHuman.stdout);
+ const bytesRows = parseDfLines(diskOutBytes.stdout);
+ const worst = findWorstMountIndex(bytesRows);
- const humanParts = humanLine.split(/\s+/);
- const bytesParts = bytesLine.split(/\s+/);
+ if (worst.totalBytes > 0) {
+ diskPercent = Math.max(
+ 0,
+ Math.min(100, (worst.usedBytes / worst.totalBytes) * 100),
+ );
- if (humanParts.length >= 6 && bytesParts.length >= 6) {
- totalHuman = humanParts[1] || null;
- usedHuman = humanParts[2] || null;
- availableHuman = humanParts[3] || null;
-
- const totalBytes = Number(bytesParts[1]);
- const usedBytes = Number(bytesParts[2]);
-
- if (
- Number.isFinite(totalBytes) &&
- Number.isFinite(usedBytes) &&
- totalBytes > 0
- ) {
- diskPercent = Math.max(
- 0,
- Math.min(100, (usedBytes / totalBytes) * 100),
- );
+ const humanRow =
+ humanRows.length === bytesRows.length
+ ? humanRows[worst.index]
+ : humanRows.find((row) => row.mount === bytesRows[worst.index].mount);
+ if (humanRow) {
+ totalHuman = humanRow.parts[1] || null;
+ usedHuman = humanRow.parts[2] || null;
+ availableHuman = humanRow.parts[3] || null;
}
}
} catch {
diff --git a/src/backend/hosts/serial.ts b/src/backend/hosts/serial.ts
index 7868c2eb..39bd7985 100644
--- a/src/backend/hosts/serial.ts
+++ b/src/backend/hosts/serial.ts
@@ -21,6 +21,12 @@ const authManager = AuthManager.getInstance();
const wss = new WebSocketServer({ port: 30011 });
+wss.on("error", (error) => {
+ sshLogger.error("Serial WebSocket server error", error, {
+ operation: "wss_error",
+ });
+});
+
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
diff --git a/src/backend/hosts/session-sharing/routes.ts b/src/backend/hosts/session-sharing/routes.ts
new file mode 100644
index 00000000..4e37452d
--- /dev/null
+++ b/src/backend/hosts/session-sharing/routes.ts
@@ -0,0 +1,539 @@
+import crypto from "crypto";
+import express from "express";
+import type { Request, Response } from "express";
+import type { AuthenticatedRequest } from "../../../types/index.js";
+import { AuthManager } from "../../utils/auth-manager.js";
+import { PermissionManager } from "../../utils/permission-manager.js";
+import { sshLogger } from "../../utils/logger.js";
+import { sessionManager } from "../terminal/session-manager.js";
+import { getGuacSessionInfo } from "../guacamole/guacamole-server.js";
+import { GuacamoleTokenService } from "../guacamole/token-service.js";
+import {
+ createCurrentSessionShareRepository,
+ createCurrentSettingsRepository,
+ createCurrentHostResolutionRepository,
+} from "../../database/repositories/factory.js";
+
+const router = express.Router();
+const authManager = AuthManager.getInstance();
+const authenticateJWT = authManager.createAuthMiddleware();
+const permissionManager = PermissionManager.getInstance();
+const tokenService = GuacamoleTokenService.getInstance();
+
+const DEFAULT_EXPIRY_HOURS = 24;
+const MAX_EXPIRY_HOURS = 24 * 30;
+
+type Protocol = "ssh" | "rdp" | "vnc" | "telnet";
+type PermissionLevel = "read-only" | "read-write";
+
+interface ResolveRateEntry {
+ count: number;
+ windowStart: number;
+}
+const resolveAttempts = new Map();
+const RESOLVE_WINDOW_MS = 60 * 1000;
+const RESOLVE_MAX_ATTEMPTS = 30;
+
+function isResolveRateLimited(ip: string): boolean {
+ const now = Date.now();
+ const entry = resolveAttempts.get(ip);
+ if (!entry || now - entry.windowStart > RESOLVE_WINDOW_MS) {
+ resolveAttempts.set(ip, { count: 1, windowStart: now });
+ return false;
+ }
+ entry.count += 1;
+ return entry.count > RESOLVE_MAX_ATTEMPTS;
+}
+
+setInterval(
+ () => {
+ const now = Date.now();
+ for (const [ip, entry] of resolveAttempts.entries()) {
+ if (now - entry.windowStart > RESOLVE_WINDOW_MS)
+ resolveAttempts.delete(ip);
+ }
+ },
+ 5 * 60 * 1000,
+);
+
+async function isSharingEnabledForHost(hostId: number): Promise<{
+ enabled: boolean;
+ hostOwnerId: string | null;
+}> {
+ const globalEnabled = await createCurrentSettingsRepository().getBoolean(
+ "session_sharing_globally_enabled",
+ true,
+ );
+ if (!globalEnabled) return { enabled: false, hostOwnerId: null };
+
+ const hostResolutionRepository = createCurrentHostResolutionRepository();
+ const hostOwnerId = await hostResolutionRepository.findHostOwnerId(hostId);
+ if (!hostOwnerId) return { enabled: false, hostOwnerId: null };
+
+ const host = await hostResolutionRepository.findHostById(hostId, hostOwnerId);
+ if (!host) return { enabled: false, hostOwnerId: null };
+
+ return {
+ enabled: host.allowSessionSharing !== false,
+ hostOwnerId,
+ };
+}
+
+function computeExpiresAt(expiryHours: number | undefined): string {
+ const hours = Math.min(
+ Math.max(expiryHours ?? DEFAULT_EXPIRY_HOURS, 1),
+ MAX_EXPIRY_HOURS,
+ );
+ return new Date(Date.now() + hours * 60 * 60 * 1000).toISOString();
+}
+
+function isLiveSessionOwnedBy(
+ protocol: Protocol,
+ sessionId: string,
+ userId: string,
+): boolean {
+ if (protocol === "ssh") {
+ const session = sessionManager.getSession(sessionId);
+ return !!session && session.isConnected && session.userId === userId;
+ }
+ const info = getGuacSessionInfo(sessionId);
+ return !!info && info.ownerUserId === userId;
+}
+
+function isLiveSession(protocol: Protocol, sessionId: string): boolean {
+ if (protocol === "ssh") {
+ const session = sessionManager.getSession(sessionId);
+ return !!session && session.isConnected;
+ }
+ return !!getGuacSessionInfo(sessionId);
+}
+
+/**
+ * @openapi
+ * /session-sharing/create:
+ * post:
+ * summary: Create a session share (link or targeted user)
+ * description: Mints a share grant for a live terminal/RDP/VNC/Telnet session. Caller must own the live session.
+ * tags:
+ * - Session Sharing
+ * security:
+ * - bearerAuth: []
+ * requestBody:
+ * required: true
+ * content:
+ * application/json:
+ * schema:
+ * type: object
+ * required:
+ * - hostId
+ * - sessionId
+ * - protocol
+ * - shareType
+ * - permissionLevel
+ * properties:
+ * hostId:
+ * type: integer
+ * sessionId:
+ * type: string
+ * tabInstanceId:
+ * type: string
+ * protocol:
+ * type: string
+ * enum: [ssh, rdp, vnc, telnet]
+ * shareType:
+ * type: string
+ * enum: [link, user]
+ * targetUserId:
+ * type: string
+ * permissionLevel:
+ * type: string
+ * enum: [read-only, read-write]
+ * expiryHours:
+ * type: number
+ * responses:
+ * 200:
+ * description: Share created
+ * 400:
+ * description: Invalid request
+ * 403:
+ * description: Sharing disabled, or caller does not own the session
+ * 500:
+ * description: Server error
+ */
+router.post("/create", authenticateJWT, async (req: Request, res: Response) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId!;
+ const {
+ hostId,
+ sessionId,
+ tabInstanceId,
+ protocol,
+ shareType,
+ targetUserId,
+ permissionLevel,
+ expiryHours,
+ } = req.body ?? {};
+
+ if (!hostId || !sessionId || !protocol || !shareType || !permissionLevel) {
+ return res.status(400).json({ error: "Missing required fields" });
+ }
+ if (!["ssh", "rdp", "vnc", "telnet"].includes(protocol)) {
+ return res.status(400).json({ error: "Invalid protocol" });
+ }
+ if (!["link", "user"].includes(shareType)) {
+ return res.status(400).json({ error: "Invalid shareType" });
+ }
+ if (!["read-only", "read-write"].includes(permissionLevel)) {
+ return res.status(400).json({ error: "Invalid permissionLevel" });
+ }
+ if (shareType === "user" && !targetUserId) {
+ return res
+ .status(400)
+ .json({ error: "targetUserId is required for user shares" });
+ }
+
+ const numericHostId = Number(hostId);
+
+ const { enabled: sharingEnabled } =
+ await isSharingEnabledForHost(numericHostId);
+ if (!sharingEnabled) {
+ return res
+ .status(403)
+ .json({ error: "Session sharing is disabled for this host" });
+ }
+
+ if (!isLiveSessionOwnedBy(protocol, String(sessionId), userId)) {
+ return res
+ .status(403)
+ .json({ error: "You do not own this live session" });
+ }
+
+ if (shareType === "user") {
+ const accessInfo = await permissionManager.canAccessHost(
+ targetUserId,
+ numericHostId,
+ "connect",
+ );
+ if (!accessInfo.hasAccess) {
+ return res.status(403).json({
+ error: "Target user does not have access to this host",
+ });
+ }
+ }
+
+ const shareId = crypto.randomUUID();
+ const linkToken =
+ shareType === "link"
+ ? crypto.randomBytes(24).toString("base64url")
+ : null;
+ const expiresAt = computeExpiresAt(expiryHours);
+
+ const created = await createCurrentSessionShareRepository().create({
+ id: shareId,
+ hostId: numericHostId,
+ ownerUserId: userId,
+ protocol,
+ sessionId: String(sessionId),
+ tabInstanceId: tabInstanceId ?? null,
+ shareType,
+ targetUserId: shareType === "user" ? targetUserId : null,
+ linkToken,
+ permissionLevel,
+ expiresAt,
+ });
+
+ res.json({
+ shareId: created.id,
+ linkToken: created.linkToken,
+ expiresAt: created.expiresAt,
+ });
+ } catch (error) {
+ sshLogger.error("Failed to create session share", error, {
+ operation: "session_share_create_error",
+ });
+ res.status(500).json({ error: "Failed to create session share" });
+ }
+});
+
+/**
+ * @openapi
+ * /session-sharing/host/{hostId}/active:
+ * get:
+ * summary: List active session shares for a host
+ * description: Returns active (non-revoked, non-expired) shares owned by the caller for the given host.
+ * tags:
+ * - Session Sharing
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: hostId
+ * required: true
+ * schema:
+ * type: integer
+ * responses:
+ * 200:
+ * description: List of active shares
+ * 400:
+ * description: Invalid host id
+ * 500:
+ * description: Server error
+ */
+router.get(
+ "/host/:hostId/active",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId!;
+ const hostId = Number.parseInt(String(req.params.hostId), 10);
+ if (!hostId || Number.isNaN(hostId)) {
+ return res.status(400).json({ error: "Invalid host ID" });
+ }
+
+ const shares =
+ await createCurrentSessionShareRepository().findActiveSharesForHost(
+ hostId,
+ userId,
+ );
+
+ res.json({ shares });
+ } catch (error) {
+ sshLogger.error("Failed to list session shares", error, {
+ operation: "session_share_list_error",
+ });
+ res.status(500).json({ error: "Failed to list session shares" });
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /session-sharing/{shareId}:
+ * delete:
+ * summary: Revoke a session share
+ * description: Revokes a share. Owner or admin only. Best-effort kick of live SSH participants; guac joins are not force-disconnected in v1.
+ * tags:
+ * - Session Sharing
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: shareId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Share revoked
+ * 403:
+ * description: Not authorized to revoke this share
+ * 404:
+ * description: Share not found
+ * 500:
+ * description: Server error
+ */
+router.delete(
+ "/:shareId",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId!;
+ const shareId = String(req.params.shareId);
+
+ const repository = createCurrentSessionShareRepository();
+ const share = await repository.findById(shareId);
+ if (!share) {
+ return res.status(404).json({ error: "Share not found" });
+ }
+
+ let revoked = await repository.revoke(shareId, userId);
+ if (!revoked) {
+ if (await permissionManager.isAdmin(userId)) {
+ revoked = await repository.revokeAsAdmin(shareId);
+ }
+ }
+
+ if (!revoked) {
+ return res
+ .status(403)
+ .json({ error: "Not authorized to revoke this share" });
+ }
+
+ // Best-effort kick of live participants. SSH sessions support ending
+ // just the guests via ownerEndSession; guac joins aren't force-kickable
+ // from a REST handler (guacamole-lite exposes no kick API), so a revoked
+ // guac link only blocks *future* resolves until the guest's own socket ends.
+ if (share.protocol === "ssh") {
+ try {
+ sessionManager.ownerEndSession(
+ share.sessionId,
+ "Session share revoked by owner",
+ );
+ } catch {
+ // best-effort only
+ }
+ }
+
+ res.json({ success: true });
+ } catch (error) {
+ sshLogger.error("Failed to revoke session share", error, {
+ operation: "session_share_revoke_error",
+ });
+ res.status(500).json({ error: "Failed to revoke session share" });
+ }
+ },
+);
+
+/**
+ * @openapi
+ * /session-sharing/resolve/{linkToken}:
+ * get:
+ * summary: Resolve a guest share link
+ * description: Public, unauthenticated endpoint for anonymous share-link guests. Never returns host name, IP, username, or hostId. Rate-limited per IP.
+ * tags:
+ * - Session Sharing
+ * parameters:
+ * - in: path
+ * name: linkToken
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Resolved share connection info
+ * 404:
+ * description: Link not found, expired, revoked, or sharing disabled
+ * 429:
+ * description: Too many requests
+ * 500:
+ * description: Server error
+ */
+router.get("/resolve/:linkToken", async (req: Request, res: Response) => {
+ try {
+ const ip = req.ip || req.socket.remoteAddress || "unknown";
+ if (isResolveRateLimited(ip)) {
+ return res.status(429).json({ error: "Too many requests" });
+ }
+
+ const linkToken = String(req.params.linkToken);
+ const repository = createCurrentSessionShareRepository();
+ const share = await repository.findByLinkToken(linkToken);
+ if (!share) {
+ return res.status(404).json({ error: "Link not found or expired" });
+ }
+
+ const { enabled: sharingEnabled } = await isSharingEnabledForHost(
+ share.hostId,
+ );
+ if (!sharingEnabled) {
+ return res.status(404).json({ error: "Link not found or expired" });
+ }
+
+ const protocol = share.protocol as Protocol;
+ if (!isLiveSession(protocol, share.sessionId)) {
+ return res.status(404).json({ error: "Session is no longer active" });
+ }
+
+ // Field-by-field by design - never spread a host row into this response.
+ // Anonymous guests must never see hostname/IP/username/hostId (decision #5).
+ const response: {
+ protocol: Protocol;
+ permissionLevel: PermissionLevel;
+ wsPath: string;
+ connectParams?: Record;
+ } = {
+ protocol,
+ permissionLevel: share.permissionLevel as PermissionLevel,
+ wsPath:
+ protocol === "ssh"
+ ? `/terminal/ws?shareToken=${encodeURIComponent(linkToken)}`
+ : "/guacamole/websocket/",
+ };
+
+ if (protocol !== "ssh") {
+ const joinToken = tokenService.createJoinToken(
+ share.sessionId,
+ share.permissionLevel === "read-only",
+ );
+ response.connectParams = { token: joinToken };
+ }
+
+ try {
+ await repository.touchShareUsage(share.id);
+ await repository.recordParticipantJoin(share.id, null, "Guest");
+ } catch {
+ // best-effort, never fail the resolve response over audit bookkeeping
+ }
+
+ res.json(response);
+ } catch (error) {
+ sshLogger.error("Failed to resolve session share link", error, {
+ operation: "session_share_resolve_error",
+ });
+ res.status(500).json({ error: "Failed to resolve share link" });
+ }
+});
+
+/**
+ * @openapi
+ * /session-sharing/{shareId}/end:
+ * post:
+ * summary: End a shared session for all participants
+ * description: Owner-only. Terminates the underlying session and notifies joined participants. Guac protocol kick is best-effort in v1.
+ * tags:
+ * - Session Sharing
+ * security:
+ * - bearerAuth: []
+ * parameters:
+ * - in: path
+ * name: shareId
+ * required: true
+ * schema:
+ * type: string
+ * responses:
+ * 200:
+ * description: Session ended
+ * 403:
+ * description: Not the owner of this share
+ * 404:
+ * description: Share not found
+ * 500:
+ * description: Server error
+ */
+router.post(
+ "/:shareId/end",
+ authenticateJWT,
+ async (req: Request, res: Response) => {
+ try {
+ const userId = (req as AuthenticatedRequest).userId!;
+ const shareId = String(req.params.shareId);
+
+ const repository = createCurrentSessionShareRepository();
+ const share = await repository.findById(shareId);
+ if (!share) {
+ return res.status(404).json({ error: "Share not found" });
+ }
+ if (share.ownerUserId !== userId) {
+ return res.status(403).json({ error: "Not the owner of this share" });
+ }
+
+ if (share.protocol === "ssh") {
+ sessionManager.ownerEndSession(
+ share.sessionId,
+ "Session ended by owner",
+ );
+ }
+ // Guac protocols: no kick API available from a REST handler in v1 - see
+ // DELETE /:shareId for the same limitation.
+
+ res.json({ success: true });
+ } catch (error) {
+ sshLogger.error("Failed to end shared session", error, {
+ operation: "session_share_end_error",
+ });
+ res.status(500).json({ error: "Failed to end shared session" });
+ }
+ },
+);
+
+export default router;
diff --git a/src/backend/hosts/terminal/index.ts b/src/backend/hosts/terminal/index.ts
index f23b2a10..b575c749 100644
--- a/src/backend/hosts/terminal/index.ts
+++ b/src/backend/hosts/terminal/index.ts
@@ -20,7 +20,14 @@ import { SSHAuthManager } from "../auth-manager.js";
import type { ProxyNode } from "../../../types/index.js";
import { SSHHostKeyVerifier } from "../host-key-verifier.js";
import { createJumpHostChain } from "../jump-host-chain.js";
-import { sessionManager } from "./session-manager.js";
+import {
+ sessionManager,
+ isMessageAllowedForParticipant,
+} from "./session-manager.js";
+import {
+ createCurrentSessionShareRepository,
+ createCurrentSettingsRepository,
+} from "../../database/repositories/factory.js";
import {
detectTmux,
attachOrCreateTmuxSession,
@@ -105,10 +112,172 @@ const wss = new WebSocketServer({
port: 30002,
});
+wss.on("error", (error) => {
+ sshLogger.error("WebSocket server error", error, {
+ operation: "wss_error",
+ });
+});
+
+/**
+ * Auth path for anonymous share-link guests (?shareToken=).
+ * Never touches DataCrypto/user credentials - guests join an already-live
+ * stream and never decrypt stored secrets.
+ */
+async function handleShareTokenConnection(
+ ws: WebSocket,
+ req: import("http").IncomingMessage,
+ shareToken: string,
+): Promise {
+ const shareRepo = createCurrentSessionShareRepository();
+ const share = await shareRepo.findByLinkToken(shareToken);
+ if (!share) {
+ ws.close(1008, "Invalid or expired share link");
+ return;
+ }
+ if (share.protocol !== "ssh") {
+ ws.close(1008, "Unsupported share protocol");
+ return;
+ }
+
+ const globallyEnabled = await createCurrentSettingsRepository().getBoolean(
+ "session_sharing_globally_enabled",
+ true,
+ );
+ if (!globallyEnabled) {
+ ws.close(1008, "Session sharing is disabled");
+ return;
+ }
+
+ const host = await createCurrentHostResolutionRepository().findHostById(
+ share.hostId,
+ share.ownerUserId,
+ );
+ if (!host || host.allowSessionSharing === false) {
+ ws.close(1008, "Session sharing is disabled for this host");
+ return;
+ }
+
+ const session = sessionManager.getSession(share.sessionId);
+ if (!session || !session.isConnected) {
+ ws.close(1008, "Session has ended");
+ return;
+ }
+
+ const permissionLevel = share.permissionLevel as "read-write" | "read-only";
+ const joined = sessionManager.joinAsParticipant(share.sessionId, ws, {
+ userId: null,
+ permissionLevel,
+ guestLabel: "Guest",
+ shareId: share.id,
+ });
+ if (!joined) {
+ ws.close(1008, "Session is no longer active");
+ return;
+ }
+
+ shareRepo.touchShareUsage(share.id).catch(() => {});
+ shareRepo.recordParticipantJoin(share.id, null, "Guest").catch(() => {});
+
+ const buffered = sessionManager.getBuffer(joined);
+ if (buffered) {
+ ws.send(JSON.stringify({ type: "data", data: buffered }));
+ }
+ ws.send(
+ JSON.stringify({ type: "sessionAttached", sessionId: share.sessionId }),
+ );
+ ws.send(JSON.stringify({ type: "connected", message: "Joined session" }));
+
+ const currentSessionId: string = share.sessionId;
+
+ let wsAlive = true;
+ ws.on("pong", () => {
+ wsAlive = true;
+ });
+ const wsPingInterval = setInterval(() => {
+ if (ws.readyState === WebSocket.OPEN) {
+ if (!wsAlive) {
+ ws.terminate();
+ return;
+ }
+ wsAlive = false;
+ ws.ping();
+ } else {
+ clearInterval(wsPingInterval);
+ }
+ }, 30000);
+
+ ws.on("close", () => {
+ clearInterval(wsPingInterval);
+ sessionManager.removeParticipant(currentSessionId, ws);
+ sshLogger.info("Guest left shared terminal session", {
+ operation: "terminal_guest_disconnect",
+ sessionId: currentSessionId,
+ shareId: share.id,
+ });
+ });
+
+ ws.on("message", (msg: RawData) => {
+ let parsed: WebSocketMessage;
+ try {
+ parsed = JSON.parse(msg.toString()) as WebSocketMessage;
+ } catch {
+ return;
+ }
+ const { type, data } = parsed;
+
+ const liveSession = sessionManager.getSession(currentSessionId);
+ const participant = liveSession
+ ? sessionManager.getParticipantForWs(liveSession, ws)
+ : null;
+ if (!isMessageAllowedForParticipant(participant, type)) {
+ return;
+ }
+
+ switch (type) {
+ case "input": {
+ const inputData = data as string;
+ sessionManager.bufferInput(currentSessionId, inputData);
+ const inputStream = liveSession?.sshStream;
+ if (inputStream) {
+ try {
+ inputStream.write(Buffer.from(inputData, "utf8"));
+ } catch {
+ inputStream.write(Buffer.from(inputData, "latin1"));
+ }
+ }
+ break;
+ }
+ case "ping":
+ ws.send(JSON.stringify({ type: "pong" }));
+ break;
+ case "disconnect":
+ sessionManager.removeParticipant(currentSessionId, ws);
+ break;
+ default:
+ break;
+ }
+ });
+}
+
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
let sessionId: string | undefined;
+ ws.on("error", (error) => {
+ sshLogger.error("WebSocket connection error", error, {
+ operation: "ws_error",
+ sessionId,
+ });
+ });
+
+ const urlObj = new URL(req.url || "", "http://localhost");
+ const shareToken = urlObj.searchParams.get("shareToken");
+
+ if (shareToken) {
+ await handleShareTokenConnection(ws, req, shareToken);
+ return;
+ }
+
try {
let token: string | undefined;
@@ -126,7 +295,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
}
if (!token) {
- const urlObj = new URL(req.url || "", "http://localhost");
const qp = urlObj.searchParams.get("token");
if (qp) token = qp;
}
@@ -242,11 +410,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
if (currentSessionId) {
const session = sessionManager.getSession(currentSessionId);
if (session?.isConnected) {
- // Only detach if this WS is still the one attached to the session.
- // If a refresh reconnected and reattached a new WS before this close
- // event fired, we must not clobber that new attachment.
- if (session.attachedWs === ws || session.attachedWs === null) {
- sessionManager.detachWs(currentSessionId);
+ const participant = sessionManager.getParticipantForWs(session, ws);
+ if (participant && !participant.isOwner) {
+ sessionManager.removeParticipant(currentSessionId, ws);
+ } else {
+ // Only detach if this WS is still the owner's attached socket, or
+ // no owner is currently attached. If a refresh reconnected and
+ // reattached a new WS before this close event fired, we must not
+ // clobber that new attachment.
+ const ownerStillAttached = Array.from(
+ session.participants.values(),
+ ).some((p) => p.isOwner && p.ws !== ws);
+ if (!ownerStillAttached) {
+ sessionManager.detachWs(currentSessionId);
+ }
}
} else {
sessionManager.destroySession(currentSessionId);
@@ -295,6 +472,21 @@ wss.on("connection", async (ws: WebSocket, req) => {
const { type, data } = parsed;
+ // Server-side gate: non-owner participants (read-only or read-write
+ // guests/joiners) may only send input/ping/disconnect - everything else
+ // (auth flows, tmux, resize, etc.) is owner-only and silently ignored.
+ if (type !== "joinSharedSession") {
+ const gateSession = currentSessionId
+ ? sessionManager.getSession(currentSessionId)
+ : null;
+ const gateParticipant = gateSession
+ ? sessionManager.getParticipantForWs(gateSession, ws)
+ : null;
+ if (!isMessageAllowedForParticipant(gateParticipant, type)) {
+ return;
+ }
+ }
+
switch (type) {
case "connectToHost": {
const connectData = data as ConnectToHostData;
@@ -445,7 +637,20 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
- case "disconnect":
+ case "disconnect": {
+ const disconnectSession = currentSessionId
+ ? sessionManager.getSession(currentSessionId)
+ : null;
+ const disconnectParticipant = disconnectSession
+ ? sessionManager.getParticipantForWs(disconnectSession, ws)
+ : null;
+ if (disconnectParticipant && !disconnectParticipant.isOwner) {
+ if (currentSessionId) {
+ sessionManager.removeParticipant(currentSessionId, ws);
+ currentSessionId = null;
+ }
+ break;
+ }
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
currentSessionId = null;
@@ -454,6 +659,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn = null;
sshStream = null;
break;
+ }
case "get_cwd": {
const activeConn =
@@ -474,10 +680,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
execStream.stderr.on("data", () => {});
execStream.on("close", () => {
const cwd = stdout.trim() || "/";
- const attachedWs =
- sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
- if (attachedWs.readyState === WebSocket.OPEN) {
- attachedWs.send(JSON.stringify({ type: "cwd", path: cwd }));
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "cwd", path: cwd }));
}
});
});
@@ -517,10 +721,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
execStream.stderr.on("data", () => {});
execStream.on("close", () => {
const resolvedPath = stdout.trim() || requestedPath;
- const attachedWs =
- sessionManager.getSession(currentSessionId)?.attachedWs ?? ws;
- if (attachedWs.readyState === WebSocket.OPEN) {
- attachedWs.send(
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
JSON.stringify({
type: "open_file_in_editor",
path: resolvedPath,
@@ -1001,6 +1203,105 @@ wss.on("connection", async (ws: WebSocket, req) => {
break;
}
+ case "joinSharedSession": {
+ const joinData = data as { shareId: string; tabInstanceId?: string };
+ try {
+ const shareRepo = createCurrentSessionShareRepository();
+ const share = await shareRepo.findActiveById(joinData.shareId);
+ if (
+ !share ||
+ share.shareType !== "user" ||
+ share.targetUserId !== userId ||
+ share.protocol !== "ssh"
+ ) {
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message: "Share not found or not accessible",
+ }),
+ );
+ break;
+ }
+
+ const { PermissionManager } =
+ await import("../../utils/permission-manager.js");
+ const access = await PermissionManager.getInstance().canAccessHost(
+ userId,
+ share.hostId,
+ "connect",
+ );
+ if (!access.hasAccess) {
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message: "Share not found or not accessible",
+ }),
+ );
+ break;
+ }
+
+ const joinedSession = sessionManager.joinAsParticipant(
+ share.sessionId,
+ ws,
+ {
+ userId,
+ permissionLevel: share.permissionLevel as
+ | "read-write"
+ | "read-only",
+ tabInstanceId: joinData.tabInstanceId,
+ shareId: share.id,
+ },
+ );
+ if (!joinedSession) {
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message: "Shared session is no longer active",
+ }),
+ );
+ break;
+ }
+
+ currentSessionId = share.sessionId;
+ sshStream = joinedSession.sshStream;
+ sshConn = joinedSession.sshConn;
+ isConnecting = false;
+ isConnected = true;
+
+ shareRepo.touchShareUsage(share.id).catch(() => {});
+ shareRepo
+ .recordParticipantJoin(share.id, userId, null)
+ .catch(() => {});
+
+ const buffered = sessionManager.getBuffer(joinedSession);
+ if (buffered) {
+ ws.send(JSON.stringify({ type: "data", data: buffered }));
+ }
+ ws.send(
+ JSON.stringify({
+ type: "sessionAttached",
+ sessionId: share.sessionId,
+ }),
+ );
+ ws.send(
+ JSON.stringify({ type: "connected", message: "Joined session" }),
+ );
+ } catch (error) {
+ sshLogger.error("Failed to join shared session", error, {
+ operation: "terminal_join_shared_session_error",
+ userId,
+ shareId: joinData.shareId,
+ });
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message: "Failed to join shared session",
+ }),
+ );
+ }
+ break;
+ }
+
default:
sshLogger.warn("Unknown message type received", {
operation: "websocket_message_unknown_type",
@@ -1288,39 +1589,56 @@ wss.on("connection", async (ws: WebSocket, req) => {
};
}
- sendLog("dns", "info", `Starting address resolution of ${ip}`);
+ const connectsViaJumpHosts = !!(
+ hostConfig.jumpHosts &&
+ hostConfig.jumpHosts.length > 0 &&
+ hostConfig.userId
+ );
+
let connectHost = ip;
- try {
- const resolution = await resolveHostForSshConnect(ip);
- connectHost = resolution.host;
- if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
- sendLog(
- "dns",
- "success",
- `Resolved ${ip} to ${resolution.resolvedAddress}`,
- { attempts: resolution.attempts },
- );
- }
- } catch (error) {
- const message = error instanceof Error ? error.message : "Unknown error";
- sshLogger.error("SSH hostname resolution failed", error, {
- operation: "terminal_dns_resolve",
- hostId: id,
- ip,
- port,
- transient: isRetriableDnsError(error),
- });
- sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
- ws.send(
- JSON.stringify({
- type: "error",
- message: isRetriableDnsError(error)
- ? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
- : "SSH error: Could not resolve hostname from the Termix server container.",
- }),
+ if (connectsViaJumpHosts) {
+ // The target is only reachable through the jump host's network (e.g. a
+ // VPN-only address), so DNS must be resolved there, not on this host.
+ sendLog(
+ "dns",
+ "info",
+ `Skipping local address resolution of ${ip} (resolved by jump host)`,
);
- cleanupAuthState(connectionTimeout);
- return;
+ } else {
+ sendLog("dns", "info", `Starting address resolution of ${ip}`);
+ try {
+ const resolution = await resolveHostForSshConnect(ip);
+ connectHost = resolution.host;
+ if (resolution.resolvedAddress && resolution.resolvedAddress !== ip) {
+ sendLog(
+ "dns",
+ "success",
+ `Resolved ${ip} to ${resolution.resolvedAddress}`,
+ { attempts: resolution.attempts },
+ );
+ }
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Unknown error";
+ sshLogger.error("SSH hostname resolution failed", error, {
+ operation: "terminal_dns_resolve",
+ hostId: id,
+ ip,
+ port,
+ transient: isRetriableDnsError(error),
+ });
+ sendLog("dns", "error", `DNS resolution failed for ${ip}: ${message}`);
+ ws.send(
+ JSON.stringify({
+ type: "error",
+ message: isRetriableDnsError(error)
+ ? "SSH error: DNS lookup temporarily failed. Check the Docker/container DNS configuration or try again."
+ : "SSH error: Could not resolve hostname from the Termix server container.",
+ }),
+ );
+ cleanupAuthState(connectionTimeout);
+ return;
+ }
}
sendLog("tcp", "info", `Connecting to ${ip} port ${port}`);
@@ -1619,12 +1937,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
const session = sessionManager.getSession(boundSessionId);
if (session) {
sessionManager.bufferOutput(boundSessionId!, utf8String);
-
- if (session.attachedWs?.readyState === WebSocket.OPEN) {
- session.attachedWs.send(
- JSON.stringify({ type: "data", data: utf8String }),
- );
- }
+ sessionManager.broadcast(boundSessionId!, {
+ type: "data",
+ data: utf8String,
+ });
}
} catch (error) {
sshLogger.error("Error encoding terminal data", error, {
@@ -1636,34 +1952,28 @@ wss.on("connection", async (ws: WebSocket, req) => {
const session = sessionManager.getSession(boundSessionId);
if (session) {
sessionManager.bufferOutput(boundSessionId!, fallback);
-
- if (session.attachedWs?.readyState === WebSocket.OPEN) {
- session.attachedWs.send(
- JSON.stringify({ type: "data", data: fallback }),
- );
- }
+ sessionManager.broadcast(boundSessionId!, {
+ type: "data",
+ data: fallback,
+ });
}
}
});
stream.on("close", (code: number | null) => {
const session = sessionManager.getSession(boundSessionId);
- if (session?.attachedWs?.readyState === WebSocket.OPEN) {
+ if (session) {
if (code != null) {
- session.attachedWs.send(
- JSON.stringify({
- type: "session_ended",
- code,
- }),
- );
+ sessionManager.broadcast(boundSessionId!, {
+ type: "session_ended",
+ code,
+ });
} else {
- session.attachedWs.send(
- JSON.stringify({
- type: "disconnected",
- message: "Connection lost",
- graceful: true,
- }),
- );
+ sessionManager.broadcast(boundSessionId!, {
+ type: "disconnected",
+ message: "Connection lost",
+ graceful: true,
+ });
}
}
if (boundSessionId) {
@@ -1683,13 +1993,11 @@ wss.on("connection", async (ws: WebSocket, req) => {
username,
});
const session = sessionManager.getSession(boundSessionId);
- if (session?.attachedWs?.readyState === WebSocket.OPEN) {
- session.attachedWs.send(
- JSON.stringify({
- type: "error",
- message: "SSH stream error: " + err.message,
- }),
- );
+ if (session) {
+ sessionManager.broadcast(boundSessionId!, {
+ type: "error",
+ message: "SSH stream error: " + err.message,
+ });
}
});
@@ -2014,7 +2322,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
sendLog(
"auth",
"error",
- "Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
+ `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity (check tailscale.com/s/ssh for the check/action ACL syntax). If your Tailscale identity maps to a different Unix user, update the username on this host.`,
);
if (currentSessionId) {
sessionManager.destroySession(currentSessionId);
@@ -2024,8 +2332,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
ws.send(
JSON.stringify({
type: "error",
- message:
- "Tailscale SSH authentication failed. Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy permits this connection.",
+ message: `Tailscale SSH authentication failed for user "${username}". Ensure Tailscale is running on the server, SSH is advertised (tailscale set --ssh), and your ACL policy grants the "${username}" user to your identity. If your Tailscale identity maps to a different Unix user, update the username on this host.`,
}),
);
return;
diff --git a/src/backend/hosts/terminal/session-manager.ts b/src/backend/hosts/terminal/session-manager.ts
index 1e1d3845..542e8c95 100644
--- a/src/backend/hosts/terminal/session-manager.ts
+++ b/src/backend/hosts/terminal/session-manager.ts
@@ -15,6 +15,16 @@ const DEFAULT_TIMEOUT_MINUTES = 30;
const HEALTH_CHECK_INTERVAL_MS = 60_000;
const MAX_SESSIONS_PER_USER = 10;
+export interface SessionParticipant {
+ ws: WebSocket;
+ userId: string | null; // null for anonymous link guests
+ permissionLevel: "read-write" | "read-only";
+ isOwner: boolean;
+ guestLabel?: string;
+ tabInstanceId?: string;
+ joinedViaShareId?: string;
+}
+
export interface TerminalSession {
id: string;
userId: string;
@@ -32,7 +42,7 @@ export interface TerminalSession {
isConnected: boolean;
createdAt: number;
- attachedWs: WebSocket | null;
+ participants: Map;
lastDetachedAt: number | null;
detachTimeout: NodeJS.Timeout | null;
@@ -48,6 +58,33 @@ export interface TerminalSession {
sessionLoggingEnabled: boolean;
sessionStartedAt: number;
lastPersistedBytes: number;
+ terminatedByOwner: boolean;
+ terminationReason: string | null;
+}
+
+/** Message types a non-owner participant may legally send. */
+const NON_OWNER_ALLOWED_MESSAGE_TYPES = new Set([
+ "input",
+ "ping",
+ "disconnect",
+]);
+
+/**
+ * Server-side gate for whether a participant may send a given WS message
+ * type. The owner may send anything; non-owners are limited to input (if
+ * read-write), ping, and disconnect. Pure function so read-only enforcement
+ * is unit-testable without a real WebSocketServer.
+ */
+export function isMessageAllowedForParticipant(
+ participant: Pick | null,
+ messageType: string,
+): boolean {
+ if (!participant || participant.isOwner) return true;
+ if (!NON_OWNER_ALLOWED_MESSAGE_TYPES.has(messageType)) return false;
+ if (messageType === "input" && participant.permissionLevel === "read-only") {
+ return false;
+ }
+ return true;
}
class TerminalSessionManager {
@@ -81,7 +118,7 @@ class TerminalSessionManager {
const userSessions = this.getUserSessions(userId);
if (userSessions.length >= MAX_SESSIONS_PER_USER) {
const detached = userSessions
- .filter((s) => s.attachedWs === null)
+ .filter((s) => this.getOwnerParticipant(s) === null)
.sort(
(a, b) =>
(a.lastDetachedAt ?? a.createdAt) -
@@ -109,7 +146,7 @@ class TerminalSessionManager {
operation: "session_tab_duplicate_skip",
existingSessionId: existing.id,
tabInstanceId,
- hasAttachedWs: existing.attachedWs !== null,
+ hasAttachedWs: this.getOwnerParticipant(existing) !== null,
},
);
return existing.id;
@@ -151,7 +188,7 @@ class TerminalSessionManager {
rows,
isConnected: false,
createdAt: now,
- attachedWs: null,
+ participants: new Map(),
lastDetachedAt: null,
detachTimeout: null,
outputBuffer: [],
@@ -166,6 +203,8 @@ class TerminalSessionManager {
sessionLoggingEnabled,
sessionStartedAt: now,
lastPersistedBytes: 0,
+ terminatedByOwner: false,
+ terminationReason: null,
};
this.sessions.set(id, session);
@@ -199,6 +238,25 @@ class TerminalSessionManager {
session.isConnected = true;
}
+ /** Finds the owner's participant entry, if currently attached. */
+ private getOwnerParticipant(
+ session: TerminalSession,
+ ): SessionParticipant | null {
+ for (const participant of session.participants.values()) {
+ if (participant.isOwner) return participant;
+ }
+ return null;
+ }
+
+ private getOwnerEntry(
+ session: TerminalSession,
+ ): [string, SessionParticipant] | null {
+ for (const entry of session.participants.entries()) {
+ if (entry[1].isOwner) return entry;
+ }
+ return null;
+ }
+
attachWs(
sessionId: string,
userId: string,
@@ -234,8 +292,9 @@ class TerminalSessionManager {
return null;
}
+ const ownerParticipant = this.getOwnerParticipant(session);
const isDetached =
- !session.attachedWs || session.attachedWs.readyState !== WebSocket.OPEN;
+ !ownerParticipant || ownerParticipant.ws.readyState !== WebSocket.OPEN;
const isOriginalTab =
(session.attachedTabInstanceId ?? session.tabInstanceId) ===
tabInstanceId;
@@ -282,9 +341,10 @@ class TerminalSessionManager {
);
}
- if (session.attachedWs && session.attachedWs !== ws) {
+ const ownerEntry = this.getOwnerEntry(session);
+ if (ownerEntry && ownerEntry[1].ws !== ws) {
try {
- session.attachedWs.send(
+ ownerEntry[1].ws.send(
JSON.stringify({
type: "sessionTakenOver",
sessionId,
@@ -294,7 +354,7 @@ class TerminalSessionManager {
} catch {
/* ignore */
}
- session.attachedWs = null;
+ session.participants.delete(ownerEntry[0]);
}
if (session.detachTimeout) {
@@ -302,7 +362,14 @@ class TerminalSessionManager {
session.detachTimeout = null;
}
- session.attachedWs = ws;
+ const participantId = crypto.randomUUID();
+ session.participants.set(participantId, {
+ ws,
+ userId,
+ permissionLevel: "read-write",
+ isOwner: true,
+ tabInstanceId,
+ });
session.attachedTabInstanceId = tabInstanceId;
session.lastDetachedAt = null;
@@ -316,6 +383,110 @@ class TerminalSessionManager {
return session;
}
+ /**
+ * Adds a non-owner participant (in-app share join or anonymous link guest).
+ * Purely additive - never evicts the owner or any other participant.
+ */
+ joinAsParticipant(
+ sessionId: string,
+ ws: WebSocket,
+ opts: {
+ userId: string | null;
+ permissionLevel: "read-write" | "read-only";
+ guestLabel?: string;
+ tabInstanceId?: string;
+ shareId?: string;
+ },
+ ): TerminalSession | null {
+ const session = this.sessions.get(sessionId);
+ if (!session || !session.isConnected) return null;
+
+ const participantId = crypto.randomUUID();
+ session.participants.set(participantId, {
+ ws,
+ userId: opts.userId,
+ permissionLevel: opts.permissionLevel,
+ isOwner: false,
+ guestLabel: opts.guestLabel,
+ tabInstanceId: opts.tabInstanceId,
+ joinedViaShareId: opts.shareId,
+ });
+
+ sshLogger.info("Participant joined shared session", {
+ operation: "session_join_participant",
+ sessionId,
+ userId: opts.userId,
+ permissionLevel: opts.permissionLevel,
+ shareId: opts.shareId,
+ });
+
+ return session;
+ }
+
+ /** Fans out a message to every OPEN participant socket; skips closed ones and send failures. */
+ broadcast(sessionId: string, message: object): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+ const payload = JSON.stringify(message);
+ for (const participant of session.participants.values()) {
+ if (participant.ws.readyState !== WebSocket.OPEN) continue;
+ try {
+ participant.ws.send(payload);
+ } catch {
+ /* ignore individual send failures, keep broadcasting to the rest */
+ }
+ }
+ }
+
+ /** Finds the participant entry (owner or not) for a given socket. */
+ getParticipantForWs(
+ session: TerminalSession,
+ ws: WebSocket,
+ ): SessionParticipant | null {
+ for (const participant of session.participants.values()) {
+ if (participant.ws === ws) return participant;
+ }
+ return null;
+ }
+
+ /**
+ * Removes a non-owner participant's socket. No detach timeout or session
+ * destruction side effects - a guest leaving must never end the session.
+ */
+ removeParticipant(sessionId: string, ws: WebSocket): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+ for (const [id, participant] of session.participants.entries()) {
+ if (participant.ws === ws && !participant.isOwner) {
+ session.participants.delete(id);
+ sshLogger.info("Participant left shared session", {
+ operation: "session_leave_participant",
+ sessionId,
+ userId: participant.userId,
+ });
+ return;
+ }
+ }
+ }
+
+ /** Broadcasts termination to all guests, then destroys the session. */
+ ownerEndSession(sessionId: string, reason: string): void {
+ const session = this.sessions.get(sessionId);
+ if (!session) return;
+
+ this.broadcast(sessionId, { type: "sessionTerminatedByOwner", reason });
+ session.terminatedByOwner = true;
+ session.terminationReason = reason;
+
+ sshLogger.info("Owner ended shared session", {
+ operation: "session_owner_end",
+ sessionId,
+ reason,
+ });
+
+ this.destroySession(sessionId);
+ }
+
detachWs(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (!session) return;
@@ -325,7 +496,10 @@ class TerminalSessionManager {
session.detachTimeout = null;
}
- session.attachedWs = null;
+ const ownerEntry = this.getOwnerEntry(session);
+ if (ownerEntry) {
+ session.participants.delete(ownerEntry[0]);
+ }
session.lastDetachedAt = Date.now();
// Persist log immediately when the user detaches so it appears right away,
@@ -365,6 +539,23 @@ class TerminalSessionManager {
fs.promises.unlink(session.recordingPath).catch(() => {});
}
+ for (const participant of session.participants.values()) {
+ if (participant.isOwner) continue;
+ if (participant.ws.readyState !== WebSocket.OPEN) continue;
+ try {
+ participant.ws.send(
+ JSON.stringify({
+ type: "sessionExpired",
+ sessionId,
+ message: "Session has ended",
+ }),
+ );
+ } catch {
+ /* ignore */
+ }
+ }
+ session.participants.clear();
+
if (session.sshStream) {
try {
session.sshStream.end();
@@ -440,12 +631,16 @@ class TerminalSessionManager {
recordingPath: session.recordingPath,
protocol: "ssh",
format: "asciicast",
+ terminatedByOwner: session.terminatedByOwner || undefined,
+ terminationReason: session.terminationReason ?? undefined,
});
session.recordingId = created.id;
} else {
await repo.updateEnded(session.recordingId, {
endedAt: new Date(endedAt).toISOString(),
duration,
+ terminatedByOwner: session.terminatedByOwner || undefined,
+ terminationReason: session.terminationReason ?? undefined,
});
}
} catch (err) {
@@ -569,10 +764,10 @@ class TerminalSessionManager {
for (const [id, session] of this.sessions) {
if (!session.isConnected) continue;
- if (
- session.attachedWs &&
- session.attachedWs.readyState === WebSocket.OPEN
- ) {
+ const hasOpenParticipant = Array.from(session.participants.values()).some(
+ (p) => p.ws.readyState === WebSocket.OPEN,
+ );
+ if (hasOpenParticipant) {
continue;
}
diff --git a/src/backend/hosts/tunnel/index.ts b/src/backend/hosts/tunnel/index.ts
index bbbfc4fb..d4378299 100644
--- a/src/backend/hosts/tunnel/index.ts
+++ b/src/backend/hosts/tunnel/index.ts
@@ -42,9 +42,21 @@ const c2sRelayWss = new WebSocketServer({
path: "/ssh/tunnel/c2s/stream",
});
+c2sRelayWss.on("error", (error) => {
+ tunnelLogger.error("C2S relay WebSocket server error", error, {
+ operation: "c2s_relay_wss_error",
+ });
+});
+
c2sRelayWss.on("connection", (ws, req) => {
let opened = false;
+ ws.on("error", (error) => {
+ tunnelLogger.error("C2S relay WebSocket connection error", error, {
+ operation: "c2s_relay_ws_error",
+ });
+ });
+
ws.once("message", async (raw) => {
try {
const token = extractRequestToken(req);
diff --git a/src/backend/starter.ts b/src/backend/starter.ts
index dfc0a8f9..15d75092 100644
--- a/src/backend/starter.ts
+++ b/src/backend/starter.ts
@@ -15,6 +15,74 @@ import {
setGlobalLogLevel,
} from "./utils/logger.js";
+async function provisionLocalDesktopUserIfNeeded(): Promise {
+ const { createCurrentUserRepository, createCurrentRoleRepository } =
+ await import("./database/repositories/factory.js");
+ const { AuthManager } = await import("./utils/auth-manager.js");
+ const crypto = await import("crypto");
+
+ const userRepository = createCurrentUserRepository();
+ const existingCount = await userRepository.countAll();
+ if (existingCount > 0) {
+ const allUsers = await userRepository.listAll();
+ for (const user of allUsers) {
+ try {
+ await AuthManager.getInstance().registerUser(user.id);
+ } catch (dekError) {
+ systemLogger.error(
+ "Failed to verify/provision data-encryption key for existing user",
+ dekError,
+ { operation: "desktop_dek_healing", userId: user.id },
+ );
+ }
+ }
+ return;
+ }
+
+ const id = crypto.randomUUID();
+ const { isFirstUser } = await userRepository.createFirstLocalUser({
+ id,
+ username: "local",
+ passwordHash: "",
+ isOidc: false,
+ clientId: "",
+ clientSecret: "",
+ issuerUrl: "",
+ authorizationUrl: "",
+ tokenUrl: "",
+ identifierPath: "",
+ namePath: "",
+ scopes: "openid email profile",
+ totpSecret: null,
+ totpEnabled: false,
+ totpBackupCodes: null,
+ });
+
+ try {
+ await createCurrentRoleRepository().assignRoleNameToUser({
+ userId: id,
+ roleName: isFirstUser ? "admin" : "user",
+ grantedBy: id,
+ });
+ } catch (roleError) {
+ systemLogger.error(
+ "Failed to assign default role to auto-provisioned local user",
+ roleError,
+ { operation: "desktop_auto_provision_role" },
+ );
+ }
+
+ await AuthManager.getInstance().registerUser(
+ id,
+ crypto.randomBytes(32).toString("hex"),
+ );
+
+ systemLogger.success("Auto-provisioned local desktop user", {
+ operation: "desktop_auto_provision",
+ userId: id,
+ });
+}
+
(async () => {
const initStartTime = Date.now();
try {
@@ -61,6 +129,8 @@ import {
}
}
}
+ process.env.VERSION = version;
+
versionLogger.info(`Termix Backend starting - Version: ${version}`, {
operation: "startup",
version: version,
@@ -105,6 +175,10 @@ import {
await import("./utils/crypto-migration/shared-host-secrets-migration.js");
await runSharedHostSecretsMigration();
+ if (process.env.ELECTRON_EMBEDDED === "true") {
+ await provisionLocalDesktopUserIfNeeded();
+ }
+
import("./utils/opkssh-binary-manager.js").then(
({ OPKSSHBinaryManager }) => {
OPKSSHBinaryManager.ensureBinary().catch((error) => {
@@ -170,6 +244,9 @@ import {
});
}
+ const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
+ startAnalyticsHeartbeat();
+
systemLogger.success("Termix backend started successfully", {
operation: "backend_init_complete",
port: process.env.PORT || 4090,
diff --git a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts
index e3bf7a67..a68bb268 100644
--- a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts
+++ b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts
@@ -32,7 +32,9 @@ describe("DashboardServiceLinkRepository", () => {
label TEXT NOT NULL,
url TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
- created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ sync_id TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
@@ -99,8 +101,10 @@ describe("DashboardServiceLinkRepository", () => {
);
expect(writeCount).toBe(2);
- expect(await repo.deleteForUser("user-2", link.id)).toBe(false);
- expect(await repo.deleteForUser("user-1", link.id)).toBe(true);
+ expect(await repo.deleteForUser("user-2", link.id)).toBeNull();
+ expect(await repo.deleteForUser("user-1", link.id)).toEqual({
+ syncId: expect.any(String),
+ });
expect(writeCount).toBe(3);
});
diff --git a/src/backend/tests/database/repositories/homepage-item-repository.test.ts b/src/backend/tests/database/repositories/homepage-item-repository.test.ts
index 09a5dc27..3ea1525e 100644
--- a/src/backend/tests/database/repositories/homepage-item-repository.test.ts
+++ b/src/backend/tests/database/repositories/homepage-item-repository.test.ts
@@ -31,6 +31,7 @@ describe("HomepageItemRepository", () => {
title TEXT,
config TEXT NOT NULL DEFAULT '{}',
folder_id INTEGER,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -107,8 +108,10 @@ describe("HomepageItemRepository", () => {
).toBeNull();
expect(writeCount).toBe(2);
- expect(await repo.deleteForUser("user-2", item.id)).toBe(false);
- expect(await repo.deleteForUser("user-1", item.id)).toBe(true);
+ expect(await repo.deleteForUser("user-2", item.id)).toBeNull();
+ expect(await repo.deleteForUser("user-1", item.id)).toEqual({
+ syncId: expect.any(String),
+ });
expect(writeCount).toBe(3);
});
diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts
index 3973618f..e97dce23 100644
--- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts
+++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts
@@ -55,6 +55,7 @@ describe("HostRepository and CredentialRepository", () => {
cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -87,6 +88,7 @@ describe("HostRepository and CredentialRepository", () => {
vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging INTEGER NOT NULL DEFAULT 1,
+ allow_session_sharing INTEGER NOT NULL DEFAULT 1,
enable_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
@@ -150,6 +152,8 @@ describe("HostRepository and CredentialRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
+ connection_origin TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
@@ -216,18 +220,27 @@ describe("HostRepository and CredentialRepository", () => {
).toBe("primary");
expect((await repo.credentials.findById(created.id))?.name).toBe("primary");
+ // Backdate updated_at so the update's CURRENT_TIMESTAMP bump is
+ // deterministically observable regardless of clock resolution --
+ // the sync engine's last-write-wins conflict resolution depends on
+ // every mutating update actually advancing this column.
+ repo.sqlite
+ .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
+ .run("2000-01-01 00:00:00", created.id);
+
const updated = await repo.credentials.updateForUser("user-1", created.id, {
folder: "ops",
tags: "linux,admin",
});
expect(updated?.folder).toBe("ops");
+ expect(updated?.updatedAt).not.toBe("2000-01-01 00:00:00");
expect(
await repo.credentials.findByIdForUser("user-2", created.id),
).toBeNull();
- expect(await repo.credentials.deleteForUser("user-1", created.id)).toBe(
- true,
- );
+ expect(await repo.credentials.deleteForUser("user-1", created.id)).toEqual({
+ syncId: expect.any(String),
+ });
expect(
await repo.credentials.findByIdForUser("user-1", created.id),
).toBeNull();
@@ -335,15 +348,20 @@ describe("HostRepository and CredentialRepository", () => {
expect(raw.password).toBe("user-encrypted-password");
+ repo.sqlite
+ .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
+ .run("2000-01-01 00:00:00", created.id);
+
await repo.credentials.updateEncryptedForUser("user-1", created.id, {
password: "updated-secret",
});
const updatedRaw = repo.sqlite
- .prepare("SELECT password FROM ssh_credentials WHERE id = ?")
- .get(created.id) as { password: string };
+ .prepare("SELECT password, updated_at FROM ssh_credentials WHERE id = ?")
+ .get(created.id) as { password: string; updated_at: string };
expect(updatedRaw.password).toBe("user-encrypted-password");
+ expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"ssh_credentials",
expect.objectContaining({ password: "updated-secret" }),
@@ -374,7 +392,7 @@ describe("HostRepository and CredentialRepository", () => {
const onWrite = vi.fn();
const repo = await createRepositories(onWrite);
- await repo.credentials.create({
+ const primary = await repo.credentials.create({
userId: "user-1",
name: "primary",
authType: "password",
@@ -392,6 +410,9 @@ describe("HostRepository and CredentialRepository", () => {
authType: "password",
folder: "prod",
});
+ repo.sqlite
+ .prepare("UPDATE ssh_credentials SET updated_at = ? WHERE id = ?")
+ .run("2000-01-01 00:00:00", primary.id);
onWrite.mockClear();
await expect(
@@ -401,6 +422,11 @@ describe("HostRepository and CredentialRepository", () => {
expect(await repo.credentials.listFolders("user-1")).toEqual(["ops"]);
expect(await repo.credentials.listFolders("user-2")).toEqual(["prod"]);
expect(onWrite).toHaveBeenCalledTimes(1);
+
+ const renamedRow = repo.sqlite
+ .prepare("SELECT updated_at FROM ssh_credentials WHERE id = ?")
+ .get(primary.id) as { updated_at: string };
+ expect(renamedRow.updated_at).not.toBe("2000-01-01 00:00:00");
});
it("returns empty credential reads when user data is locked", async () => {
@@ -441,14 +467,21 @@ describe("HostRepository and CredentialRepository", () => {
(await repo.hosts.listByUserId("user-1")).map((item) => item.id),
).toEqual([host.id]);
+ repo.sqlite
+ .prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
+ .run("2000-01-01 00:00:00", host.id);
+
const updated = await repo.hosts.updateForUser("user-1", host.id, {
name: "web-1-renamed",
folder: "prod",
});
expect(updated?.name).toBe("web-1-renamed");
+ expect(updated?.updatedAt).not.toBe("2000-01-01 00:00:00");
expect(await repo.hosts.findByIdForUser("user-2", host.id)).toBeNull();
- expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
+ expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
+ syncId: expect.any(String),
+ });
expect(await repo.hosts.findById(host.id)).toBeNull();
});
@@ -484,15 +517,20 @@ describe("HostRepository and CredentialRepository", () => {
expect(raw.password).toBe("encrypted-host-password");
+ repo.sqlite
+ .prepare("UPDATE ssh_data SET updated_at = ? WHERE id = ?")
+ .run("2000-01-01 00:00:00", created.id);
+
await repo.hosts.updateEncryptedForUser("user-1", created.id, {
password: "updated-secret",
});
const updatedRaw = repo.sqlite
- .prepare("SELECT password FROM ssh_data WHERE id = ?")
- .get(created.id) as { password: string };
+ .prepare("SELECT password, updated_at FROM ssh_data WHERE id = ?")
+ .get(created.id) as { password: string; updated_at: string };
expect(updatedRaw.password).toBe("encrypted-host-password");
+ expect(updatedRaw.updated_at).not.toBe("2000-01-01 00:00:00");
expect(DataCrypto.encryptRecord).toHaveBeenCalledWith(
"ssh_data",
expect.objectContaining({ password: "updated-secret" }),
@@ -617,6 +655,9 @@ describe("HostRepository and CredentialRepository", () => {
username: "root",
authType: "password",
});
+ repo.sqlite
+ .prepare("UPDATE ssh_data SET updated_at = ? WHERE id IN (?, ?)")
+ .run("2000-01-01 00:00:00", first.id, second.id);
onWrite.mockClear();
const states = await repo.hosts.listBulkUpdateState("user-1", [
@@ -634,6 +675,12 @@ describe("HostRepository and CredentialRepository", () => {
expect((await repo.hosts.findById(first.id))?.folder).toBe("ops");
expect((await repo.hosts.findById(other.id))?.folder).toBeNull();
expect(onWrite).toHaveBeenCalledTimes(1);
+ expect((await repo.hosts.findById(first.id))?.updatedAt).not.toBe(
+ "2000-01-01 00:00:00",
+ );
+ expect((await repo.hosts.findById(second.id))?.updatedAt).not.toBe(
+ "2000-01-01 00:00:00",
+ );
});
it("records credential usage and increments usage counters", async () => {
@@ -686,6 +733,8 @@ describe("HostRepository and CredentialRepository", () => {
.run(host.id, "user-2", "user-1");
expect(await repo.hosts.deleteAccessForHost(host.id)).toBe(1);
- expect(await repo.hosts.deleteForUser("user-1", host.id)).toBe(true);
+ expect(await repo.hosts.deleteForUser("user-1", host.id)).toEqual({
+ syncId: expect.any(String),
+ });
});
});
diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts
index ca7df0c6..7b94bea4 100644
--- a/src/backend/tests/database/repositories/host-folder-repository.test.ts
+++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts
@@ -35,6 +35,7 @@ describe("HostFolderRepository", () => {
name TEXT NOT NULL,
folder TEXT,
auth_type TEXT NOT NULL,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -66,6 +67,7 @@ describe("HostFolderRepository", () => {
vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging INTEGER NOT NULL DEFAULT 1,
+ allow_session_sharing INTEGER NOT NULL DEFAULT 1,
enable_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
@@ -129,6 +131,8 @@ describe("HostFolderRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
+ connection_origin TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -139,6 +143,8 @@ describe("HostFolderRepository", () => {
name TEXT NOT NULL,
color TEXT,
icon TEXT,
+ credential_id INTEGER,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -216,6 +222,7 @@ describe("HostFolderRepository", () => {
"prod",
"#abcdef",
"folder",
+ undefined,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -228,6 +235,7 @@ describe("HostFolderRepository", () => {
"new",
null,
null,
+ null,
"2026-03-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -237,6 +245,28 @@ describe("HostFolderRepository", () => {
expect(writes).toBe(2);
});
+ it("assigns a credential to a folder and resolves it for nested paths", async () => {
+ const { repository } = await createRepository();
+
+ await expect(
+ repository.upsertMetadata(
+ "user-1",
+ "prod",
+ undefined,
+ undefined,
+ 1,
+ "2026-02-01T00:00:00.000Z",
+ ),
+ ).resolves.toMatchObject({
+ created: false,
+ folder: { credentialId: 1 },
+ });
+
+ const folders = await repository.listFolders("user-1");
+ const prodFolder = folders.find((f) => f.name === "prod");
+ expect(prodFolder?.credentialId).toBe(1);
+ });
+
it("lists and deletes hosts and folder records in a folder tree", async () => {
let writes = 0;
const { repository, sqlite } = await createRepository(() => {
diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts
index e90eb576..ad9d0351 100644
--- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts
+++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts
@@ -61,6 +61,7 @@ describe("HostResolutionRepository", () => {
vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging INTEGER NOT NULL DEFAULT 1,
+ allow_session_sharing INTEGER NOT NULL DEFAULT 1,
enable_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
@@ -124,6 +125,8 @@ describe("HostResolutionRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
+ connection_origin TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -147,6 +150,7 @@ describe("HostResolutionRepository", () => {
cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -165,6 +169,18 @@ describe("HostResolutionRepository", () => {
override_credential_id INTEGER
);
+ CREATE TABLE ssh_folders (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ color TEXT,
+ icon TEXT,
+ credential_id INTEGER,
+ sync_id TEXT,
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
INSERT INTO users (id, username, password_hash)
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
INSERT INTO ssh_data (
@@ -185,6 +201,11 @@ describe("HostResolutionRepository", () => {
host_id, user_id, granted_by, permission_level, override_credential_id
)
VALUES (1, 'user-2', 'user-1', 'execute', 8);
+ INSERT INTO ssh_folders (user_id, name, credential_id)
+ VALUES
+ ('user-1', 'switches', 7),
+ ('user-1', 'switches / floor1', NULL),
+ ('user-1', 'no-cred', NULL);
`);
return new HostResolutionRepository(context, onWrite);
@@ -492,4 +513,24 @@ describe("HostResolutionRepository", () => {
repository.findOverrideCredentialId(1, "user-1"),
).resolves.toBeNull();
});
+
+ it("resolves a folder's assigned credential, walking up to parent folders", async () => {
+ const repository = await createRepository();
+
+ await expect(
+ repository.findFolderCredentialId("user-1", "switches"),
+ ).resolves.toBe(7);
+ await expect(
+ repository.findFolderCredentialId("user-1", "switches / floor1"),
+ ).resolves.toBe(7);
+ await expect(
+ repository.findFolderCredentialId("user-1", "no-cred"),
+ ).resolves.toBeNull();
+ await expect(
+ repository.findFolderCredentialId("user-1", "unknown"),
+ ).resolves.toBeNull();
+ await expect(
+ repository.findFolderCredentialId("user-1", ""),
+ ).resolves.toBeNull();
+ });
});
diff --git a/src/backend/tests/database/repositories/session-share-repository.test.ts b/src/backend/tests/database/repositories/session-share-repository.test.ts
new file mode 100644
index 00000000..a7cc0318
--- /dev/null
+++ b/src/backend/tests/database/repositories/session-share-repository.test.ts
@@ -0,0 +1,393 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { TestSqliteDatabase } from "./test-support.js";
+import { SessionShareRepository } from "../../../database/repositories/session-share-repository.js";
+
+describe("SessionShareRepository", () => {
+ let adapter: TestSqliteDatabase | null = null;
+
+ afterEach(async () => {
+ if (adapter) {
+ await adapter.close();
+ adapter = null;
+ }
+ });
+
+ async function createRepository(
+ onWrite?: () => void | Promise,
+ ): Promise {
+ adapter = new TestSqliteDatabase();
+ const context = await adapter.connect();
+ context.sqlite?.exec(`
+ CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ password_hash TEXT NOT NULL
+ );
+
+ CREATE TABLE ssh_data (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ ip TEXT
+ );
+
+ CREATE TABLE session_shares (
+ id TEXT PRIMARY KEY,
+ host_id INTEGER NOT NULL,
+ owner_user_id TEXT NOT NULL,
+ protocol TEXT NOT NULL,
+ session_id TEXT NOT NULL,
+ tab_instance_id TEXT,
+ share_type TEXT NOT NULL,
+ target_user_id TEXT,
+ link_token TEXT UNIQUE,
+ permission_level TEXT NOT NULL DEFAULT 'read-only',
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ expires_at TEXT NOT NULL,
+ revoked_at TEXT,
+ last_joined_at TEXT,
+ join_count INTEGER NOT NULL DEFAULT 0
+ );
+
+ CREATE TABLE session_share_participants (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ share_id TEXT NOT NULL,
+ user_id TEXT,
+ guest_label TEXT,
+ joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ left_at TEXT
+ );
+
+ INSERT INTO users (id, username, password_hash)
+ VALUES ('owner-1', 'alice', 'hash'), ('guest-1', 'bob', 'hash');
+ INSERT INTO ssh_data (id, user_id, name, ip)
+ VALUES (1, 'owner-1', 'host-one', '10.0.0.1'), (2, 'owner-1', 'host-two', '10.0.0.2');
+ `);
+
+ return new SessionShareRepository(context, onWrite);
+ }
+
+ const FAR_FUTURE = "2999-01-01T00:00:00.000Z";
+ const FAR_PAST = "2000-01-01T00:00:00.000Z";
+
+ it("creates a share and finds it by id", async () => {
+ const repo = await createRepository();
+
+ const created = await repo.create({
+ id: "share-1",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-abc",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ expect(created).toMatchObject({
+ id: "share-1",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ linkToken: "token-abc",
+ permissionLevel: "read-only",
+ });
+
+ const found = await repo.findById("share-1");
+ expect(found).toMatchObject({ id: "share-1", sessionId: "session-1" });
+ });
+
+ it("findByLinkToken excludes revoked shares", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-revoked",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-revoked",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ expect(await repo.findByLinkToken("token-revoked")).not.toBeNull();
+
+ await repo.revoke("share-revoked", "owner-1");
+
+ expect(await repo.findByLinkToken("token-revoked")).toBeNull();
+ });
+
+ it("findByLinkToken excludes expired shares", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-expired",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-expired",
+ permissionLevel: "read-only",
+ expiresAt: FAR_PAST,
+ });
+
+ expect(await repo.findByLinkToken("token-expired")).toBeNull();
+ });
+
+ it("findByLinkToken returns active, non-expired, non-revoked shares", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-active",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "vnc",
+ sessionId: "guac-session-1",
+ shareType: "link",
+ linkToken: "token-active",
+ permissionLevel: "read-write",
+ expiresAt: FAR_FUTURE,
+ });
+
+ const found = await repo.findByLinkToken("token-active");
+ expect(found).toMatchObject({
+ id: "share-active",
+ protocol: "vnc",
+ permissionLevel: "read-write",
+ });
+ });
+
+ it("findSharesTargetingUser returns only active user-targeted shares with host/owner metadata", async () => {
+ const repo = await createRepository();
+
+ await repo.create({
+ id: "share-user-active",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "user",
+ targetUserId: "guest-1",
+ permissionLevel: "read-write",
+ expiresAt: FAR_FUTURE,
+ });
+
+ // Expired user share for the same target - must be excluded
+ await repo.create({
+ id: "share-user-expired",
+ hostId: 2,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-2",
+ shareType: "user",
+ targetUserId: "guest-1",
+ permissionLevel: "read-only",
+ expiresAt: FAR_PAST,
+ });
+
+ // Link share, not targeting a user - must be excluded even though it's active
+ await repo.create({
+ id: "share-link-active",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-3",
+ shareType: "link",
+ linkToken: "token-unrelated",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ const shares = await repo.findSharesTargetingUser("guest-1");
+ expect(shares).toHaveLength(1);
+ expect(shares[0]).toMatchObject({
+ id: "share-user-active",
+ hostName: "host-one",
+ ownerUsername: "alice",
+ });
+ });
+
+ it("revoke only affects the requesting owner's own share", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-owned",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-owned",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ expect(await repo.revoke("share-owned", "guest-1")).toBe(false);
+ expect(await repo.revoke("share-owned", "owner-1")).toBe(true);
+ });
+
+ it("revokeAsAdmin revokes regardless of owner", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-admin-target",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-admin",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ expect(await repo.revokeAsAdmin("share-admin-target")).toBe(true);
+ expect(await repo.findByLinkToken("token-admin")).toBeNull();
+ });
+
+ it("deleteExpiredShares removes only expired rows", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-old",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-old",
+ permissionLevel: "read-only",
+ expiresAt: FAR_PAST,
+ });
+ await repo.create({
+ id: "share-current",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-2",
+ shareType: "link",
+ linkToken: "token-current",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ const deletedCount = await repo.deleteExpiredShares();
+ expect(deletedCount).toBe(1);
+ expect(await repo.findById("share-old")).toBeNull();
+ expect(await repo.findById("share-current")).not.toBeNull();
+ });
+
+ it("touchShareUsage increments joinCount and sets lastJoinedAt", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-touch",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-touch",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ await repo.touchShareUsage("share-touch", "2026-01-01T00:00:00.000Z");
+ let row = await repo.findById("share-touch");
+ expect(row?.joinCount).toBe(1);
+ expect(row?.lastJoinedAt).toBe("2026-01-01T00:00:00.000Z");
+
+ await repo.touchShareUsage("share-touch", "2026-01-02T00:00:00.000Z");
+ row = await repo.findById("share-touch");
+ expect(row?.joinCount).toBe(2);
+ });
+
+ it("records and closes participant joins", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-participants",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-participants",
+ permissionLevel: "read-write",
+ expiresAt: FAR_FUTURE,
+ });
+
+ const participant = await repo.recordParticipantJoin(
+ "share-participants",
+ null,
+ "Guest",
+ );
+ expect(participant).toMatchObject({
+ shareId: "share-participants",
+ userId: null,
+ guestLabel: "Guest",
+ });
+ expect(participant.leftAt).toBeNull();
+
+ await repo.recordParticipantLeave(participant.id);
+ });
+
+ it("write hook fires on mutating operations", async () => {
+ let writeCount = 0;
+ const repo = await createRepository(() => {
+ writeCount += 1;
+ });
+
+ await repo.create({
+ id: "share-write-hook",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-write-hook",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+ expect(writeCount).toBe(1);
+
+ await repo.revoke("share-write-hook", "owner-1");
+ expect(writeCount).toBe(2);
+ });
+
+ it("deleteSharesForHost removes all shares for a host", async () => {
+ const repo = await createRepository();
+ await repo.create({
+ id: "share-host-1a",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "token-h1a",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+ await repo.create({
+ id: "share-host-1b",
+ hostId: 1,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-2",
+ shareType: "link",
+ linkToken: "token-h1b",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+ await repo.create({
+ id: "share-host-2",
+ hostId: 2,
+ ownerUserId: "owner-1",
+ protocol: "ssh",
+ sessionId: "session-3",
+ shareType: "link",
+ linkToken: "token-h2",
+ permissionLevel: "read-only",
+ expiresAt: FAR_FUTURE,
+ });
+
+ expect(await repo.deleteSharesForHost(1)).toBe(2);
+ expect(await repo.findById("share-host-2")).not.toBeNull();
+ });
+});
diff --git a/src/backend/tests/database/repositories/snippet-repository.test.ts b/src/backend/tests/database/repositories/snippet-repository.test.ts
index 8dc1d4cf..f4d51377 100644
--- a/src/backend/tests/database/repositories/snippet-repository.test.ts
+++ b/src/backend/tests/database/repositories/snippet-repository.test.ts
@@ -29,6 +29,7 @@ describe("SnippetRepository", () => {
description TEXT,
folder TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
host_filter TEXT
@@ -40,6 +41,7 @@ describe("SnippetRepository", () => {
name TEXT NOT NULL,
color TEXT,
icon TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
diff --git a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts
new file mode 100644
index 00000000..cd85f2d9
--- /dev/null
+++ b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts
@@ -0,0 +1,138 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { TestSqliteDatabase } from "./test-support.js";
+import { SyncTombstoneRepository } from "../../../database/repositories/sync-tombstone-repository.js";
+
+describe("SyncTombstoneRepository", () => {
+ let adapter: TestSqliteDatabase | null = null;
+
+ afterEach(async () => {
+ if (adapter) {
+ await adapter.close();
+ adapter = null;
+ }
+ });
+
+ async function createRepository(
+ onWrite?: () => void | Promise,
+ ): Promise {
+ adapter = new TestSqliteDatabase();
+ const context = await adapter.connect();
+ context.sqlite?.exec(`
+ CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ password_hash TEXT NOT NULL
+ );
+
+ CREATE TABLE sync_tombstones (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ entity_type TEXT NOT NULL,
+ sync_id TEXT NOT NULL,
+ deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ INSERT INTO users (id, username, password_hash)
+ VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
+ `);
+
+ return new SyncTombstoneRepository(context, onWrite);
+ }
+
+ it("records a tombstone and lists it back for the owning user", async () => {
+ let writeCount = 0;
+ const repo = await createRepository(() => {
+ writeCount += 1;
+ });
+
+ await repo.record("user-1", "hosts", "sync-abc");
+ expect(writeCount).toBe(1);
+
+ const rows = await repo.listSince("user-1", "hosts", null);
+ expect(rows).toHaveLength(1);
+ expect(rows[0]).toMatchObject({
+ userId: "user-1",
+ entityType: "hosts",
+ syncId: "sync-abc",
+ });
+ });
+
+ it("does not record a tombstone for an empty syncId", async () => {
+ const repo = await createRepository();
+ await repo.record("user-1", "hosts", "");
+ const rows = await repo.listSince("user-1", "hosts", null);
+ expect(rows).toHaveLength(0);
+ });
+
+ it("recordMany writes multiple tombstones and filters out falsy ids", async () => {
+ let writeCount = 0;
+ const repo = await createRepository(() => {
+ writeCount += 1;
+ });
+
+ await repo.recordMany("user-1", "hosts", ["a", "", "b", "c"]);
+ expect(writeCount).toBe(1);
+
+ const rows = await repo.listSince("user-1", "hosts", null);
+ expect(rows.map((r) => r.syncId).sort()).toEqual(["a", "b", "c"]);
+ });
+
+ it("recordMany is a no-op when given no syncIds", async () => {
+ let writeCount = 0;
+ const repo = await createRepository(() => {
+ writeCount += 1;
+ });
+
+ await repo.recordMany("user-1", "hosts", []);
+ expect(writeCount).toBe(0);
+ });
+
+ it("scopes listSince by userId and entityType", async () => {
+ const repo = await createRepository();
+ await repo.record("user-1", "hosts", "sync-1");
+ await repo.record("user-1", "snippets", "sync-2");
+ await repo.record("user-2", "hosts", "sync-3");
+
+ const rows = await repo.listSince("user-1", "hosts", null);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].syncId).toBe("sync-1");
+ });
+
+ it("filters listSince by the since timestamp", async () => {
+ const adapterLocal = new TestSqliteDatabase();
+ adapter = adapterLocal;
+ const context = await adapterLocal.connect();
+ context.sqlite?.exec(`
+ CREATE TABLE users (
+ id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ password_hash TEXT NOT NULL
+ );
+
+ CREATE TABLE sync_tombstones (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id TEXT NOT NULL,
+ entity_type TEXT NOT NULL,
+ sync_id TEXT NOT NULL,
+ deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );
+
+ INSERT INTO users (id, username, password_hash)
+ VALUES ('user-1', 'alice', 'hash');
+
+ INSERT INTO sync_tombstones (user_id, entity_type, sync_id, deleted_at)
+ VALUES
+ ('user-1', 'hosts', 'old', '2026-01-01T00:00:00.000Z'),
+ ('user-1', 'hosts', 'new', '2026-06-01T00:00:00.000Z');
+ `);
+ const repo = new SyncTombstoneRepository(context);
+
+ const rows = await repo.listSince(
+ "user-1",
+ "hosts",
+ "2026-03-01T00:00:00.000Z",
+ );
+ expect(rows).toHaveLength(1);
+ expect(rows[0].syncId).toBe("new");
+ });
+});
diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts
index d52c2c35..2a2796f3 100644
--- a/src/backend/tests/database/repositories/user-data-export-repository.test.ts
+++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts
@@ -49,6 +49,7 @@ describe("UserDataExportRepository", () => {
vault_profile_id INTEGER,
enable_terminal INTEGER NOT NULL DEFAULT 1,
enable_session_logging INTEGER NOT NULL DEFAULT 1,
+ allow_session_sharing INTEGER NOT NULL DEFAULT 1,
enable_command_history INTEGER NOT NULL DEFAULT 1,
enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT,
@@ -112,6 +113,8 @@ describe("UserDataExportRepository", () => {
host_key_first_seen TEXT,
host_key_last_verified TEXT,
host_key_changed_count INTEGER DEFAULT 0,
+ connection_origin TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -135,6 +138,7 @@ describe("UserDataExportRepository", () => {
cert_public_key TEXT,
usage_count INTEGER NOT NULL DEFAULT 0,
last_used TEXT,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
diff --git a/src/backend/tests/database/repositories/user-preference-repository.test.ts b/src/backend/tests/database/repositories/user-preference-repository.test.ts
index 3ff56e19..307fa363 100644
--- a/src/backend/tests/database/repositories/user-preference-repository.test.ts
+++ b/src/backend/tests/database/repositories/user-preference-repository.test.ts
@@ -47,6 +47,8 @@ describe("UserPreferenceRepository", () => {
hidden_rail_tabs TEXT,
compact_host_view INTEGER,
status_color_scheme TEXT,
+ custom_themes TEXT,
+ custom_keybindings TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
diff --git a/src/backend/tests/database/repositories/vault-profile-repository.test.ts b/src/backend/tests/database/repositories/vault-profile-repository.test.ts
index 2c6cdfc9..c0ade51e 100644
--- a/src/backend/tests/database/repositories/vault-profile-repository.test.ts
+++ b/src/backend/tests/database/repositories/vault-profile-repository.test.ts
@@ -40,6 +40,7 @@ describe("VaultProfileRepository", () => {
valid_principals TEXT,
key_type TEXT,
shared INTEGER NOT NULL DEFAULT 0,
+ sync_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -119,8 +120,8 @@ describe("VaultProfileRepository", () => {
});
expect(await repo.updateById(999, { name: "missing" })).toBeNull();
- expect(await repo.deleteById(1)).toBe(true);
- expect(await repo.deleteById(1)).toBe(false);
+ expect(await repo.deleteById(1)).toEqual({ syncId: null });
+ expect(await repo.deleteById(1)).toBeNull();
expect(await repo.findById(1)).toBeNull();
expect(writeCount).toBe(2);
});
diff --git a/src/backend/tests/database/routes/desktop-auto-session.test.ts b/src/backend/tests/database/routes/desktop-auto-session.test.ts
new file mode 100644
index 00000000..9c11826e
--- /dev/null
+++ b/src/backend/tests/database/routes/desktop-auto-session.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from "vitest";
+import type { Request } from "express";
+import type { UserRecord } from "../../../database/repositories/user-repository.js";
+import {
+ isLoopbackRequest,
+ extractBearerOrCookieToken,
+ resolveDesktopAutoSessionUser,
+} from "../../../database/routes/desktop-auto-session.js";
+
+function makeUser(overrides: Partial = {}): UserRecord {
+ return {
+ id: "user-1",
+ username: "local",
+ passwordHash: "",
+ isOidc: false,
+ totpEnabled: false,
+ isAdmin: false,
+ registeredAt: "2026-01-01T00:00:00.000Z",
+ ...overrides,
+ } as UserRecord;
+}
+
+describe("isLoopbackRequest", () => {
+ it.each(["127.0.0.1", "::1", "::ffff:127.0.0.1"])(
+ "accepts %s as loopback",
+ (ip) => {
+ expect(isLoopbackRequest({ ip, socket: {} } as unknown as Request)).toBe(
+ true,
+ );
+ },
+ );
+
+ it("accepts an IPv4-mapped loopback suffix", () => {
+ expect(
+ isLoopbackRequest({
+ ip: "::ffff:127.0.0.1",
+ socket: {},
+ } as unknown as Request),
+ ).toBe(true);
+ });
+
+ it("rejects a non-loopback IP", () => {
+ expect(
+ isLoopbackRequest({
+ ip: "192.168.1.50",
+ socket: {},
+ } as unknown as Request),
+ ).toBe(false);
+ });
+
+ it("falls back to socket.remoteAddress when req.ip is empty", () => {
+ expect(
+ isLoopbackRequest({
+ ip: "",
+ socket: { remoteAddress: "127.0.0.1" },
+ } as unknown as Request),
+ ).toBe(true);
+ });
+});
+
+describe("extractBearerOrCookieToken", () => {
+ it("prefers the jwt cookie over the Authorization header", () => {
+ const req = {
+ cookies: { jwt: "cookie-token" },
+ headers: { authorization: "Bearer header-token" },
+ } as unknown as Request;
+ expect(extractBearerOrCookieToken(req)).toBe("cookie-token");
+ });
+
+ it("falls back to a Bearer Authorization header", () => {
+ const req = {
+ cookies: {},
+ headers: { authorization: "Bearer header-token" },
+ } as unknown as Request;
+ expect(extractBearerOrCookieToken(req)).toBe("header-token");
+ });
+
+ it("returns undefined when neither is present", () => {
+ const req = { cookies: {}, headers: {} } as unknown as Request;
+ expect(extractBearerOrCookieToken(req)).toBeUndefined();
+ });
+
+ it("ignores a non-Bearer Authorization header", () => {
+ const req = {
+ cookies: {},
+ headers: { authorization: "Basic abc123" },
+ } as unknown as Request;
+ expect(extractBearerOrCookieToken(req)).toBeUndefined();
+ });
+});
+
+describe("resolveDesktopAutoSessionUser", () => {
+ it("returns the sole local user regardless of having a real password", () => {
+ const user = makeUser({ passwordHash: "$2a$10$realbcryptvaluehere" });
+ expect(resolveDesktopAutoSessionUser([user])).toBe(user);
+ });
+
+ it("returns the sole local user even when OIDC-enabled", () => {
+ const user = makeUser({ isOidc: true });
+ expect(resolveDesktopAutoSessionUser([user])).toBe(user);
+ });
+
+ it("returns the sole local user even when TOTP-enabled", () => {
+ const user = makeUser({ totpEnabled: true });
+ expect(resolveDesktopAutoSessionUser([user])).toBe(user);
+ });
+
+ it("returns the auto-provisioned passwordless placeholder", () => {
+ const user = makeUser({ passwordHash: "" });
+ expect(resolveDesktopAutoSessionUser([user])).toBe(user);
+ });
+
+ it("declines when zero users exist", () => {
+ expect(resolveDesktopAutoSessionUser([])).toBeNull();
+ });
+
+ it("never declines for a multi-user local database -- prefers the admin account", () => {
+ const admin = makeUser({
+ id: "user-2",
+ isAdmin: true,
+ registeredAt: "2026-02-01T00:00:00.000Z",
+ });
+ const result = resolveDesktopAutoSessionUser([
+ makeUser({
+ id: "user-1",
+ isAdmin: false,
+ registeredAt: "2026-01-01T00:00:00.000Z",
+ }),
+ admin,
+ ]);
+ expect(result).toBe(admin);
+ });
+
+ it("falls back to the earliest-registered account when no admin exists", () => {
+ const earliest = makeUser({
+ id: "user-1",
+ registeredAt: "2026-01-01T00:00:00.000Z",
+ });
+ const result = resolveDesktopAutoSessionUser([
+ makeUser({ id: "user-2", registeredAt: "2026-03-01T00:00:00.000Z" }),
+ earliest,
+ makeUser({ id: "user-3", registeredAt: "2026-02-01T00:00:00.000Z" }),
+ ]);
+ expect(result).toBe(earliest);
+ });
+});
diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts
index eb0a863f..812f99bf 100644
--- a/src/backend/tests/database/routes/host-normalizers.test.ts
+++ b/src/backend/tests/database/routes/host-normalizers.test.ts
@@ -160,6 +160,28 @@ describe("stripSensitiveFields", () => {
expect(result.hasPassword).toBe(false);
expect(result.hasKey).toBe(false);
});
+
+ it("strips rdp/vnc/telnet passwords and adds their presence flags", () => {
+ const result = stripSensitiveFields({
+ name: "rdp-box",
+ rdpPassword: "rdp-secret",
+ vncPassword: "vnc-secret",
+ telnetPassword: "telnet-secret",
+ });
+ expect(result.rdpPassword).toBeUndefined();
+ expect(result.vncPassword).toBeUndefined();
+ expect(result.telnetPassword).toBeUndefined();
+ expect(result.hasRdpPassword).toBe(true);
+ expect(result.hasVncPassword).toBe(true);
+ expect(result.hasTelnetPassword).toBe(true);
+ });
+
+ it("marks rdp/vnc/telnet presence flags false when absent", () => {
+ const result = stripSensitiveFields({ name: "rdp-box" });
+ expect(result.hasRdpPassword).toBe(false);
+ expect(result.hasVncPassword).toBe(false);
+ expect(result.hasTelnetPassword).toBe(false);
+ });
});
describe("transformHostResponse", () => {
diff --git a/src/backend/tests/database/routes/keybinding-validation.test.ts b/src/backend/tests/database/routes/keybinding-validation.test.ts
new file mode 100644
index 00000000..0802be37
--- /dev/null
+++ b/src/backend/tests/database/routes/keybinding-validation.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest";
+import {
+ isValidKeyCombo,
+ isValidKeybindingAction,
+ isValidKeybinding,
+} from "../../../database/routes/keybinding-validation.js";
+
+const validCombo = {
+ key: "c",
+ isCode: false,
+ ctrl: true,
+ alt: false,
+ shift: false,
+ meta: false,
+};
+
+describe("isValidKeyCombo", () => {
+ it("accepts a well-formed combo", () => {
+ expect(isValidKeyCombo(validCombo)).toBe(true);
+ });
+
+ it("rejects a combo missing a boolean field", () => {
+ const { ctrl: _ctrl, ...rest } = validCombo;
+ expect(isValidKeyCombo(rest)).toBe(false);
+ });
+
+ it("rejects a non-object", () => {
+ expect(isValidKeyCombo("ctrl+c")).toBe(false);
+ expect(isValidKeyCombo(null)).toBe(false);
+ });
+});
+
+describe("isValidKeybindingAction", () => {
+ it("accepts copy and paste with no extra fields", () => {
+ expect(isValidKeybindingAction({ type: "copy" })).toBe(true);
+ expect(isValidKeybindingAction({ type: "paste" })).toBe(true);
+ });
+
+ it("rejects an unknown action type", () => {
+ expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
+ });
+
+ it("requires text for sendText", () => {
+ expect(isValidKeybindingAction({ type: "sendText" })).toBe(false);
+ expect(isValidKeybindingAction({ type: "sendText", text: "ls -la" })).toBe(
+ true,
+ );
+ });
+
+ it("requires a single-letter controlCode for sendControlCode", () => {
+ expect(
+ isValidKeybindingAction({ type: "sendControlCode", controlCode: "w" }),
+ ).toBe(true);
+ expect(
+ isValidKeybindingAction({ type: "sendControlCode", controlCode: "ww" }),
+ ).toBe(false);
+ expect(
+ isValidKeybindingAction({ type: "sendControlCode", controlCode: "1" }),
+ ).toBe(false);
+ expect(isValidKeybindingAction({ type: "sendControlCode" })).toBe(false);
+ });
+
+ it("requires snippetId for runSnippet", () => {
+ expect(
+ isValidKeybindingAction({ type: "runSnippet", snippetId: "42" }),
+ ).toBe(true);
+ expect(isValidKeybindingAction({ type: "runSnippet" })).toBe(false);
+ });
+});
+
+describe("isValidKeybinding", () => {
+ const base = {
+ id: "kb-1",
+ enabled: true,
+ combo: validCombo,
+ action: { type: "copy" },
+ };
+
+ it("accepts a well-formed keybinding", () => {
+ expect(isValidKeybinding(base)).toBe(true);
+ });
+
+ it("rejects a keybinding missing id", () => {
+ const { id: _id, ...rest } = base;
+ expect(isValidKeybinding(rest)).toBe(false);
+ });
+
+ it("rejects a keybinding missing enabled", () => {
+ const { enabled: _enabled, ...rest } = base;
+ expect(isValidKeybinding(rest)).toBe(false);
+ });
+
+ it("rejects a keybinding with an invalid combo", () => {
+ expect(isValidKeybinding({ ...base, combo: {} })).toBe(false);
+ });
+
+ it("rejects a keybinding with an invalid action", () => {
+ expect(isValidKeybinding({ ...base, action: { type: "sendText" } })).toBe(
+ false,
+ );
+ });
+});
diff --git a/src/backend/tests/database/routes/sync.test.ts b/src/backend/tests/database/routes/sync.test.ts
new file mode 100644
index 00000000..55d27bb2
--- /dev/null
+++ b/src/backend/tests/database/routes/sync.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import {
+ isValidEntityType,
+ stripWritePayload,
+} from "../../../database/routes/sync.js";
+
+describe("isValidEntityType", () => {
+ it("accepts every whitelisted sync entity type", () => {
+ for (const type of [
+ "hosts",
+ "sshCredentials",
+ "sshFolders",
+ "snippets",
+ "snippetFolders",
+ "vaultProfiles",
+ "dashboardServiceLinks",
+ "homepageItems",
+ ]) {
+ expect(isValidEntityType(type)).toBe(true);
+ }
+ });
+
+ it("rejects unknown or non-string entity types", () => {
+ expect(isValidEntityType("hostAccess")).toBe(false);
+ expect(isValidEntityType("")).toBe(false);
+ expect(isValidEntityType(undefined)).toBe(false);
+ expect(isValidEntityType(42)).toBe(false);
+ });
+});
+
+describe("stripWritePayload", () => {
+ it("strips id, userId, and syncId from every entity type", () => {
+ const payload = {
+ id: 1,
+ userId: "user-1",
+ syncId: "abc",
+ name: "prod-db",
+ };
+ expect(stripWritePayload("sshFolders", payload)).toEqual({
+ name: "prod-db",
+ });
+ });
+
+ it("also strips desktop-only fields flagged read-only for hosts", () => {
+ const payload = {
+ id: 1,
+ userId: "user-1",
+ syncId: "abc",
+ name: "web",
+ connectionOrigin: "remote",
+ };
+ expect(stripWritePayload("hosts", payload)).toEqual({ name: "web" });
+ });
+
+ it("does not mutate the original payload object", () => {
+ const payload = { id: 1, userId: "user-1", syncId: "abc", name: "x" };
+ stripWritePayload("snippets", payload);
+ expect(payload).toEqual({
+ id: 1,
+ userId: "user-1",
+ syncId: "abc",
+ name: "x",
+ });
+ });
+});
diff --git a/src/backend/tests/hosts/auth-manager.test.ts b/src/backend/tests/hosts/auth-manager.test.ts
new file mode 100644
index 00000000..2edcc9d7
--- /dev/null
+++ b/src/backend/tests/hosts/auth-manager.test.ts
@@ -0,0 +1,204 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("../../database/repositories/factory.js", () => ({
+ createCurrentHostResolutionRepository: () => ({
+ findCredentialByIdForUser: async () => null,
+ }),
+}));
+
+vi.mock("../../utils/logger.js", () => ({
+ sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+ authLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
+}));
+
+import { SSHAuthManager } from "../../hosts/auth-manager.js";
+
+function createManager() {
+ const sent: Record[] = [];
+ const ws = { send: (data: string) => sent.push(JSON.parse(data)) } as any;
+ const manager = new SSHAuthManager({
+ userId: "user-1",
+ ws,
+ hostId: 1,
+ isKeyboardInteractive: false,
+ keyboardInteractiveResponded: false,
+ keyboardInteractiveFinish: null,
+ totpPromptSent: false,
+ warpgateAuthPromptSent: false,
+ totpTimeout: null,
+ warpgateAuthTimeout: null,
+ totpAttempts: 0,
+ });
+ return { manager, sent };
+}
+
+describe("SSHAuthManager.handleKeyboardInteractive", () => {
+ beforeEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("routes a TOTP verification prompt to the totp flow", () => {
+ const { manager, sent } = createManager();
+ const finish = vi.fn();
+
+ manager.handleKeyboardInteractive(
+ "",
+ "",
+ "",
+ [{ prompt: "Verification code: ", echo: true }],
+ finish,
+ { username: "root", authType: "none" },
+ );
+
+ expect(sent).toEqual([
+ {
+ type: "connection_log",
+ data: {
+ stage: "auth",
+ level: "info",
+ message: "TOTP verification required",
+ },
+ },
+ { type: "totp_required", prompt: "Verification code: " },
+ ]);
+ expect(finish).not.toHaveBeenCalled();
+ });
+
+ it("forwards echo:true for a JumpCloud-style push/TOTP menu prompt", () => {
+ const { manager, sent } = createManager();
+ const finish = vi.fn();
+
+ manager.handleKeyboardInteractive(
+ "",
+ "",
+ "",
+ [{ prompt: "Choose [1] Push, or [2] TOTP: ", echo: true }],
+ finish,
+ { username: "root", authType: "none" },
+ );
+
+ expect(sent).toEqual([
+ {
+ type: "connection_log",
+ data: {
+ stage: "auth",
+ level: "info",
+ message: "Password authentication required",
+ },
+ },
+ {
+ type: "password_required",
+ prompt: "Choose [1] Push, or [2] TOTP: ",
+ echo: true,
+ },
+ ]);
+ });
+
+ it("silently auto-answers a plain password prompt when a stored password exists", () => {
+ const { manager, sent } = createManager();
+ const finish = vi.fn();
+
+ manager.handleKeyboardInteractive(
+ "",
+ "",
+ "",
+ [{ prompt: "Password: ", echo: false }],
+ finish,
+ { username: "root", password: "hunter2", authType: "password" },
+ );
+
+ expect(finish).toHaveBeenCalledWith(["hunter2"]);
+ expect(sent).toEqual([]);
+ });
+
+ it("prompts the user for a push-confirm prompt and accepts an empty response", () => {
+ const { manager, sent } = createManager();
+ const finish = vi.fn();
+
+ manager.handleKeyboardInteractive(
+ "",
+ "",
+ "",
+ [{ prompt: "Press enter to send Push request: ", echo: true }],
+ finish,
+ { username: "root", authType: "none" },
+ );
+
+ expect(sent).toEqual([
+ {
+ type: "connection_log",
+ data: {
+ stage: "auth",
+ level: "info",
+ message: "Password authentication required",
+ },
+ },
+ {
+ type: "password_required",
+ prompt: "Press enter to send Push request: ",
+ echo: true,
+ },
+ ]);
+
+ manager.context.keyboardInteractiveFinish?.([""]);
+
+ expect(finish).toHaveBeenCalledWith([""]);
+ });
+
+ it("uses a longer timeout for push-style prompts than generic prompts", () => {
+ vi.useFakeTimers();
+ try {
+ const { manager, sent } = createManager();
+ const finish = vi.fn();
+
+ manager.handleKeyboardInteractive(
+ "",
+ "",
+ "",
+ [{ prompt: "Press enter to send Push request: ", echo: true }],
+ finish,
+ { username: "root", authType: "none" },
+ );
+
+ vi.advanceTimersByTime(180001);
+ expect(sent.some((m) => m.type === "error")).toBe(false);
+
+ vi.advanceTimersByTime(120000);
+ expect(sent.some((m) => m.type === "error")).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("routes Warpgate prompts to the warpgate flow, not the generic path", () => {
+ const { manager, sent } = createManager();
+ const finish = vi.fn();
+
+ manager.handleKeyboardInteractive(
+ "Warpgate Authentication",
+ "Visit https://warpgate.example.com/auth to continue. Security key: AB12",
+ "",
+ [{ prompt: "Press enter once done: ", echo: true }],
+ finish,
+ { username: "root", authType: "none" },
+ );
+
+ expect(sent).toEqual([
+ {
+ type: "connection_log",
+ data: {
+ stage: "auth",
+ level: "info",
+ message: "Warpgate authentication required",
+ },
+ },
+ {
+ type: "warpgate_auth_required",
+ url: "https://warpgate.example.com/auth",
+ securityKey: "AB12",
+ instructions:
+ "Visit https://warpgate.example.com/auth to continue. Security key: AB12",
+ },
+ ]);
+ });
+});
diff --git a/src/backend/tests/hosts/guacamole/token-service.test.ts b/src/backend/tests/hosts/guacamole/token-service.test.ts
index de3069a5..d6cfc994 100644
--- a/src/backend/tests/hosts/guacamole/token-service.test.ts
+++ b/src/backend/tests/hosts/guacamole/token-service.test.ts
@@ -65,4 +65,41 @@ describe("GuacamoleTokenService", () => {
expect(tokenService.decryptToken(token)?.recording).toEqual(recording);
});
+
+ it("preserves termixMeta through the encrypt/decrypt round trip", () => {
+ const termixMeta = {
+ termixConnectId: "connect-1",
+ hostId: 7,
+ ownerUserId: "user-1",
+ protocol: "rdp" as const,
+ };
+ const token = tokenService.createRdpToken(
+ "windows.example.test",
+ "Administrator",
+ "secret",
+ {},
+ undefined,
+ termixMeta,
+ );
+
+ expect(tokenService.decryptToken(token)?.termixMeta).toEqual(termixMeta);
+ });
+
+ it("createJoinToken sets connection.join, not connection.type", () => {
+ const token = tokenService.createJoinToken("guacd-conn-123", true);
+ const decrypted = tokenService.decryptToken(token);
+
+ expect(decrypted?.connection.join).toBe("guacd-conn-123");
+ expect(decrypted?.connection.type).toBeUndefined();
+ expect(decrypted?.connection.readOnly).toBe(true);
+ });
+
+ it("createJoinToken round-trips a read-write join through decryptToken", () => {
+ const token = tokenService.createJoinToken("guacd-conn-456", false);
+ const decrypted = tokenService.decryptToken(token);
+
+ expect(decrypted?.connection.join).toBe("guacd-conn-456");
+ expect(decrypted?.connection.readOnly).toBe(false);
+ expect(decrypted?.recording).toBeUndefined();
+ });
});
diff --git a/src/backend/tests/hosts/host-resolver.test.ts b/src/backend/tests/hosts/host-resolver.test.ts
index 9729fd97..68a9fc56 100644
--- a/src/backend/tests/hosts/host-resolver.test.ts
+++ b/src/backend/tests/hosts/host-resolver.test.ts
@@ -8,6 +8,7 @@ const state = vi.hoisted(() => ({
credentials: new Map>(),
sharedSecret: null as Record | null,
auditCalls: [] as Record[],
+ folderCredentialId: null as number | null,
}));
vi.mock("../../database/repositories/factory.js", () => ({
@@ -17,6 +18,7 @@ vi.mock("../../database/repositories/factory.js", () => ({
findOverrideCredentialId: async () => state.overrideCredentialId,
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ?? null,
+ findFolderCredentialId: async () => state.folderCredentialId,
}),
createCurrentVaultProfileRepository: () => ({
findById: async () => null,
@@ -101,6 +103,7 @@ beforeEach(() => {
state.credentials.clear();
state.sharedSecret = null;
state.auditCalls = [];
+ state.folderCredentialId = null;
});
describe("resolveHostById", () => {
@@ -138,6 +141,63 @@ describe("resolveHostById", () => {
expect(host.sudoPassword).toBe("owner-sudo");
});
+ it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
+ state.host = baseHost({
+ authType: "credential",
+ credentialId: null,
+ folder: "switches",
+ username: "",
+ password: null,
+ });
+ state.folderCredentialId = 11;
+ state.credentials.set("11:owner", {
+ id: 11,
+ username: "folder-user",
+ authType: "password",
+ password: "folder-pass",
+ privateKey: null,
+ key: null,
+ keyPassword: null,
+ keyType: null,
+ });
+
+ const host = (await resolveHostById(42, "owner")) as Record<
+ string,
+ unknown
+ >;
+ expect(host.password).toBe("folder-pass");
+ expect(host.username).toBe("folder-user");
+ expect(host.authType).toBe("password");
+ });
+
+ it("prefers the host's own credential over its folder's credential", async () => {
+ state.host = baseHost({
+ authType: "credential",
+ credentialId: 9,
+ folder: "switches",
+ username: "",
+ password: null,
+ });
+ state.folderCredentialId = 11;
+ state.credentials.set("9:owner", {
+ id: 9,
+ username: "host-user",
+ authType: "password",
+ password: "host-pass",
+ privateKey: null,
+ key: null,
+ keyPassword: null,
+ keyType: null,
+ });
+
+ const host = (await resolveHostById(42, "owner")) as Record<
+ string,
+ unknown
+ >;
+ expect(host.username).toBe("host-user");
+ expect(host.password).toBe("host-pass");
+ });
+
it("uses the share snapshot for a non-owner and strips owner-only secrets", async () => {
state.host = baseHost({ username: "" });
state.sharedSecret = {
diff --git a/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts b/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts
new file mode 100644
index 00000000..70d17e30
--- /dev/null
+++ b/src/backend/tests/hosts/metrics/widgets/disk-collector.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from "vitest";
+import {
+ parseDfLines,
+ findWorstMountIndex,
+} from "../../../../hosts/metrics/widgets/disk-collector.js";
+
+describe("parseDfLines", () => {
+ it("parses df -P output into rows", () => {
+ const output =
+ "/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
+ "/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n";
+ const rows = parseDfLines(output);
+ expect(rows).toHaveLength(2);
+ expect(rows[0].mount).toBe("/");
+ expect(rows[1].mount).toBe("/data");
+ });
+
+ it("filters out pseudo filesystems", () => {
+ const output =
+ "tmpfs 8000 0 8000 0% /dev/shm\n" +
+ "overlay 100 50 50 50% /\n" +
+ "/dev/sda1 100 50 50 50% /mnt/data\n";
+ const rows = parseDfLines(output);
+ expect(rows).toHaveLength(1);
+ expect(rows[0].mount).toBe("/mnt/data");
+ });
+});
+
+describe("findWorstMountIndex", () => {
+ it("picks the most-utilized mount, not just the first row", () => {
+ const rows = parseDfLines(
+ "/dev/nvme0n1p2 3848290697216 1046898851840 2606516101120 29% /\n" +
+ "/dev/nvme1n1p1 15393162788864 15239230844928 153931922841 99% /data\n",
+ );
+ const worst = findWorstMountIndex(rows);
+ expect(worst.index).toBe(1);
+ expect(worst.totalBytes).toBe(15393162788864);
+ expect(worst.usedBytes).toBe(15239230844928);
+ });
+
+ it("falls back to the only mount available", () => {
+ const rows = parseDfLines("/dev/sda1 100 30 70 30% /\n");
+ const worst = findWorstMountIndex(rows);
+ expect(worst.index).toBe(0);
+ });
+
+ it("skips rows with invalid or zero totals", () => {
+ const rows = parseDfLines(
+ "/dev/sda1 0 0 0 0% /broken\n" + "/dev/sda2 100 40 60 40% /ok\n",
+ );
+ const worst = findWorstMountIndex(rows);
+ expect(worst.index).toBe(1);
+ });
+
+ it("returns index -1 when there are no usable rows", () => {
+ const worst = findWorstMountIndex([]);
+ expect(worst.index).toBe(-1);
+ expect(worst.totalBytes).toBe(0);
+ });
+});
diff --git a/src/backend/tests/hosts/session-sharing/routes.test.ts b/src/backend/tests/hosts/session-sharing/routes.test.ts
new file mode 100644
index 00000000..052d1701
--- /dev/null
+++ b/src/backend/tests/hosts/session-sharing/routes.test.ts
@@ -0,0 +1,513 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Request, Response } from "express";
+
+const state = vi.hoisted(() => ({
+ currentUserId: "user-1",
+ globalSharingEnabled: true,
+ hosts: new Map(),
+ hostOwnerAccess: new Map(), // `${userId}:${hostId}` -> hasAccess
+ sshSessions: new Map(),
+ guacSessions: new Map<
+ string,
+ { ownerUserId: string; hostId: number; protocol: string }
+ >(),
+ shares: new Map>(),
+ admins: new Set(),
+}));
+
+vi.mock("../../../utils/logger.js", () => ({
+ sshLogger: {
+ error: vi.fn(),
+ warn: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ },
+}));
+
+vi.mock("../../../utils/auth-manager.js", () => ({
+ AuthManager: {
+ getInstance: () => ({
+ createAuthMiddleware:
+ () =>
+ (req: Record, _res: unknown, next: () => void) => {
+ req.userId = state.currentUserId;
+ next();
+ },
+ }),
+ },
+}));
+
+vi.mock("../../../utils/permission-manager.js", () => ({
+ PermissionManager: {
+ getInstance: () => ({
+ canAccessHost: async (
+ userId: string,
+ hostId: number,
+ _action: string,
+ ) => ({
+ hasAccess: state.hostOwnerAccess.get(`${userId}:${hostId}`) ?? false,
+ }),
+ isAdmin: async (userId: string) => state.admins.has(userId),
+ }),
+ },
+}));
+
+vi.mock("../../../hosts/terminal/session-manager.js", () => ({
+ sessionManager: {
+ getSession: (sessionId: string) => {
+ const session = state.sshSessions.get(sessionId);
+ if (!session) return null;
+ return { ...session };
+ },
+ ownerEndSession: vi.fn(),
+ },
+}));
+
+vi.mock("../../../hosts/guacamole/guacamole-server.js", () => ({
+ getGuacSessionInfo: (guacamoleConnectionId: string) =>
+ state.guacSessions.get(guacamoleConnectionId) ?? null,
+}));
+
+vi.mock("../../../hosts/guacamole/token-service.js", () => ({
+ GuacamoleTokenService: {
+ getInstance: () => ({
+ createJoinToken: (guacamoleConnectionId: string, readOnly: boolean) =>
+ `join-token:${guacamoleConnectionId}:${readOnly}`,
+ }),
+ },
+}));
+
+vi.mock("../../../database/repositories/factory.js", () => ({
+ createCurrentSessionShareRepository: () => ({
+ create: async (input: Record) => {
+ const row = {
+ ...input,
+ createdAt: "2026-07-20T00:00:00.000Z",
+ revokedAt: null,
+ lastJoinedAt: null,
+ joinCount: 0,
+ };
+ state.shares.set(input.id as string, row);
+ return row;
+ },
+ findById: async (id: string) => state.shares.get(id) ?? null,
+ findByLinkToken: async (linkToken: string) => {
+ for (const share of state.shares.values()) {
+ if (
+ share.linkToken === linkToken &&
+ !share.revokedAt &&
+ (share.expiresAt as string) > new Date().toISOString()
+ ) {
+ return share;
+ }
+ }
+ return null;
+ },
+ findActiveSharesForHost: async (hostId: number, ownerUserId: string) => {
+ return [...state.shares.values()].filter(
+ (s) =>
+ s.hostId === hostId && s.ownerUserId === ownerUserId && !s.revokedAt,
+ );
+ },
+ revoke: async (shareId: string, requestingUserId: string) => {
+ const share = state.shares.get(shareId);
+ if (!share || share.ownerUserId !== requestingUserId) return false;
+ share.revokedAt = "2026-07-20T01:00:00.000Z";
+ return true;
+ },
+ revokeAsAdmin: async (shareId: string) => {
+ const share = state.shares.get(shareId);
+ if (!share) return false;
+ share.revokedAt = "2026-07-20T01:00:00.000Z";
+ return true;
+ },
+ touchShareUsage: async () => {},
+ recordParticipantJoin: async () => ({ id: 1 }),
+ }),
+ createCurrentSettingsRepository: () => ({
+ getBoolean: async () => state.globalSharingEnabled,
+ }),
+ createCurrentHostResolutionRepository: () => ({
+ findHostOwnerId: async (hostId: number) =>
+ state.hosts.get(hostId)?.userId ?? null,
+ findHostById: async (hostId: number) => {
+ const host = state.hosts.get(hostId);
+ if (!host) return null;
+ return { allowSessionSharing: host.allowSessionSharing };
+ },
+ }),
+}));
+
+const { default: router } =
+ await import("../../../hosts/session-sharing/routes.js");
+
+type RouteLayer = {
+ route?: {
+ path: string;
+ methods: Record;
+ stack: {
+ handle: (req: Request, res: Response, next: () => void) => unknown;
+ }[];
+ };
+};
+
+function findHandlers(method: string, path: string) {
+ const layers = (router as unknown as { stack: RouteLayer[] }).stack;
+ const layer = layers.find(
+ (l) => l.route?.path === path && l.route.methods[method],
+ );
+ if (!layer?.route) throw new Error(`No route for ${method} ${path}`);
+ return layer.route.stack.map((s) => s.handle);
+}
+
+function makeReqRes(overrides: {
+ body?: Record;
+ params?: Record;
+ ip?: string;
+}) {
+ const req = {
+ body: overrides.body ?? {},
+ params: overrides.params ?? {},
+ headers: {},
+ ip: overrides.ip ?? "127.0.0.1",
+ socket: { remoteAddress: overrides.ip ?? "127.0.0.1" },
+ } as unknown as Request;
+
+ const res = {
+ statusCode: 200,
+ jsonBody: null as unknown,
+ status(code: number) {
+ (this as unknown as { statusCode: number }).statusCode = code;
+ return this;
+ },
+ json(payload: unknown) {
+ (this as unknown as { jsonBody: unknown }).jsonBody = payload;
+ return this;
+ },
+ } as unknown as Response & { statusCode: number; jsonBody: unknown };
+
+ return { req, res };
+}
+
+async function invoke(
+ method: string,
+ path: string,
+ overrides: {
+ body?: Record;
+ params?: Record;
+ ip?: string;
+ } = {},
+) {
+ const handlers = findHandlers(method, path);
+ const { req, res } = makeReqRes(overrides);
+
+ for (const handler of handlers) {
+ let calledNext = false;
+ await handler(req, res, () => {
+ calledNext = true;
+ });
+ if (!calledNext) break;
+ }
+
+ return res as unknown as {
+ statusCode: number;
+ jsonBody: Record | null;
+ };
+}
+
+beforeEach(() => {
+ state.currentUserId = "user-1";
+ state.globalSharingEnabled = true;
+ state.hosts = new Map([
+ [1, { userId: "user-1", allowSessionSharing: true }],
+ [2, { userId: "user-1", allowSessionSharing: false }],
+ ]);
+ state.hostOwnerAccess = new Map([["user-2:1", true]]);
+ state.sshSessions = new Map([
+ ["session-1", { userId: "user-1", isConnected: true }],
+ ]);
+ state.guacSessions = new Map([
+ ["guac-conn-1", { ownerUserId: "user-1", hostId: 1, protocol: "vnc" }],
+ ]);
+ state.shares = new Map();
+ state.admins = new Set();
+});
+
+describe("POST /session-sharing/create", () => {
+ it("rejects a caller who does not own the live session", async () => {
+ state.currentUserId = "user-2";
+ const res = await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.jsonBody).toMatchObject({
+ error: "You do not own this live session",
+ });
+ });
+
+ it("creates a link share for the session owner", async () => {
+ const res = await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect(res.jsonBody).toMatchObject({ shareId: expect.any(String) });
+ expect((res.jsonBody as Record).linkToken).toBeTruthy();
+ });
+
+ it("rejects a user share when the target lacks host access", async () => {
+ const res = await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "user",
+ targetUserId: "no-access-user",
+ permissionLevel: "read-write",
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.jsonBody).toMatchObject({
+ error: "Target user does not have access to this host",
+ });
+ });
+
+ it("global kill switch overrides an enabled per-host toggle", async () => {
+ state.globalSharingEnabled = false;
+ const res = await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ expect(res.jsonBody).toMatchObject({
+ error: "Session sharing is disabled for this host",
+ });
+ });
+
+ it("rejects when the per-host toggle is off even though global is on", async () => {
+ const res = await invoke("post", "/create", {
+ body: {
+ hostId: 2,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+
+ expect(res.statusCode).toBe(403);
+ });
+});
+
+describe("GET /session-sharing/resolve/:linkToken", () => {
+ async function createActiveLinkShare(
+ overrides: Partial> = {},
+ ) {
+ await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ ...overrides,
+ },
+ });
+ const [share] = [...state.shares.values()];
+ return share as { linkToken: string; id: string };
+ }
+
+ it("never includes hostname, ip, username, or hostId in the response body", async () => {
+ const share = await createActiveLinkShare();
+
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: share.linkToken },
+ });
+
+ expect(res.statusCode).toBe(200);
+ const body = res.jsonBody as Record;
+ const serialized = JSON.stringify(body).toLowerCase();
+
+ expect(body).not.toHaveProperty("hostname");
+ expect(body).not.toHaveProperty("ip");
+ expect(body).not.toHaveProperty("username");
+ expect(body).not.toHaveProperty("hostId");
+ expect(body).not.toHaveProperty("hostName");
+ expect(serialized).not.toContain("10.0.0");
+ expect(serialized).not.toContain("hostname");
+ expect(serialized).not.toContain('"ip"');
+ expect(serialized).not.toContain("username");
+ });
+
+ it("returns only protocol/permissionLevel/wsPath(/connectParams) for ssh", async () => {
+ const share = await createActiveLinkShare();
+
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: share.linkToken },
+ });
+
+ expect(res.jsonBody).toEqual({
+ protocol: "ssh",
+ permissionLevel: "read-only",
+ wsPath: `/terminal/ws?shareToken=${encodeURIComponent(share.linkToken)}`,
+ });
+ });
+
+ it("mints a fresh join token for guac protocols", async () => {
+ await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "guac-conn-1",
+ protocol: "vnc",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+ const [share] = [...state.shares.values()] as {
+ linkToken: string;
+ }[];
+
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: share.linkToken },
+ });
+
+ expect(res.statusCode).toBe(200);
+ expect((res.jsonBody as Record).connectParams).toEqual({
+ token: "join-token:guac-conn-1:true",
+ });
+ });
+
+ it("rejects an unknown link token", async () => {
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: "does-not-exist" },
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("rejects a revoked link token", async () => {
+ const share = await createActiveLinkShare();
+ await invoke("delete", "/:shareId", { params: { shareId: share.id } });
+
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: share.linkToken },
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("rejects an expired link token", async () => {
+ state.shares.set("share-expired", {
+ id: "share-expired",
+ hostId: 1,
+ ownerUserId: "user-1",
+ protocol: "ssh",
+ sessionId: "session-1",
+ shareType: "link",
+ linkToken: "expired-token",
+ permissionLevel: "read-only",
+ expiresAt: "2000-01-01T00:00:00.000Z",
+ revokedAt: null,
+ });
+
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: "expired-token" },
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+
+ it("re-checks the global kill switch at resolve time, not just at creation time", async () => {
+ const share = await createActiveLinkShare();
+
+ state.globalSharingEnabled = false;
+
+ const res = await invoke("get", "/resolve/:linkToken", {
+ params: { linkToken: share.linkToken },
+ });
+
+ expect(res.statusCode).toBe(404);
+ });
+});
+
+describe("DELETE /session-sharing/:shareId", () => {
+ it("allows the owner to revoke their own share", async () => {
+ await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+ const [share] = [...state.shares.values()] as { id: string }[];
+
+ const res = await invoke("delete", "/:shareId", {
+ params: { shareId: share.id },
+ });
+
+ expect(res.statusCode).toBe(200);
+ });
+
+ it("rejects a non-owner, non-admin caller", async () => {
+ await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+ const [share] = [...state.shares.values()] as { id: string }[];
+
+ state.currentUserId = "user-2";
+ const res = await invoke("delete", "/:shareId", {
+ params: { shareId: share.id },
+ });
+
+ expect(res.statusCode).toBe(403);
+ });
+
+ it("allows an admin to revoke someone else's share", async () => {
+ await invoke("post", "/create", {
+ body: {
+ hostId: 1,
+ sessionId: "session-1",
+ protocol: "ssh",
+ shareType: "link",
+ permissionLevel: "read-only",
+ },
+ });
+ const [share] = [...state.shares.values()] as { id: string }[];
+
+ state.currentUserId = "admin-1";
+ state.admins.add("admin-1");
+ const res = await invoke("delete", "/:shareId", {
+ params: { shareId: share.id },
+ });
+
+ expect(res.statusCode).toBe(200);
+ });
+});
diff --git a/src/backend/tests/hosts/terminal/session-manager.test.ts b/src/backend/tests/hosts/terminal/session-manager.test.ts
index 9a369716..5b94ed1e 100644
--- a/src/backend/tests/hosts/terminal/session-manager.test.ts
+++ b/src/backend/tests/hosts/terminal/session-manager.test.ts
@@ -49,9 +49,19 @@ vi.mock("fs", () => ({
},
}));
-const { sessionManager } =
+const { sessionManager, isMessageAllowedForParticipant } =
await import("../../../hosts/terminal/session-manager.js");
+// Minimal fake WebSocket - only the surface session-manager touches.
+function makeFakeWs(readyState = 1 /* OPEN */) {
+ return {
+ readyState,
+ send: vi.fn(),
+ } as unknown as import("ws").WebSocket;
+}
+const WS_OPEN = 1;
+const WS_CLOSED = 3;
+
describe("TerminalSessionManager - session logging", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -150,3 +160,273 @@ describe("TerminalSessionManager - session logging", () => {
sessionManager.destroySession(id);
});
});
+
+describe("TerminalSessionManager - multiplayer participants", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockMkdir.mockResolvedValue(undefined);
+ mockWriteFile.mockResolvedValue(undefined);
+ mockCreate.mockResolvedValue({ id: 1 });
+ mockUpdateEnded.mockResolvedValue(undefined);
+ });
+
+ function createConnectedSession(): string {
+ const id = sessionManager.createSession(
+ "owner-1",
+ 1,
+ "host",
+ 80,
+ 24,
+ undefined,
+ false,
+ );
+ // Mark connected without a real ssh2 stream - only isConnected is read
+ // by attachWs/joinAsParticipant.
+ const session = sessionManager.getSession(id)!;
+ session.isConnected = true;
+ return id;
+ }
+
+ it("joinAsParticipant adds a participant without evicting the owner", () => {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ const guestWs = makeFakeWs();
+ const session = sessionManager.joinAsParticipant(id, guestWs, {
+ userId: null,
+ permissionLevel: "read-only",
+ guestLabel: "Guest",
+ });
+
+ expect(session).not.toBeNull();
+ expect(session!.participants.size).toBe(2);
+ const ownerParticipant = sessionManager.getParticipantForWs(
+ session!,
+ ownerWs,
+ );
+ expect(ownerParticipant?.isOwner).toBe(true);
+ expect(ownerWs.send).not.toHaveBeenCalled();
+
+ sessionManager.destroySession(id);
+ });
+
+ it("joinAsParticipant returns null for a nonexistent or unconnected session", () => {
+ expect(
+ sessionManager.joinAsParticipant("does-not-exist", makeFakeWs(), {
+ userId: null,
+ permissionLevel: "read-only",
+ }),
+ ).toBeNull();
+ });
+
+ it("broadcast sends to all OPEN participant sockets and skips CLOSED ones", () => {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs(WS_OPEN);
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ const openGuestWs = makeFakeWs(WS_OPEN);
+ const closedGuestWs = makeFakeWs(WS_CLOSED);
+ sessionManager.joinAsParticipant(id, openGuestWs, {
+ userId: null,
+ permissionLevel: "read-only",
+ });
+ sessionManager.joinAsParticipant(id, closedGuestWs, {
+ userId: null,
+ permissionLevel: "read-only",
+ });
+
+ sessionManager.broadcast(id, { type: "data", data: "hello" });
+
+ expect(ownerWs.send).toHaveBeenCalledWith(
+ JSON.stringify({ type: "data", data: "hello" }),
+ );
+ expect(openGuestWs.send).toHaveBeenCalledWith(
+ JSON.stringify({ type: "data", data: "hello" }),
+ );
+ expect(closedGuestWs.send).not.toHaveBeenCalled();
+
+ sessionManager.destroySession(id);
+ });
+
+ it("broadcast does not throw if a socket's send throws", () => {
+ const id = createConnectedSession();
+ const throwingWs = makeFakeWs(WS_OPEN);
+ (throwingWs.send as ReturnType).mockImplementation(() => {
+ throw new Error("send failed");
+ });
+ sessionManager.attachWs(id, "owner-1", throwingWs);
+
+ expect(() =>
+ sessionManager.broadcast(id, { type: "data", data: "x" }),
+ ).not.toThrow();
+
+ sessionManager.destroySession(id);
+ });
+
+ it("broadcast is a no-op for a nonexistent session", () => {
+ expect(() =>
+ sessionManager.broadcast("does-not-exist", { type: "data" }),
+ ).not.toThrow();
+ });
+
+ it("owner detach arms the idle timeout (existing behavior)", () => {
+ vi.useFakeTimers();
+ try {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ sessionManager.detachWs(id);
+ const session = sessionManager.getSession(id);
+ expect(session?.detachTimeout).not.toBeNull();
+ expect(session?.lastDetachedAt).not.toBeNull();
+
+ sessionManager.destroySession(id);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("removeParticipant on a non-owner does not arm a timeout or destroy the session", () => {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ const guestWs = makeFakeWs();
+ sessionManager.joinAsParticipant(id, guestWs, {
+ userId: null,
+ permissionLevel: "read-write",
+ });
+
+ sessionManager.removeParticipant(id, guestWs);
+
+ const session = sessionManager.getSession(id);
+ expect(session).not.toBeNull();
+ expect(session?.detachTimeout).toBeNull();
+ expect(session?.participants.size).toBe(1);
+ expect(sessionManager.getParticipantForWs(session!, guestWs)).toBeNull();
+
+ sessionManager.destroySession(id);
+ });
+
+ it("removeParticipant is a no-op when the ws belongs to the owner", () => {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ sessionManager.removeParticipant(id, ownerWs);
+
+ const session = sessionManager.getSession(id);
+ expect(session?.participants.size).toBe(1);
+ expect(sessionManager.getParticipantForWs(session!, ownerWs)?.isOwner).toBe(
+ true,
+ );
+
+ sessionManager.destroySession(id);
+ });
+
+ it("destroySession cleans up all participants, not just the owner", () => {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ const guestWs = makeFakeWs();
+ sessionManager.joinAsParticipant(id, guestWs, {
+ userId: null,
+ permissionLevel: "read-only",
+ });
+
+ sessionManager.destroySession(id);
+
+ expect(guestWs.send).toHaveBeenCalled();
+ expect(sessionManager.getSession(id)).toBeNull();
+ });
+
+ it("ownerEndSession notifies non-owner participants and destroys the session", () => {
+ const id = createConnectedSession();
+ const ownerWs = makeFakeWs();
+ sessionManager.attachWs(id, "owner-1", ownerWs);
+
+ const guestWs = makeFakeWs();
+ sessionManager.joinAsParticipant(id, guestWs, {
+ userId: null,
+ permissionLevel: "read-write",
+ });
+
+ sessionManager.ownerEndSession(id, "owner ended the session");
+
+ expect(guestWs.send).toHaveBeenCalledWith(
+ JSON.stringify({
+ type: "sessionTerminatedByOwner",
+ reason: "owner ended the session",
+ }),
+ );
+ expect(sessionManager.getSession(id)).toBeNull();
+ });
+});
+
+describe("isMessageAllowedForParticipant", () => {
+ it("allows any message type for the owner or when there is no participant", () => {
+ expect(isMessageAllowedForParticipant(null, "connectToHost")).toBe(true);
+ expect(
+ isMessageAllowedForParticipant(
+ { isOwner: true, permissionLevel: "read-write" },
+ "resize",
+ ),
+ ).toBe(true);
+ });
+
+ it("drops input from a read-only participant", () => {
+ expect(
+ isMessageAllowedForParticipant(
+ { isOwner: false, permissionLevel: "read-only" },
+ "input",
+ ),
+ ).toBe(false);
+ });
+
+ it("allows input from a read-write non-owner participant", () => {
+ expect(
+ isMessageAllowedForParticipant(
+ { isOwner: false, permissionLevel: "read-write" },
+ "input",
+ ),
+ ).toBe(true);
+ });
+
+ it("allows ping and disconnect for any non-owner participant", () => {
+ expect(
+ isMessageAllowedForParticipant(
+ { isOwner: false, permissionLevel: "read-only" },
+ "ping",
+ ),
+ ).toBe(true);
+ expect(
+ isMessageAllowedForParticipant(
+ { isOwner: false, permissionLevel: "read-only" },
+ "disconnect",
+ ),
+ ).toBe(true);
+ });
+
+ it("blocks resize and auth/tmux message types for non-owner participants regardless of permission level", () => {
+ for (const type of [
+ "resize",
+ "totp_response",
+ "password_response",
+ "tmux_attach",
+ "tmux_detach",
+ "get_cwd",
+ "vault_start_auth",
+ "opkssh_start_auth",
+ ]) {
+ expect(
+ isMessageAllowedForParticipant(
+ { isOwner: false, permissionLevel: "read-write" },
+ type,
+ ),
+ ).toBe(false);
+ }
+ });
+});
diff --git a/src/backend/tests/utils/analytics.test.ts b/src/backend/tests/utils/analytics.test.ts
new file mode 100644
index 00000000..a64b3bdd
--- /dev/null
+++ b/src/backend/tests/utils/analytics.test.ts
@@ -0,0 +1,137 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+const mockGetBoolean = vi.fn();
+const mockGet = vi.fn();
+const mockSet = vi.fn();
+const mockPost = vi.fn();
+
+function makeChain(resolveValue: unknown) {
+ const chain: Record = {};
+ const methods = ["from", "where", "groupBy"];
+ for (const m of methods) {
+ chain[m] = vi.fn(() => chain);
+ }
+ (chain as unknown as Promise).then = (cb: (v: unknown) => unknown) =>
+ Promise.resolve(resolveValue).then(cb);
+ return chain;
+}
+
+vi.mock("../../database/repositories/factory.js", () => ({
+ createCurrentSettingsRepository: () => ({
+ getBoolean: mockGetBoolean,
+ get: mockGet,
+ set: mockSet,
+ }),
+ createCurrentRepositoryContext: () => ({
+ drizzle: {
+ select: vi.fn(() => makeChain([{ count: 0 }])),
+ },
+ }),
+}));
+
+vi.mock("../../database/db/schema.js", () => ({
+ users: {},
+ hosts: {},
+ recentActivity: { type: "type", timestamp: "timestamp" },
+}));
+
+vi.mock("../../utils/logger.js", () => ({
+ Logger: class {
+ info = vi.fn();
+ warn = vi.fn();
+ error = vi.fn();
+ },
+}));
+
+vi.mock("axios", () => ({
+ default: { post: mockPost },
+}));
+
+describe("analytics", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
+ mockGetBoolean.mockResolvedValue(true);
+ const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
+
+ const result = await isAnalyticsEnabled();
+
+ expect(result).toBe(true);
+ expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
+ });
+
+ it("getOrCreateInstanceId returns the existing id without generating one", async () => {
+ mockGet.mockResolvedValue("existing-id");
+ const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
+
+ const id = await getOrCreateInstanceId();
+
+ expect(id).toBe("existing-id");
+ expect(mockSet).not.toHaveBeenCalled();
+ });
+
+ it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
+ mockGet.mockResolvedValue(null);
+ const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
+
+ const id = await getOrCreateInstanceId();
+
+ expect(id).toMatch(/^[0-9a-f-]{36}$/);
+ expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
+ });
+
+ it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
+ delete process.env.POSTHOG_API_KEY;
+ const { collectAndSendHeartbeat } =
+ await import("../../utils/analytics.js");
+
+ await collectAndSendHeartbeat();
+
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
+ process.env.POSTHOG_API_KEY = "phc_test";
+ mockGetBoolean.mockResolvedValue(false);
+ const { collectAndSendHeartbeat } =
+ await import("../../utils/analytics.js");
+
+ await collectAndSendHeartbeat();
+
+ expect(mockPost).not.toHaveBeenCalled();
+ });
+
+ it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
+ process.env.POSTHOG_API_KEY = "phc_test";
+ mockGetBoolean.mockResolvedValue(true);
+ mockGet.mockResolvedValue("instance-123");
+ mockPost.mockResolvedValue({});
+ const { collectAndSendHeartbeat } =
+ await import("../../utils/analytics.js");
+
+ await collectAndSendHeartbeat();
+
+ expect(mockPost).toHaveBeenCalledTimes(1);
+ const [url, body] = mockPost.mock.calls[0];
+ expect(url).toContain("/capture/");
+ expect(body).toMatchObject({
+ api_key: "phc_test",
+ event: "instance_heartbeat",
+ distinct_id: "instance-123",
+ properties: expect.objectContaining({
+ user_count: 0,
+ host_count: 0,
+ used_terminal: 0,
+ }),
+ });
+ });
+});
diff --git a/src/backend/tests/utils/safe-outbound-fetch.test.ts b/src/backend/tests/utils/safe-outbound-fetch.test.ts
new file mode 100644
index 00000000..f20b5406
--- /dev/null
+++ b/src/backend/tests/utils/safe-outbound-fetch.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it, vi } from "vitest";
+import type { LookupAddress, LookupAllOptions } from "dns";
+import {
+ createDnsLookupHook,
+ isBlockedAddress,
+} from "../../utils/safe-outbound-fetch.js";
+
+describe("isBlockedAddress", () => {
+ it("allows public IPv4 addresses", () => {
+ expect(isBlockedAddress("8.8.8.8")).toBe(false);
+ expect(isBlockedAddress("104.21.52.150")).toBe(false);
+ });
+
+ it("blocks private/reserved IPv4 ranges", () => {
+ expect(isBlockedAddress("10.0.0.1")).toBe(true);
+ expect(isBlockedAddress("172.16.0.1")).toBe(true);
+ expect(isBlockedAddress("192.168.1.1")).toBe(true);
+ expect(isBlockedAddress("127.0.0.1")).toBe(true);
+ expect(isBlockedAddress("169.254.1.1")).toBe(true);
+ expect(isBlockedAddress("100.64.0.1")).toBe(true);
+ });
+
+ it("allows public IPv6 addresses", () => {
+ expect(isBlockedAddress("2606:4700:3034::ac43:c88d")).toBe(false);
+ expect(isBlockedAddress("2001:4860:4860::8888")).toBe(false);
+ });
+
+ it("blocks private/reserved IPv6 ranges", () => {
+ expect(isBlockedAddress("::1")).toBe(true);
+ expect(isBlockedAddress("fc00::1")).toBe(true);
+ expect(isBlockedAddress("fe80::1")).toBe(true);
+ });
+
+ it("blocks IPv4-mapped-IPv6 spoofing of private addresses", () => {
+ expect(isBlockedAddress("::ffff:127.0.0.1")).toBe(true);
+ expect(isBlockedAddress("::ffff:192.168.1.1")).toBe(true);
+ expect(isBlockedAddress("::ffff:10.0.0.1")).toBe(true);
+ });
+
+ it("does not block IPv4-mapped-IPv6 form of public addresses", () => {
+ expect(isBlockedAddress("::ffff:104.21.52.150")).toBe(false);
+ expect(isBlockedAddress("::ffff:8.8.8.8")).toBe(false);
+ });
+
+ it("blocks unparseable input", () => {
+ expect(isBlockedAddress("not-an-ip")).toBe(true);
+ });
+});
+
+// These exercise createDnsLookupHook directly against a fake resolver,
+// bypassing fetch()/undici entirely. That's the actual code path the
+// original bug lived in — a public IPv4 address getting misclassified as
+// private — and testing it through a real Agent/fetch call would only
+// add flakiness (real TCP connects, undici's own quirks) without adding
+// coverage of the logic that actually broke.
+function runHook(
+ addresses: LookupAddress[],
+ error: NodeJS.ErrnoException | null = null,
+) {
+ const fakeLookup = (
+ _host: string,
+ _opts: LookupAllOptions,
+ cb: (err: NodeJS.ErrnoException | null, addrs: LookupAddress[]) => void,
+ ) => cb(error, addresses);
+
+ const hook = createDnsLookupHook(fakeLookup);
+ const callback = vi.fn();
+ hook("example.invalid", { all: true }, callback);
+ return callback;
+}
+
+describe("createDnsLookupHook", () => {
+ it("allows a public IPv4 address through", () => {
+ const callback = runHook([{ address: "104.21.52.150", family: 4 }]);
+ expect(callback).toHaveBeenCalledWith(null, "104.21.52.150", 4);
+ });
+
+ it("rejects a private address with the private-destination error", () => {
+ const callback = runHook([{ address: "192.168.1.1", family: 4 }]);
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: "Private destinations are not allowed",
+ }),
+ "",
+ 0,
+ );
+ });
+
+ it("rejects with a distinct error when DNS returns no addresses", () => {
+ const callback = runHook([]);
+ expect(callback).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: "DNS resolution returned no addresses",
+ }),
+ "",
+ 0,
+ );
+ });
+
+ it("propagates a real DNS lookup error untouched", () => {
+ const dnsError = Object.assign(new Error("getaddrinfo ENOTFOUND"), {
+ code: "ENOTFOUND",
+ });
+ const callback = runHook([], dnsError);
+ expect(callback).toHaveBeenCalledWith(dnsError, "", 0);
+ });
+});
diff --git a/src/backend/utils/analytics.ts b/src/backend/utils/analytics.ts
new file mode 100644
index 00000000..9271962a
--- /dev/null
+++ b/src/backend/utils/analytics.ts
@@ -0,0 +1,134 @@
+import crypto from "crypto";
+import axios from "axios";
+import { sql } from "drizzle-orm";
+import { users, hosts, recentActivity } from "../database/db/schema.js";
+import {
+ createCurrentSettingsRepository,
+ createCurrentRepositoryContext,
+} from "../database/repositories/factory.js";
+import { Logger } from "./logger.js";
+
+export const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
+
+const FEATURE_ACTIVITY_TYPES = [
+ "terminal",
+ "file_manager",
+ "tunnel",
+ "docker",
+ "telnet",
+ "vnc",
+ "rdp",
+ "server_stats",
+] as const;
+
+const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
+const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
+
+export async function isAnalyticsEnabled(): Promise {
+ return createCurrentSettingsRepository().getBoolean(
+ "analytics_enabled",
+ true,
+ );
+}
+
+export async function getOrCreateInstanceId(): Promise {
+ const settings = createCurrentSettingsRepository();
+ const existing = await settings.get("analytics_instance_id");
+ if (existing) return existing;
+
+ const id = crypto.randomUUID();
+ await settings.set("analytics_instance_id", id);
+ return id;
+}
+
+function getAppVersion(): string {
+ return process.env.VERSION || "unknown";
+}
+
+async function collectFeatureUsage(): Promise> {
+ const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
+ const db = createCurrentRepositoryContext().drizzle;
+
+ const rows = await db
+ .select({
+ type: recentActivity.type,
+ count: sql`count(*)`,
+ })
+ .from(recentActivity)
+ .where(sql`${recentActivity.timestamp} >= ${since}`)
+ .groupBy(recentActivity.type);
+
+ const counts = new Map(rows.map((row) => [row.type, Number(row.count)]));
+ const usage: Record = {};
+ for (const type of FEATURE_ACTIVITY_TYPES) {
+ usage[`used_${type}`] = counts.get(type) ?? 0;
+ }
+ return usage;
+}
+
+async function collectCounts(): Promise<{
+ userCount: number;
+ hostCount: number;
+}> {
+ const db = createCurrentRepositoryContext().drizzle;
+
+ const [userRows, hostRows] = await Promise.all([
+ db.select({ count: sql`count(*)` }).from(users),
+ db.select({ count: sql`count(*)` }).from(hosts),
+ ]);
+
+ return {
+ userCount: Number(userRows[0]?.count ?? 0),
+ hostCount: Number(hostRows[0]?.count ?? 0),
+ };
+}
+
+export async function collectAndSendHeartbeat(): Promise {
+ const apiKey = process.env.POSTHOG_API_KEY;
+ if (!apiKey) return;
+
+ try {
+ if (!(await isAnalyticsEnabled())) return;
+
+ const instanceId = await getOrCreateInstanceId();
+ const { userCount, hostCount } = await collectCounts();
+ const featureUsage = await collectFeatureUsage();
+
+ await axios.post(
+ `${POSTHOG_HOST}/capture/`,
+ {
+ api_key: apiKey,
+ event: "instance_heartbeat",
+ distinct_id: instanceId,
+ properties: {
+ version: getAppVersion(),
+ user_count: userCount,
+ host_count: hostCount,
+ ...featureUsage,
+ },
+ },
+ { timeout: 10000 },
+ );
+
+ analyticsLogger.info("Sent daily usage heartbeat", {
+ operation: "analytics_heartbeat_sent",
+ });
+ } catch (err) {
+ analyticsLogger.warn("Failed to send usage heartbeat", {
+ operation: "analytics_heartbeat_failed",
+ error: err instanceof Error ? err.message : "Unknown error",
+ });
+ }
+}
+
+export function startAnalyticsHeartbeat(): void {
+ if (!process.env.POSTHOG_API_KEY) {
+ analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
+ operation: "analytics_disabled_no_key",
+ });
+ return;
+ }
+
+ void collectAndSendHeartbeat();
+ setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
+}
diff --git a/src/backend/utils/safe-outbound-fetch.ts b/src/backend/utils/safe-outbound-fetch.ts
index 1448be0e..46f5bbbb 100644
--- a/src/backend/utils/safe-outbound-fetch.ts
+++ b/src/backend/utils/safe-outbound-fetch.ts
@@ -1,28 +1,54 @@
-import { lookup } from "dns";
+import { lookup, type LookupAddress, type LookupAllOptions } from "dns";
import { BlockList, isIP } from "net";
import { Agent } from "undici";
+type DnsLookupFn = (
+ hostname: string,
+ options: LookupAllOptions,
+ callback: (
+ err: NodeJS.ErrnoException | null,
+ addresses: LookupAddress[],
+ ) => void,
+) => void;
+
+type LookupHookCallback = (
+ error: NodeJS.ErrnoException | Error | null,
+ address: string,
+ family: number,
+) => void;
+
const blockedAddresses = new BlockList();
-for (const [network, prefix] of [
+// Derived, not hand-duplicated: Node's BlockList matches addresses across
+// families through their IPv4-mapped-IPv6 form regardless of which `type`
+// you pass to check()/addSubnet() (see the addAddress('123.123.123.123') /
+// check('::ffff:123.123.123.123') example on
+// https://nodejs.org/api/net.html#class-netblocklist). So every IPv4 range
+// below needs an "::ffff:" mirror in the IPv6 list, or a spoofed
+// literal like "::ffff:127.0.0.1" slips through unblocked. Generating the
+// mirror from this list instead of maintaining two lists by hand means the
+// two can't drift out of sync the way they did before.
+const blockedIpv4Ranges = [
["0.0.0.0", 8],
["10.0.0.0", 8],
- ["100.64.0.0", 10],
+ ["100.64.0.0", 10], // CGNAT
["127.0.0.0", 8],
- ["169.254.0.0", 16],
+ ["169.254.0.0", 16], // link-local
["172.16.0.0", 12],
["192.168.0.0", 16],
- ["198.18.0.0", 15],
- ["224.0.0.0", 4],
- ["240.0.0.0", 4],
-] as const) {
+ ["198.18.0.0", 15], // benchmarking
+ ["224.0.0.0", 4], // multicast
+ ["240.0.0.0", 4], // reserved
+] as const;
+
+for (const [network, prefix] of blockedIpv4Ranges) {
blockedAddresses.addSubnet(network, prefix, "ipv4");
+ blockedAddresses.addSubnet(`::ffff:${network}`, prefix + 96, "ipv6");
}
for (const [network, prefix] of [
["::", 128],
["::1", 128],
- ["::ffff:0:0", 96],
["fc00::", 7],
["fe80::", 10],
["ff00::", 8],
@@ -30,7 +56,7 @@ for (const [network, prefix] of [
blockedAddresses.addSubnet(network, prefix, "ipv6");
}
-function isBlockedAddress(address: string): boolean {
+export function isBlockedAddress(address: string): boolean {
const family = isIP(address);
return (
family === 0 ||
@@ -38,6 +64,42 @@ function isBlockedAddress(address: string): boolean {
);
}
+// Extracted so the blocklist decision can be tested directly against a
+// fake DNS resolver, instead of only through a real fetch()/Agent call —
+// the actual bug here lived entirely in this callback, several layers
+// below where undici's own "fetch failed" wrapping would otherwise hide it.
+export function createDnsLookupHook(dnsLookup: DnsLookupFn = lookup) {
+ return function lookupHook(
+ host: string,
+ lookupOptions: LookupAllOptions,
+ callback: LookupHookCallback,
+ ): void {
+ dnsLookup(
+ host,
+ { ...lookupOptions, all: true, verbatim: true },
+ (error, addresses) => {
+ if (error) return callback(error, "", 0);
+ if (!addresses.length) {
+ return callback(
+ new Error("DNS resolution returned no addresses"),
+ "",
+ 0,
+ );
+ }
+ if (addresses.some(({ address }) => isBlockedAddress(address))) {
+ return callback(
+ new Error("Private destinations are not allowed"),
+ "",
+ 0,
+ );
+ }
+ const selected = addresses[0];
+ callback(null, selected.address, selected.family);
+ },
+ );
+ };
+}
+
export async function safeOutboundFetch(
rawUrl: string,
options: RequestInit,
@@ -58,27 +120,7 @@ export async function safeOutboundFetch(
const dispatcher = new Agent({
connect: {
- lookup(host, lookupOptions, callback) {
- lookup(
- host,
- { ...lookupOptions, all: true, verbatim: true },
- (error, addresses) => {
- if (error) return callback(error, "", 0);
- if (
- !addresses.length ||
- addresses.some(({ address }) => isBlockedAddress(address))
- ) {
- return callback(
- new Error("Private destinations are not allowed"),
- "",
- 0,
- );
- }
- const selected = addresses[0];
- callback(null, selected.address, selected.family);
- },
- );
- },
+ lookup: createDnsLookupHook(),
},
});
diff --git a/src/backend/utils/ssh-algorithms.ts b/src/backend/utils/ssh-algorithms.ts
index 40a59122..ca643933 100644
--- a/src/backend/utils/ssh-algorithms.ts
+++ b/src/backend/utils/ssh-algorithms.ts
@@ -31,13 +31,13 @@ try {
nativeRequire("ssh2/lib/protocol/crypto/build/Release/sshcrypto.node");
ssh2BindingAvailable = true;
} catch {
- try {
- // ESM fallback: check if chacha20 works via OpenSSL createCipheriv
- crypto.createCipheriv("chacha20", Buffer.alloc(32), Buffer.alloc(16));
- ssh2BindingAvailable = true;
- } catch {
- ssh2BindingAvailable = false;
- }
+ // The pure-JS fallback in ssh2 for chacha20-poly1305@openssh.com is broken and
+ // corrupts the transport: the target sshd aborts the KEX with
+ // "ssh_dispatch_run_fatal: ... incomplete message [preauth]" and the client times out.
+ // A working OpenSSL "chacha20" cipher is NOT sufficient here — only the native
+ // binding (sshcrypto.node) makes chacha20-poly1305 usable. Keep it disabled otherwise
+ // so filterCiphers() drops it and the connection negotiates AES-GCM instead.
+ ssh2BindingAvailable = false;
}
function filterCiphers(list: CipherAlgorithm[]): CipherAlgorithm[] {
diff --git a/src/main.tsx b/src/main.tsx
index 1e4c8894..fb07801e 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -65,6 +65,12 @@ const ElectronVersionCheck = lazy(() =>
})),
);
+// Anonymous guest view for shared terminal/RDP/VNC/Telnet sessions (?view=shared&token=).
+// Rendered outside FullscreenAppGate since guests never have a JWT/cookie to verify.
+const SharedSessionView = lazy(
+ () => import("@/features/session-sharing/SharedSessionView"),
+);
+
type Phase =
| "verifying"
| "idle-auth"
@@ -174,11 +180,15 @@ function App() {
stored?.loggedIn ? "verifying" : "idle-auth",
);
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
+ const [verifyRetryCount, setVerifyRetryCount] = useState(0);
const timerRef = useRef | null>(null);
// Track whether fading-in came from a fresh login (vs. session verification on page load).
// When session-verified, Auth must not mount during the transition — it would trigger
// silent OIDC redirect and cause an infinite refresh loop.
const fadingInFromLoginRef = useRef(false);
+ // Dedupes concurrent handleLogout() calls within the same tick -- see
+ // handleLogout for why phase state alone isn't sufficient for this.
+ const loggingOutRef = useRef(false);
useEffect(() => {
const savedAccent = localStorage.getItem("termix-accent");
@@ -204,7 +214,18 @@ function App() {
if (isElectron()) {
try {
const token = await getCurrentToken();
- if (token) localStorage.setItem("jwt", token);
+ if (token) {
+ localStorage.setItem("jwt", token);
+ // Remote Sync's engine (main process) needs this local JWT to
+ // authenticate against the embedded backend during sync, same
+ // as a fresh login provides via handleLogin below -- a session
+ // restore (the common case on every normal launch) must hand
+ // it over too, or sync silently never runs after the first
+ // app restart.
+ window.electronAPI
+ ?.invoke?.("notify-local-login", token)
+ .catch(() => {});
+ }
} catch {
// Non-fatal: WebSocket connections will fall back to cookie auth
}
@@ -213,36 +234,83 @@ function App() {
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
})
- .catch(() => {
- clearStoredAuth();
- setPhase("idle-auth");
+ .catch((err: unknown) => {
+ // Only treat a genuine auth rejection (401/403) as "not logged in".
+ // Anything else (network hiccup, backend still starting up, a
+ // transient 5xx) is not proof the session is invalid -- clearing
+ // stored auth here would drop the user back to Auth.tsx, which in
+ // Electron immediately mints a brand-new auto-session, silently
+ // swapping out the JWT/cookie from under any still-in-flight
+ // requests and causing spurious "Session expired" toasts.
+ const status =
+ (err as { status?: number; response?: { status?: number } })
+ ?.status ??
+ (err as { response?: { status?: number } })?.response?.status;
+ if (status === 401 || status === 403) {
+ clearStoredAuth();
+ setPhase("idle-auth");
+ return;
+ }
+ // Transient failure: retry rather than logging out. In Electron the
+ // embedded local backend is bundled, always-on infrastructure that
+ // always eventually comes up (a slow cold boot just takes longer),
+ // and Auth.tsx never shows a login form for it anyway -- so there's
+ // no reason to ever give up and manufacture a logout here. Outside
+ // Electron a genuinely broken backend still needs to surface the
+ // login screen eventually, so that case keeps a retry cap.
+ if (!isElectron() && verifyRetryCount >= 5) {
+ clearStoredAuth();
+ setPhase("idle-auth");
+ return;
+ }
+ const delay = isElectron()
+ ? Math.min(1000 * 2 ** verifyRetryCount, 10000)
+ : 3000;
+ timerRef.current = setTimeout(() => {
+ setVerifyRetryCount((c) => c + 1);
+ }, delay);
});
- }, [phase]);
+ }, [phase, verifyRetryCount]);
function handleLogin(u: string) {
+ loggingOutRef.current = false;
setAuthUsername(u);
fadingInFromLoginRef.current = true;
setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
if (isElectron()) {
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
+ const localJwt = localStorage.getItem("jwt");
+ if (localJwt) {
+ window.electronAPI
+ ?.invoke?.("notify-local-login", localJwt)
+ .catch(() => {});
+ }
}
}
function handleLogout() {
+ // A single background hiccup can trigger several independent 401s at
+ // once (e.g. a burst of unrelated polls all failing together in the
+ // same tick), each calling this. React batches the resulting setPhase
+ // calls, so checking `phase` here can't distinguish the first call in
+ // a batch from the second -- both would see the same pre-update value
+ // and both would proceed, each overwriting timerRef with a fresh
+ // 450ms timer. A steady trickle of these could keep resetting the
+ // countdown so the transition never actually completes, which looks
+ // exactly like "nothing happens." loggingOutRef is synchronous and
+ // isn't subject to batching, so it correctly dedupes within one tick.
+ if (loggingOutRef.current) return;
+ loggingOutRef.current = true;
clearStoredAuth();
setPhase("fading-out");
timerRef.current = setTimeout(() => {
setAuthUsername("");
setPhase("idle-auth");
+ loggingOutRef.current = false;
}, 450);
}
- function handleChangeServer() {
- localStorage.setItem("termix_show_server_config", "true");
- handleLogout();
- }
-
const showApp =
phase === "idle-app" || phase === "fading-in" || phase === "fading-out";
const showAuth =
@@ -288,11 +356,7 @@ function App() {
}}
>
-
+
)}
@@ -322,6 +386,16 @@ function RootApp() {
const searchParams = new URLSearchParams(window.location.search);
const isFullscreen = searchParams.has("view");
+ // Anonymous guests have no cookie/JWT at all, so this bypasses FullscreenAppGate's
+ // auth check entirely rather than waiting on a getUserInfo() call that would always fail.
+ if (searchParams.get("view") === "shared") {
+ return (
+
+
+
+ );
+ }
+
if (isFullscreen) {
return (
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index 61a38183..91cc7d4a 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -64,6 +64,15 @@ export interface ElectronAPI {
started: number;
errors: string[];
}>;
+ onRemoteSyncStatusChanged?: (
+ callback: (status: {
+ connected: boolean;
+ syncing: boolean;
+ lastSyncedAt: string | null;
+ lastError: string | null;
+ needsReauth: boolean;
+ }) => void,
+ ) => () => void;
clearSessionCookies: () => Promise;
getSessionCookie: (
name: string,
@@ -157,7 +166,6 @@ declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
- configuredServerUrl?: string | null;
electronClipboard?: {
writeText(text: string): Promise;
readText(): Promise;
diff --git a/src/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts
index 3dbb36c3..24079905 100644
--- a/src/types/guacamole-common-js.d.ts
+++ b/src/types/guacamole-common-js.d.ts
@@ -97,6 +97,39 @@ declare module "guacamole-common-js" {
up: boolean;
down: boolean;
}
+
+ interface MouseEvent {
+ state: Mouse.State;
+ preventDefault(): void;
+ stopPropagation(): void;
+ }
+
+ class Touchpad {
+ constructor(element: HTMLElement);
+ currentState: Mouse.State;
+ clickTimingThreshold: number;
+ clickMoveThreshold: number;
+ scrollThreshold: number;
+ onEach(
+ types: string[],
+ listener: (event: Mouse.MouseEvent) => void,
+ ): void;
+ on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
+ }
+
+ class Touchscreen {
+ constructor(element: HTMLElement);
+ currentState: Mouse.State;
+ clickTimingThreshold: number;
+ clickMoveThreshold: number;
+ scrollThreshold: number;
+ longPressThreshold: number;
+ onEach(
+ types: string[],
+ listener: (event: Mouse.MouseEvent) => void,
+ ): void;
+ on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
+ }
}
class Keyboard {
diff --git a/src/types/index.ts b/src/types/index.ts
index 3b2b9a03..0c1b8c80 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -151,6 +151,7 @@ export interface Host {
enableDocker: boolean;
enableProxmox: boolean;
enableTmuxMonitor: boolean;
+ allowSessionSharing?: boolean;
proxmoxConfig?: ProxmoxConfig | null;
showTerminalInSidebar: boolean;
showFileManagerInSidebar: boolean;
@@ -207,7 +208,7 @@ export interface Host {
telnetUser?: string;
telnetPassword?: string;
telnetCredentialId?: number | null;
- rdpAuthType?: "direct" | "credential" | null;
+ rdpAuthType?: "direct" | "credential" | "none" | null;
vncAuthType?: "direct" | "credential" | null;
telnetAuthType?: "direct" | "credential" | null;
createdAt: string;
@@ -272,6 +273,7 @@ export interface HostData {
enableDocker?: boolean;
enableProxmox?: boolean;
enableTmuxMonitor?: boolean;
+ allowSessionSharing?: boolean;
proxmoxConfig?: ProxmoxConfig | Record | null;
showTerminalInSidebar?: boolean;
showFileManagerInSidebar?: boolean;
@@ -329,7 +331,7 @@ export interface HostData {
telnetUser?: string;
telnetPassword?: string;
telnetCredentialId?: number | null;
- rdpAuthType?: "direct" | "credential" | null;
+ rdpAuthType?: "direct" | "credential" | "none" | null;
vncAuthType?: "direct" | "credential" | null;
telnetAuthType?: "direct" | "credential" | null;
}
@@ -343,6 +345,7 @@ export interface SSHFolder {
name: string;
color?: string;
icon?: string;
+ credentialId?: number | null;
createdAt: string;
updatedAt: string;
}
diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts
index 304d4127..4064ca60 100644
--- a/src/types/ui-types.ts
+++ b/src/types/ui-types.ts
@@ -71,6 +71,7 @@ export type Host = {
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
+ connectionOrigin?: "local" | "remote" | null;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: {
@@ -152,19 +153,26 @@ export type Host = {
vncPort: number;
telnetPort: number;
+ rdpAuthType?: "direct" | "credential" | "none";
rdpCredentialId?: string;
rdpUser?: string;
rdpPassword?: string;
+ hasRdpPassword?: boolean;
domain?: string;
security?: string;
ignoreCert?: boolean;
+ vncAuthType?: "direct" | "credential";
vncCredentialId?: string;
vncPassword?: string;
+ hasVncPassword?: boolean;
vncUser?: string;
+ telnetAuthType?: "direct" | "credential";
+ telnetCredentialId?: string;
telnetUser?: string;
telnetPassword?: string;
+ hasTelnetPassword?: boolean;
guacamoleConfig?: Record;
forceKeyboardInteractive?: boolean;
@@ -217,6 +225,7 @@ export type HostFolder = {
path?: string;
color?: string;
icon?: string;
+ credentialId?: number | null;
};
export type TabType =
@@ -276,6 +285,9 @@ export type Tab = {
host?: Host;
openedAt: number;
restoredSessionId?: string | null;
+ /** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
+ joinSharedSessionId?: string | null;
+ joinShareId?: string | null;
initialFilePath?: string;
serialConfig?: SerialConfig;
terminalRef?: import("react").RefObject<{
@@ -286,6 +298,8 @@ export type Tab = {
fit?: () => void;
notifyResize?: () => void;
getApplicationCursorKeysMode?: () => boolean;
+ openShareModal?: () => void;
+ canShare?: () => boolean;
} | null>;
};
diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx
index 5c905e55..2729ce48 100644
--- a/src/ui/AppShell.tsx
+++ b/src/ui/AppShell.tsx
@@ -126,10 +126,13 @@ import {
getActiveSessions,
getUserPreferences,
dismissDonationModal,
+ isElectron,
type UserPreferences,
type OpenTabRecord,
} from "@/main-axios";
import { DonationReminderModal } from "@/user/DonationReminderModal.tsx";
+import { RemoteSyncBanner } from "@/components/RemoteSyncBanner.tsx";
+import { MigrationNoticeDialog } from "@/components/MigrationNoticeDialog.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor";
import type { SSHHostWithStatus } from "@/main-axios";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
@@ -141,7 +144,10 @@ import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
function buildHostTree(
hosts: SSHHostWithStatus[],
- folderMeta?: Map,
+ folderMeta?: Map<
+ string,
+ { color?: string; icon?: string; credentialId?: number | null }
+ >,
): HostFolder {
const root: HostFolder = { name: "root", children: [] };
const folderMap = new Map();
@@ -159,6 +165,7 @@ function buildHostTree(
path: accumulated,
color: meta?.color,
icon: meta?.icon,
+ credentialId: meta?.credentialId ?? null,
children: [],
};
folderMap.set(accumulated, folder);
@@ -189,11 +196,9 @@ export { tabIcon, renderTabContent } from "@/shell/tabUtils";
export function AppShell({
username,
onLogout,
- onChangeServer,
}: {
username: string;
onLogout: () => void;
- onChangeServer?: () => void;
}) {
const { t, i18n } = useTranslation();
const { setTheme } = useTheme();
@@ -218,11 +223,14 @@ export function AppShell({
const [splitMode, setSplitMode] = useState(
() => (localStorage.getItem("termix_splitMode") as SplitMode) ?? "none",
);
- const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(
- () =>
- JSON.parse(localStorage.getItem("termix_paneTabIds") ?? "null") ??
- Array(6).fill(null),
+ // paneTabIds holds live tab.id values, which change on every restore, so we
+ // can't restore it from storage directly. It starts empty and gets filled in
+ // once by the reconciliation effect below, keyed off the stable instanceId
+ // values saved in termix_paneInstanceIds.
+ const [paneTabIds, setPaneTabIds] = useState<(string | null)[]>(() =>
+ Array(6).fill(null),
);
+ const paneLayoutRestoredRef = useRef(false);
useEffect(() => {
paneTabIdsRef.current = paneTabIds;
}, [paneTabIds]);
@@ -231,6 +239,13 @@ export function AppShell({
const [hostsLoading, setHostsLoading] = useState(true);
const [allHosts, setAllHosts] = useState([]);
const [isAdmin, setIsAdmin] = useState(false);
+ // Remote sync is not yet configurable (added in a later phase), so this
+ // is always false for now -- admin/user-management UI stays hidden until
+ // the desktop app is connected to a remote Termix server, since a
+ // standalone local install has exactly one implicit user and nothing to
+ // administer.
+ const [isRemoteSyncConnected] = useState(false);
+ const showMultiUserUI = isAdmin && (!isElectron() || isRemoteSyncConnected);
const [userId, setUserId] = useState(null);
const [showDonationModal, setShowDonationModal] = useState(false);
const [backgroundTabRecords, setBackgroundTabRecords] = useState<
@@ -239,6 +254,9 @@ export function AppShell({
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState("hosts");
+ const [remoteSyncInitialServerUrl, setRemoteSyncInitialServerUrl] = useState<
+ string | undefined
+ >(undefined);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("termix_sidebarWidth");
return saved ? parseInt(saved, 10) : 291;
@@ -258,8 +276,15 @@ export function AppShell({
}, [splitMode]);
useEffect(() => {
- localStorage.setItem("termix_paneTabIds", JSON.stringify(paneTabIds));
- }, [paneTabIds]);
+ // Don't overwrite the saved layout with the empty initial state before
+ // reconciliation has had a chance to restore it.
+ if (!paneLayoutRestoredRef.current) return;
+ const instanceIds = paneTabIds.map((id) => {
+ if (id == null) return null;
+ return tabs.find((t) => t.id === id)?.instanceId ?? null;
+ });
+ localStorage.setItem("termix_paneInstanceIds", JSON.stringify(instanceIds));
+ }, [paneTabIds, tabs]);
const isMobile = useIsMobile();
@@ -798,11 +823,15 @@ export function AppShell({
]);
const converted = raw.map(sshHostToHost);
setAllHosts(converted);
- const folderMeta = new Map();
+ const folderMeta = new Map<
+ string,
+ { color?: string; icon?: string; credentialId?: number | null }
+ >();
for (const f of folders) {
folderMeta.set(f.name, {
color: f.color ?? undefined,
icon: f.icon ?? undefined,
+ credentialId: f.credentialId ?? null,
});
}
setRealHostTree(buildHostTree(raw, folderMeta));
@@ -968,6 +997,35 @@ export function AppShell({
loadSavedTabs();
}, [hostsLoaded, userPrefsLoaded]);
+ // Restore split-screen pane assignments once tabs are settled. Saved assignments are
+ // keyed by instanceId (stable across reloads) and remapped to the live tab.id here,
+ // since tab.id is regenerated every time a tab is (re)opened.
+ useEffect(() => {
+ if (!tabsReady || paneLayoutRestoredRef.current) return;
+ paneLayoutRestoredRef.current = true;
+
+ try {
+ const savedInstanceIds: (string | null)[] = JSON.parse(
+ localStorage.getItem("termix_paneInstanceIds") ?? "null",
+ );
+ if (!Array.isArray(savedInstanceIds)) return;
+
+ const restored = savedInstanceIds.map((instanceId) => {
+ if (instanceId == null) return null;
+ return tabs.find((t) => t.instanceId === instanceId)?.id ?? null;
+ });
+ if (restored.some((id) => id != null)) {
+ setPaneTabIds(restored);
+ } else {
+ // None of the saved panes could be restored (e.g. reopen-tabs-on-login
+ // is disabled), so drop back to a single view instead of an empty split.
+ setSplitMode("none");
+ }
+ } catch {
+ // silently fail
+ }
+ }, [tabsReady, tabs]);
+
// Debounced tab-order sync: when tab order changes, patch each persistent tab's tabOrder in DB.
const orderSyncTimeoutRef = useRef | null>(
null,
@@ -1006,6 +1064,8 @@ export function AppShell({
savedLabel?: string;
initialFilePath?: string;
serialConfig?: SerialConfig;
+ joinSharedSessionId?: string | null;
+ joinShareId?: string | null;
},
) {
const tabId = `${host.name}-${type}-${Date.now()}`;
@@ -1022,6 +1082,8 @@ export function AppShell({
const savedLabel = restore?.savedLabel;
const initialFilePath = restore?.initialFilePath;
const serialConfig = restore?.serialConfig;
+ const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
+ const joinShareId = restore?.joinShareId ?? null;
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
const isCustomLabel =
savedLabel != null &&
@@ -1043,6 +1105,8 @@ export function AppShell({
openedAt,
terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null,
+ joinSharedSessionId,
+ joinShareId,
initialFilePath,
serialConfig,
},
@@ -1075,6 +1139,8 @@ export function AppShell({
openedAt,
terminalRef: ref,
restoredSessionId: restore?.restoredSessionId ?? null,
+ joinSharedSessionId,
+ joinShareId,
initialFilePath,
serialConfig,
},
@@ -1330,6 +1396,17 @@ export function AppShell({
}
}
+ function openShareForTab(id: string) {
+ const tab = tabs.find((t) => t.id === id);
+ if (!tab) return;
+ const ref = tab.terminalRef?.current;
+ if (ref?.canShare?.()) {
+ ref.openShareModal?.();
+ } else {
+ toast.error(t("sessionSharing.notReadyToShare"));
+ }
+ }
+
function closeTab(id: string) {
const tab = tabs.find((t) => t.id === id);
const confirmEnabled = localStorage.getItem("confirmTabClose") === "true";
@@ -1675,6 +1752,56 @@ export function AppShell({
}}
onRenameTab={renameTab}
onReorderTabs={setTabs}
+ onJoinSharedSession={(session) => {
+ if (!session.shareId) return;
+ const existingHost = allHosts.find(
+ (h) => h.id === String(session.hostId),
+ );
+ const host: Host = existingHost ?? {
+ id: String(session.hostId),
+ name: session.hostName,
+ username: "",
+ ip: "",
+ port: 0,
+ folder: "",
+ online: false,
+ cpu: null,
+ ram: null,
+ lastAccess: new Date().toISOString(),
+ authType: "none",
+ enableTerminal: false,
+ enableCommandHistory: false,
+ enableTunnel: false,
+ enableFileManager: false,
+ enableDocker: false,
+ enableProxmox: false,
+ enableTmuxMonitor: false,
+ enableSsh: false,
+ enableRdp: false,
+ enableVnc: false,
+ enableTelnet: false,
+ sshPort: 22,
+ rdpPort: 3389,
+ vncPort: 5900,
+ telnetPort: 23,
+ serverTunnels: [],
+ quickActions: [],
+ };
+ const instanceId =
+ typeof crypto.randomUUID === "function"
+ ? crypto.randomUUID()
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
+ openTab(host, "terminal", {
+ instanceId,
+ restoredSessionId: null,
+ joinSharedSessionId: session.sessionId,
+ joinShareId: session.shareId,
+ savedLabel: t("connections.sharedSessionLabel", {
+ hostName: session.hostName,
+ }),
+ });
+ if (isMobile) setSidebarOpen(false);
+ }}
/>
)}
@@ -1690,16 +1817,16 @@ export function AppShell({
setUserPrefs((current) => ({ ...current, ...updates }))
}
+ remoteSyncInitialServerUrl={remoteSyncInitialServerUrl}
/>
)}
- {railView === "admin-settings" && isAdmin && (
+ {railView === "admin-settings" && showMultiUserUI && (
-
- {/* Skinny icon rail — desktop only, hidden on mobile */}
-
-
- {/* Desktop: inline resizable sidebar */}
- {!isMobile && (
-
- {sidebarHeader}
- {sidebarPanelContent}
-
- {sidebarOpen && !sidebarEditing && (
-
- )}
-
- )}
-
- {/* Mobile: sidebar as overlay sheet */}
- {isMobile && (
-
-
- {sidebarHeader}
- {sidebarPanelContent}
-
-
- )}
-
- {/* Main content area */}
-
- {!isMobile && !sidebarOpen && (
-
setSidebarOpen(true)}
- title="Open Sidebar"
- className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
- >
-
-
- )}
-
-
{
- const targetTab = tabs.find((t) => t.id === tabId);
- if (targetTab?.host) openTab(targetTab.host, "files");
+
+ {isElectron() && (
+ <>
+
{
+ setRailView("user-profile");
+ if (!sidebarOpen) setSidebarOpen(true);
}}
- isAppFullscreen={isAppFullscreen}
- onToggleAppFullscreen={toggleAppFullscreen}
/>
-
- {/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
- {!isMobile && (
-
-
-
- )}
-
- {/* Normal-view container. Tab nodes are appended here (or to pane elements)
- by the DOM-placement effect above. React portals each tab's content
- into its stable per-tab node so the component is never remounted.
- When split is active, shown on top only if the active tab is not in a pane. */}
-
- {tabs.map((tab) => {
- const tabNode = getTabNode(tab.id, tab.type === "terminal");
- const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
- const inPane = paneIdx !== -1;
- const activeInline = !inPane && tab.id === activeTabId;
- return createPortal(
- renderTabContent(
- tab,
- openSingletonTab,
- openTab,
- closeTab,
- inPane || activeInline,
- (host, filePath) =>
- openTab(host, "files", {
- instanceId:
- typeof crypto.randomUUID === "function"
- ? crypto.randomUUID()
- : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
- restoredSessionId: null,
- initialFilePath: filePath,
- }),
- (host, _path) => openTab(host, "files"),
- (host, path) =>
- openTab(host, "terminal", {
- instanceId:
- typeof crypto.randomUUID === "function"
- ? crypto.randomUUID()
- : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
- restoredSessionId: null,
- initialFilePath: path,
- }),
- renameTab,
- saveQuickConnectHost,
- ),
- tabNode,
- tab.id,
- );
- })}
-
-
-
-
- {/* Bottom nav bar — mobile only */}
- {
+ setRemoteSyncInitialServerUrl(url);
+ setRailView("user-profile");
+ if (!sidebarOpen) setSidebarOpen(true);
+ }}
+ />
+ >
+ )}
+
+ {/* Skinny icon rail — desktop only, hidden on mobile */}
+
+
+ {/* Desktop: inline resizable sidebar */}
+ {!isMobile && (
+
+ {sidebarHeader}
+ {sidebarPanelContent}
+
+ {sidebarOpen && !sidebarEditing && (
+
+ )}
+
+ )}
+
+ {/* Mobile: sidebar as overlay sheet */}
+ {isMobile && (
+
+
+ {sidebarHeader}
+ {sidebarPanelContent}
+
+
+ )}
+
+ {/* Main content area */}
+
+ {!isMobile && !sidebarOpen && (
+
setSidebarOpen(true)}
+ title="Open Sidebar"
+ className="absolute left-0 top-0 bottom-0 z-20 flex items-center justify-center w-6 bg-sidebar border-r border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors"
+ >
+
+
+ )}
+
+
{
+ const targetTab = tabs.find((t) => t.id === tabId);
+ if (targetTab?.host) openTab(targetTab.host, "files");
+ }}
+ onOpenShare={openShareForTab}
+ isAppFullscreen={isAppFullscreen}
+ onToggleAppFullscreen={toggleAppFullscreen}
+ />
+
+ {/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
+ {!isMobile && (
+
+
+
+ )}
+
+ {/* Normal-view container. Tab nodes are appended here (or to pane elements)
+ by the DOM-placement effect above. React portals each tab's content
+ into its stable per-tab node so the component is never remounted.
+ When split is active, shown on top only if the active tab is not in a pane. */}
+
+ {tabs.map((tab) => {
+ const tabNode = getTabNode(tab.id, tab.type === "terminal");
+ const paneIdx = isSplit ? paneTabIds.indexOf(tab.id) : -1;
+ const inPane = paneIdx !== -1;
+ const activeInline = !inPane && tab.id === activeTabId;
+ return createPortal(
+ renderTabContent(
+ tab,
+ openSingletonTab,
+ openTab,
+ closeTab,
+ inPane || activeInline,
+ (host, filePath) =>
+ openTab(host, "files", {
+ instanceId:
+ typeof crypto.randomUUID === "function"
+ ? crypto.randomUUID()
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
+ restoredSessionId: null,
+ initialFilePath: filePath,
+ }),
+ (host, _path) => openTab(host, "files"),
+ (host, path) =>
+ openTab(host, "terminal", {
+ instanceId:
+ typeof crypto.randomUUID === "function"
+ ? crypto.randomUUID()
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
+ restoredSessionId: null,
+ initialFilePath: path,
+ }),
+ renameTab,
+ saveQuickConnectHost,
+ ),
+ tabNode,
+ tab.id,
+ );
+ })}
+
+
+
+
+ {/* Bottom nav bar — mobile only */}
+
+
diff --git a/src/ui/api/acme-ssl-api.ts b/src/ui/api/acme-ssl-api.ts
index 64bb286a..c9e9234d 100644
--- a/src/ui/api/acme-ssl-api.ts
+++ b/src/ui/api/acme-ssl-api.ts
@@ -1,6 +1,6 @@
import { authApi, handleApiError } from "@/main-axios";
-export type AcmeChallengeType = "http-webroot" | "dns-cloudflare";
+export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
export type AcmeSettings = {
enabled: boolean;
@@ -45,3 +45,15 @@ export async function requestAcmeCertificate(): Promise<
handleApiError(error, "request ACME certificate");
}
}
+
+export async function uploadManualSslCertificate(payload: {
+ certificate: string;
+ privateKey: string;
+}): Promise
{
+ try {
+ const response = await authApi.post("/users/manual-ssl-upload", payload);
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "upload manual SSL certificate");
+ }
+}
diff --git a/src/ui/api/admin-user-data-api.ts b/src/ui/api/admin-user-data-api.ts
index ecac34ea..e6c48dc7 100644
--- a/src/ui/api/admin-user-data-api.ts
+++ b/src/ui/api/admin-user-data-api.ts
@@ -99,7 +99,14 @@ export async function adminDeleteUserHost(
export async function adminGetHostPassword(
targetUserId: string,
hostId: number,
- field: "password" | "sudoPassword" | "vncPassword" = "password",
+ field:
+ | "password"
+ | "sudoPassword"
+ | "rdpPassword"
+ | "vncPassword"
+ | "telnetPassword"
+ | "key"
+ | "keyPassword" = "password",
): Promise {
try {
const response = await sshHostApi.get(
diff --git a/src/ui/api/credentials-api.ts b/src/ui/api/credentials-api.ts
index 7c0f1e7b..c8362bad 100644
--- a/src/ui/api/credentials-api.ts
+++ b/src/ui/api/credentials-api.ts
@@ -96,7 +96,14 @@ export async function getSSHHostWithCredentials(
export async function getHostPassword(
hostId: number,
- field: "password" | "sudoPassword" | "vncPassword" = "password",
+ field:
+ | "password"
+ | "sudoPassword"
+ | "rdpPassword"
+ | "vncPassword"
+ | "telnetPassword"
+ | "key"
+ | "keyPassword" = "password",
): Promise {
try {
const response = await sshHostApi.get(
@@ -200,6 +207,7 @@ export async function updateFolderMetadata(
name: string,
color?: string,
icon?: string,
+ credentialId?: number | null,
): Promise {
try {
sshLogger.info("Updating folder metadata", {
@@ -207,12 +215,14 @@ export async function updateFolderMetadata(
name,
color,
icon,
+ credentialId,
});
await authApi.put("/host/folders/metadata", {
name,
color,
icon,
+ credentialId,
});
sshLogger.success("Folder metadata updated successfully", {
diff --git a/src/ui/api/guacamole-api.ts b/src/ui/api/guacamole-api.ts
index abb59e60..f47dd455 100644
--- a/src/ui/api/guacamole-api.ts
+++ b/src/ui/api/guacamole-api.ts
@@ -72,6 +72,7 @@ export interface GuacamoleTokenRequest {
export interface GuacamoleTokenResponse {
token: string;
+ guacamoleConnectionId?: string | null;
}
type GuacamoleConfigSource = {
@@ -208,12 +209,18 @@ export async function getGuacamoleToken(
export async function getGuacamoleTokenFromHost(
hostId: number,
protocol?: "rdp" | "vnc" | "telnet",
+ promptedCredentials?: { username?: string; password?: string },
): Promise {
try {
- const response = await authApi.post(
- `/guacamole/connect-host/${hostId}`,
- protocol ? { protocol } : {},
- );
+ const response = await authApi.post(`/guacamole/connect-host/${hostId}`, {
+ ...(protocol ? { protocol } : {}),
+ ...(promptedCredentials?.username
+ ? { promptedUsername: promptedCredentials.username }
+ : {}),
+ ...(promptedCredentials?.password
+ ? { promptedPassword: promptedCredentials.password }
+ : {}),
+ });
return response.data;
} catch (error) {
throw handleApiError(error, "get guacamole token from host");
diff --git a/src/ui/api/host-metrics-api.ts b/src/ui/api/host-metrics-api.ts
index f45b89e8..f7e06ee3 100644
--- a/src/ui/api/host-metrics-api.ts
+++ b/src/ui/api/host-metrics-api.ts
@@ -1,6 +1,15 @@
import { handleApiError, statsApi } from "@/main-axios";
import type { HostMetricsLayout } from "@/types/host-metrics";
+// Every function below is keyed by a host's numeric database id, and the
+// receiving backend must own that host in its own database -- a synced
+// host has a different numeric id on each side (only its syncId matches
+// across them). These calls always target the embedded local backend; see
+// getAllServerStatuses in host-metrics-status-api.ts for the one metrics
+// call that IS safely merged across local + remote (a process-local,
+// in-memory aggregate keyed by whichever host ids that process happens to
+// know about, not a per-host lookup).
+
export interface MetricsHistoryRow {
ts: string;
cpu_percent: number | null;
diff --git a/src/ui/api/host-metrics-status-api.ts b/src/ui/api/host-metrics-status-api.ts
index 0a6eebe7..5ae0949f 100644
--- a/src/ui/api/host-metrics-status-api.ts
+++ b/src/ui/api/host-metrics-status-api.ts
@@ -1,8 +1,31 @@
import axios, { type AxiosRequestConfig } from "axios";
-import { handleApiError, statsApi } from "@/main-axios";
+import {
+ handleApiError,
+ statsApi,
+ getRemoteStatsApi,
+ isElectron,
+} from "@/main-axios";
import type { ServerMetrics, ServerStatus } from "@/main-axios";
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
+// Metrics collection/viewer registration below (startMetricsPolling,
+// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
+// receives the call must own the target host by numeric database id, and a
+// synced host has a different numeric id in each database (only its
+// syncId matches across them). Only the aggregate status read is merged
+// across local + remote, same as tunnel status.
+async function isRemoteSyncConnected(): Promise {
+ if (!isElectron()) return false;
+ try {
+ const config = (await window.electronAPI?.invoke?.(
+ "get-remote-sync-config",
+ )) as { serverUrl?: string } | null;
+ return !!config?.serverUrl;
+ } catch {
+ return false;
+ }
+}
+
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
@@ -76,6 +99,7 @@ export async function getAllServerStatuses(): Promise<
> {
return getCachedServerStatuses(async () => {
let lastError: unknown = null;
+ let localStatuses: Record = {};
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
@@ -89,7 +113,9 @@ export async function getAllServerStatuses(): Promise<
// blips don't look like real outages.
__silentRetry: !isFinalAttempt,
} as AxiosRequestConfig & { __silentRetry?: boolean });
- return response.data || {};
+ localStatuses = response.data || {};
+ lastError = null;
+ break;
} catch (error) {
lastError = error;
if (!isTransientStatusError(error)) {
@@ -102,8 +128,24 @@ export async function getAllServerStatuses(): Promise<
}
}
- handleApiError(lastError, "fetch server statuses");
- return {};
+ if (lastError) {
+ handleApiError(lastError, "fetch server statuses");
+ return {};
+ }
+
+ if (await isRemoteSyncConnected()) {
+ try {
+ const remoteResult = await getRemoteStatsApi().get("/status", {
+ timeout: 8000,
+ __silentRetry: true,
+ } as AxiosRequestConfig & { __silentRetry?: boolean });
+ return { ...localStatuses, ...(remoteResult.data || {}) };
+ } catch {
+ // remote unreachable this tick -- fall back to local-only statuses
+ }
+ }
+
+ return localStatuses;
});
}
diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts
index e9eb5a7c..747fdb95 100644
--- a/src/ui/api/open-tabs-api.ts
+++ b/src/ui/api/open-tabs-api.ts
@@ -1,5 +1,7 @@
import { authApi } from "@/main-axios";
import { createTtlRequestCache } from "@/lib/ttl-request-cache";
+import type { TerminalTheme } from "@/lib/terminal-themes";
+import type { CustomKeybinding } from "@/types/keybindings";
// OPEN TABS API
// ============================================================================
@@ -41,6 +43,10 @@ export interface ActiveSessionInfo {
tabInstanceId: string | null;
isConnected: boolean;
createdAt: number;
+ isOwnSession: boolean;
+ sharedByUsername: string | null;
+ permissionLevel: string | null;
+ shareId: string | null;
}
const activeSessionsCache = createTtlRequestCache(2_000);
@@ -82,6 +88,12 @@ export async function getActiveSessions(): Promise {
// USER PREFERENCES API
// ============================================================================
+export interface SavedCustomTheme {
+ id: string;
+ name: string;
+ colors: TerminalTheme["colors"];
+}
+
export interface UserPreferences {
reopenTabsOnLogin: boolean;
theme?: string | null;
@@ -102,6 +114,30 @@ export interface UserPreferences {
hiddenRailTabs?: string | null;
compactHostView?: boolean | null;
statusColorScheme?: string | null;
+ customThemes?: string | null;
+ customKeybindings?: string | null;
+}
+
+export function parseCustomThemes(raw?: string | null): SavedCustomTheme[] {
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+export function parseCustomKeybindings(
+ raw?: string | null,
+): CustomKeybinding[] {
+ if (!raw) return [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
}
export async function getUserPreferences(): Promise {
diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts
index 63f2553a..808843f8 100644
--- a/src/ui/api/rbac-api.ts
+++ b/src/ui/api/rbac-api.ts
@@ -124,6 +124,31 @@ export async function shareHost(
}
}
+export async function shareFolder(
+ folder: string,
+ shareData: {
+ targets: ShareTarget[];
+ permissionLevel: SharePermissionLevel;
+ durationHours?: number;
+ },
+): Promise<{
+ success: boolean;
+ expiresAt: string | null;
+ hostsShared: number;
+ hostsTotal: number;
+ hostResults: Array<{ hostId: number; shared: boolean; reason?: string }>;
+}> {
+ try {
+ const response = await rbacApi.post("/rbac/folder/share", {
+ folder,
+ ...shareData,
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "share folder");
+ }
+}
+
export async function updateHostAccess(
hostId: number,
accessId: number,
diff --git a/src/ui/api/session-sharing-api.ts b/src/ui/api/session-sharing-api.ts
new file mode 100644
index 00000000..343a15bc
--- /dev/null
+++ b/src/ui/api/session-sharing-api.ts
@@ -0,0 +1,192 @@
+import axios from "axios";
+import { getBasePath } from "@/lib/base-path";
+import { isElectron } from "@/lib/electron";
+import { authApi, handleApiError } from "@/main-axios";
+
+export interface ResolvedShareLink {
+ protocol: "ssh" | "rdp" | "vnc" | "telnet";
+ permissionLevel: "read-only" | "read-write";
+ wsPath: string;
+ connectParams?: { token: string };
+}
+
+export type ShareLinkErrorKind = "not-found" | "rate-limited" | "unknown";
+
+export class ShareLinkError extends Error {
+ constructor(
+ message: string,
+ public readonly kind: ShareLinkErrorKind,
+ ) {
+ super(message);
+ this.name = "ShareLinkError";
+ }
+}
+
+const isDev = (): boolean =>
+ !isElectron() &&
+ process.env.NODE_ENV === "development" &&
+ (window.location.port === "3000" ||
+ window.location.port === "5173" ||
+ window.location.port === "");
+
+// Guests have no session/JWT, so this deliberately builds a bare base URL
+// rather than going through main-axios's authenticated instances. The
+// desktop app always runs its embedded local backend as the source of
+// truth, so a share link opened there always resolves against it --
+// joining a session hosted on someone else's remote server isn't
+// supported from the desktop app today.
+async function resolveApiBaseUrl(): Promise {
+ if (isDev()) {
+ const protocol = window.location.protocol === "https:" ? "https" : "http";
+ return `${protocol}://localhost:30001`;
+ }
+ if (isElectron()) {
+ return "http://127.0.0.1:30001";
+ }
+ return getBasePath();
+}
+
+export async function resolveShareLink(
+ linkToken: string,
+): Promise {
+ const baseUrl = await resolveApiBaseUrl();
+ try {
+ const response = await axios.get(
+ `${baseUrl}/session-sharing/resolve/${encodeURIComponent(linkToken)}`,
+ );
+ return response.data;
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ if (error.response?.status === 404) {
+ throw new ShareLinkError(
+ "Share link is invalid, expired, or revoked",
+ "not-found",
+ );
+ }
+ if (error.response?.status === 429) {
+ throw new ShareLinkError(
+ "Too many attempts, please try again shortly",
+ "rate-limited",
+ );
+ }
+ }
+ throw new ShareLinkError("Failed to resolve share link", "unknown");
+ }
+}
+
+// ============================================================================
+// SESSION SHARING (authenticated owner-side API)
+// ============================================================================
+
+export type SessionShareProtocol = "ssh" | "rdp" | "vnc" | "telnet";
+export type SessionShareType = "link" | "user";
+export type SessionSharePermissionLevel = "read-only" | "read-write";
+
+export interface SessionShareRecord {
+ id: string;
+ hostId: number;
+ ownerUserId: string;
+ protocol: SessionShareProtocol;
+ sessionId: string;
+ tabInstanceId: string | null;
+ shareType: SessionShareType;
+ targetUserId: string | null;
+ linkToken: string | null;
+ permissionLevel: SessionSharePermissionLevel;
+ createdAt: string;
+ expiresAt: string;
+ revokedAt: string | null;
+ lastJoinedAt: string | null;
+ joinCount: number;
+}
+
+export interface CreateSessionShareRequest {
+ hostId: number;
+ sessionId: string;
+ tabInstanceId?: string;
+ protocol: SessionShareProtocol;
+ shareType: SessionShareType;
+ targetUserId?: string;
+ permissionLevel: SessionSharePermissionLevel;
+ expiryHours?: number;
+}
+
+export interface CreateSessionShareResponse {
+ shareId: string;
+ linkToken: string | null;
+ expiresAt: string;
+}
+
+export async function createSessionShare(
+ request: CreateSessionShareRequest,
+): Promise {
+ try {
+ const response = await authApi.post("/session-sharing/create", request);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "create session share");
+ }
+}
+
+export async function getActiveSessionShares(
+ hostId: number,
+): Promise<{ shares: SessionShareRecord[] }> {
+ try {
+ const response = await authApi.get(
+ `/session-sharing/host/${hostId}/active`,
+ );
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "fetch active session shares");
+ }
+}
+
+export async function revokeSessionShare(
+ shareId: string,
+): Promise<{ success: true }> {
+ try {
+ const response = await authApi.delete(`/session-sharing/${shareId}`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "revoke session share");
+ }
+}
+
+export async function endSessionShareSession(
+ shareId: string,
+): Promise<{ success: true }> {
+ try {
+ const response = await authApi.post(`/session-sharing/${shareId}/end`);
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "end shared session");
+ }
+}
+
+// ============================================================================
+// GLOBAL ADMIN TOGGLE
+// ============================================================================
+
+export async function getSessionSharingGloballyEnabled(): Promise<{
+ enabled: boolean;
+}> {
+ try {
+ const response = await authApi.get("/users/session-sharing-enabled");
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "fetch session sharing enabled setting");
+ }
+}
+
+export async function updateSessionSharingGloballyEnabled(
+ enabled: boolean,
+): Promise<{ enabled: boolean }> {
+ try {
+ const response = await authApi.patch("/users/session-sharing-enabled", {
+ enabled,
+ });
+ return response.data;
+ } catch (error) {
+ throw handleApiError(error, "update session sharing enabled setting");
+ }
+}
diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts
index 4eeed208..f49f8ff4 100644
--- a/src/ui/api/settings-api.ts
+++ b/src/ui/api/settings-api.ts
@@ -145,6 +145,32 @@ export async function updateGuacamoleSettings(settings: {
}
}
+// ============================================================================
+// ANALYTICS SETTINGS
+// ============================================================================
+
+export async function getAnalyticsEnabled(): Promise<{ enabled: boolean }> {
+ try {
+ const response = await authApi.get("/users/analytics-enabled");
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "fetch analytics enabled setting");
+ }
+}
+
+export async function updateAnalyticsEnabled(
+ enabled: boolean,
+): Promise<{ enabled: boolean }> {
+ try {
+ const response = await authApi.patch("/users/analytics-enabled", {
+ enabled,
+ });
+ return response.data;
+ } catch (error) {
+ handleApiError(error, "update analytics enabled setting");
+ }
+}
+
// ============================================================================
// HOST DEFAULTS SETTINGS
// ============================================================================
diff --git a/src/ui/api/ssh-file-operations-api.ts b/src/ui/api/ssh-file-operations-api.ts
index 082f4c96..f2798fe8 100644
--- a/src/ui/api/ssh-file-operations-api.ts
+++ b/src/ui/api/ssh-file-operations-api.ts
@@ -1,5 +1,13 @@
import axios from "axios";
-import { authApi, fileManagerApi, handleApiError } from "@/main-axios";
+import {
+ authApi,
+ fileManagerApi,
+ handleApiError,
+ getFileManagerApiForSession,
+ setSessionOrigin,
+ clearSessionOrigin,
+} from "@/main-axios";
+import { resolveConnectionOrigin } from "@/lib/connection-origin";
import { fileLogger } from "@/lib/frontend-logger";
import type { SSHHost } from "@/types/index";
@@ -72,7 +80,7 @@ export async function connectSSH(
},
): Promise> {
try {
- const response = await fileManagerApi.post(
+ const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/connect",
{ sessionId, ...config },
{ timeout: 120000 },
@@ -121,12 +129,15 @@ export async function disconnectSSH(
sessionId: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/disconnect", {
- sessionId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/disconnect",
+ { sessionId },
+ );
return response.data;
} catch (error) {
handleApiError(error, "disconnect SSH");
+ } finally {
+ clearSessionOrigin(sessionId);
}
}
@@ -135,10 +146,10 @@ export async function verifySSHTOTP(
totpCode: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/connect-totp", {
- sessionId,
- totpCode,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/connect-totp",
+ { sessionId, totpCode },
+ );
return response.data;
} catch (error) {
handleApiError(error, "verify SSH TOTP");
@@ -149,9 +160,10 @@ export async function verifySSHWarpgate(
sessionId: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/connect-warpgate", {
- sessionId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/connect-warpgate",
+ { sessionId },
+ );
return response.data;
} catch (error) {
handleApiError(error, "verify SSH Warpgate");
@@ -239,9 +251,10 @@ export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
try {
- const response = await fileManagerApi.get("/ssh/status", {
- params: { sessionId },
- });
+ const response = await getFileManagerApiForSession(sessionId).get(
+ "/ssh/status",
+ { params: { sessionId } },
+ );
return response.data;
} catch (error) {
handleApiError(error, "get SSH status");
@@ -252,9 +265,10 @@ export async function keepSSHAlive(
sessionId: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/keepalive", {
- sessionId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/keepalive",
+ { sessionId },
+ );
return response.data;
} catch (error) {
handleApiError(error, "SSH keepalive");
@@ -266,9 +280,10 @@ export async function listSSHFiles(
path: string,
): Promise<{ files: unknown[]; path: string }> {
try {
- const response = await fileManagerApi.get("/ssh/listFiles", {
- params: { sessionId, path },
- });
+ const response = await getFileManagerApiForSession(sessionId).get(
+ "/ssh/listFiles",
+ { params: { sessionId, path } },
+ );
return response.data || { files: [], path };
} catch (error) {
handleApiError(error, "list SSH files");
@@ -281,9 +296,10 @@ export async function identifySSHSymlink(
path: string,
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
- const response = await fileManagerApi.get("/ssh/identifySymlink", {
- params: { sessionId, path },
- });
+ const response = await getFileManagerApiForSession(sessionId).get(
+ "/ssh/identifySymlink",
+ { params: { sessionId, path } },
+ );
return response.data;
} catch (error) {
handleApiError(error, "identify SSH symlink");
@@ -295,9 +311,10 @@ export async function resolveSSHPath(
path: string,
): Promise {
try {
- const response = await fileManagerApi.get("/ssh/resolvePath", {
- params: { sessionId, path },
- });
+ const response = await getFileManagerApiForSession(sessionId).get(
+ "/ssh/resolvePath",
+ { params: { sessionId, path } },
+ );
return response.data?.resolvedPath || path;
} catch {
return path;
@@ -313,9 +330,10 @@ export async function readSSHFile(
encoding?: "base64" | "utf8";
}> {
try {
- const response = await fileManagerApi.get("/ssh/readFile", {
- params: { sessionId, path },
- });
+ const response = await getFileManagerApiForSession(sessionId).get(
+ "/ssh/readFile",
+ { params: { sessionId, path } },
+ );
return response.data;
} catch (error: unknown) {
if (error.response?.status === 404) {
@@ -340,13 +358,10 @@ export async function writeSSHFile(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/writeFile", {
- sessionId,
- path,
- content,
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/writeFile",
+ { sessionId, path, content, hostId, userId },
+ );
if (
response.data &&
@@ -410,7 +425,7 @@ export async function uploadSSHFile(
form.append("totalSize", String(file.size));
form.append("chunk", chunkBlob, fileName);
- const response = await fileManagerApi.postForm(
+ const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileChunk",
form,
{ timeout: 0 },
@@ -444,7 +459,7 @@ export async function uploadSSHFile(
if (userId !== undefined) form.append("userId", userId);
form.append("file", file, fileName);
- const response = await fileManagerApi.postForm(
+ const response = await getFileManagerApiForSession(sessionId).postForm(
"/ssh/uploadFileStream",
form,
{
@@ -464,7 +479,7 @@ export async function downloadSSHFile(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.post(
+ const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFile",
{
sessionId,
@@ -484,7 +499,7 @@ export async function downloadSSHFileStream(
sessionId: string,
filePath: string,
): Promise {
- const response = await fileManagerApi.post(
+ const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/downloadFileStream",
{ sessionId, path: filePath },
{ responseType: "blob", timeout: 0 },
@@ -503,14 +518,10 @@ export async function createSSHFile(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/createFile", {
- sessionId,
- path,
- fileName,
- content,
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/createFile",
+ { sessionId, path, fileName, content, hostId, userId },
+ );
return response.data;
} catch (error) {
handleApiError(error, "create SSH file");
@@ -525,13 +536,10 @@ export async function createSSHFolder(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.post("/ssh/createFolder", {
- sessionId,
- path,
- folderName,
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/createFolder",
+ { sessionId, path, folderName, hostId, userId },
+ );
return response.data;
} catch (error) {
handleApiError(error, "create SSH folder");
@@ -546,15 +554,18 @@ export async function deleteSSHItem(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.delete("/ssh/deleteItem", {
- data: {
- sessionId,
- path,
- isDirectory,
- hostId,
- userId,
+ const response = await getFileManagerApiForSession(sessionId).delete(
+ "/ssh/deleteItem",
+ {
+ data: {
+ sessionId,
+ path,
+ isDirectory,
+ hostId,
+ userId,
+ },
},
- });
+ );
return response.data;
} catch (error) {
handleApiError(error, "delete SSH item");
@@ -566,7 +577,7 @@ export async function setSudoPassword(
password: string,
): Promise {
try {
- await fileManagerApi.post("/sudo-password", {
+ await getFileManagerApiForSession(sessionId).post("/sudo-password", {
sessionId,
password,
});
@@ -583,7 +594,7 @@ export async function copySSHItem(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.post(
+ const response = await getFileManagerApiForSession(sessionId).post(
"/ssh/copyItem",
{
sessionId,
@@ -611,13 +622,10 @@ export async function renameSSHItem(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.put("/ssh/renameItem", {
- sessionId,
- oldPath,
- newName,
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).put(
+ "/ssh/renameItem",
+ { sessionId, oldPath, newName, hostId, userId },
+ );
return response.data;
} catch (error) {
handleApiError(error, "rename SSH item");
@@ -633,7 +641,7 @@ export async function moveSSHItem(
userId?: string,
): Promise> {
try {
- const response = await fileManagerApi.put(
+ const response = await getFileManagerApiForSession(sessionId).put(
"/ssh/moveItem",
{
sessionId,
@@ -670,13 +678,10 @@ export async function changeSSHPermissions(
userId,
});
- const response = await fileManagerApi.post("/ssh/changePermissions", {
- sessionId,
- path,
- permissions,
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/changePermissions",
+ { sessionId, path, permissions, hostId, userId },
+ );
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
@@ -715,13 +720,10 @@ export async function extractSSHArchive(
userId,
});
- const response = await fileManagerApi.post("/ssh/extractArchive", {
- sessionId,
- archivePath,
- extractPath,
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/extractArchive",
+ { sessionId, archivePath, extractPath, hostId, userId },
+ );
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
@@ -762,14 +764,17 @@ export async function compressSSHFiles(
userId,
});
- const response = await fileManagerApi.post("/ssh/compressFiles", {
- sessionId,
- paths,
- archiveName,
- format: format || "zip",
- hostId,
- userId,
- });
+ const response = await getFileManagerApiForSession(sessionId).post(
+ "/ssh/compressFiles",
+ {
+ sessionId,
+ paths,
+ archiveName,
+ format: format || "zip",
+ hostId,
+ userId,
+ },
+ );
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
@@ -811,6 +816,12 @@ export async function ensureSSHSessionForHost(
host: SSHHost,
): Promise {
const sessionId = host.id.toString();
+ const origin = await resolveConnectionOrigin({
+ connectionType: host.connectionType,
+ connectionOrigin: host.connectionOrigin,
+ });
+ setSessionOrigin(sessionId, origin);
+
try {
const status = await getSSHStatus(sessionId);
if (status?.connected) {
diff --git a/src/ui/api/system-status-api.ts b/src/ui/api/system-status-api.ts
index 63e42b72..50e770d9 100644
--- a/src/ui/api/system-status-api.ts
+++ b/src/ui/api/system-status-api.ts
@@ -1,10 +1,5 @@
import { AxiosError } from "axios";
-import {
- authApi,
- handleApiError,
- isElectron,
- markUserAuthenticated,
-} from "@/main-axios";
+import { authApi, handleApiError, markUserAuthenticated } from "@/main-axios";
import type { AuthResponse } from "@/main-axios";
// ALERTS
@@ -63,25 +58,6 @@ export async function verifyTOTPLogin(
rememberMe,
});
- const isInIframe =
- typeof window !== "undefined" && window.self !== window.top;
-
- if (isInIframe && isElectron() && response.data.success) {
- try {
- window.parent.postMessage(
- {
- type: "AUTH_SUCCESS",
- source: "totp_verify",
- platform: "desktop",
- timestamp: Date.now(),
- },
- window.location.origin,
- );
- } catch (e) {
- console.error("[main-axios] Error posting message to parent:", e);
- }
- }
-
if (response.data.success) {
markUserAuthenticated();
}
diff --git a/src/ui/api/tunnel-api.ts b/src/ui/api/tunnel-api.ts
index 311dc73f..ec445f43 100644
--- a/src/ui/api/tunnel-api.ts
+++ b/src/ui/api/tunnel-api.ts
@@ -1,5 +1,11 @@
import axios from "axios";
-import { authApi, handleApiError, tunnelApi } from "@/main-axios";
+import {
+ authApi,
+ handleApiError,
+ tunnelApi,
+ getRemoteTunnelApi,
+ isElectron,
+} from "@/main-axios";
import type {
C2STunnelPreset,
TunnelConfig,
@@ -9,13 +15,46 @@ import type {
// TUNNEL MANAGEMENT
// ============================================================================
+//
+// Tunnel status is a process-local, in-memory view (no DB lookup) so it's
+// safe to read from both the embedded backend and a connected remote server
+// and merge the results. connectTunnel/disconnectTunnel/cancelTunnel are
+// NOT origin-routed: they resolve the target host by numeric database id
+// against whichever backend receives the request, and a synced host has a
+// different numeric id in each database (only its syncId matches across
+// them) -- routing those calls to a remote backend would need a
+// local-id-to-remote-id resolution step that doesn't exist yet. They always
+// target the embedded local backend for now.
+
+async function isRemoteSyncConnected(): Promise {
+ if (!isElectron()) return false;
+ try {
+ const config = (await window.electronAPI?.invoke?.(
+ "get-remote-sync-config",
+ )) as { serverUrl?: string } | null;
+ return !!config?.serverUrl;
+ } catch {
+ return false;
+ }
+}
export async function getTunnelStatuses(): Promise<
Record
> {
try {
- const response = await tunnelApi.get("/tunnel/status");
- return response.data || {};
+ const [localResult, remoteConnected] = await Promise.all([
+ tunnelApi.get("/tunnel/status"),
+ isRemoteSyncConnected(),
+ ]);
+ const localStatuses = localResult.data || {};
+ if (!remoteConnected) return localStatuses;
+
+ try {
+ const remoteResult = await getRemoteTunnelApi().get("/tunnel/status");
+ return { ...localStatuses, ...(remoteResult.data || {}) };
+ } catch {
+ return localStatuses;
+ }
} catch (error) {
handleApiError(error, "fetch tunnel statuses");
}
@@ -30,9 +69,18 @@ export function subscribeTunnelStatuses(
withCredentials: true,
});
+ let latestLocal: Record = {};
+ let latestRemote: Record = {};
+ let remotePollTimer: ReturnType | null = null;
+
+ const emitMerged = () => {
+ onStatuses({ ...latestLocal, ...latestRemote });
+ };
+
source.addEventListener("statuses", (event) => {
try {
- onStatuses(JSON.parse(event.data) as Record);
+ latestLocal = JSON.parse(event.data) as Record;
+ emitMerged();
} catch {
onError?.();
}
@@ -42,7 +90,27 @@ export function subscribeTunnelStatuses(
onError?.();
};
- return () => source.close();
+ // Remote tunnel status has no SSE stream exposed to the desktop app yet,
+ // so poll it at a modest interval when a remote server is connected.
+ isRemoteSyncConnected().then((connected) => {
+ if (!connected) return;
+ const pollRemote = async () => {
+ try {
+ const result = await getRemoteTunnelApi().get("/tunnel/status");
+ latestRemote = result.data || {};
+ emitMerged();
+ } catch {
+ // remote unreachable this tick -- keep last known remote statuses
+ }
+ };
+ pollRemote();
+ remotePollTimer = setInterval(pollRemote, 5000);
+ });
+
+ return () => {
+ source.close();
+ if (remotePollTimer) clearInterval(remotePollTimer);
+ };
}
export async function getTunnelStatusByName(
diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx
index b34a4c48..80fc649d 100644
--- a/src/ui/auth/Auth.tsx
+++ b/src/ui/auth/Auth.tsx
@@ -29,17 +29,13 @@ import {
completePasswordReset,
getOIDCAuthorizeUrl,
verifyTOTPLogin,
- getServerConfig,
- saveServerConfig,
isElectron,
- getEmbeddedServerStatus,
getCurrentToken,
getOidcSilentLoginDefault,
+ requestDesktopAutoSession,
} from "@/main-axios";
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
import type { SSOProviderPublic } from "@/types/index";
-import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
-import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import { Checkbox } from "@/components/checkbox";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import {
@@ -263,13 +259,23 @@ export function Auth({ onLogin }: AuthProps) {
const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
const [dbHealthChecking, setDbHealthChecking] = useState(true);
-
- const [showServerConfig, setShowServerConfig] = useState(
- null,
- );
- const [currentServerUrl, setCurrentServerUrl] = useState("");
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
+ // Electron, non-iframed only: the desktop app never shows a login form
+ // when running standalone -- the embedded backend auto-provisions a
+ // single local user on first boot, and this component silently exchanges
+ // that for a session instead of rendering login/register.
+ // null = probe still in flight (Electron only, blocks rendering below).
+ // true = probe settled with no auto-login (not applicable outside
+ // Electron, multiple users exist, or setup is genuinely required) --
+ // safe to fall through to the normal form/health-check flow.
+ // Auto-login success never sets this; it calls onLogin directly and this
+ // component unmounts.
+ const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState<
+ boolean | null
+ >(!isElectron() || isInElectronWebView() ? true : null);
+ const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState(0);
+
useEffect(() => {
try {
localStorage.setItem("rememberMe", rememberMe.toString());
@@ -320,7 +326,11 @@ export function Auth({ onLogin }: AuthProps) {
}, []);
useEffect(() => {
- if (showServerConfig) return;
+ // Runs once the auto-session probe has settled (immediately outside
+ // Electron, since it starts at true there; after the probe resolves in
+ // Electron). Waiting avoids flashing a login screen the user is about
+ // to skip past via auto-login.
+ if (desktopAutoSessionDone !== true) return;
setDbHealthChecking(true);
getSetupRequired()
.then((res) => {
@@ -332,53 +342,59 @@ export function Auth({ onLogin }: AuthProps) {
})
.catch(() => setDbConnectionFailed(true))
.finally(() => setDbHealthChecking(false));
- }, [showServerConfig]);
+ }, [desktopAutoSessionDone]);
+ // A cold first launch spawns the embedded backend as a separate process
+ // that can take anywhere from a couple seconds to much longer to finish
+ // booting (DB init, SSL, antivirus scanning a freshly-unpacked binary,
+ // slow disks, etc.) -- well after the renderer has already mounted. The
+ // embedded backend is bundled, always-on infrastructure, not something
+ // that can be "not there" -- it always eventually comes up. So a
+ // "retry" outcome (connection error, not a real verdict) is retried
+ // forever with capped backoff rather than ever giving up and falling
+ // through to the login form: that form is not a valid destination for a
+ // standalone install with no remote sync configured, since the only
+ // local account has no password to log in with. Only a definitive
+ // "declined" (backend reachable and says no -- multiple users, or the
+ // sole local user has a real credential) stops retrying and shows the
+ // real form.
useEffect(() => {
- const checkElectron = async () => {
- if (isInElectronWebView()) {
- setShowServerConfig(false);
- return;
- }
- if (isElectron()) {
- const forceShow = localStorage.getItem("termix_show_server_config");
- if (forceShow === "true") {
- localStorage.removeItem("termix_show_server_config");
- try {
- const config = await getServerConfig();
- setCurrentServerUrl(config?.serverUrl || "");
- } catch {
- // ignore
- }
- setShowServerConfig(true);
+ if (desktopAutoSessionDone !== null) return;
+ let cancelled = false;
+ let retryTimer: ReturnType | null = null;
+ requestDesktopAutoSession()
+ .then((outcome) => {
+ if (cancelled) return;
+ if (outcome.kind === "success") {
+ storeAuth(outcome.data.username || "");
+ onLogin(
+ outcome.data.username || "",
+ outcome.data.userId || undefined,
+ !!outcome.data.is_admin,
+ );
return;
}
- try {
- const [config, status] = await Promise.all([
- getServerConfig(),
- getEmbeddedServerStatus(),
- ]);
- if (
- status?.embedded &&
- status?.running &&
- config &&
- !config.serverUrl
- ) {
- setShowServerConfig(false);
- setCurrentServerUrl("");
- return;
- }
- setCurrentServerUrl(config?.serverUrl || "");
- setShowServerConfig(!config || !config.serverUrl);
- } catch {
- setShowServerConfig(true);
+ if (outcome.kind === "retry") {
+ const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000);
+ retryTimer = setTimeout(() => {
+ if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1);
+ }, delay);
+ return;
}
- } else {
- setShowServerConfig(false);
- }
+ setDesktopAutoSessionDone(true);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000);
+ retryTimer = setTimeout(() => {
+ if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1);
+ }, delay);
+ });
+ return () => {
+ cancelled = true;
+ if (retryTimer) clearTimeout(retryTimer);
};
- checkElectron();
- }, []);
+ }, [desktopAutoSessionDone, desktopAutoSessionRetries, onLogin]);
useEffect(() => {
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
@@ -474,36 +490,6 @@ export function Auth({ onLogin }: AuthProps) {
}
}, [onLogin, t]);
- const handleElectronAuthSuccess = useCallback(
- async (token: string | null) => {
- try {
- if (!token) {
- // No token in postMessage — fall back to waiting for the HttpOnly cookie
- const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
- "jwt",
- currentServerUrl,
- null,
- 5000,
- );
- if (cookieReady && !cookieReady.success)
- throw new Error(cookieReady.error || "Auth cookie not ready");
- }
- const meRes = await getUserInfo();
- if (!meRes) throw new Error("Failed to get user info");
- storeAuth(meRes.username || "");
- onLogin(
- meRes.username || "",
- meRes.userId || undefined,
- !!meRes.is_admin,
- );
- toast.success(t("messages.loginSuccess"));
- } catch {
- toast.error(t("errors.failedUserInfo"));
- }
- },
- [onLogin, currentServerUrl, t],
- );
-
function resetAll() {
setUsername("");
setPassword("");
@@ -549,11 +535,19 @@ export function Auth({ onLogin }: AuthProps) {
return;
}
if (isInElectronWebView()) {
+ // The iframe's login request never carries the X-Electron-App header
+ // (only the top-level Electron renderer's axios instances do), so the
+ // backend never includes the JWT in the login response body -- it
+ // only lands in an HttpOnly cookie scoped to this iframe's origin.
+ // Read it back via /users/me/token, same as the mobile-webview OIDC
+ // callback below does, so the parent window can persist it.
+ const token = res?.token ?? (await getCurrentToken());
window.parent.postMessage(
{
type: "AUTH_SUCCESS",
source: "auth_component",
platform: "desktop",
+ token: token ?? null,
timestamp: Date.now(),
},
"*",
@@ -607,6 +601,35 @@ export function Auth({ onLogin }: AuthProps) {
setView("totp");
return;
}
+ if (isInMobileWebView()) {
+ // Native-app requests get the JWT in the login response body.
+ const token = res?.token ?? "";
+ (window as ExtendedWindow).ReactNativeWebView?.postMessage(
+ JSON.stringify({ type: "AUTH_SUCCESS", token }),
+ );
+ setWebviewAuthSuccess(true);
+ return;
+ }
+ if (isInElectronWebView()) {
+ // Registration inside the Remote Sync iframe must hand off to the
+ // parent window the same way handleLogin does -- otherwise this
+ // component's own onLogin() below fires on the iframe's own,
+ // independent copy of the app, rendering the full remote AppShell
+ // inside the small login dialog instead of closing it.
+ const token = res?.token ?? (await getCurrentToken());
+ window.parent.postMessage(
+ {
+ type: "AUTH_SUCCESS",
+ source: "auth_component",
+ platform: "desktop",
+ token: token ?? null,
+ timestamp: Date.now(),
+ },
+ "*",
+ );
+ setWebviewAuthSuccess(true);
+ return;
+ }
const meRes = await getUserInfo();
storeAuth(meRes.username || username.trim());
toast.success(t("messages.registrationSuccess"));
@@ -650,11 +673,16 @@ export function Auth({ onLogin }: AuthProps) {
return;
}
if (isInElectronWebView()) {
+ // See the equivalent branch in handleLogin: the iframe never sends
+ // X-Electron-App, so the JWT never lands in the response body here
+ // either -- read it back from the HttpOnly cookie that was just set.
+ const token = res?.token ?? (await getCurrentToken());
window.parent.postMessage(
{
type: "AUTH_SUCCESS",
source: "totp_auth_component",
platform: "desktop",
+ token: token ?? null,
timestamp: Date.now(),
},
"*",
@@ -935,46 +963,19 @@ export function Auth({ onLogin }: AuthProps) {
oidcSilentLoginDefaultLoaded,
]);
- // Electron server config / webview auth success screens
- if (isElectron() && !isInElectronWebView()) {
- if (showServerConfig === null)
- return (
-
- );
- if (showServerConfig)
- return (
-
-
- window.location.reload()}
- onUseEmbedded={async () => {
- await saveServerConfig({
- serverUrl: "",
- lastUpdated: new Date().toISOString(),
- });
- setShowServerConfig(false);
- setCurrentServerUrl("");
- }}
- onCancel={() => setShowServerConfig(false)}
- isFirstTime={!currentServerUrl}
- />
-
-
- );
- if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl)
- return (
-
-
- setShowServerConfig(true)}
- />
-
-
- );
+ // Electron, non-iframed: wait for the auto-session probe before rendering
+ // anything, so a standalone desktop install never flashes a login form
+ // it's about to skip past.
+ if (
+ isElectron() &&
+ !isInElectronWebView() &&
+ desktopAutoSessionDone === null
+ ) {
+ return (
+
+ );
}
if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess))
@@ -1018,30 +1019,11 @@ export function Auth({ onLogin }: AuthProps) {
))}
- {isElectron() && currentServerUrl && (
-
-
-
- {t("serverConfig.serverUrl")}
-
-
- {currentServerUrl}
-
-
-
setShowServerConfig(true)}
- >
- {t("common.edit")}
-
-
- )}
);
- if (dbHealthChecking && showServerConfig === false)
+ if (dbHealthChecking)
return (
@@ -1068,21 +1050,6 @@ export function Auth({ onLogin }: AuthProps) {
return (
- {isElectron() && !isInElectronWebView() && showServerConfig === false && (
-
-
setShowServerConfig(true)}
- className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
- >
-
- {t("serverConfig.changeServer")}
-
-
- {t("serverConfig.localServer")}
-
-
-
- )}
{/* Left decorative panel */}
diff --git a/src/ui/auth/ElectronLoginForm.tsx b/src/ui/auth/ElectronLoginForm.tsx
index b49cb358..3abc0569 100644
--- a/src/ui/auth/ElectronLoginForm.tsx
+++ b/src/ui/auth/ElectronLoginForm.tsx
@@ -7,6 +7,12 @@ interface ElectronLoginFormProps {
serverUrl: string;
onAuthSuccess: (token: string | null) => void | Promise
;
onChangeServer: () => void;
+ // "local" (default): the app's own login, JWT goes to localStorage like
+ // every other client. "remoteSync": this iframe is authenticating a
+ // Settings-triggered connection to a remote Termix server for the sync
+ // engine -- the JWT is handed to the Electron main process's encrypted
+ // store instead, never exposed to the renderer's localStorage.
+ targetPurpose?: "local" | "remoteSync";
}
const AUTH_MESSAGE_SOURCES = new Set([
@@ -19,6 +25,7 @@ export function ElectronLoginForm({
serverUrl,
onAuthSuccess,
onChangeServer,
+ targetPurpose = "local",
}: ElectronLoginFormProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
@@ -43,7 +50,11 @@ export function ElectronLoginForm({
try {
if (token) {
- localStorage.setItem("jwt", token);
+ if (targetPurpose === "remoteSync") {
+ await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
+ } else {
+ localStorage.setItem("jwt", token);
+ }
}
await onAuthSuccessRef.current(token);
} catch {
@@ -53,7 +64,7 @@ export function ElectronLoginForm({
hasAuthenticatedRef.current = false;
}
},
- [t],
+ [t, targetPurpose],
);
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
@@ -204,7 +215,7 @@ export function ElectronLoginForm({
const isEmbeddedServer = serverUrl.includes("localhost:30001");
return (
-
+
{isAuthenticating && (
@@ -212,7 +223,7 @@ export function ElectronLoginForm({
)}
{!isAuthenticating && (
-
+
u !== url);
- urls.unshift(url);
- localStorage.setItem(
- SAVED_URLS_KEY,
- JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
- );
-}
-
-function removeSavedUrl(url: string) {
- const urls = getSavedUrls().filter((u) => u !== url);
- localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
-}
-
-interface ServerConfigProps {
- onServerConfigured: (serverUrl: string) => void;
- onUseEmbedded?: () => void;
- onCancel?: () => void;
- isFirstTime?: boolean;
-}
-
-export function ElectronServerConfig({
- onServerConfigured,
- onUseEmbedded,
- onCancel,
- isFirstTime = false,
-}: ServerConfigProps) {
- const { t } = useTranslation();
- const [serverUrl, setServerUrl] = useState("");
- const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
- const [loading, setLoading] = useState(false);
- const [embeddedLoading, setEmbeddedLoading] = useState(false);
- const [error, setError] = useState(null);
- const [embeddedAvailable, setEmbeddedAvailable] = useState(
- null,
- );
- const [savedUrls, setSavedUrls] = useState([]);
- const [dropdownOpen, setDropdownOpen] = useState(false);
- const dropdownRef = useRef(null);
-
- useEffect(() => {
- loadServerConfig();
- checkEmbeddedBackend();
- setSavedUrls(getSavedUrls());
- }, []);
-
- useEffect(() => {
- function handleClickOutside(e: MouseEvent) {
- if (
- dropdownRef.current &&
- !dropdownRef.current.contains(e.target as Node)
- ) {
- setDropdownOpen(false);
- }
- }
- if (dropdownOpen) {
- document.addEventListener("mousedown", handleClickOutside);
- }
- return () => document.removeEventListener("mousedown", handleClickOutside);
- }, [dropdownOpen]);
-
- const loadServerConfig = async () => {
- try {
- const config = await getServerConfig();
- if (config?.serverUrl) {
- setServerUrl(config.serverUrl);
- }
- setAllowInvalidCertificate(!!config?.allowInvalidCertificate);
- } catch (error) {
- console.error("Server config operation failed:", error);
- }
- };
-
- const checkEmbeddedBackend = async () => {
- try {
- const status = await getEmbeddedServerStatus();
- setEmbeddedAvailable(!!status?.embedded);
- } catch {
- setEmbeddedAvailable(true);
- }
- };
-
- const probeBackend = async (): Promise => {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 3000);
- try {
- const res = await fetch("http://localhost:30001/health", {
- signal: controller.signal,
- });
- clearTimeout(timer);
- return res.ok;
- } catch {
- clearTimeout(timer);
- }
- const controller2 = new AbortController();
- const timer2 = setTimeout(() => controller2.abort(), 3000);
- try {
- await fetch("http://localhost:30001/version", {
- signal: controller2.signal,
- });
- clearTimeout(timer2);
- return true;
- } catch {
- clearTimeout(timer2);
- return false;
- }
- };
-
- const handleUseEmbedded = async () => {
- setEmbeddedLoading(true);
- setError(null);
-
- try {
- await new Promise((r) => setTimeout(r, 1500));
- const maxRetries = 15;
- for (let i = 0; i < maxRetries; i++) {
- if (await probeBackend()) {
- setEmbeddedMode(true);
- if (onUseEmbedded) {
- onUseEmbedded();
- } else {
- onServerConfigured("http://localhost:30001");
- }
- return;
- }
- if (i < maxRetries - 1) {
- await new Promise((r) => setTimeout(r, 2000));
- }
- }
- setError(t("serverConfig.embeddedNotReady"));
- } catch (err) {
- setError(
- err instanceof Error ? err.message : t("serverConfig.embeddedNotReady"),
- );
- } finally {
- setEmbeddedLoading(false);
- }
- };
-
- const handleSaveConfig = async () => {
- if (!serverUrl.trim()) {
- setError(t("serverConfig.enterServerUrl"));
- return;
- }
-
- setLoading(true);
- setError(null);
-
- try {
- const normalizedUrl = serverUrl.trim();
-
- if (
- !normalizedUrl.startsWith("http://") &&
- !normalizedUrl.startsWith("https://")
- ) {
- setError(t("serverConfig.mustIncludeProtocol"));
- setLoading(false);
- return;
- }
-
- const config: ServerConfig = {
- serverUrl: normalizedUrl,
- lastUpdated: new Date().toISOString(),
- allowInvalidCertificate:
- normalizedUrl.startsWith("https://") && allowInvalidCertificate,
- };
-
- const success = await saveServerConfig(config);
-
- if (success) {
- addSavedUrl(normalizedUrl);
- setSavedUrls(getSavedUrls());
- onServerConfigured(normalizedUrl);
- } else {
- setError(t("serverConfig.saveFailed"));
- }
- } catch {
- setError(t("serverConfig.saveError"));
- } finally {
- setLoading(false);
- }
- };
-
- const handleUrlChange = (value: string) => {
- setServerUrl(value);
- setError(null);
- };
-
- return (
-
-
-
-
-
-
{t("serverConfig.title")}
-
-
- {t("serverConfig.description")}
-
-
-
- {embeddedAvailable !== false && (
- <>
-
- {embeddedLoading ? (
-
-
- {t("serverConfig.embeddedConnecting")}
-
- ) : (
-
-
- {t("serverConfig.useEmbedded")}
-
- BETA
-
-
- )}
-
-
- {t("serverConfig.embeddedDesc")}
-
-
-
-
- {t("common.or") || "OR"}
-
-
-
- >
- )}
-
-
-
-
{t("serverConfig.serverUrl")}
-
-
handleUrlChange(e.target.value)}
- disabled={loading || embeddedLoading}
- className={savedUrls.length > 0 ? "pr-9" : ""}
- onFocus={() => {
- if (savedUrls.length > 0) setDropdownOpen(true);
- }}
- />
- {savedUrls.length > 0 && (
-
setDropdownOpen((o) => !o)}
- disabled={loading || embeddedLoading}
- className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
- aria-label={t("serverConfig.savedServers")}
- >
-
-
- )}
- {dropdownOpen && savedUrls.length > 0 && (
-
-
- {t("serverConfig.savedServers")}
-
- {savedUrls.map((url) => (
-
- {
- handleUrlChange(url);
- setDropdownOpen(false);
- }}
- >
- {url}
-
- {
- e.stopPropagation();
- removeSavedUrl(url);
- const updated = getSavedUrls();
- setSavedUrls(updated);
- if (updated.length === 0) setDropdownOpen(false);
- }}
- >
-
-
-
- ))}
-
- )}
-
-
-
- {serverUrl.trim().startsWith("https://") && (
-
-
-
- {t("serverConfig.allowInvalidCertificate")}
-
-
- {t("serverConfig.allowInvalidCertificateDesc")}
-
-
-
-
- )}
-
- {error && (
-
- {t("common.error")}
- {error}
-
- )}
-
-
- {onCancel && !isFirstTime && (
-
- {t("common.cancel")}
-
- )}
-
- {loading ? (
-
-
- {t("serverConfig.saving")}
-
- ) : (
- t("serverConfig.saveConfig")
- )}
-
-
-
-
- {t("serverConfig.helpText")}
-
-
-
-
- );
-}
diff --git a/src/ui/auth/LoginPage.tsx b/src/ui/auth/LoginPage.tsx
deleted file mode 100644
index 4efd8497..00000000
--- a/src/ui/auth/LoginPage.tsx
+++ /dev/null
@@ -1,1981 +0,0 @@
-/* eslint-disable react-hooks/exhaustive-deps */
-import React, { useState, useEffect, useCallback, useRef } from "react";
-import { Button } from "@/components/button.tsx";
-import { Input } from "@/components/input.tsx";
-import { PasswordInput } from "@/components/password-input.tsx";
-import { Label } from "@/components/label.tsx";
-import { Checkbox } from "@/components/checkbox.tsx";
-import { Alert, AlertDescription, AlertTitle } from "@/components/alert.tsx";
-import { Tabs, TabsList, TabsTrigger } from "@/components/tabs.tsx";
-import { useTranslation } from "react-i18next";
-import { LanguageSwitcher } from "@/user/LanguageSwitcher.tsx";
-import { toast } from "sonner";
-import { Sun, Moon, Monitor } from "lucide-react";
-import { useTheme } from "@/components/theme-provider";
-import {
- registerUser,
- loginUser,
- getUserInfo,
- getRegistrationAllowed,
- getPasswordLoginAllowed,
- getSetupRequired,
- initiatePasswordReset,
- verifyPasswordResetCode,
- completePasswordReset,
- getOIDCAuthorizeUrl,
- verifyTOTPLogin,
- getServerConfig,
- saveServerConfig,
- isElectron,
- getEmbeddedServerStatus,
- getCurrentToken,
- getOidcSilentLoginDefault,
-} from "@/main-axios";
-import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
-import { authenticateWithWebAuthn } from "@/api/webauthn-api";
-import type { SSOProviderPublic } from "@/types/index";
-import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig.tsx";
-import { ElectronLoginForm } from "@/auth/ElectronLoginForm.tsx";
-import {
- removeSilentSigninFromSearch,
- shouldTriggerSilentSignin,
-} from "./silent-signin";
-
-interface ExtendedWindow extends Window {
- IS_ELECTRON_WEBVIEW?: boolean;
- ReactNativeWebView?: { postMessage: (msg: string) => void };
-}
-
-const isInMobileWebView = () =>
- /Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
- !!(window as ExtendedWindow).ReactNativeWebView;
-
-interface AuthProps extends React.ComponentProps<"div"> {
- setLoggedIn: (loggedIn: boolean) => void;
- setIsAdmin: (isAdmin: boolean) => void;
- setUsername: (username: string | null) => void;
- setUserId: (userId: string | null) => void;
- loggedIn: boolean;
- authLoading: boolean;
- setDbError: (error: string | null) => void;
- onAuthSuccess: (authData: {
- isAdmin: boolean;
- username: string | null;
- userId: string | null;
- }) => void;
-}
-
-export function Auth({
- className,
- setLoggedIn,
- setIsAdmin,
- setUsername,
- setUserId,
- loggedIn,
- authLoading,
- setDbError,
- onAuthSuccess,
- ...props
-}: AuthProps) {
- const { t } = useTranslation();
- const { theme, setTheme } = useTheme();
-
- 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";
-
- const isInElectronWebView = useCallback(() => {
- if (isInMobileWebView()) return false;
- if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) {
- return true;
- }
- try {
- if (window.self !== window.top) {
- return true;
- }
- } catch {
- return true;
- }
- return false;
- }, []);
-
- const [tab, setTab] = useState<"login" | "signup" | "reset">("login");
- const [localUsername, setLocalUsername] = useState("");
- const [password, setPassword] = useState("");
- const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
- const [rememberMe, setRememberMe] = useState(() => {
- try {
- const saved = localStorage.getItem("rememberMe");
- return saved === "true";
- } catch {
- return false;
- }
- });
- const [loading, setLoading] = useState(false);
- const [passkeyLoading, setPasskeyLoading] = useState(false);
- const [oidcLoading, setOidcLoading] = useState(false);
- const [internalLoggedIn, setInternalLoggedIn] = useState(false);
- const [firstUser, setFirstUser] = useState(false);
- const [firstUserToastShown, setFirstUserToastShown] = useState(false);
- const [registrationAllowed, setRegistrationAllowed] = useState(true);
- const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
- const [ssoProviders, setSsoProviders] = useState([]);
- const [ssoProvidersLoaded, setSsoProvidersLoaded] = useState(false);
- const [ldapProviderId, setLdapProviderId] = useState(null);
- const [ldapUsername, setLdapUsername] = useState("");
- const [ldapPassword, setLdapPassword] = useState("");
- const [ldapLoading, setLdapLoading] = useState(false);
- const silentSigninHandledRef = useRef(false);
- const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
- const [oidcSilentLoginDefaultLoaded, setOidcSilentLoginDefaultLoaded] =
- useState(false);
-
- const [resetStep, setResetStep] = useState<
- "initiate" | "verify" | "newPassword"
- >("initiate");
- const [resetCode, setResetCode] = useState("");
- const [newPassword, setNewPassword] = useState("");
- const [confirmPassword, setConfirmPassword] = useState("");
- const [tempToken, setTempToken] = useState("");
- const [resetLoading, setResetLoading] = useState(false);
- const [resetSuccess, setResetSuccess] = useState(false);
-
- const [totpRequired, setTotpRequired] = useState(false);
- const [totpCode, setTotpCode] = useState("");
- const [totpTempToken, setTotpTempToken] = useState("");
- const [totpLoading, setTotpLoading] = useState(false);
- const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
- const totpInputRef = React.useRef(null);
-
- // Hand the JWT to the native app embedding this page in a React Native WebView.
- // The mobile onMessage handler only reads { type, token }.
- const postMobileAuthSuccess = useCallback((token: string) => {
- (window as ExtendedWindow).ReactNativeWebView?.postMessage(
- JSON.stringify({ type: "AUTH_SUCCESS", token }),
- );
- setWebviewAuthSuccess(true);
- }, []);
-
- const [showServerConfig, setShowServerConfig] = useState(
- null,
- );
- const [currentServerUrl, setCurrentServerUrl] = useState("");
- const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
- const [dbHealthChecking, setDbHealthChecking] = useState(false);
-
- const handleElectronAuthSuccess = useCallback(async () => {
- try {
- // token was stored in localStorage by ElectronLoginForm before this runs,
- // so getUserInfo() can authenticate via the cookie interceptor or localStorage jwt.
- let retries = 5;
- let meRes = null;
- while (retries-- > 0) {
- try {
- meRes = await getUserInfo();
- break;
- } catch (err: unknown) {
- const isNoServer =
- (err as { code?: string })?.code === "NO_SERVER_CONFIGURED" ||
- (err as Error)?.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);
- setUsername(meRes.username || null);
- setUserId(meRes.userId || null);
- onAuthSuccess({
- isAdmin: !!meRes.is_admin,
- username: meRes.username || null,
- userId: meRes.userId || null,
- });
- toast.success(t("messages.loginSuccess"));
- } catch {
- toast.error(t("errors.failedUserInfo"));
- }
- }, [
- onAuthSuccess,
- setLoggedIn,
- setIsAdmin,
- setUsername,
- setUserId,
- t,
- setInternalLoggedIn,
- ]);
-
- useEffect(() => {
- setInternalLoggedIn(loggedIn);
- }, [loggedIn]);
-
- useEffect(() => {
- if (totpRequired && totpInputRef.current) {
- totpInputRef.current.focus();
- }
- }, [totpRequired]);
-
- useEffect(() => {
- try {
- localStorage.setItem("rememberMe", rememberMe.toString());
- } catch {
- // expected - localStorage might not be available
- }
- }, [rememberMe]);
-
- useEffect(() => {
- getRegistrationAllowed().then((res) => {
- setRegistrationAllowed(res.allowed);
- });
- }, [isInElectronWebView]);
-
- useEffect(() => {
- getPasswordLoginAllowed()
- .then((res) => {
- setPasswordLoginAllowed(res.allowed);
- })
- .catch((err) => {
- if (err.code !== "NO_SERVER_CONFIGURED") {
- console.error("Failed to fetch password login status:", err);
- }
- });
- }, []);
-
- useEffect(() => {
- getSSOProviders()
- .then((providers) => {
- setSsoProviders(providers || []);
- })
- .catch(() => {
- setSsoProviders([]);
- })
- .finally(() => {
- setSsoProvidersLoaded(true);
- });
- }, []);
-
- useEffect(() => {
- getOidcSilentLoginDefault()
- .then((res) => {
- setOidcSilentLoginDefault(res.enabled);
- })
- .catch(() => {})
- .finally(() => {
- setOidcSilentLoginDefaultLoaded(true);
- });
- }, []);
-
- useEffect(() => {
- if (showServerConfig) {
- return;
- }
-
- setDbHealthChecking(true);
- getSetupRequired()
- .then((res) => {
- if (res.setup_required) {
- setFirstUser(true);
- setTab("signup");
- if (!firstUserToastShown) {
- toast.info(t("auth.firstUserMessage"));
- setFirstUserToastShown(true);
- }
- } else {
- setFirstUser(false);
- }
- setDbError(null);
- setDbConnectionFailed(false);
- })
- .catch(() => {
- setDbConnectionFailed(true);
- })
- .finally(() => {
- setDbHealthChecking(false);
- });
- }, [setDbError, firstUserToastShown, showServerConfig, t]);
-
- // When password login is disabled and SSO is available, stay on login tab
- // (SSO buttons appear below the form regardless of tab)
-
- async function handleSubmit(e: React.FormEvent) {
- e.preventDefault();
- setLoading(true);
-
- if (!localUsername.trim()) {
- toast.error(t("errors.requiredField"));
- setLoading(false);
- return;
- }
-
- if (!passwordLoginAllowed && !firstUser) {
- toast.error(t("errors.passwordLoginDisabled"));
- setLoading(false);
- return;
- }
-
- try {
- let res;
- if (tab === "login") {
- res = await loginUser(localUsername, password, rememberMe);
- } else {
- if (password !== signupConfirmPassword) {
- toast.error(t("errors.passwordMismatch"));
- setLoading(false);
- return;
- }
- if (password.length < 6) {
- toast.error(t("errors.minLength", { min: 6 }));
- setLoading(false);
- return;
- }
-
- await registerUser(localUsername, password);
- res = await loginUser(localUsername, password, rememberMe);
- }
-
- if (res.requires_totp) {
- setTotpRequired(true);
- setTotpTempToken(res.temp_token);
- setLoading(false);
- return;
- }
-
- if (!res || !res.success) {
- throw new Error(t("errors.loginFailed"));
- }
-
- if (isInMobileWebView()) {
- // Native-app requests get the JWT in the login response body.
- postMobileAuthSuccess(res.token || "");
- return;
- }
-
- if (isInElectronWebView()) {
- try {
- window.parent.postMessage(
- {
- type: "AUTH_SUCCESS",
- source: "auth_component",
- platform: "desktop",
- token: res.token || null,
- timestamp: Date.now(),
- },
- "*",
- );
- setWebviewAuthSuccess(true);
- return;
- } catch (e) {
- console.error("Error posting auth success message:", e);
- }
- }
-
- const [meRes] = await Promise.all([getUserInfo()]);
-
- setInternalLoggedIn(true);
- setLoggedIn(true);
- setIsAdmin(!!meRes.is_admin);
- setUsername(meRes.username || null);
- setUserId(meRes.userId || null);
- setDbError(null);
- onAuthSuccess({
- isAdmin: !!meRes.is_admin,
- username: meRes.username || null,
- userId: meRes.userId || null,
- });
- setInternalLoggedIn(true);
- if (tab === "signup") {
- setSignupConfirmPassword("");
- toast.success(t("messages.registrationSuccess"));
- } else {
- toast.success(t("messages.loginSuccess"));
- }
- setTotpRequired(false);
- setTotpCode("");
- setTotpTempToken("");
- } catch (err: unknown) {
- const error = err as {
- message?: string;
- response?: { data?: { error?: string } };
- };
- const errorMessage =
- error?.response?.data?.error ||
- error?.message ||
- t("errors.unknownError");
- toast.error(errorMessage);
- setInternalLoggedIn(false);
- setLoggedIn(false);
- setIsAdmin(false);
- setUsername(null);
- setUserId(null);
- if (error?.response?.data?.error?.includes("Database")) {
- setDbConnectionFailed(true);
- } else {
- setDbError(null);
- }
- } finally {
- setLoading(false);
- }
- }
-
- async function handleInitiatePasswordReset() {
- setResetLoading(true);
- try {
- await initiatePasswordReset(localUsername);
- setResetStep("verify");
- toast.success(t("messages.resetCodeSent"));
- } catch (err: unknown) {
- const error = err as {
- message?: string;
- response?: { data?: { error?: string } };
- };
- toast.error(
- error?.response?.data?.error ||
- error?.message ||
- t("errors.failedPasswordReset"),
- );
- } finally {
- setResetLoading(false);
- }
- }
-
- async function handleVerifyResetCode() {
- setResetLoading(true);
- try {
- const response = await verifyPasswordResetCode(localUsername, resetCode);
- setTempToken(response.tempToken);
- setResetStep("newPassword");
- toast.success(t("messages.codeVerified"));
- } catch (err: unknown) {
- const error = err as {
- response?: {
- data?: {
- error?: string;
- code?: string;
- remainingTime?: number;
- remainingAttempts?: number;
- };
- };
- };
- const errorCode = error?.response?.data?.code;
- const remainingTime = error?.response?.data?.remainingTime;
- const remainingAttempts = error?.response?.data?.remainingAttempts;
-
- let errorMessage =
- error?.response?.data?.error || t("errors.failedVerifyCode");
-
- if (errorCode === "RESET_CODE_RATE_LIMITED") {
- if (remainingTime) {
- errorMessage = t("errors.resetCodeRateLimitedWithTime", {
- time: remainingTime,
- });
- } else {
- errorMessage = t("errors.resetCodeRateLimited");
- }
- } else if (
- remainingAttempts !== undefined &&
- remainingAttempts <= 2 &&
- remainingAttempts > 0
- ) {
- errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
- }
-
- toast.error(errorMessage);
- } finally {
- setResetLoading(false);
- }
- }
-
- async function handleCompletePasswordReset() {
- setResetLoading(true);
-
- if (newPassword !== confirmPassword) {
- toast.error(t("errors.passwordMismatch"));
- setResetLoading(false);
- return;
- }
-
- if (newPassword.length < 6) {
- toast.error(t("errors.minLength", { min: 6 }));
- setResetLoading(false);
- return;
- }
-
- try {
- try {
- await completePasswordReset(localUsername, tempToken, newPassword);
- } catch (err: unknown) {
- const error = err as {
- response?: { data?: { code?: string } };
- };
- if (error?.response?.data?.code !== "DATA_WIPE_REQUIRED") {
- throw err;
- }
- if (!window.confirm(t("auth.confirmResetDataWipe"))) {
- setResetLoading(false);
- return;
- }
- await completePasswordReset(
- localUsername,
- tempToken,
- newPassword,
- true,
- );
- }
-
- setResetStep("initiate");
- setResetCode("");
- setNewPassword("");
- setConfirmPassword("");
- setTempToken("");
-
- setResetSuccess(true);
- toast.success(t("messages.passwordResetSuccess"));
-
- setTab("login");
- resetPasswordState();
- } catch (err: unknown) {
- const error = err as { response?: { data?: { error?: string } } };
- toast.error(
- error?.response?.data?.error || t("errors.failedCompleteReset"),
- );
- } finally {
- setResetLoading(false);
- }
- }
-
- function resetPasswordState() {
- setResetStep("initiate");
- setResetCode("");
- setNewPassword("");
- setConfirmPassword("");
- setTempToken("");
- setResetSuccess(false);
- setSignupConfirmPassword("");
- }
-
- function clearFormFields() {
- setPassword("");
- setSignupConfirmPassword("");
- }
-
- async function handleTOTPVerification() {
- if (totpCode.length !== 6) {
- toast.error(t("auth.enterCode"));
- return;
- }
-
- setTotpLoading(true);
-
- try {
- const res = await verifyTOTPLogin(totpTempToken, totpCode, rememberMe);
-
- if (!res || !res.success) {
- throw new Error(t("errors.loginFailed"));
- }
-
- if (isInMobileWebView()) {
- // Native-app requests get the JWT in the verify response body.
- postMobileAuthSuccess(res.token || "");
- setTotpLoading(false);
- return;
- }
-
- if (isInElectronWebView()) {
- try {
- window.parent.postMessage(
- {
- type: "AUTH_SUCCESS",
- source: "totp_auth_component",
- platform: "desktop",
- token: res.token || null,
- timestamp: Date.now(),
- },
- "*",
- );
- setWebviewAuthSuccess(true);
- setTotpLoading(false);
- return;
- } catch (e) {
- console.error("Error posting auth success message:", e);
- }
- }
-
- setLoggedIn(true);
- setIsAdmin(!!res.is_admin);
- setUsername(res.username || null);
- setUserId(res.userId || null);
- setDbError(null);
-
- onAuthSuccess({
- isAdmin: !!res.is_admin,
- username: res.username || null,
- userId: res.userId || null,
- });
-
- setInternalLoggedIn(true);
- setTotpRequired(false);
- setTotpCode("");
- setTotpTempToken("");
- toast.success(t("messages.loginSuccess"));
- } catch (err: unknown) {
- const error = err as {
- message?: string;
- response?: {
- data?: {
- code?: string;
- error?: string;
- remainingTime?: number;
- remainingAttempts?: number;
- };
- };
- };
- const errorCode = error?.response?.data?.code;
- const remainingTime = error?.response?.data?.remainingTime;
- const remainingAttempts = error?.response?.data?.remainingAttempts;
-
- let errorMessage =
- error?.response?.data?.error ||
- error?.message ||
- t("errors.invalidTotpCode");
-
- if (errorCode === "SESSION_EXPIRED") {
- setTotpRequired(false);
- setTotpCode("");
- setTotpTempToken("");
- setTab("login");
- toast.error(t("errors.sessionExpired"));
- } else if (errorCode === "TOTP_RATE_LIMITED") {
- if (remainingTime) {
- errorMessage = t("errors.totpRateLimitedWithTime", {
- time: remainingTime,
- });
- } else {
- errorMessage = t("errors.totpRateLimited");
- }
- toast.error(errorMessage);
- } else {
- if (
- remainingAttempts !== undefined &&
- remainingAttempts <= 2 &&
- remainingAttempts > 0
- ) {
- errorMessage = `${errorMessage} (${remainingAttempts} ${t("auth.attemptsRemaining")})`;
- }
- toast.error(errorMessage);
- }
- } finally {
- setTotpLoading(false);
- }
- }
-
- async function handlePasskeyLogin() {
- setPasskeyLoading(true);
- try {
- const res = await authenticateWithWebAuthn(
- localUsername,
- rememberMe,
- "preferred",
- );
-
- if (res.requires_totp) {
- setTotpRequired(true);
- setTotpTempToken(res.temp_token || "");
- return;
- }
-
- if (!res || !res.success) {
- throw new Error(t("errors.loginFailed"));
- }
-
- if (isInMobileWebView()) {
- postMobileAuthSuccess(res.token || "");
- return;
- }
-
- if (isInElectronWebView()) {
- window.parent.postMessage(
- {
- type: "AUTH_SUCCESS",
- source: "passkey_auth_component",
- platform: "desktop",
- token: res.token || null,
- timestamp: Date.now(),
- },
- "*",
- );
- setWebviewAuthSuccess(true);
- return;
- }
-
- const meRes = await getUserInfo();
- setInternalLoggedIn(true);
- setLoggedIn(true);
- setIsAdmin(!!meRes.is_admin);
- setUsername(meRes.username || null);
- setUserId(meRes.userId || null);
- setDbError(null);
- onAuthSuccess({
- isAdmin: !!meRes.is_admin,
- username: meRes.username || null,
- userId: meRes.userId || null,
- });
- toast.success(t("messages.loginSuccess"));
- } catch (err: unknown) {
- const error = err as {
- message?: string;
- response?: { data?: { error?: string } };
- };
- toast.error(
- error?.response?.data?.error ||
- error?.message ||
- t("auth.passkeyLoginFailed"),
- );
- } finally {
- setPasskeyLoading(false);
- }
- }
-
- const handleOIDCLogin = useCallback(
- async (providerId?: number) => {
- setOidcLoading(true);
- try {
- const authResponse = await getOIDCAuthorizeUrl(
- rememberMe,
- undefined,
- providerId,
- );
- const { auth_url: authUrl } = authResponse;
-
- if (!authUrl || authUrl === "undefined") {
- throw new Error(t("errors.invalidAuthUrl"));
- }
-
- window.location.replace(authUrl);
- } catch (err: unknown) {
- const error = err as {
- message?: string;
- response?: { data?: { error?: string } };
- };
- const errorMessage =
- error?.response?.data?.error ||
- error?.message ||
- t("errors.failedOidcLogin");
- toast.error(errorMessage);
- setOidcLoading(false);
- }
- },
- [rememberMe, t],
- );
-
- const handleLDAPLogin = useCallback(
- async (providerId: number) => {
- if (!ldapUsername.trim() || !ldapPassword) {
- toast.error(t("errors.requiredField"));
- return;
- }
- setLdapLoading(true);
- try {
- await ldapLogin(providerId, ldapUsername, ldapPassword, rememberMe);
- const meRes = await getUserInfo();
- setInternalLoggedIn(true);
- setLoggedIn(true);
- setIsAdmin(!!meRes.is_admin);
- setUsername(meRes.username || null);
- setUserId(meRes.userId || null);
- setDbError(null);
- onAuthSuccess({
- isAdmin: !!meRes.is_admin,
- username: meRes.username || null,
- userId: meRes.userId || null,
- });
- toast.success(t("messages.loginSuccess"));
- } catch (err: unknown) {
- const error = err as {
- response?: { data?: { error?: string } };
- message?: string;
- };
- toast.error(
- error?.response?.data?.error ||
- error?.message ||
- t("auth.ldapLoginFailed"),
- );
- } finally {
- setLdapLoading(false);
- }
- },
- [
- ldapUsername,
- ldapPassword,
- rememberMe,
- onAuthSuccess,
- setLoggedIn,
- setIsAdmin,
- setUsername,
- setUserId,
- setDbError,
- t,
- ],
- );
-
- useEffect(() => {
- if (!ssoProvidersLoaded || silentSigninHandledRef.current) return;
- if (!oidcSilentLoginDefaultLoaded) return;
-
- const urlTriggered = shouldTriggerSilentSignin(window.location.search);
- if (!urlTriggered && !oidcSilentLoginDefault) return;
-
- if (urlTriggered) {
- const nextSearch = removeSilentSigninFromSearch(window.location.search);
- window.history.replaceState(
- {},
- document.title,
- `${window.location.pathname}${nextSearch}${window.location.hash}`,
- );
- }
-
- silentSigninHandledRef.current = true;
- const oidcProvider = ssoProviders.find(
- (p) => p.type === "oidc" || p.type === "github" || p.type === "google",
- );
- if (oidcProvider && !isElectron()) {
- handleOIDCLogin(oidcProvider.id);
- return;
- }
-
- if (ssoProviders.length > 0 && !isElectron()) {
- const first = ssoProviders[0];
- if (first.type !== "ldap") handleOIDCLogin(first.id);
- return;
- }
-
- if (urlTriggered) {
- toast.info(t("errors.silentSigninOidcUnavailable"));
- }
- }, [
- handleOIDCLogin,
- ssoProvidersLoaded,
- ssoProviders,
- t,
- oidcSilentLoginDefault,
- oidcSilentLoginDefaultLoaded,
- ]);
-
- useEffect(() => {
- const urlParams = new URLSearchParams(window.location.search);
- const success = urlParams.get("success");
- const error = urlParams.get("error");
-
- if (error) {
- if (error === "registration_disabled") {
- toast.error(t("messages.registrationDisabled"));
- } else if (error === "user_not_allowed") {
- toast.error(t("messages.userNotAllowed"));
- } else {
- toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
- }
- setOidcLoading(false);
- window.history.replaceState({}, document.title, window.location.pathname);
- return;
- }
-
- if (success) {
- setOidcLoading(true);
-
- if (isInMobileWebView()) {
- // The OIDC callback authenticated via an HttpOnly cookie on this origin,
- // so prefer a token in the URL (termix-mobile:-origin callbacks include
- // one), otherwise read it back from the cookie via /users/me/token.
- const finish = (token: string) => {
- postMobileAuthSuccess(token);
- setOidcLoading(false);
- window.history.replaceState(
- {},
- document.title,
- window.location.pathname,
- );
- };
- const urlToken = urlParams.get("token");
- if (urlToken) {
- finish(urlToken);
- } else {
- getCurrentToken()
- .then((token) => finish(token ?? ""))
- .catch(() => finish(""));
- }
- return;
- }
-
- if (isInElectronWebView()) {
- try {
- const urlToken = urlParams.get("token");
- window.parent.postMessage(
- {
- type: "AUTH_SUCCESS",
- source: "oidc_callback",
- platform: "desktop",
- token: urlToken || null,
- timestamp: Date.now(),
- },
- "*",
- );
- setWebviewAuthSuccess(true);
- setOidcLoading(false);
- window.history.replaceState(
- {},
- document.title,
- window.location.pathname,
- );
- return;
- } catch (e) {
- console.error("Error posting auth success message:", e);
- }
- }
-
- getUserInfo()
- .then((meRes) => {
- setInternalLoggedIn(true);
- setLoggedIn(true);
- setIsAdmin(!!meRes.is_admin);
- setUsername(meRes.username || null);
- setUserId(meRes.userId || null);
- setDbError(null);
- onAuthSuccess({
- isAdmin: !!meRes.is_admin,
- username: meRes.username || null,
- userId: meRes.userId || null,
- });
- setInternalLoggedIn(true);
- window.history.replaceState(
- {},
- document.title,
- window.location.pathname,
- );
- })
- .catch((err) => {
- console.error("Failed to get user info after OIDC callback:", err);
- toast.error(t("errors.failedUserInfo"));
- setInternalLoggedIn(false);
- setLoggedIn(false);
- setIsAdmin(false);
- setUsername(null);
- setUserId(null);
- window.history.replaceState(
- {},
- document.title,
- window.location.pathname,
- );
- })
- .finally(() => {
- setOidcLoading(false);
- });
- }
- }, [
- onAuthSuccess,
- setDbError,
- setIsAdmin,
- setLoggedIn,
- setUserId,
- setUsername,
- t,
- isInElectronWebView,
- ]);
-
- const Spinner = (
-
-
-
-
- );
-
- useEffect(() => {
- if (dbConnectionFailed) {
- toast.error(t("errors.databaseConnection"));
- }
- }, [dbConnectionFailed, t]);
-
- useEffect(() => {
- const checkServerConfig = async () => {
- if (isInElectronWebView()) {
- setShowServerConfig(false);
- return;
- }
-
- if (isElectron()) {
- try {
- const [config, status] = await Promise.all([
- getServerConfig(),
- getEmbeddedServerStatus(),
- ]);
-
- if (
- status?.embedded &&
- status?.running &&
- config &&
- !config.serverUrl
- ) {
- setCurrentServerUrl("");
- setShowServerConfig(false);
- return;
- }
-
- setCurrentServerUrl(config?.serverUrl || "");
- setShowServerConfig(!config || !config.serverUrl);
- } catch {
- setShowServerConfig(true);
- }
- } else {
- setShowServerConfig(false);
- }
- };
-
- checkServerConfig();
- }, []);
-
- if (showServerConfig === null && !isInElectronWebView()) {
- return (
-
- );
- }
-
- if (showServerConfig && !isInElectronWebView()) {
- return (
-
- {
- window.location.reload();
- }}
- onUseEmbedded={async () => {
- await saveServerConfig({
- serverUrl: "",
- lastUpdated: new Date().toISOString(),
- });
- setShowServerConfig(false);
- setCurrentServerUrl("");
- }}
- onCancel={() => {
- setShowServerConfig(false);
- }}
- isFirstTime={!currentServerUrl}
- />
-
- );
- }
-
- if (
- isElectron() &&
- currentServerUrl &&
- authLoading &&
- !isInElectronWebView()
- ) {
- return (
-
-
-
-
-
-
- {t("common.checkingAuthentication")}
-
-
-
-
-
- );
- }
-
- if (isElectron() && currentServerUrl && !loggedIn && !isInElectronWebView()) {
- return (
-
-
- {
- setShowServerConfig(true);
- }}
- />
-
-
- );
- }
-
- if (dbHealthChecking && !dbConnectionFailed) {
- return (
-
-
-
-
-
-
- {t("common.checkingDatabase")}
-
-
-
-
-
- );
- }
-
- if (dbConnectionFailed) {
- return (
-
-
-
-
- {t("errors.databaseConnection")}
-
-
- {t("messages.databaseConnectionFailed")}
-
-
-
-
- window.location.reload()}
- >
- {t("common.refresh")}
-
-
-
-
-
- {
- const isDark =
- theme === "dark" ||
- (theme === "system" &&
- window.matchMedia("(prefers-color-scheme: dark)")
- .matches);
- setTheme(isDark ? "light" : "dark");
- }}
- >
- {theme === "dark" ||
- (theme === "system" &&
- window.matchMedia("(prefers-color-scheme: dark)").matches) ? (
-
- ) : (
-
- )}
-
-
-
- {isElectron() && currentServerUrl && (
-
-
-
- Server
-
-
- {currentServerUrl}
-
-
-
setShowServerConfig(true)}
- className="h-8 px-3"
- >
- Edit
-
-
- )}
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- {t("common.appName").toUpperCase()}
-
-
- {t("auth.tagline")}
-
-
-
-
-
-
- {isInElectronWebView() && !webviewAuthSuccess && (
-
-
- {t("auth.desktopApp")}
-
- {t("auth.loggingInToDesktopApp")}
-
-
- )}
- {(isInElectronWebView() || isInMobileWebView()) &&
- webviewAuthSuccess && (
-
-
-
- {t("messages.loginSuccess")}
-
-
- {t("auth.redirectingToApp")}
-
-
-
- )}
- {!webviewAuthSuccess && totpRequired && (
-
- )}
-
- {!webviewAuthSuccess &&
- !loggedIn &&
- !authLoading &&
- !totpRequired && (
- <>
- {(() => {
- const hasLogin = passwordLoginAllowed && !firstUser;
- const hasSignup =
- (passwordLoginAllowed || firstUser) &&
- registrationAllowed;
- const hasPasskey = !firstUser;
- const hasSso = ssoProviders.length > 0;
- const hasAnyAuth =
- hasLogin || hasSignup || hasPasskey || hasSso;
-
- if (!hasAnyAuth) {
- return (
-
-
- {t("auth.authenticationDisabled")}
-
-
- {t("auth.authenticationDisabledDesc")}
-
-
- );
- }
-
- return (
- <>
-
{
- const newTab = v as "login" | "signup" | "reset";
- setTab(newTab);
- if (tab === "reset") resetPasswordState();
- if (
- (tab === "login" && newTab === "signup") ||
- (tab === "signup" && newTab === "login")
- ) {
- clearFormFields();
- }
- }}
- className="w-full mb-8"
- >
-
- {passwordLoginAllowed && (
-
- {t("common.login")}
-
- )}
- {(passwordLoginAllowed || firstUser) &&
- registrationAllowed && (
-
- {t("common.register")}
-
- )}
-
-
-
-
-
- {tab === "login"
- ? t("auth.loginTitle")
- : tab === "signup"
- ? t("auth.registerTitle")
- : t("auth.forgotPassword")}
-
-
-
- {tab === "reset" ? (
-
- {resetStep === "initiate" && (
- <>
-
- {t("common.warning")}
-
- {t("auth.dataLossWarning")}
-
-
-
-
{t("auth.resetCodeDesc")}
-
-
-
-
- {t("common.username")}
-
-
- setLocalUsername(e.target.value)
- }
- disabled={resetLoading}
- />
-
-
- {resetLoading
- ? Spinner
- : t("auth.sendResetCode")}
-
-
- >
- )}
-
- {resetStep === "verify" && (
- <>
-
-
- {t("auth.enterResetCode")}{" "}
- {localUsername}
-
-
-
-
-
- {t("auth.resetCode")}
-
-
- setResetCode(
- e.target.value.replace(/\D/g, ""),
- )
- }
- disabled={resetLoading}
- placeholder="000000"
- />
-
-
- {resetLoading
- ? Spinner
- : t("auth.verifyCodeButton")}
-
-
{
- setResetStep("initiate");
- setResetCode("");
- }}
- >
- {t("common.back")}
-
-
- >
- )}
-
- {resetStep === "newPassword" && !resetSuccess && (
- <>
-
-
- {t("auth.enterNewPassword")}{" "}
- {localUsername}
-
-
-
-
-
- {t("auth.newPassword")}
-
-
- setNewPassword(e.target.value)
- }
- disabled={resetLoading}
- autoComplete="new-password"
- />
-
-
-
- {t("auth.confirmNewPassword")}
-
-
- setConfirmPassword(e.target.value)
- }
- disabled={resetLoading}
- autoComplete="new-password"
- />
-
-
- {resetLoading
- ? Spinner
- : t("auth.resetPasswordButton")}
-
-
{
- setResetStep("verify");
- setNewPassword("");
- setConfirmPassword("");
- }}
- >
- {t("common.back")}
-
-
- >
- )}
-
- ) : (
-
- )}
-
-
-
- {
- const isDark =
- theme === "dark" ||
- (theme === "system" &&
- window.matchMedia(
- "(prefers-color-scheme: dark)",
- ).matches);
- setTheme(isDark ? "light" : "dark");
- }}
- >
- {theme === "dark" ||
- (theme === "system" &&
- window.matchMedia(
- "(prefers-color-scheme: dark)",
- ).matches) ? (
-
- ) : (
-
- )}
-
-
-
- {isElectron() && currentServerUrl && (
-
-
-
- {t("serverConfig.serverUrl")}
-
-
- {currentServerUrl}
-
-
-
setShowServerConfig(true)}
- className="h-8 px-3"
- >
- {t("common.edit")}
-
-
- )}
-
- >
- );
- })()}
- >
- )}
-
-
-
-
- );
-}
diff --git a/src/ui/auth/LoginScreen.tsx b/src/ui/auth/LoginScreen.tsx
deleted file mode 100644
index 9fcf715c..00000000
--- a/src/ui/auth/LoginScreen.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React, { useState } from "react";
-import { Auth } from "@/auth/LoginPage.tsx";
-
-interface LoginScreenProps {
- authLoading: boolean;
- onAuthSuccess: (authData: {
- isAdmin: boolean;
- username: string | null;
- userId: string | null;
- }) => void;
-}
-
-export function LoginScreen({
- authLoading,
- onAuthSuccess,
-}: LoginScreenProps): React.ReactElement {
- const [loggedIn, setLoggedIn] = useState(false);
- const [, setIsAdmin] = useState(false);
- const [, setUsername] = useState(null);
- const [, setUserId] = useState(null);
- const [, setDbError] = useState(null);
-
- return (
-
- );
-}
diff --git a/src/ui/components/MigrationNoticeDialog.tsx b/src/ui/components/MigrationNoticeDialog.tsx
new file mode 100644
index 00000000..19278554
--- /dev/null
+++ b/src/ui/components/MigrationNoticeDialog.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Server } from "lucide-react";
+import { isElectron } from "@/lib/electron";
+import { Button } from "@/components/button.tsx";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/dialog.tsx";
+
+type DesktopSettings = {
+ defaultConnectionOrigin: "local" | "remote";
+ migrationNoticeAcknowledged?: boolean;
+};
+
+// One-time notice for Electron installs that previously pointed the whole
+// app at a remote Termix server (the pre-2.6.0 architecture). That install
+// now runs a fully local, standalone backend by default -- the hosts,
+// credentials, and snippets that lived on the old remote server won't show
+// up here until the user explicitly turns on Remote Sync and reconnects to
+// that same server. A fresh install never had a legacy serverUrl, so this
+// never fires for anyone who didn't go through the old flow, and it only
+// ever shows once per install (tracked in desktop-settings.json).
+export function MigrationNoticeDialog({
+ onOpenRemoteSync,
+}: {
+ onOpenRemoteSync: (serverUrl: string) => void;
+}) {
+ const { t } = useTranslation();
+ const [legacyServerUrl, setLegacyServerUrl] = useState(null);
+ const [open, setOpen] = useState(false);
+
+ useEffect(() => {
+ if (!isElectron()) return;
+ let cancelled = false;
+
+ Promise.all([
+ window.electronAPI?.invoke?.("get-legacy-server-config") as Promise<{
+ serverUrl: string | null;
+ } | null>,
+ window.electronAPI?.invoke?.("get-desktop-settings") as Promise<
+ DesktopSettings | undefined
+ >,
+ ])
+ .then(([legacyConfig, settings]) => {
+ if (cancelled) return;
+ const serverUrl = legacyConfig?.serverUrl || null;
+ if (serverUrl && !settings?.migrationNoticeAcknowledged) {
+ setLegacyServerUrl(serverUrl);
+ setOpen(true);
+ }
+ })
+ .catch(() => {});
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const acknowledge = async () => {
+ setOpen(false);
+ const settings = ((await window.electronAPI?.invoke?.(
+ "get-desktop-settings",
+ )) as DesktopSettings | undefined) ?? { defaultConnectionOrigin: "local" };
+ await window.electronAPI?.invoke?.("save-desktop-settings", {
+ ...settings,
+ migrationNoticeAcknowledged: true,
+ });
+ };
+
+ const handleSetUpSync = async () => {
+ const url = legacyServerUrl || "";
+ await acknowledge();
+ onOpenRemoteSync(url);
+ };
+
+ if (!legacyServerUrl) return null;
+
+ return (
+ !next && acknowledge()}>
+
+
+
+
+ {t("migrationNotice.title")}
+
+
+ {t("migrationNotice.body1")}
+ {t("migrationNotice.body2", { url: legacyServerUrl })}
+
+
+
+
+ {t("migrationNotice.dismiss")}
+
+
+ {t("migrationNotice.setUpSync")}
+
+
+
+
+ );
+}
diff --git a/src/ui/components/RemoteSyncBanner.tsx b/src/ui/components/RemoteSyncBanner.tsx
new file mode 100644
index 00000000..f554ac47
--- /dev/null
+++ b/src/ui/components/RemoteSyncBanner.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { AlertTriangle } from "lucide-react";
+import { isElectron } from "@/lib/electron";
+
+interface RemoteSyncStatus {
+ connected: boolean;
+ syncing: boolean;
+ lastSyncedAt: string | null;
+ lastError: string | null;
+ needsReauth: boolean;
+}
+
+// Non-blocking banner shown when a connected remote sync server needs
+// re-authentication. Never gates or hides any other UI -- the local app
+// keeps working fully regardless of remote sync state.
+export function RemoteSyncBanner({ onReconnect }: { onReconnect: () => void }) {
+ const { t } = useTranslation();
+ const [status, setStatus] = useState(null);
+
+ useEffect(() => {
+ if (!isElectron()) return;
+ window.electronAPI
+ ?.invoke?.("get-remote-sync-status")
+ .then((s) => setStatus((s as RemoteSyncStatus) ?? null))
+ .catch(() => {});
+ const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(
+ (nextStatus: RemoteSyncStatus) => setStatus(nextStatus),
+ );
+ return () => unsubscribe?.();
+ }, []);
+
+ if (!status?.connected || !status.needsReauth) return null;
+
+ return (
+
+
+
+
{t("remoteSync.bannerMessage")}
+
+
+ {t("remoteSync.bannerReconnect")}
+
+
+ );
+}
diff --git a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx
index 7259504a..d823b339 100644
--- a/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx
+++ b/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx
@@ -121,11 +121,14 @@ export function ProxmoxDiscoverDialog({
const credId = defaultCredentialId ?? discoveredCredentialId;
const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId);
- const toImport = guests
- .filter((g) => selected.has(g.vmid))
+ const selectedGuests = guests.filter((g) => selected.has(g.vmid));
+ const skippedNoIp = selectedGuests.filter((g) => !g.ip).length;
+
+ const toImport = selectedGuests
+ .filter((g) => !!g.ip)
.map((g) => ({
name: g.name,
- ip: g.ip ?? "0.0.0.0",
+ ip: g.ip as string,
port: g.connectionType === "rdp" ? 3389 : 22,
username: defaultUsername ?? "root",
folder: importFolder,
@@ -152,10 +155,15 @@ export function ProxmoxDiscoverDialog({
},
}));
- const result = await bulkImportSSHHosts(toImport, false);
- const updated = await getSSHHosts();
- onHostsChanged(updated);
- window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
+ const result = toImport.length
+ ? await bulkImportSSHHosts(toImport, false)
+ : { success: 0, updated: 0, skipped: 0, failed: 0 };
+
+ if (toImport.length) {
+ const updated = await getSSHHosts();
+ onHostsChanged(updated);
+ window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
+ }
const msg = [
result.success
@@ -167,6 +175,9 @@ export function ProxmoxDiscoverDialog({
result.failed
? t("hosts.proxmoxResultFailed", { count: result.failed })
: null,
+ skippedNoIp
+ ? t("hosts.proxmoxResultSkippedNoIp", { count: skippedNoIp })
+ : null,
]
.filter(Boolean)
.join(", ");
diff --git a/src/ui/dashboard/DashboardTab.tsx b/src/ui/dashboard/DashboardTab.tsx
index 50154591..fddd09bc 100644
--- a/src/ui/dashboard/DashboardTab.tsx
+++ b/src/ui/dashboard/DashboardTab.tsx
@@ -43,6 +43,7 @@ import {
getServiceLinks,
createServiceLink,
deleteServiceLink,
+ isElectron,
} from "@/main-axios";
import type { RecentActivityItem, ServiceLink } from "@/main-axios";
import { useTranslation } from "react-i18next";
@@ -1365,7 +1366,13 @@ export function DashboardTab({
load();
getUserInfo()
- .then((info) => setIsAdmin(!!info.is_admin))
+ .then((info) => {
+ // Remote sync is not yet configurable (added in a later phase), so
+ // a standalone desktop install never shows admin/user-management
+ // UI -- it has exactly one implicit user and nothing to administer.
+ const isRemoteSyncConnected = false;
+ setIsAdmin(!!info.is_admin && (!isElectron() || isRemoteSyncConnected));
+ })
.catch(() => {});
getUptime()
.then((u) => setUptimeFormatted(u.formatted))
diff --git a/src/ui/features/FullScreenAppWrapper.tsx b/src/ui/features/FullScreenAppWrapper.tsx
index 8d658204..70b0f6e7 100644
--- a/src/ui/features/FullScreenAppWrapper.tsx
+++ b/src/ui/features/FullScreenAppWrapper.tsx
@@ -2,9 +2,14 @@ import React, { useEffect, useState } from "react";
import { TabProvider } from "@/shell/TabContext.tsx";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import { SidebarProvider } from "@/components/sidebar.tsx";
-import { getSSHHosts, getUserInfo } from "@/main-axios.ts";
+import {
+ getSSHHosts,
+ getUserInfo,
+ isElectron,
+ requestDesktopAutoSession,
+} from "@/main-axios.ts";
import type { SSHHost } from "@/types";
-import { LoginScreen } from "@/auth/LoginScreen.tsx";
+import { Auth } from "@/auth/Auth.tsx";
import { Toaster } from "@/components/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
@@ -38,20 +43,70 @@ export const FullScreenAppWrapper: React.FC = ({
}, []);
useEffect(() => {
- const checkAuth = async () => {
+ // Popped-out windows (terminal/tunnel/file-manager/etc.) share the main
+ // window's session rather than owning their own login -- there's no
+ // login form to fall back to here. On a cold Electron launch this
+ // window's renderer can start before the embedded backend has finished
+ // booting, so a bare failure isn't proof of "logged out"; in Electron
+ // it's retried forever (the embedded backend is bundled, always-on
+ // infrastructure that always eventually comes up) with a fallback to
+ // the same auto-session exchange the main window uses, since a login
+ // form must never appear for the local backend. Outside Electron a
+ // capped retry still applies, matching the web app's normal behavior.
+ let cancelled = false;
+ let retryTimer: ReturnType | null = null;
+ const maxAttempts = 10;
+
+ const checkAuth = async (attempt: number) => {
try {
const userInfo = await getUserInfo();
+ if (cancelled) return;
if (userInfo) {
setIsAuthenticated(true);
+ setAuthLoading(false);
+ return;
}
} catch {
- setIsAuthenticated(false);
- } finally {
- setAuthLoading(false);
+ // fall through to retry/auto-session below
}
+ if (cancelled) return;
+
+ if (isElectron()) {
+ const outcome = await requestDesktopAutoSession();
+ if (cancelled) return;
+ if (outcome.kind === "success") {
+ setIsAuthenticated(true);
+ setAuthLoading(false);
+ return;
+ }
+ if (outcome.kind === "declined") {
+ setIsAuthenticated(false);
+ setAuthLoading(false);
+ return;
+ }
+ // "retry": keep retrying indefinitely with capped backoff.
+ const delay = Math.min(1000 * 2 ** attempt, 10000);
+ retryTimer = setTimeout(() => {
+ if (!cancelled) checkAuth(attempt + 1);
+ }, delay);
+ return;
+ }
+
+ if (attempt >= maxAttempts) {
+ setIsAuthenticated(false);
+ setAuthLoading(false);
+ return;
+ }
+ retryTimer = setTimeout(() => {
+ if (!cancelled) checkAuth(attempt + 1);
+ }, 1000);
};
- checkAuth();
+ checkAuth(0);
+ return () => {
+ cancelled = true;
+ if (retryTimer) clearTimeout(retryTimer);
+ };
}, []);
useEffect(() => {
@@ -84,6 +139,11 @@ export const FullScreenAppWrapper: React.FC = ({
window.location.reload();
};
+ // Electron never reaches an unauthenticated render: checkAuth above
+ // retries and falls back to the same auto-session exchange the main
+ // window uses, so this branch is web-only there -- a real "not logged
+ // in" case for a bookmarked/shared full-screen link.
+
if (authLoading) {
return (
= ({
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "var(--bg-base)" }}
>
-
+
f.value === terminalConfig.fontFamily,
);
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
+ ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.cursorBlink = terminalConfig.cursorBlink;
terminal.options.cursorStyle = terminalConfig.cursorStyle;
@@ -263,7 +269,7 @@ export function ConsoleTerminal({
}
}, [terminal]);
- const connect = React.useCallback(() => {
+ const connect = React.useCallback(async () => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
@@ -285,20 +291,30 @@ export function ConsoleTerminal({
window.location.port === "5173" ||
window.location.port === "");
- const baseWsUrl = isDev
- ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
- : isElectronApp
- ? (() => {
- 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}/docker/console/`;
- })()
- : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
+ let baseWsUrl: string;
+ if (isDev) {
+ baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`;
+ } else if (isElectronApp) {
+ const origin = await resolveConnectionOrigin({
+ connectionType: "ssh",
+ connectionOrigin: hostConfig.connectionOrigin,
+ });
+ const resolvedUrl = await buildOriginWsUrl({
+ origin,
+ localPort: 30009,
+ localPath: "/docker/console/",
+ remotePath: "/docker/console/",
+ includeLocalJwt: false,
+ });
+ if (!resolvedUrl) {
+ setIsConnecting(false);
+ toast.error(t("errors.remoteServerRequired"));
+ return;
+ }
+ baseWsUrl = resolvedUrl;
+ } else {
+ baseWsUrl = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
+ }
const ws = new WebSocket(baseWsUrl);
diff --git a/src/ui/features/guacamole/GuacamoleApp.tsx b/src/ui/features/guacamole/GuacamoleApp.tsx
index 5e98717e..a978557d 100644
--- a/src/ui/features/guacamole/GuacamoleApp.tsx
+++ b/src/ui/features/guacamole/GuacamoleApp.tsx
@@ -8,33 +8,49 @@ import React, {
import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
+ type GuacamoleTouchMode,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
import {
getGuacamoleTokenFromHost,
getGuacdStatus,
getSSHHosts,
logActivity,
+ isElectron,
} from "@/main-axios.ts";
+import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { Button } from "@/components/button.tsx";
+import { Input } from "@/components/input.tsx";
+import { PasswordInput } from "@/components/password-input.tsx";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/dialog.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
+import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { SSHHost } from "@/types";
interface GuacamoleAppProps {
hostId?: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
+ isVisible?: boolean;
}
export interface GuacamoleAppHandle {
disconnect: () => void;
isConnected: () => boolean;
+ openShareModal: () => void;
+ canShare: () => boolean;
}
const GuacamoleApp = React.forwardRef(
- function GuacamoleApp({ hostId, tabId, protocol }, ref) {
+ function GuacamoleApp({ hostId, tabId, protocol, isVisible = true }, ref) {
const { t } = useTranslation();
const [hostConfig, setHostConfig] = useState(null);
const [loading, setLoading] = useState(true);
@@ -88,6 +104,7 @@ const GuacamoleApp = React.forwardRef(
hostName={hostConfig.name || hostConfig.ip || String(hostId)}
tabId={tabId}
protocol={protocol}
+ isVisible={isVisible}
ref={ref}
/>
);
@@ -96,60 +113,134 @@ const GuacamoleApp = React.forwardRef(
interface GuacamoleAppInnerProps {
hostId: number;
- hostConfig: Pick;
+ hostConfig: Pick<
+ SSHHost,
+ "connectionType" | "guacamoleConfig" | "rdpAuthType"
+ >;
hostName: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
+ isVisible: boolean;
}
const GuacamoleAppInner = React.forwardRef<
GuacamoleAppHandle,
GuacamoleAppInnerProps
>(function GuacamoleAppInner(
- { hostId, hostConfig, hostName, tabId, protocol },
+ { hostId, hostConfig, hostName, tabId, protocol, isVisible },
ref,
) {
const { t } = useTranslation();
const [token, setToken] = useState(null);
+ const [guacamoleConnectionId, setGuacamoleConnectionId] = useState<
+ string | null
+ >(null);
+ const [shareModalOpen, setShareModalOpen] = useState(false);
const [error, setError] = useState(null);
const [connectionError, setConnectionError] = useState(null);
const [retryCount, setRetryCount] = useState(0);
+ const [touchMode, setTouchMode] = useState(() =>
+ typeof window !== "undefined" &&
+ (navigator.maxTouchPoints > 0 || "ontouchstart" in window)
+ ? "touchscreen"
+ : null,
+ );
const displayRef = useRef(null);
+ const resolvedProtocolForConnect = (protocol ??
+ hostConfig.connectionType ??
+ "rdp") as "rdp" | "vnc" | "telnet";
+ const needsCredentialPrompt =
+ resolvedProtocolForConnect === "rdp" && hostConfig.rdpAuthType === "none";
+
+ const [promptedCredentials, setPromptedCredentials] = useState<{
+ username: string;
+ password: string;
+ } | null>(null);
+ const [promptOpen, setPromptOpen] = useState(needsCredentialPrompt);
+ const [promptUsername, setPromptUsername] = useState("");
+ const [promptPassword, setPromptPassword] = useState("");
+
useImperativeHandle(ref, () => ({
disconnect: () => displayRef.current?.disconnect(),
isConnected: () => displayRef.current?.isConnected() === true,
+ openShareModal: () => setShareModalOpen(true),
+ canShare: () => guacamoleConnectionId !== null,
}));
useEffect(() => {
+ if (needsCredentialPrompt && !promptedCredentials) {
+ setPromptOpen(true);
+ return;
+ }
+
setToken(null);
+ setGuacamoleConnectionId(null);
setError(null);
- getGuacdStatus()
- .then((status) => {
+
+ (async () => {
+ if (isElectron()) {
+ const origin = await resolveConnectionOrigin({
+ connectionType: resolvedProtocolForConnect,
+ });
+ if (origin === "remote") {
+ const remoteConfig = (await window.electronAPI?.invoke?.(
+ "get-remote-sync-config",
+ )) as { serverUrl?: string } | null;
+ if (!remoteConfig?.serverUrl) {
+ setError(t("errors.remoteServerRequired"));
+ return;
+ }
+ }
+ }
+
+ try {
+ const status = await getGuacdStatus();
if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable"));
return;
}
- return getGuacamoleTokenFromHost(hostId, protocol);
- })
- .then((result) => {
+ const result = await getGuacamoleTokenFromHost(
+ hostId,
+ protocol,
+ promptedCredentials ?? undefined,
+ );
if (result) {
setToken(result.token);
- const resolvedProtocol = (protocol ??
- hostConfig.connectionType ??
- "rdp") as "rdp" | "vnc" | "telnet";
- logActivity(resolvedProtocol, hostId, hostName).catch(() => {});
+ setGuacamoleConnectionId(result.guacamoleConnectionId ?? null);
+ logActivity(resolvedProtocolForConnect, hostId, hostName).catch(
+ () => {},
+ );
}
- })
- .catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
- }, [hostConfig.connectionType, hostId, hostName, protocol, retryCount, t]);
+ } catch (err: unknown) {
+ const message =
+ err instanceof Error ? err.message : t("guacamole.failedToConnect");
+ setError(message || t("guacamole.failedToConnect"));
+ }
+ })();
+ }, [
+ hostId,
+ hostName,
+ protocol,
+ retryCount,
+ t,
+ needsCredentialPrompt,
+ promptedCredentials,
+ resolvedProtocolForConnect,
+ ]);
const handleReconnect = useCallback(() => {
setConnectionError(null);
setError(null);
setToken(null);
+ if (needsCredentialPrompt) {
+ setPromptedCredentials(null);
+ setPromptUsername("");
+ setPromptPassword("");
+ setPromptOpen(true);
+ }
setRetryCount((c) => c + 1);
- }, []);
+ }, [needsCredentialPrompt]);
useEffect(() => {
if (!tabId) return;
@@ -162,6 +253,67 @@ const GuacamoleAppInner = React.forwardRef<
window.removeEventListener("termix:refresh-guacamole", handler);
}, [tabId, handleReconnect]);
+ if (promptOpen) {
+ return (
+ {
+ if (!open) setPromptOpen(false);
+ }}
+ >
+
+
+
+ {t("guacamole.credentialPromptTitle")}
+
+
+ {t("guacamole.credentialPromptDescription")}
+
+
+
+
+
+ );
+ }
+
if (error) {
return (
)}
setConnectionError(err)}
/>
-
+
+ {shareModalOpen && guacamoleConnectionId && (
+ setShareModalOpen(false)}
+ hostId={hostId}
+ sessionId={guacamoleConnectionId}
+ protocol={resolvedProtocol}
+ tabInstanceId={tabId}
+ />
+ )}
);
});
diff --git a/src/ui/features/guacamole/GuacamoleDisplay.tsx b/src/ui/features/guacamole/GuacamoleDisplay.tsx
index 5a7876e7..671fa6f7 100644
--- a/src/ui/features/guacamole/GuacamoleDisplay.tsx
+++ b/src/ui/features/guacamole/GuacamoleDisplay.tsx
@@ -8,10 +8,14 @@ import {
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
-import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
+import { getGuacamoleToken, isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { getBasePath } from "@/lib/base-path.ts";
import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
+import {
+ resolveConnectionOrigin,
+ buildOriginWsUrl,
+} from "@/lib/connection-origin.ts";
import {
isFirefoxBrowser,
isPasteShortcut,
@@ -44,9 +48,12 @@ export interface GuacamoleDisplayHandle {
setClipboard: (data: string) => void;
}
+export type GuacamoleTouchMode = "touchscreen" | "touchpad";
+
interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean;
+ touchMode?: GuacamoleTouchMode | null;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: string) => void;
@@ -58,7 +65,7 @@ export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
>(function GuacamoleDisplay(
- { connectionConfig, isVisible, onConnect, onDisconnect, onError },
+ { connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError },
ref,
) {
const { t } = useTranslation();
@@ -123,11 +130,11 @@ export const GuacamoleDisplay = forwardRef<
},
}));
- const getWebSocketUrl = useCallback(
+ const getWebSocketConnection = useCallback(
async (
containerWidth: number,
containerHeight: number,
- ): Promise => {
+ ): Promise<{ url: string; query: string } | null> => {
try {
let token: string;
const connectionProtocol =
@@ -166,15 +173,31 @@ export const GuacamoleDisplay = forwardRef<
connectionConfig.dpi,
);
- const wsBase = buildGuacamoleWebSocketBaseUrl({
- isDev,
- isElectronApp: isElectron(),
- isEmbeddedApp: isEmbeddedMode(),
- configuredServerUrl: (window as { configuredServerUrl?: string })
- .configuredServerUrl,
- basePath: getBasePath(),
- location: window.location,
- });
+ let wsBase: string | null;
+ if (isElectron()) {
+ const origin = await resolveConnectionOrigin({
+ connectionType: connectionProtocol,
+ });
+ wsBase = await buildOriginWsUrl({
+ origin,
+ localPort: 30008,
+ localPath: "/guacamole/websocket/",
+ remotePath: "/guacamole/websocket/",
+ includeLocalJwt: false,
+ });
+ if (!wsBase) {
+ onError?.(t("errors.remoteServerRequired"));
+ return null;
+ }
+ } else {
+ wsBase = buildGuacamoleWebSocketBaseUrl({
+ isDev,
+ isElectronApp: false,
+ isEmbeddedApp: false,
+ basePath: getBasePath(),
+ location: window.location,
+ });
+ }
const params = new URLSearchParams({
token,
@@ -182,7 +205,7 @@ export const GuacamoleDisplay = forwardRef<
height: String(displaySize.height),
});
if (displaySize.dpi) params.set("dpi", String(displaySize.dpi));
- return `${wsBase}?${params.toString()}`;
+ return { url: wsBase, query: params.toString() };
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
@@ -190,7 +213,7 @@ export const GuacamoleDisplay = forwardRef<
return null;
}
},
- [connectionConfig, onError],
+ [connectionConfig, onError, t],
);
const refreshKeyboardHandlers = useCallback(() => {
@@ -285,10 +308,9 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(false);
setHasError(false);
- // Wait two frames so the container is fully laid out before measuring.
- await new Promise((resolve) =>
- requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
- );
+ // Let layout settle before measuring without depending on animation frames,
+ // which may be throttled while Electron windows or tabs are inactive.
+ await new Promise((resolve) => setTimeout(resolve, 0));
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
@@ -310,9 +332,7 @@ export const GuacamoleDisplay = forwardRef<
(containerWidth < 100 || containerHeight < 100) && attempt < 40;
attempt++
) {
- await new Promise((resolve) =>
- requestAnimationFrame(() => resolve()),
- );
+ await new Promise((resolve) => setTimeout(resolve, 25));
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
@@ -325,19 +345,28 @@ export const GuacamoleDisplay = forwardRef<
containerHeight = window.innerHeight || 720;
}
- const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
+ const wsConnection = await getWebSocketConnection(
+ containerWidth,
+ containerHeight,
+ );
if (!isMountedRef.current) {
isConnectingRef.current = false;
return;
}
- if (!wsUrl) {
+ if (!wsConnection) {
isConnectingRef.current = false;
return;
}
- const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
+ const tunnel = new Guacamole.WebSocketTunnel(wsConnection.url);
const client = new Guacamole.Client(tunnel);
clientRef.current = client;
+ let connectWatchdog: ReturnType | null = null;
+ const clearConnectWatchdog = () => {
+ if (!connectWatchdog) return;
+ clearTimeout(connectWatchdog);
+ connectWatchdog = null;
+ };
const display = client.getDisplay();
const displayElement = display.getElement();
@@ -378,7 +407,7 @@ export const GuacamoleDisplay = forwardRef<
}
display.onresize = () => {
- if (!isMountedRef.current) return;
+ if (!isMountedRef.current || clientRef.current !== client) return;
rescaleDisplay(true);
setIsReady(true);
};
@@ -388,26 +417,46 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(true);
}
- const mouse = new Guacamole.Mouse(displayElement);
- const sendMouseState = (state: Guacamole.Mouse.State) => {
+ const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
- const adjustedX = Math.round(state.x / scale);
- const adjustedY = Math.round(state.y / scale);
-
+ const state = event.state;
const adjustedState = new Guacamole.Mouse.State(
- adjustedX,
- adjustedY,
+ Math.round(state.x / scale),
+ Math.round(state.y / scale),
state.left,
state.middle,
state.right,
state.up,
state.down,
) as Guacamole.Mouse.State;
-
client.sendMouseState(adjustedState);
};
- mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
+
+ if (touchMode === "touchscreen") {
+ const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement);
+ touchscreen.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
+ } else if (touchMode === "touchpad") {
+ const touchpad = new Guacamole.Mouse.Touchpad(displayElement);
+ touchpad.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
+ } else {
+ const mouse = new Guacamole.Mouse(displayElement);
+ const sendMouseState = (state: Guacamole.Mouse.State) => {
+ displayElement.focus({ preventScroll: true });
+ const scale = scaleRef.current;
+ const adjustedState = new Guacamole.Mouse.State(
+ Math.round(state.x / scale),
+ Math.round(state.y / scale),
+ state.left,
+ state.middle,
+ state.right,
+ state.up,
+ state.down,
+ ) as Guacamole.Mouse.State;
+ client.sendMouseState(adjustedState);
+ };
+ mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
+ }
const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard;
@@ -425,10 +474,13 @@ export const GuacamoleDisplay = forwardRef<
displayElement.addEventListener("focus", handleDisplayFocus);
displayElement.addEventListener("blur", handleDisplayBlur);
displayElement.addEventListener("mousedown", handleDisplayFocus);
+ displayElement.addEventListener("touchstart", handleDisplayFocus, {
+ passive: true,
+ });
refreshKeyboardHandlers();
client.onstatechange = (state: number) => {
- if (!isMountedRef.current) return;
+ if (!isMountedRef.current || clientRef.current !== client) return;
switch (state) {
case 0:
break;
@@ -437,6 +489,7 @@ export const GuacamoleDisplay = forwardRef<
case 2:
break;
case 3:
+ clearConnectWatchdog();
isConnectingRef.current = false;
setIsReady(true);
onConnect?.();
@@ -456,16 +509,21 @@ export const GuacamoleDisplay = forwardRef<
case 4:
break;
case 5:
+ clearConnectWatchdog();
+ isConnectingRef.current = false;
setIsReady(false);
+ setHasError(true);
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
+ onError?.(t("guacamole.connectionError"));
onDisconnect?.();
break;
}
};
client.onerror = (error: Guacamole.Status) => {
- if (!isMountedRef.current) return;
+ if (!isMountedRef.current || clientRef.current !== client) return;
+ clearConnectWatchdog();
const errorMessage = error.message || t("guacamole.connectionError");
setIsReady(false);
setHasError(true);
@@ -509,8 +567,21 @@ export const GuacamoleDisplay = forwardRef<
};
try {
- client.connect();
+ connectWatchdog = setTimeout(() => {
+ if (
+ !isMountedRef.current ||
+ clientRef.current !== client ||
+ !isConnectingRef.current
+ ) {
+ return;
+ }
+
+ disconnectClient();
+ void connect();
+ }, 8000);
+ client.connect(wsConnection.query);
} catch (error) {
+ clearConnectWatchdog();
isConnectingRef.current = false;
if (!isMountedRef.current) return;
setIsReady(false);
@@ -520,15 +591,17 @@ export const GuacamoleDisplay = forwardRef<
);
}
}, [
- getWebSocketUrl,
+ getWebSocketConnection,
onConnect,
onDisconnect,
onError,
refreshKeyboardHandlers,
rescaleDisplay,
+ disconnectClient,
connectionConfig.protocol,
connectionConfig.type,
connectionConfig.dpi,
+ touchMode,
t,
]);
diff --git a/src/ui/features/guacamole/GuacamoleToolbar.tsx b/src/ui/features/guacamole/GuacamoleToolbar.tsx
index e28bae0f..2736d688 100644
--- a/src/ui/features/guacamole/GuacamoleToolbar.tsx
+++ b/src/ui/features/guacamole/GuacamoleToolbar.tsx
@@ -13,6 +13,8 @@ import {
ChevronUp,
ChevronDown,
ChevronsLeftRight,
+ Touchpad,
+ MousePointer,
} from "lucide-react";
import {
Tooltip,
@@ -20,13 +22,18 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
-import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
+import type {
+ GuacamoleDisplayHandle,
+ GuacamoleTouchMode,
+} from "@/features/guacamole/GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface GuacamoleToolbarProps {
displayRef: React.RefObject;
protocol: "rdp" | "vnc" | "telnet";
+ touchMode?: GuacamoleTouchMode | null;
+ onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
}
const MODIFIER_KEYSYMS = {
@@ -107,6 +114,8 @@ function TipIconBtn({
export const GuacamoleToolbar: React.FC = ({
displayRef,
protocol,
+ touchMode,
+ onTouchModeChange,
}) => {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: 0, y: 12 });
@@ -287,6 +296,39 @@ export const GuacamoleToolbar: React.FC = ({
+ {/* Touch mode toggle — touch devices only */}
+ {touchMode != null && onTouchModeChange && (
+ <>
+
+
+
+
+ onTouchModeChange(
+ touchMode === "touchscreen"
+ ? "touchpad"
+ : "touchscreen",
+ )
+ }
+ className={cn(BTN_ICON)}
+ >
+ {touchMode === "touchscreen" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {touchMode === "touchscreen"
+ ? t("guacamole.toolbar.switchToTrackpad")
+ : t("guacamole.toolbar.switchToTouch")}
+
+
+ >
+ )}
+
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
diff --git a/src/ui/features/serial/Serial.tsx b/src/ui/features/serial/Serial.tsx
index 969b0ad6..851abb15 100644
--- a/src/ui/features/serial/Serial.tsx
+++ b/src/ui/features/serial/Serial.tsx
@@ -10,10 +10,10 @@ import { FitAddon } from "@xterm/addon-fit";
import { useTranslation } from "react-i18next";
import { TriangleAlert } from "lucide-react";
import { isElectron } from "@/lib/electron";
-import { isEmbeddedMode } from "@/main-axios";
import { useTheme } from "@/components/theme-provider";
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
+import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
import type { SerialConfig } from "@/types/ui-types";
import type { SerialHandle } from "./serial-types";
@@ -67,6 +67,7 @@ export const Serial = forwardRef(function Serial(
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
);
+ ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
terminal.options.theme = {
background: themeColors.background,
foreground: themeColors.foreground,
@@ -99,30 +100,10 @@ export const Serial = forwardRef(function Serial(
// ── WebSocket (Electron) path ──────────────────────────────────────────
const buildWsUrl = useCallback(() => {
- const isDev =
- !isElectron() &&
- process.env.NODE_ENV === "development" &&
- (window.location.port === "3000" ||
- window.location.port === "5173" ||
- window.location.port === "");
-
- if (isDev || isEmbeddedMode()) {
- const token = localStorage.getItem("jwt");
- const base = "ws://127.0.0.1:30011";
- return token ? `${base}?token=${encodeURIComponent(token)}` : base;
- }
-
- const configuredUrl = (window as { configuredServerUrl?: string | null })
- .configuredServerUrl;
-
- if (!configuredUrl) return null;
-
- const wsProtocol = configuredUrl.startsWith("https://")
- ? "wss://"
- : "ws://";
- const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
+ // Serial is always local -- the device is physically attached to this
+ // desktop machine, so it never routes through a remote server.
const token = localStorage.getItem("jwt");
- const base = `${wsProtocol}${wsHost}/serial/websocket/`;
+ const base = "ws://127.0.0.1:30011";
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
}, []);
diff --git a/src/ui/features/session-sharing/ShareSessionModal.tsx b/src/ui/features/session-sharing/ShareSessionModal.tsx
new file mode 100644
index 00000000..082c301c
--- /dev/null
+++ b/src/ui/features/session-sharing/ShareSessionModal.tsx
@@ -0,0 +1,429 @@
+import { useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Check, Copy, Link2, Search, Shield, User, Users } from "lucide-react";
+import { toast } from "sonner";
+import { Button } from "@/components/button";
+import { Input } from "@/components/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/dropdown-menu";
+import { getUserList } from "@/main-axios";
+import {
+ createSessionShare,
+ getActiveSessionShares,
+ revokeSessionShare,
+ type SessionShareProtocol,
+ type SessionSharePermissionLevel,
+ type SessionShareRecord,
+} from "@/api/session-sharing-api";
+
+const EXPIRY_PRESETS = [
+ { key: "oneHour", hours: 1 },
+ { key: "oneDay", hours: 24 },
+ { key: "sevenDays", hours: 24 * 7 },
+ { key: "thirtyDays", hours: 24 * 30 },
+ { key: "custom", hours: undefined },
+] as const;
+
+type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
+
+export function ShareSessionModal({
+ open,
+ onClose,
+ hostId,
+ sessionId,
+ protocol,
+ tabInstanceId,
+}: {
+ open: boolean;
+ onClose: () => void;
+ hostId: number;
+ sessionId: string | null;
+ protocol: SessionShareProtocol;
+ tabInstanceId?: string;
+}) {
+ const { t } = useTranslation();
+ const [mode, setMode] = useState<"link" | "user">("link");
+ const [permissionLevel, setPermissionLevel] =
+ useState("read-only");
+ const [expiryPreset, setExpiryPreset] = useState("oneDay");
+ const [customHours, setCustomHours] = useState("");
+ const [search, setSearch] = useState("");
+ const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
+ const [selectedUserId, setSelectedUserId] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [createdLink, setCreatedLink] = useState(null);
+ const [shares, setShares] = useState([]);
+ const [sharesLoaded, setSharesLoaded] = useState(false);
+
+ useEffect(() => {
+ if (!open) return;
+ setMode("link");
+ setPermissionLevel("read-only");
+ setExpiryPreset("oneDay");
+ setCustomHours("");
+ setSearch("");
+ setSelectedUserId(null);
+ setCreatedLink(null);
+ setSharesLoaded(false);
+ setShares([]);
+ }, [open, sessionId]);
+
+ useEffect(() => {
+ if (!open || sharesLoaded) return;
+ setSharesLoaded(true);
+ Promise.all([
+ getUserList().catch(() => ({ users: [] })),
+ getActiveSessionShares(hostId).catch(() => ({ shares: [] })),
+ ]).then(([usersRes, sharesRes]) => {
+ setUsers(
+ (usersRes.users ?? []).map((u) => ({
+ id: String(u.userId),
+ username: u.username,
+ })),
+ );
+ setShares(sharesRes.shares ?? []);
+ });
+ }, [open, hostId, sharesLoaded]);
+
+ const filteredUsers = useMemo(() => {
+ const q = search.trim().toLowerCase();
+ return q
+ ? users.filter((u) => u.username.toLowerCase().includes(q))
+ : users;
+ }, [users, search]);
+
+ const expiryHours = (() => {
+ if (expiryPreset === "custom") {
+ const hours = Number(customHours);
+ return Number.isFinite(hours) && hours > 0 ? hours : undefined;
+ }
+ return EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours;
+ })();
+
+ async function refreshShares() {
+ try {
+ const res = await getActiveSessionShares(hostId);
+ setShares(res.shares ?? []);
+ } catch {
+ // silently ignore
+ }
+ }
+
+ async function handleCreate() {
+ if (!sessionId) return;
+ if (mode === "user" && !selectedUserId) return;
+ if (expiryPreset === "custom" && !expiryHours) return;
+
+ setSubmitting(true);
+ try {
+ const result = await createSessionShare({
+ hostId,
+ sessionId,
+ tabInstanceId,
+ protocol,
+ shareType: mode,
+ targetUserId:
+ mode === "user" ? (selectedUserId ?? undefined) : undefined,
+ permissionLevel,
+ expiryHours,
+ });
+
+ if (mode === "link" && result.linkToken) {
+ const url = `${window.location.origin}${window.location.pathname}?view=shared&token=${result.linkToken}`;
+ setCreatedLink(url);
+ toast.success(t("sessionSharing.linkCreated"));
+ } else {
+ toast.success(t("sessionSharing.shareCreated"));
+ setSelectedUserId(null);
+ }
+ await refreshShares();
+ } catch (error) {
+ const status = (error as { status?: number })?.status;
+ if (mode === "user" && status === 403) {
+ toast.error(t("sessionSharing.userLacksHostAccess"));
+ } else {
+ toast.error(t("sessionSharing.shareFailed"));
+ }
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ async function handleCopyLink() {
+ if (!createdLink) return;
+ try {
+ await navigator.clipboard.writeText(createdLink);
+ toast.success(t("sessionSharing.linkCopied"));
+ } catch {
+ // clipboard API unavailable, ignore
+ }
+ }
+
+ async function handleRevoke(shareId: string) {
+ try {
+ await revokeSessionShare(shareId);
+ setShares((prev) => prev.filter((s) => s.id !== shareId));
+ toast.success(t("sessionSharing.revoked"));
+ } catch {
+ toast.error(t("sessionSharing.revokeFailed"));
+ }
+ }
+
+ return (
+ !next && onClose()}>
+
+
+ {t("sessionSharing.modalTitle")}
+
+ {mode === "link"
+ ? t("sessionSharing.linkModeDescription")
+ : t("sessionSharing.userModeDescription")}
+
+
+
+
+
+ {(["link", "user"] as const).map((m) => (
+ {
+ setMode(m);
+ setCreatedLink(null);
+ }}
+ className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${mode === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
+ >
+ {m === "link" ? (
+
+ ) : (
+
+ )}
+ {m === "link"
+ ? t("sessionSharing.modeTab.link")
+ : t("sessionSharing.modeTab.user")}
+
+ ))}
+
+
+ {mode === "user" && (
+ <>
+
+
+ setSearch(e.target.value)}
+ className="pl-8"
+ />
+
+
+ {filteredUsers.length === 0 ? (
+
+ {t("sessionSharing.noUsersFound")}
+
+ ) : (
+ filteredUsers.map((user) => {
+ const isSelected = selectedUserId === user.id;
+ return (
+
setSelectedUserId(user.id)}
+ className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
+ >
+
+ {isSelected && (
+
+ )}
+
+
+ {user.username}
+
+ );
+ })
+ )}
+
+ >
+ )}
+
+
+
+
+ {t("sessionSharing.permissionLevel.label")}
+
+
+ setPermissionLevel(
+ e.target.value as SessionSharePermissionLevel,
+ )
+ }
+ className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
+ >
+
+ {t("sessionSharing.permissionLevel.readOnly")}
+
+
+ {t("sessionSharing.permissionLevel.readWrite")}
+
+
+
+
+
+
+
+
+ {t("sessionSharing.expiryLabel")}
+
+
+ {t(`hosts.sharing.expiry.${expiryPreset}`)}
+
+
+
+
+ {EXPIRY_PRESETS.map((preset) => (
+ setExpiryPreset(preset.key)}
+ >
+ {expiryPreset === preset.key ? (
+
+ ) : (
+
+ )}
+ {t(`hosts.sharing.expiry.${preset.key}`)}
+
+ ))}
+
+
+
+
+ {expiryPreset === "custom" && (
+
setCustomHours(e.target.value)}
+ className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
+ />
+ )}
+
+
+ {permissionLevel === "read-only"
+ ? t("sessionSharing.permissionLevel.readOnlyDescription")
+ : t("sessionSharing.permissionLevel.readWriteDescription")}
+
+
+
+ {mode === "link"
+ ? t("sessionSharing.createLinkButton")
+ : t("sessionSharing.createShareButton")}
+
+
+ {createdLink && (
+
+
+
+
+
+
+ )}
+
+
+
+
+ {t("sessionSharing.activeShares")}
+ {shares.length > 0 && (
+
+ ({shares.length})
+
+ )}
+
+
+ {shares.length === 0 && (
+
+ {t("sessionSharing.noActiveShares")}
+
+ )}
+ {shares.map((share) => {
+ const targetUser = users.find(
+ (u) => u.id === share.targetUserId,
+ );
+ return (
+
+
+ {share.shareType === "link" ? (
+
+ ) : (
+
+ )}
+
+
+ {share.shareType === "link"
+ ? t("sessionSharing.linkShareBadge")
+ : t("sessionSharing.userShareBadge", {
+ username:
+ targetUser?.username ??
+ share.targetUserId ??
+ "?",
+ })}
+
+
+ {share.permissionLevel === "read-write"
+ ? t("sessionSharing.permissionLevel.readWrite")
+ : t("sessionSharing.permissionLevel.readOnly")}
+ {" · "}
+ {t("sessionSharing.expiresAt", {
+ date: new Date(share.expiresAt).toLocaleString(),
+ })}
+
+
+
+
handleRevoke(share.id)}
+ >
+ {t("sessionSharing.revoke")}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/ui/features/session-sharing/SharedSessionView.tsx b/src/ui/features/session-sharing/SharedSessionView.tsx
new file mode 100644
index 00000000..8827b5ca
--- /dev/null
+++ b/src/ui/features/session-sharing/SharedSessionView.tsx
@@ -0,0 +1,320 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useXTerm } from "react-xtermjs";
+import { FitAddon } from "@xterm/addon-fit";
+import { AlertCircle, Eye } from "lucide-react";
+import {
+ resolveShareLink,
+ type ResolvedShareLink,
+ type ShareLinkErrorKind,
+} from "@/api/session-sharing-api";
+import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
+import { getBasePath } from "@/lib/base-path";
+import { isElectron } from "@/lib/electron";
+import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
+
+const PING_INTERVAL_MS = 30000;
+
+interface TerminalWsMessage {
+ type: string;
+ data?: string;
+ [key: string]: unknown;
+}
+
+// Mirrors Terminal.tsx's baseWsUrl construction (dev/electron/embedded/prod).
+// Duplicated rather than extracted from that file to avoid touching it here.
+// A shared session link is always resolved against the desktop app's
+// embedded local backend -- joining a session hosted on someone else's
+// remote server isn't supported from the desktop app today.
+async function resolveTerminalWsBaseUrl(): Promise {
+ const isDev =
+ !isElectron() &&
+ process.env.NODE_ENV === "development" &&
+ (window.location.port === "3000" ||
+ window.location.port === "5173" ||
+ window.location.port === "");
+
+ if (isDev) {
+ return `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`;
+ }
+ if (isElectron()) {
+ return "ws://127.0.0.1:30002";
+ }
+ const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
+ return `${wsProtocol}://${window.location.host}${getBasePath()}/ssh/websocket/`;
+}
+
+function ReadOnlyBadge({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function CenteredMessage({
+ icon,
+ message,
+}: {
+ icon: React.ReactNode;
+ message: string;
+}) {
+ return (
+
+ {icon}
+
+ {message}
+
+
+ );
+}
+
+function GuestTerminalView({
+ share,
+ linkToken,
+}: {
+ share: ResolvedShareLink;
+ linkToken: string;
+}) {
+ const { t } = useTranslation();
+ const { instance: terminal, ref: xtermRef } = useXTerm();
+ const [ended, setEnded] = useState(null);
+ const wsRef = useRef(null);
+ const pingIntervalRef = useRef | null>(null);
+
+ useEffect(() => {
+ if (!terminal || !xtermRef.current) return;
+
+ terminal.options.theme = { background: "#0c0d0b" };
+
+ const fitAddon = new FitAddon();
+ terminal.loadAddon(fitAddon);
+ terminal.open(xtermRef.current);
+ fitAddon.fit();
+
+ const resizeObserver = new ResizeObserver(() => fitAddon.fit());
+ resizeObserver.observe(xtermRef.current);
+
+ let cancelled = false;
+ let ws: WebSocket | null = null;
+
+ resolveTerminalWsBaseUrl().then((baseWsUrl) => {
+ if (cancelled) return;
+ const separator = baseWsUrl.includes("?") ? "&" : "?";
+ ws = new WebSocket(
+ `${baseWsUrl}${separator}shareToken=${encodeURIComponent(linkToken)}`,
+ );
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ pingIntervalRef.current = setInterval(() => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "ping" }));
+ }
+ }, PING_INTERVAL_MS);
+ };
+
+ ws.onmessage = (event) => {
+ let msg: TerminalWsMessage;
+ try {
+ msg = JSON.parse(event.data);
+ } catch {
+ return;
+ }
+
+ switch (msg.type) {
+ case "data":
+ if (typeof msg.data === "string") terminal.write(msg.data);
+ break;
+ case "sessionExpired":
+ case "sessionTerminatedByOwner":
+ case "session_ended":
+ setEnded(t("sessionSharing.guestView.sessionEnded"));
+ break;
+ default:
+ break;
+ }
+ };
+
+ ws.onclose = () => {
+ setEnded((prev) => prev ?? t("sessionSharing.guestView.sessionEnded"));
+ };
+
+ if (share.permissionLevel === "read-write") {
+ terminal.onData((data) => {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "input", data }));
+ }
+ });
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ resizeObserver.disconnect();
+ if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
+ ws?.close();
+ wsRef.current = null;
+ };
+ // Deliberately runs once terminal mounts - share/token/permission are stable for the view's lifetime.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [terminal, linkToken]);
+
+ return (
+
+ {share.permissionLevel === "read-only" && (
+
+ )}
+ {ended && (
+
+
+ }
+ message={ended}
+ />
+
+ )}
+
+
+ );
+}
+
+function GuestGuacamoleView({ share }: { share: ResolvedShareLink }) {
+ const { t } = useTranslation();
+ const [connectionError, setConnectionError] = useState(null);
+
+ if (!share.connectParams?.token) {
+ return (
+
+ }
+ message={t("sessionSharing.guestView.linkInvalid")}
+ />
+ );
+ }
+
+ return (
+
+ {share.permissionLevel === "read-only" && (
+
+ )}
+ {connectionError && (
+
+
+ }
+ message={connectionError}
+ />
+
+ )}
+
setConnectionError(err)}
+ />
+
+ );
+}
+
+export default function SharedSessionView() {
+ const { t } = useTranslation();
+ const [share, setShare] = useState(null);
+ const [linkToken, setLinkToken] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const token = params.get("token");
+ if (!token) {
+ setError(t("sessionSharing.guestView.linkInvalid"));
+ setLoading(false);
+ return;
+ }
+ setLinkToken(token);
+
+ resolveShareLink(token)
+ .then((resolved) => setShare(resolved))
+ .catch((err) => {
+ const kind = (err as { kind?: ShareLinkErrorKind })?.kind;
+ if (kind === "rate-limited") {
+ setError(t("sessionSharing.guestView.rateLimited"));
+ } else {
+ setError(t("sessionSharing.guestView.linkInvalid"));
+ }
+ })
+ .finally(() => setLoading(false));
+ }, [t]);
+
+ return (
+
+
+ {loading && (
+
+ )}
+ {!loading && error && (
+
+ }
+ message={error}
+ />
+ )}
+ {!loading &&
+ !error &&
+ share &&
+ linkToken &&
+ (share.protocol === "ssh" ? (
+
+ ) : (
+
+ ))}
+
+
+ );
+}
diff --git a/src/ui/features/terminal/MobileTerminalKeyboard.tsx b/src/ui/features/terminal/MobileTerminalKeyboard.tsx
index 83d0123a..f21364e1 100644
--- a/src/ui/features/terminal/MobileTerminalKeyboard.tsx
+++ b/src/ui/features/terminal/MobileTerminalKeyboard.tsx
@@ -5,11 +5,13 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
+ Clipboard,
Pencil,
X,
Plus,
RotateCcw,
} from "lucide-react";
+import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -252,6 +254,18 @@ export function MobileTerminalKeyboard({
terminalRef.current?.sendInput?.(seq);
}
+ async function handlePaste() {
+ try {
+ const text = window.electronClipboard
+ ? await window.electronClipboard.readText()
+ : ((await navigator.clipboard?.readText?.()) ?? "");
+ if (text) terminalRef.current?.paste?.(text);
+ else toast.error(t("terminal.clipboardReadFailed"));
+ } catch {
+ toast.error(t("terminal.clipboardReadFailed"));
+ }
+ }
+
function toggleCtrl() {
setCtrlActive((v) => !v);
setShiftActive(false);
@@ -322,6 +336,18 @@ export function MobileTerminalKeyboard({
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
+ {/* Paste */}
+ {
+ e.preventDefault();
+ handlePaste();
+ }}
+ title={t("mobileKeyboard.paste")}
+ >
+
+
+
{/* Ctrl */}
diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx
index 7eda9981..f13dc7d8 100644
--- a/src/ui/features/terminal/Terminal.tsx
+++ b/src/ui/features/terminal/Terminal.tsx
@@ -17,16 +17,18 @@ import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { useTranslation } from "react-i18next";
import { getBasePath } from "@/lib/base-path";
+import {
+ resolveConnectionOrigin,
+ buildOriginWsUrl,
+} from "@/lib/connection-origin.ts";
import {
getCookie,
isElectron,
- isEmbeddedMode,
logActivity,
getSnippets,
deleteCommandFromHistory,
getCommandHistory,
getHostPassword,
- getServerConfig,
} from "@/main-axios.ts";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
@@ -39,7 +41,7 @@ import {
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/lib/terminal-themes.ts";
-import "./terminal-global-styles.ts";
+import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
import { useTheme } from "@/components/theme-provider.tsx";
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
@@ -57,11 +59,19 @@ import { toast } from "sonner";
import { Button } from "@/components/button";
import { Save } from "lucide-react";
import { resolveTermixThemeColors } from "./terminal-theme.ts";
+import { ShareSessionModal } from "@/features/session-sharing/ShareSessionModal.tsx";
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
} from "./terminal-font-zoom.ts";
+import {
+ getUserPreferences,
+ parseCustomKeybindings,
+} from "@/api/open-tabs-api";
+import { findMatchingKeybinding } from "@/lib/keybinding-match";
+import { dispatchKeybindingAction } from "@/lib/keybinding-dispatch";
+import type { CustomKeybinding } from "@/types/keybindings";
export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
type HostKeyVerificationData = Omit<
@@ -158,12 +168,17 @@ const TerminalInner = forwardRef(
: themeColors.background;
const fitAddonRef = useRef(null);
const webSocketRef = useRef(null);
+ const customKeybindingsRef = useRef([]);
+ const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
+ null,
+ );
const resizeTimeout = useRef(null);
const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef(null);
const pongReceivedRef = useRef(true);
const pongTimeoutRef = useRef(null);
const [isConnected, setIsConnected] = useState(false);
+ const [shareModalOpen, setShareModalOpen] = useState(false);
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
@@ -182,6 +197,10 @@ const TerminalInner = forwardRef(
const [totpRequired, setTotpRequired] = useState(false);
const [totpPrompt, setTotpPrompt] = useState("");
const [isPasswordPrompt, setIsPasswordPrompt] = useState(false);
+ const [mfaPromptMode, setMfaPromptMode] = useState<
+ "totp" | "password" | "menu" | "push"
+ >("totp");
+ const [mfaWaiting, setMfaWaiting] = useState(false);
const [showAuthDialog, setShowAuthDialog] = useState(false);
const [authDialogReason, setAuthDialogReason] = useState<
"no_keyboard" | "auth_failed" | "timeout"
@@ -512,17 +531,25 @@ const TerminalInner = forwardRef(
}
function handleTotpSubmit(code: string) {
- if (webSocketRef.current && code) {
- if (totpTimeoutRef.current) {
- clearTimeout(totpTimeoutRef.current);
- totpTimeoutRef.current = null;
- }
+ const isPushMode = mfaPromptMode === "push";
+ if (webSocketRef.current && (code || isPushMode)) {
webSocketRef.current.send(
JSON.stringify({
type: isPasswordPrompt ? "password_response" : "totp_response",
data: { code },
}),
);
+ if (isPushMode) {
+ // Server blocks until the phone approval completes; keep the dialog
+ // open in a waiting state rather than closing it immediately. The
+ // existing timeout continues running until "connected" or "error".
+ setMfaWaiting(true);
+ return;
+ }
+ if (totpTimeoutRef.current) {
+ clearTimeout(totpTimeoutRef.current);
+ totpTimeoutRef.current = null;
+ }
setTotpRequired(false);
setTotpPrompt("");
setIsPasswordPrompt(false);
@@ -536,6 +563,9 @@ const TerminalInner = forwardRef(
}
setTotpRequired(false);
setTotpPrompt("");
+ setIsPasswordPrompt(false);
+ setMfaPromptMode("totp");
+ setMfaWaiting(false);
if (onClose) onClose();
}
@@ -824,6 +854,9 @@ const TerminalInner = forwardRef(
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
}
},
+ paste: (text: string) => {
+ terminal?.paste(text);
+ },
notifyResize: () => {
try {
const cols = terminal?.cols ?? undefined;
@@ -846,8 +879,20 @@ const TerminalInner = forwardRef(
onOpenFileManager?.("/");
}
},
+ openShareModal: () => setShareModalOpen(true),
+ canShare: () =>
+ isConnected &&
+ !isQuickConnect &&
+ !hostConfig.joinShareId &&
+ typeof hostConfig.id === "number",
}),
- [isConnected, terminal],
+ [
+ isConnected,
+ terminal,
+ isQuickConnect,
+ hostConfig.joinShareId,
+ hostConfig.id,
+ ],
);
function getUseRightClickCopyPaste() {
@@ -956,52 +1001,28 @@ const TerminalInner = forwardRef(
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";
- const storedJwt = localStorage.getItem("jwt");
- if (storedJwt) {
- baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
- }
- } else if (!configuredUrl) {
- console.error("No configured server URL available for Electron SSH");
+ const origin = await resolveConnectionOrigin({
+ connectionType: "ssh",
+ connectionOrigin: hostConfig.connectionOrigin as
+ | "local"
+ | "remote"
+ | null
+ | undefined,
+ });
+ const resolvedUrl = await buildOriginWsUrl({
+ origin,
+ localPort: 30002,
+ localPath: "",
+ remotePath: "/ssh/websocket/",
+ });
+ if (!resolvedUrl) {
setIsConnected(false);
setIsConnecting(false);
- updateConnectionError(t("errors.failedToLoadServer"));
+ updateConnectionError(t("errors.remoteServerRequired"));
isConnectingRef.current = false;
return;
- } else {
- const wsProtocol = configuredUrl.startsWith("https://")
- ? "wss://"
- : "ws://";
- const wsHost = configuredUrl
- .replace(/^https?:\/\//, "")
- .replace(/\/$/, "");
- baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
- const storedJwt = localStorage.getItem("jwt");
- if (storedJwt) {
- baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
- }
}
+ baseWsUrl = resolvedUrl;
} else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
}
@@ -1076,7 +1097,19 @@ const TerminalInner = forwardRef(
const restoredSessionId = pendingRestoredSessionIdRef.current;
pendingRestoredSessionIdRef.current = null;
- if (restoredSessionId) {
+ if (hostConfig.joinShareId) {
+ isAttachingSessionRef.current = true;
+
+ ws.send(
+ JSON.stringify({
+ type: "joinSharedSession",
+ data: {
+ shareId: hostConfig.joinShareId,
+ tabInstanceId: hostConfig.instanceId,
+ },
+ }),
+ );
+ } else if (restoredSessionId) {
sessionIdRef.current = restoredSessionId;
isAttachingSessionRef.current = true;
@@ -1327,6 +1360,8 @@ const TerminalInner = forwardRef(
setTotpRequired(true);
setTotpPrompt(msg.prompt || t("terminal.totpCodeLabel"));
setIsPasswordPrompt(false);
+ setMfaPromptMode("totp");
+ setMfaWaiting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -1342,10 +1377,24 @@ const TerminalInner = forwardRef(
}, 180000);
} else if (msg.type === "totp_retry") {
// Existing prompt remains visible while the backend asks for another code.
+ setMfaWaiting(false);
} else if (msg.type === "password_required") {
+ const promptText: string = msg.prompt || "";
+ const pushPromptPattern =
+ /choose.*push.*totp|press enter.*(push|send)|push notification|authentication by phone/i;
+ const isPush = pushPromptPattern.test(promptText);
+ const isMenu = !isPush && msg.echo === true;
+ const mode: "menu" | "push" | "password" = isPush
+ ? "push"
+ : isMenu
+ ? "menu"
+ : "password";
+
setTotpRequired(true);
- setTotpPrompt(msg.prompt || t("common.password"));
+ setTotpPrompt(promptText || t("common.password"));
setIsPasswordPrompt(true);
+ setMfaPromptMode(mode);
+ setMfaWaiting(false);
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
@@ -1353,12 +1402,15 @@ const TerminalInner = forwardRef(
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
}
- totpTimeoutRef.current = setTimeout(() => {
- setTotpRequired(false);
- if (webSocketRef.current) {
- webSocketRef.current.close();
- }
- }, 180000);
+ totpTimeoutRef.current = setTimeout(
+ () => {
+ setTotpRequired(false);
+ if (webSocketRef.current) {
+ webSocketRef.current.close();
+ }
+ },
+ isPush ? 300000 : 180000,
+ );
} else if (msg.type === "warpgate_auth_required") {
setWarpgateAuthRequired(true);
setWarpgateAuthUrl(msg.url || "");
@@ -1986,6 +2038,7 @@ const TerminalInner = forwardRef