Compare commits

..
5 Commits
Author SHA1 Message Date
0149260926 v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

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

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-10 14:58:27 -05:00
LukeGus a075ff4808 chore: add publish sdk workflow to main 2026-10-09 16:21:15 -05:00
Luke Gustafson 2acd858ca4 Update copyright year in LICENSE file 2026-10-06 20:48:27 -05:00
LukeGus 064fb7ea3c chore: bump Homebrew cask to 2.9.2 2026-10-06 02:12:33 +00:00
Luke Gustafson 2b309e9582 release-2.9.2 (#1566)
* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows
2026-10-05 20:24:46 -05:00
107 changed files with 2317 additions and 569 deletions

No files matched your search

+13 -65
View File
@@ -253,7 +253,7 @@ jobs:
git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
crowdin:
sync-main:
needs: [prep, normalize]
if: ${{ needs.prep.outputs.channel == 'Stable' && needs.prep.outputs.mode != 'Dry run' && needs.prep.outputs.mode != 'Overwrite release' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
@@ -265,79 +265,27 @@ jobs:
fetch-depth: 0
token: ${{ secrets.TERMIX_PAT }}
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: ".nvmrc"
- name: Merge main into dev branch
run: |
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git fetch origin main
git merge origin/main -X ours --no-edit || true
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
- name: Clean up stale Crowdin git integration branch
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.TERMIX_PAT }}
run: |
PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
if [ -n "$PR_NUMBER" ]; then
gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
# -X ours leaves modify/delete conflicts, so settle those to the dev side too
if ! git merge origin/main -X ours --no-edit; then
git diff --name-only --diff-filter=U | while read -r f; do
if git cat-file -e "HEAD:$f" 2>/dev/null; then
git checkout --ours -- "$f" && git add -- "$f"
else
git rm -q -- "$f"
fi
done
git commit --no-edit
fi
git push origin --delete i18n_translate || true
- name: Upload sources to Crowdin
uses: crowdin/github-action@v3
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 }}
# Off until the machine translation credits are topped up.
- name: Pre-translate untranslated strings
if: false
env:
CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
run: node scripts/crowdin-pretranslate.cjs
- name: Download translations from Crowdin
uses: crowdin/github-action@v3
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 to dev branch
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 for ${{ needs.prep.outputs.version }}"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
merge-to-main:
needs: [prep, normalize, crowdin]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
needs: [prep, normalize, sync-main]
if: ${{ always() && needs.prep.result == 'success' && needs.prep.outputs.channel == 'Stable' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.sync-main.result == 'success' || needs.sync-main.result == 'skipped') }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
main_sha: ${{ steps.merge.outputs.main_sha }}
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "2.9.1"
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
version "2.9.2"
sha256 "808d876b37ab802c511e964c232619b1ba2949ec39ccd2b1f97e264544739172"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
+1
View File
@@ -1,3 +1,4 @@
Copyright 2026 Luke Gustafson
Apache License
Version 2.0, January 2004
-9
View File
@@ -1,9 +0,0 @@
project_id: "858252"
api_token: "env:CROWDIN_API_TOKEN"
preserve_hierarchy: true
files:
# Core has always lived at /en.json in Crowdin; moving it would orphan its translations.
- source: /src/ui/locales/en.json
dest: /en.json
translation: /src/ui/locales/translated/%locale_with_underscore%.json
+34 -34
View File
@@ -4,19 +4,19 @@
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.0/acme-ssl-1.0.0.tmxplug",
"sha256": "05b7b4c27897135437af6340c5c31501d2e96ac013dca0672940b74c76085433"
"sha256": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ed7a64a4be5978238427d4abbd2153ef2b44c17ebfec78fcb5df579967d01eed"
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
"onboarding": {
"recommended": true,
"consent": false
@@ -26,13 +26,13 @@
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.0/automations-1.0.0.tmxplug",
"sha256": "0972aef1c14917321c035f7497db81fe95f6b9e47d854b95b1c762444d9cb2a2"
"sha256": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
"onboarding": {
"recommended": true,
"consent": false
@@ -42,7 +42,7 @@
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.0/file-manager-1.0.0.tmxplug",
"sha256": "d1d92ae051c79c7ce6f05c7dbf33c96de9d8b58083eaccb104d1d21a6bd8b58f",
"sha256": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
"onboarding": {
"recommended": true,
"consent": false
@@ -52,19 +52,19 @@
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.0/fleets-1.0.0.tmxplug",
"sha256": "410b19f3a6f47e2a292821b11876b8b5a0bdf779f8d852d5ac04a7501d0f12c0"
"sha256": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "5652e378dbb208c7bb349ba2262034077f1cfca95b2d6d029f4186c70ed27999"
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.0/host-metrics-1.0.0.tmxplug",
"sha256": "ca50e27f06bc7a275bd1bfb1deb5ac97f19023c84567105fe74e075602ec86da",
"sha256": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
"onboarding": {
"recommended": true,
"consent": false
@@ -74,31 +74,31 @@
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.0/ldap-1.0.0.tmxplug",
"sha256": "44d3f9e9be913b9791a084aff588d7d4217dd5cd3f61178006ed4fa2455cad23"
"sha256": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.0/network-topology-1.0.0.tmxplug",
"sha256": "ba3db2a6bc87533c8273648549c466b95f2c7538700407b2d62e48f813526bac"
"sha256": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "05884e2244b4cc7f7f0465fe4b775ff95a99183ce6aba60848b68f1f3ec4078f"
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "3a304ba327352093f4bf5dff1a7d6fc981a83180ffc2e06fe1c63e59600e8e3c"
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.0/remote-desktop-1.0.0.tmxplug",
"sha256": "2dd13c79066c99816f78b3c8aa5f3e31e17d3d2568f2e48575ef1e01f1195c28",
"sha256": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
"onboarding": {
"recommended": true,
"consent": false
@@ -108,13 +108,13 @@
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.0/secret-sources-1.0.0.tmxplug",
"sha256": "fdab87ca97ea05c60d7008b22da4f56c4897abf11505b6fa8b5c7bd8bc1a53e3"
"sha256": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -124,19 +124,19 @@
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.0/session-recording-1.0.0.tmxplug",
"sha256": "3a9ab3a3638d0cf13a4c7debbcd315d9a324b9991faea1c192ae1e8fda1e525b"
"sha256": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.0/session-sharing-1.0.0.tmxplug",
"sha256": "f838e53401f6d75923821797829806b9ca03f0dd64fe87f8524d14a2e05f684e"
"sha256": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
"onboarding": {
"recommended": true,
"consent": false
@@ -146,7 +146,7 @@
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.0/ssh-terminal-1.0.0.tmxplug",
"sha256": "64031970a7b6eb1c860aac79c1587a582ccd38b2ffcd5974b51a716240a8474d",
"sha256": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
"onboarding": {
"recommended": true,
"consent": false
@@ -156,7 +156,7 @@
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.0/sso-1.0.0.tmxplug",
"sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
"sha256": "d9f69f7f52b778d1b144c58e5a65006780498d6fa171b5aeb18e15b7b033ca6b",
"onboarding": {
"recommended": false,
"consent": false,
@@ -167,19 +167,19 @@
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.0/step-ca-1.0.0.tmxplug",
"sha256": "fc38ac6dcf8c43c550b7a7c65836a095ec5a8bae76c8797b5563b2c6c8e8dad1"
"sha256": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "f7732afe584044258cc46205fee327223cdd290e1ba6e6858ddd22be6b3ef12d"
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
"onboarding": {
"recommended": true,
"consent": true
@@ -189,19 +189,19 @@
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.0/termix-identity-1.0.0.tmxplug",
"sha256": "f1f712178b5d64ae263227640b61d1f5ea5a301eae3ff8adb8b52de5f20892fe"
"sha256": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.0/tmux-monitor-1.0.0.tmxplug",
"sha256": "d6a98bcf99dbb31201d7079baf348100bca1c0eda0928aec4aeb075ea373a8d5"
"sha256": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
"onboarding": {
"recommended": true,
"consent": false
@@ -211,7 +211,7 @@
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.0/tunnels-1.0.0.tmxplug",
"sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
"sha256": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
"onboarding": {
"recommended": true,
"consent": false
@@ -221,31 +221,31 @@
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.0/vault-1.0.0.tmxplug",
"sha256": "0bbd95002866807ddd469f102d09bff9cf1b39e005dec6b96530066c6c3b0d19"
"sha256": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.0/wake-on-lan-1.0.0.tmxplug",
"sha256": "39b97642e44e7b128df3632b10962bff6a26313f2e989e5acd29760cf91c4e04"
"sha256": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "a7b7ed6d39639d0d6050958417d33d646a0304f99dec4eb9da71a46e42ae80f1"
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.0/web-endpoint-1.0.0.tmxplug",
"sha256": "22f5f276f6dab85e9b211f8e990f82fcbf63ebb8fa90379463f64d0fa38905ab"
"sha256": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
"onboarding": {
"recommended": true,
"consent": false
@@ -255,7 +255,7 @@
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.0/workspaces-1.0.0.tmxplug",
"sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
"sha256": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
"onboarding": {
"recommended": true,
"consent": false
+1 -1
View File
@@ -12,6 +12,7 @@
"packages/*"
],
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -2876,7 +2877,6 @@
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
+1
View File
@@ -47,6 +47,7 @@
"plugins:pin": "node scripts/pin-bundled-plugins.cjs"
},
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
+1 -1
View File
@@ -95,7 +95,7 @@ HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
HostFeatureFieldsProps (type): HostFeatureFieldsProps
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" fill="#141513"/>
<rect x="227" y="244" width="570" height="92" rx="46" fill="#ffffff"/>
<path d="M474 336H550V786A38 38 0 0 1 474 786V572.2L542 545V447L474 419.8ZM498 647.5V761.5H550V647.5Z" fill="#ffffff" fill-rule="evenodd"/>
<path d="M306 387.2L510 468.8V523.2L306 604.8V547.5L435.5 496L306 444.5Z" fill="#f59145"/>
<rect x="530.5" y="679.5" width="213" height="50" fill="#f59145"/>
</svg>

After

Width:  |  Height:  |  Size: 528 B

BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
<path d="M0 0 C1.24605143 -0.00320648 2.49210286 -0.00641296 3.77591342 -0.0097166 C7.20551324 -0.01654213 10.63489336 -0.01454336 14.06448046 -0.00866088 C17.80678204 -0.00446316 21.54906441 -0.01190796 25.29136121 -0.01781338 C31.84084796 -0.02655123 38.39030342 -0.02789611 44.93979454 -0.02431011 C54.67961151 -0.01899148 64.41939833 -0.02511011 74.15921202 -0.03339304 C92.56926142 -0.04861937 110.97929706 -0.05033948 129.38935144 -0.04787002 C143.60890976 -0.04603597 157.82846357 -0.0477993 172.04802132 -0.05209446 C174.09235779 -0.05269687 176.13669427 -0.05329806 178.18103074 -0.05389806 C181.26182901 -0.05480534 184.34262728 -0.05571401 187.42342555 -0.05663041 C216.06152484 -0.06505806 244.69962243 -0.06939698 273.33772278 -0.06522369 C274.95656045 -0.06498913 274.95656045 -0.06498913 276.60810192 -0.06474983 C282.07778518 -0.06394407 287.54746842 -0.0630983 293.01715167 -0.06223653 C294.64653437 -0.06197989 294.64653437 -0.06197989 296.30883388 -0.06171808 C298.50550538 -0.06137142 300.70217687 -0.06102289 302.89884837 -0.06067249 C337.07170733 -0.05531644 371.24454049 -0.06324318 405.41739464 -0.08170605 C443.83052411 -0.10243565 482.24363759 -0.11314686 520.65677297 -0.10913056 C524.75506425 -0.10873523 528.85335552 -0.10837368 532.9516468 -0.10803318 C533.96071349 -0.10794245 534.96978018 -0.10785172 536.00942464 -0.10775824 C551.22334929 -0.10659366 566.43725776 -0.11323001 581.65117836 -0.1238451 C598.96103916 -0.13591042 616.2708644 -0.13744077 633.58072574 -0.12505493 C643.25916158 -0.11843905 652.93751231 -0.12002798 662.6159413 -0.13364345 C669.09234848 -0.1419165 675.56869223 -0.13915349 682.04509432 -0.12766818 C685.72751758 -0.12143989 689.40977538 -0.11973724 693.09218879 -0.13153151 C742.29620511 -0.27803261 783.72404367 14.44275388 819.33878136 49.25447559 C838.15454949 68.05987805 851.6807596 92.15656693 859.15128136 117.62947559 C859.50259117 118.81223072 859.50259117 118.81223072 859.86099815 120.01887989 C864.84716349 137.93439184 865.34131204 155.41560974 865.28668402 173.91294083 C865.27899556 177.65690687 865.28888432 181.40083665 865.29641402 185.14479738 C865.3071966 191.69097825 865.30695291 198.23708474 865.29927254 204.78326893 C865.2878824 214.51804038 865.2937484 224.25274959 865.30289746 233.98752068 C865.31866453 251.37833898 865.3173375 268.76912415 865.30866072 286.15994558 C865.30110466 301.37985494 865.29914832 316.59975219 865.30272865 331.81966305 C865.30295781 332.83047421 865.30318696 333.84128537 865.30342306 334.88272717 C865.30435713 338.98627537 865.30531233 343.08982357 865.30630235 347.19337176 C865.3154731 385.66415573 865.30716901 424.13491865 865.29101166 462.60569937 C865.27677663 496.7590625 865.27804855 530.91239206 865.29239464 565.06575489 C865.30847087 603.46608751 865.31482155 641.86640153 865.30555785 680.26673704 C865.30459574 684.36298215 865.30365722 688.45922726 865.30272865 692.55547237 C865.3023728 694.06829454 865.3023728 694.06829454 865.30200977 695.61167876 C865.29860234 710.81857886 865.3025789 726.02546547 865.31016731 741.2323637 C865.31875621 758.53008151 865.31657695 775.82776109 865.30032508 793.12547415 C865.29153676 802.79747308 865.2909114 812.46938917 865.3022636 822.1413859 C865.30901854 828.61208821 865.30475417 835.08272447 865.2917493 841.55341703 C865.28465281 845.23332826 865.2820887 848.9130756 865.29296724 852.59298133 C865.3442355 871.30113003 864.89727324 889.46647244 859.15128136 907.44197559 C858.89524132 908.2652837 858.63920128 909.0885918 858.37540245 909.93684864 C855.79685812 917.9856683 852.58102984 925.65348896 848.90128136 933.25447559 C848.61591515 933.86033497 848.33054893 934.46619434 848.03653526 935.09041309 C830.37472338 972.22703729 796.06237029 1001.64914703 757.52628136 1015.44197559 C741.01943895 1021.1452558 724.41526408 1024.57505987 706.89996243 1024.5117445 C705.65228729 1024.51525057 704.40461215 1024.51875665 703.11912867 1024.52236897 C699.68034267 1024.53174518 696.24166373 1024.52885318 692.80287276 1024.52381342 C689.05227316 1024.52052693 685.3016955 1024.52884577 681.55110306 1024.53563178 C674.98544107 1024.54591004 668.41980856 1024.54877936 661.85413933 1024.54671955 C652.09014292 1024.54366388 642.32617902 1024.55200613 632.56218895 1024.5624688 C614.10585117 1024.58179372 595.64952453 1024.58743648 577.19317783 1024.58858292 C562.93642719 1024.5895027 548.6796809 1024.59362298 534.42293167 1024.59998417 C530.31618419 1024.60177932 526.2094367 1024.60355895 522.10268921 1024.60533392 C521.08167553 1024.60577602 520.06066186 1024.60621812 519.00870837 1024.60667361 C490.29523693 1024.61897939 461.581767 1024.62687502 432.86829281 1024.62598705 C431.78608639 1024.6259559 430.70387998 1024.62592474 429.58887942 1024.62589264 C424.10406817 1024.62572643 418.61925692 1024.62552835 413.13444567 1024.62532322 C412.04518472 1024.62528333 410.95592376 1024.62524344 409.8336549 1024.62520234 C407.63090343 1024.62512147 405.42815196 1024.62503787 403.22540049 1024.62495156 C368.96540695 10Line truncated
<path d="M0 0 C2.3607826 -0.00533069 4.7215618 -0.01239937 7.08233458 -0.02104706 C12.86781777 -0.03768809 18.65308544 -0.03586118 24.43857937 -0.02603391 C31.37506207 -0.01528955 38.31145513 -0.02784201 45.24792682 -0.04094901 C58.83711965 -0.06583655 72.42624267 -0.0677834 86.01545164 -0.0596749 C97.90842776 -0.05259347 109.80137684 -0.05255306 121.6943531 -0.05953789 C123.29160454 -0.06044923 124.88885599 -0.06135999 126.48610743 -0.06227018 C128.89321227 -0.06365335 131.3003171 -0.06504445 133.70742193 -0.06645631 C156.07605562 -0.07943052 178.44468029 -0.0845595 200.81331539 -0.07292271 C202.07720574 -0.07226575 202.07720574 -0.07226575 203.36662924 -0.07159552 C210.20845318 -0.06798894 217.05027701 -0.06415405 223.89210082 -0.06030459 C250.57968088 -0.04545695 277.26715859 -0.05423912 303.95472717 -0.08226013 C333.96091556 -0.11370558 363.96704337 -0.12865264 393.97324854 -0.11846012 C397.17413714 -0.11742339 400.37502576 -0.11643616 403.57591438 -0.11547661 C404.75806397 -0.11510328 404.75806397 -0.11510328 405.96409537 -0.11472241 C417.84673069 -0.11128624 429.72931241 -0.12057354 441.61193657 -0.13624763 C455.12861173 -0.15403651 468.64515667 -0.15411882 482.16182631 -0.13089598 C489.05584211 -0.11951551 495.94963463 -0.11592963 502.84363937 -0.13519669 C509.15952167 -0.15202846 515.47494573 -0.1456129 521.79079849 -0.1209799 C524.07057599 -0.11610521 526.35039063 -0.11958862 528.63013696 -0.13246734 C545.55919757 -0.22117063 559.49258132 1.40958132 573.70204163 11.5146637 C574.65143616 12.18948792 574.65143616 12.18948792 575.62001038 12.87794495 C584.56469704 19.88757798 591.37473569 31.41173592 593.70204163 42.5146637 C593.95009484 46.21967671 593.9332442 49.92717174 593.95204163 53.6396637 C593.98297913 55.13239807 593.98297913 55.13239807 594.01454163 56.6552887 C594.09018739 71.14145219 588.46971331 82.75864333 578.63954163 93.3271637 C572.7611904 98.9075711 566.35707715 102.79152692 558.70204163 105.5146637 C557.98319668 105.77360066 557.26435173 106.03253763 556.5237236 106.29932117 C545.67592079 109.56985516 534.52679546 108.91143924 523.30848694 108.85523987 C520.90407196 108.85431984 518.49965616 108.85472308 516.09524155 108.85633564 C509.68138016 108.85701034 503.26768815 108.83967715 496.85386658 108.81850433 C490.82587678 108.80136501 484.79792729 108.80061201 478.76991272 108.79847717 C461.16393343 108.7827621 443.55798536 108.74087841 425.95204163 108.7021637 C386.59954163 108.6402887 347.24704163 108.5784137 306.70204163 108.5146637 C306.70204163 227.6446637 306.70204163 346.7746637 306.70204163 469.5146637 C286.90204163 469.5146637 267.10204163 469.5146637 246.70204163 469.5146637 C246.70204163 513.4046637 246.70204163 557.2946637 246.70204163 602.5146637 C266.50204163 602.5146637 286.30204163 602.5146637 306.70204163 602.5146637 C306.79485413 611.0843512 306.79485413 611.0843512 306.88954163 619.8271637 C306.91685364 621.60647278 306.94416565 623.38578186 306.9723053 625.2190094 C307.11177013 640.67747815 304.32285969 652.54241746 293.63954163 664.2646637 C283.35164203 674.1494599 271.29383334 675.99381057 257.74501038 675.7881012 C245.19699202 675.32053069 236.72499202 668.99701836 228.38954163 660.0771637 C220.57277107 650.9239274 218.39160693 641.68285062 218.46153259 629.87820435 C218.45896932 628.28889812 218.45896932 628.28889812 218.45635426 626.6674847 C218.45306486 623.123718 218.46405419 619.58014142 218.47499084 616.03639221 C218.47555508 613.49352015 218.47523817 610.95064775 218.47410583 608.40777588 C218.47365067 602.93649447 218.47931674 597.46527407 218.49058151 591.9940052 C218.50685769 584.08354929 218.51204668 576.17311643 218.5145575 568.26264559 C218.51893128 555.42744908 218.53223178 542.59227803 218.55116272 529.75709534 C218.56952972 517.29127632 218.58368221 504.82546422 218.59217834 492.3596344 C218.59270379 491.5908286 218.59322924 490.82202281 218.59377061 490.02991987 C218.59638074 486.17292397 218.59890815 482.31592802 218.60139441 478.45893204 C218.62211705 446.47749075 218.65733395 414.49608076 218.70204163 382.5146637 C224.61760486 380.08952596 230.53376566 377.66585017 236.45030212 375.24308777 C238.45381022 374.42248424 240.45719208 373.60157242 242.46043396 372.78031921 C294.33562193 351.5146637 294.33562193 351.5146637 297.70204163 351.5146637 C297.70204163 313.8946637 297.70204163 276.2746637 297.70204163 237.5146637 C293.24704163 236.0296637 293.24704163 236.0296637 288.70204163 234.5146637 C286.21106492 233.56633242 283.72835319 232.59599737 281.25453186 231.60377502 C280.55959405 231.32587222 279.86465624 231.04796942 279.14865971 230.76164532 C276.93500856 229.87574275 274.72279495 228.98631453 272.51063538 228.09669495 C271.12991114 227.54352131 269.74909265 226.9905829 268.36817932 226.43788147 C263.29082801 224.40381573 258.21529078 222.36522413 253.13954163 220.3271637 C241.77516663 215.7690387 230.41079163 211.2109137 218.70204163 206.5146637 C218.70204163 174.1746637 218.70204163 141.8346637 218.70204163 108.5146637 C211.4392218 1Line truncated
<path d="M0 0 C5.86817508 1.95605836 11.59073119 3.95365747 17.30737305 6.27001953 C18.09306325 6.58584142 18.87875345 6.9016633 19.68825245 7.22705555 C22.29709699 8.27645462 24.90439611 9.32963844 27.51171875 10.3828125 C29.39488199 11.14115547 31.2781448 11.89925122 33.16149902 12.65711975 C37.21264727 14.28789145 41.26312379 15.92031644 45.31307983 17.55404663 C51.7542863 20.15236465 58.19696081 22.74702807 64.63989258 25.34106445 C71.41130904 28.06755974 78.18261714 30.79432275 84.95341492 33.52235413 C108.15087431 42.86883922 131.35537268 52.19778488 154.5609436 61.52410889 C159.994401 63.70786608 165.42764165 65.89216193 170.86079502 68.07667542 C193.23239581 77.07158187 215.60890835 86.05370489 238 95 C238 116.12 238 137.24 238 159 C232.01359375 161.39185547 232.01359375 161.39185547 225.90625 163.83203125 C206.57998543 171.55973546 187.26934996 179.32631616 167.95794678 187.09106445 C161.36560101 189.74165676 154.77289802 192.39135988 148.18017578 195.04101562 C126.79552102 203.63604381 105.41240644 212.23486217 84.03399658 220.84541321 C75.73587703 224.18760199 67.43708216 227.52811242 59.13812637 230.86822414 C52.33470621 233.60660835 45.53222385 236.34730609 38.73039818 239.08964825 C34.51543173 240.78839672 30.29959748 242.48498283 26.08351517 244.1809597 C24.12464123 244.96946815 22.16613491 245.75889055 20.20801926 246.54928017 C17.53921709 247.62640467 14.86914587 248.70032007 12.19873047 249.7734375 C11.02965778 250.24663689 11.02965778 250.24663689 9.83696747 250.72939587 C6.56202144 252.04197447 3.43106937 253.14223266 0 254 C0 231.56 0 209.12 0 186 C21.85641702 177.6267728 43.73124762 169.30784563 65.63814926 161.06789398 C71.62769516 158.81489337 77.61678737 156.56068672 83.60580635 154.30628586 C85.09797285 153.74460399 86.5901539 153.18296077 88.08234978 152.62135696 C97.85972778 148.94125789 107.63197621 145.24805417 117.3984375 141.5390625 C119.52367608 140.73273275 121.64892001 139.92641707 123.77416992 139.12011719 C127.62723328 137.65810939 131.4786996 136.19199339 135.3293457 134.72363281 C137.01507528 134.08382248 138.70086226 133.44416335 140.38671875 132.8046875 C141.51894363 132.37079208 141.51894363 132.37079208 142.67404175 131.9281311 C147.77255889 130 147.77255889 130 150 130 C149.67 128.02 149.34 126.04 149 124 C147.88878784 123.58995728 147.88878784 123.58995728 146.75512695 123.17163086 C117.43275623 112.3398187 88.19328622 101.28796181 58.94955444 90.24617195 C50.58387921 87.08758433 42.21738212 83.93117504 33.85093689 80.77462769 C22.56697288 76.5172614 11.28352836 72.25852172 0 68 C0 45.56 0 23.12 0 0 Z " fill="#F39044" transform="translate(272,342)"/>
<path d="M0 0 C81.84 0 163.68 0 248 0 C248 19.47 248 38.94 248 59 C166.16 59 84.32 59 0 59 C0 39.53 0 20.06 0 0 Z " fill="#F39044" transform="translate(533,681)"/>
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<rect width="1024" height="1024" rx="225" fill="#141513"/>
<rect x="227" y="244" width="570" height="92" rx="46" fill="#ffffff"/>
<path d="M474 336H550V786A38 38 0 0 1 474 786V572.2L542 545V447L474 419.8ZM498 647.5V761.5H550V647.5Z" fill="#ffffff" fill-rule="evenodd"/>
<path d="M306 387.2L510 468.8V523.2L306 604.8V547.5L435.5 496L306 444.5Z" fill="#f59145"/>
<rect x="530.5" y="679.5" width="213" height="50" fill="#f59145"/>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1021 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 B

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 683 B

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

-126
View File
@@ -1,126 +0,0 @@
const API = "https://api.crowdin.com/api/v2";
const ENGINE_ID = 649248; // Google Translate MT engine
const PROJECT_NAME = "termix-ssh";
const SOURCE_FILE = "en.json";
function token() {
const t = process.env.CROWDIN_API_KEY;
if (!t) throw new Error("CROWDIN_API_KEY is not set");
return t;
}
async function request(method, pathname, body) {
const res = await fetch(`${API}${pathname}`, {
method,
headers: {
Authorization: `Bearer ${token()}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${method} ${pathname} -> ${res.status}: ${text}`);
}
return res.json();
}
async function paged(pathname) {
const items = [];
let offset = 0;
const limit = 500;
for (;;) {
const sep = pathname.includes("?") ? "&" : "?";
const page = await request(
"GET",
`${pathname}${sep}limit=${limit}&offset=${offset}`,
);
const batch = (page.data || []).map((row) => row.data);
items.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return items;
}
async function resolveProjectId() {
const projects = await paged("/projects");
const match = projects.find(
(p) => p.identifier === PROJECT_NAME || p.name === PROJECT_NAME,
);
if (!match) throw new Error(`project "${PROJECT_NAME}" not found`);
return { id: match.id, targetLanguageIds: match.targetLanguageIds || [] };
}
function isSourceFile(file) {
return file.path === `/${SOURCE_FILE}`;
}
async function resolveFileIds(projectId) {
const files = await paged(`/projects/${projectId}/files`);
const ids = files.filter(isSourceFile).map((f) => f.id);
if (ids.length === 0)
throw new Error(`no "${SOURCE_FILE}" source files found in project`);
return ids;
}
async function pollPreTranslation(projectId, preTranslationId) {
for (;;) {
const { data } = await request(
"GET",
`/projects/${projectId}/pre-translations/${preTranslationId}`,
);
if (data.status === "finished") return data;
if (data.status === "failed" || data.status === "canceled") {
throw new Error(
`pre-translation ${data.status} (progress ${data.progress}%)`,
);
}
await new Promise((r) => setTimeout(r, 5000));
}
}
async function main() {
const { id: projectId, targetLanguageIds } = await resolveProjectId();
if (targetLanguageIds.length === 0) {
throw new Error("project has no target languages configured");
}
const fileIds = await resolveFileIds(projectId);
console.log(
`Pre-translating project ${projectId}, ${fileIds.length} files, ${targetLanguageIds.length} languages`,
);
// Translation memory first so strings that moved between files keep their
// existing translations, then machine translation for whatever is left.
const passes = [
{ method: "tm", translateUntranslatedOnly: true },
{ method: "mt", engineId: ENGINE_ID, scope: "untranslated" },
];
for (const pass of passes) {
const { data } = await request(
"POST",
`/projects/${projectId}/pre-translations`,
{ languageIds: targetLanguageIds, fileIds, ...pass },
);
const result = await pollPreTranslation(projectId, data.identifier);
console.log(
`${pass.method} pre-translation finished (${result.progress}%)`,
);
}
}
module.exports = {
resolveProjectId,
resolveFileIds,
isSourceFile,
pollPreTranslation,
};
if (require.main === module) {
main().catch((err) => {
console.error(`crowdin-pretranslate: ${err.message}`);
process.exit(1);
});
}
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { isSourceFile } = require("./crowdin-pretranslate.cjs") as {
isSourceFile: (file: { path?: string }) => boolean;
};
describe("isSourceFile", () => {
it("matches the core source file", () => {
expect(isSourceFile({ path: "/en.json" })).toBe(true);
});
it("skips anything else", () => {
expect(isSourceFile({ path: "/src/ui/locales/en.json" })).toBe(false);
expect(isSourceFile({ path: "/plugins/acme-ssl/locales/en.json" })).toBe(
false,
);
expect(isSourceFile({})).toBe(false);
});
});
+73 -99
View File
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -10,124 +10,98 @@ const iconsDir = join(publicDir, "icons");
mkdirSync(iconsDir, { recursive: true });
const svgBuffer = readFileSync(join(publicDir, "icon.svg"));
const svgBuffer = readFileSync(join(publicDir, "icon-square.svg"));
const render = (size) =>
sharp(svgBuffer, { density: 72 * Math.max(1, size / 1024) })
.resize(size, size)
.png({ compressionLevel: 9 })
.toBuffer();
const pngSizes = [16, 24, 32, 48, 64, 128, 256, 512, 1024];
const png = Object.fromEntries(
await Promise.all(pngSizes.map(async (s) => [s, await render(s)])),
);
console.log("Generating PNG icons...");
await Promise.all(
pngSizes.map((size) =>
sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(join(iconsDir, `${size}x${size}.png`))
.then(() => console.log(` ✓ icons/${size}x${size}.png`)),
for (const size of pngSizes) {
writeFileSync(join(iconsDir, `${size}x${size}.png`), png[size]);
}
writeFileSync(join(publicDir, "icon.png"), png[1024]);
writeFileSync(join(publicDir, "full-icon.png"), png[1024]);
writeFileSync(join(publicDir, "icon-mac.png"), png[512]);
writeFileSync(
join(publicDir, "favicon.ico"),
buildIco(
[16, 32, 48].map((s) => png[s]),
[16, 32, 48],
),
);
// icon.png (1024x1024) for Linux electron-builder
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "icon.png"));
console.log(" ✓ icon.png");
// icon-mac.png (512x512) for macOS
await sharp(svgBuffer)
.resize(512, 512)
.png()
.toFile(join(publicDir, "icon-mac.png"));
console.log(" ✓ icon-mac.png");
// full-icon.png (1024x1024) used in app UI
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(publicDir, "full-icon.png"));
console.log(" ✓ full-icon.png");
// favicon.ico: embed 16, 32, 48 px layers
console.log("Generating favicon.ico...");
const icoSizes = [16, 32, 48];
const icoBuffers = await Promise.all(
icoSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
const winSizes = [16, 24, 32, 48, 64, 128, 256];
writeFileSync(
join(publicDir, "icon.ico"),
buildIco(
winSizes.map((s) => png[s]),
winSizes,
),
);
writeFileSync(join(publicDir, "favicon.ico"), buildIco(icoBuffers, icoSizes));
console.log(" ✓ favicon.ico");
// icon.ico: embed 16, 32, 48, 64, 128, 256 px layers
console.log("Generating icon.ico...");
const winSizes = [16, 32, 48, 64, 128, 256];
const winBuffers = await Promise.all(
winSizes.map((size) => sharp(svgBuffer).resize(size, size).png().toBuffer()),
writeFileSync(
join(publicDir, "icon.icns"),
buildIcns([
["icp4", png[16]],
["icp5", png[32]],
["icp6", png[64]],
["ic07", png[128]],
["ic08", png[256]],
["ic09", png[512]],
["ic10", png[1024]],
["ic11", png[32]],
["ic12", png[64]],
["ic13", png[256]],
["ic14", png[512]],
]),
);
writeFileSync(join(publicDir, "icon.ico"), buildIco(winBuffers, winSizes));
console.log(" ✓ icon.ico");
// icons/icon.ico and icons/icon.icns placeholders (stubs pointing to source)
// electron-builder generates .icns; copy the 1024 PNG as icons/icon.png for reference
await sharp(svgBuffer)
.resize(1024, 1024)
.png()
.toFile(join(iconsDir, "icon.ico").replace("icon.ico", "1024x1024.png"));
// Copy icon.ico and icon.icns into icons/ as well
import { copyFileSync } from "fs";
copyFileSync(join(publicDir, "icon.ico"), join(iconsDir, "icon.ico"));
console.log(" ✓ icons/icon.ico");
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
console.log(
"\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.",
);
console.log("Icons generated from public/icon-square.svg");
/**
* Builds a minimal ICO file from PNG buffers.
* @param {Buffer[]} pngBuffers
* @param {number[]} sizes
* @returns {Buffer}
*/
function buildIco(pngBuffers, sizes) {
const count = pngBuffers.length;
const headerSize = 6;
const dirEntrySize = 16;
const dirSize = headerSize + count * dirEntrySize;
let offset = dirSize;
let offset = headerSize + pngBuffers.length * dirEntrySize;
const entries = pngBuffers.map((buf, i) => {
const size = sizes[i];
const entry = {
size,
width: size > 255 ? 0 : size,
height: size > 255 ? 0 : size,
buf,
offset,
};
const entry = { size: sizes[i], buf, offset };
offset += buf.length;
return entry;
});
const totalSize = offset;
const ico = Buffer.alloc(totalSize);
// ICO header
ico.writeUInt16LE(0, 0); // reserved
ico.writeUInt16LE(1, 2); // type: 1 = ICO
ico.writeUInt16LE(count, 4); // image count
// Directory entries
const ico = Buffer.alloc(offset);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(entries.length, 4);
entries.forEach((e, i) => {
const base = headerSize + i * dirEntrySize;
ico.writeUInt8(e.width, base); // width (0 = 256)
ico.writeUInt8(e.height, base + 1); // height (0 = 256)
ico.writeUInt8(0, base + 2); // color count
ico.writeUInt8(0, base + 3); // reserved
ico.writeUInt16LE(1, base + 4); // planes
ico.writeUInt16LE(32, base + 6); // bit count
ico.writeUInt32LE(e.buf.length, base + 8); // size of image data
ico.writeUInt32LE(e.offset, base + 12); // offset of image data
ico.writeUInt8(e.size > 255 ? 0 : e.size, base);
ico.writeUInt8(e.size > 255 ? 0 : e.size, base + 1);
ico.writeUInt16LE(1, base + 4);
ico.writeUInt16LE(32, base + 6);
ico.writeUInt32LE(e.buf.length, base + 8);
ico.writeUInt32LE(e.offset, base + 12);
e.buf.copy(ico, e.offset);
});
// Image data
entries.forEach((e) => e.buf.copy(ico, e.offset));
return ico;
}
function buildIcns(chunks) {
const parts = chunks.map(([type, data]) => {
const head = Buffer.alloc(8);
head.write(type, 0, "ascii");
head.writeUInt32BE(data.length + 8, 4);
return Buffer.concat([head, data]);
});
const body = Buffer.concat(parts);
const head = Buffer.alloc(8);
head.write("icns", 0, "ascii");
head.writeUInt32BE(body.length + 8, 4);
return Buffer.concat([head, body]);
}
+1 -1
View File
@@ -275,7 +275,7 @@ export async function findOrProvisionExternalUser(
provider: identity.provider,
});
throw new LoginMethodError(
"Registration is disabled",
"You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
403,
"registration_disabled",
);
+3 -2
View File
@@ -3,7 +3,8 @@
* one-time 2.8 moves that copied them into plugins went with them. A 2.8
* database has to pass through 2.9 first, or that data is lost.
*
* A 2.9 database has the plugins table; a 2.8 one has hosts but no plugins.
* 2.8 already had a plugins table, so the marker is plugin_migrations, which
* 2.9.0 added on every dialect. A 2.8 database has hosts but not that.
*/
export const PRE_29_DATABASE_ERROR =
"This database is from Termix 2.8 or older. Upgrade to Termix 2.9.1 and start it once so it can move your data, then upgrade to this version.";
@@ -11,7 +12,7 @@ export const PRE_29_DATABASE_ERROR =
export async function assertNotPre29Database(
hasTable: (name: string) => boolean | Promise<boolean>,
): Promise<void> {
if ((await hasTable("ssh_data")) && !(await hasTable("plugins"))) {
if ((await hasTable("ssh_data")) && !(await hasTable("plugin_migrations"))) {
throw new Error(PRE_29_DATABASE_ERROR);
}
}
@@ -152,6 +152,15 @@ export class CredentialAccessRepository {
return rows[0] ?? null;
}
/** Credentials holding a share this user handed out. */
async listCredentialIdsGrantedBy(userId: string): Promise<number[]> {
const rows = await this.context.drizzle
.selectDistinct({ credentialId: credentialAccess.credentialId })
.from(credentialAccess)
.where(eq(credentialAccess.grantedBy, userId));
return rows.map((row) => row.credentialId);
}
async revoke(accessId: number, credentialId: number): Promise<void> {
await this.context.drizzle
.delete(credentialAccess)
@@ -173,6 +173,15 @@ export class RbacAccessRepository {
return rowsAffected(result);
}
/** Hosts holding a share this user handed out. */
async listHostIdsGrantedBy(userId: string): Promise<number[]> {
const rows = await this.context.drizzle
.selectDistinct({ hostId: hostAccess.hostId })
.from(hostAccess)
.where(eq(hostAccess.grantedBy, userId));
return rows.map((row) => row.hostId);
}
/** Grants the departing user handed out now count as the successor's. */
async reassignHostAccessGrantedBy(
fromUserId: string,
@@ -23,6 +23,7 @@ import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { sshLogger } from "../../utils/logger.js";
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { pluginEvents, TOPICS } from "../../plugins/events.js";
import {
createCurrentCredentialRepository,
createCurrentHostRepository,
@@ -135,6 +136,16 @@ export function parseSSHConfig(content: string): SSHConfigHost[] {
return results;
}
/**
* Tells status checks and other pollers about hosts an import wrote, the way
* a single host create or update does.
*/
export function notifyImportedHosts(userId: string, hostIds: number[]): void {
for (const hostId of hostIds) {
pluginEvents.emit(TOPICS.hostUpdated, { hostId, userId });
}
}
export function importedHostUsername(
connectionType: string,
authType: unknown,
@@ -817,6 +828,7 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -1022,6 +1034,7 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -80,6 +80,14 @@ export function containsOwnerPrivateAuthUpdate(
);
}
/**
* An update that names no auth type leaves the stored password and key
* alone. Clearing them would leave a password host with no password.
*/
export function keepsStoredSshAuth(authType: unknown): boolean {
return authType === undefined || authType === null;
}
const FOLDER_PATH_SEPARATOR = " / ";
/**
+3
View File
@@ -31,6 +31,7 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
@@ -921,6 +922,8 @@ router.put(
sshDataObj.key = null;
sshDataObj.keyPassword = null;
sshDataObj.keyType = null;
} else if (keepsStoredSshAuth(effectiveAuthType)) {
// The update says nothing about authentication.
} else if (effectiveAuthType === "password") {
if (password) {
sshDataObj.password = password;
+43 -25
View File
@@ -82,37 +82,55 @@ router.get("/public", async (_req: Request, res: Response) => {
try {
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
const plugins = records.flatMap((record) => {
if (record.state !== "enabled") return [];
const loaded = loader.get(record.id);
let manifest: {
contributes?: { guest?: boolean; guestViews?: string[] };
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
type GuestManifest = {
contributes?: {
guest?: boolean;
guestViews?: string[];
tabs?: unknown[];
panels?: unknown[];
};
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const guests = records.flatMap((record) => {
if (record.state !== "enabled") return [];
try {
manifest = JSON.parse(record.manifestJson);
const manifest = JSON.parse(record.manifestJson) as GuestManifest;
return manifest?.contributes?.guest === true
? [{ record, manifest }]
: [];
} catch {
return [];
}
if (manifest?.contributes?.guest !== true) return [];
return [
{
id: record.id,
name: record.name,
// Not before login: it tells a scanner which known bugs apply.
version: "",
enabled: true,
state: loaded?.state ?? record.state,
contributes: {
guest: true,
guestViews: manifest.contributes.guestViews ?? [],
},
dependencies: manifest.dependencies ?? {},
optionalDependencies: manifest.optionalDependencies ?? {},
...describePluginFrontend(loaded),
});
// Only guest plugins load on a guest page, so a dependency that is not
// one would block the plugin there for nothing.
const guestIds = new Set(guests.map(({ record }) => record.id));
const onlyGuests = (deps: Record<string, string> = {}) =>
Object.fromEntries(
Object.entries(deps).filter(([id]) => guestIds.has(id)),
);
const plugins = guests.map(({ record, manifest }) => {
const loaded = loader.get(record.id);
return {
id: record.id,
name: record.name,
// Not before login: it tells a scanner which known bugs apply.
version: "",
enabled: true,
state: loaded?.state ?? record.state,
// The tab and panel ids a guest page may register, which the
// browser checks against the manifest.
contributes: {
guest: true,
guestViews: manifest.contributes?.guestViews ?? [],
tabs: manifest.contributes?.tabs ?? [],
panels: manifest.contributes?.panels ?? [],
},
];
dependencies: onlyGuests(manifest.dependencies),
optionalDependencies: onlyGuests(manifest.optionalDependencies),
...describePluginFrontend(loaded),
};
});
res.json(plugins);
} catch (error) {
+75
View File
@@ -75,6 +75,74 @@ async function canManageHostSharing(
return { allowed: access.hasAccess, isOwner: access.isOwner };
}
/**
* Drops shares handed out by someone who can no longer manage sharing on the
* host, then repeats so shares they passed on further go too.
*/
export async function pruneHostGrants(hostId: number): Promise<number> {
const repository = createCurrentRbacAccessRepository();
const ownerId =
await createCurrentHostResolutionRepository().findHostOwnerId(hostId);
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listHostAccess(hostId)) {
if (!grant.grantedBy || grant.grantedBy === ownerId) continue;
if ((await canManageHostSharing(grant.grantedBy, hostId)).allowed) {
continue;
}
await repository.revokeHostAccess(grant.id, hostId);
removed++;
changed = true;
}
}
return removed;
}
/** pruneHostGrants for credential shares. */
export async function pruneCredentialGrants(
credentialId: number,
): Promise<number> {
const repository = createCurrentCredentialAccessRepository();
const owner =
await createCurrentCredentialRepository().findById(credentialId);
if (!owner) return 0;
let removed = 0;
for (let changed = true; changed;) {
changed = false;
for (const grant of await repository.listForCredential(credentialId)) {
if (!grant.grantedBy || grant.grantedBy === owner.userId) continue;
if (
(await canManageCredentialSharing(grant.grantedBy, credentialId))
.allowed
) {
continue;
}
await repository.revoke(grant.id, credentialId);
removed++;
changed = true;
}
}
return removed;
}
/** Prunes everything these users shared, after they lost a role. */
export async function pruneGrantsBy(userIds: string[]): Promise<void> {
for (const userId of userIds) {
permissionManager.invalidateUserPermissionCache(userId);
for (const hostId of await createCurrentRbacAccessRepository().listHostIdsGrantedBy(
userId,
)) {
await pruneHostGrants(hostId);
}
for (const credentialId of await createCurrentCredentialAccessRepository().listCredentialIdsGrantedBy(
userId,
)) {
await pruneCredentialGrants(credentialId);
}
}
}
export interface ShareTarget {
type: "user" | "role";
id: string | number;
@@ -300,6 +368,7 @@ router.post(
});
}
await pruneHostGrants(hostId);
databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success",
userId,
@@ -780,6 +849,7 @@ router.patch(
hostId,
update,
);
await pruneHostGrants(hostId);
databaseLogger.info("Host access grant updated", {
operation: "rbac_host_access_update",
@@ -863,6 +933,7 @@ router.delete(
accessId,
hostId,
);
await pruneHostGrants(hostId);
databaseLogger.info("Permission revoked", {
operation: "rbac_permission_revoke",
adminId: userId,
@@ -1383,6 +1454,7 @@ router.delete(
for (const userId of deletedUserIds) {
permissionManager.invalidateUserPermissionCache(userId);
}
await pruneGrantsBy(deletedUserIds);
res.json({
success: true,
@@ -1675,6 +1747,7 @@ router.delete(
}
permissionManager.invalidateUserPermissionCache(targetUserId);
await pruneGrantsBy([targetUserId]);
databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove",
adminId: req.userId!,
@@ -1888,6 +1961,7 @@ router.post(
success: true,
});
await pruneCredentialGrants(credentialId);
res.json({ success: true, permissionLevel, expiresAt });
} catch (error) {
databaseLogger.error("Failed to share credential", error, {
@@ -1971,6 +2045,7 @@ router.delete(
return res.status(404).json({ error: "Access grant not found" });
}
await repository.revoke(accessId, credentialId);
await pruneCredentialGrants(credentialId);
res.json({ success: true });
} catch (error) {
databaseLogger.error("Failed to revoke credential access", error, {
@@ -0,0 +1,64 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import type { AuthManager } from "../../utils/auth-manager.js";
import { authLogger } from "../../utils/logger.js";
import { isCookieOriginAllowed } from "../../utils/ws-auth.js";
type UserSocketTicketRoutesDeps = {
authenticateJWT: RequestHandler;
authManager: AuthManager;
};
export function registerUserSocketTicketRoutes(
router: Router,
{ authenticateJWT, authManager }: UserSocketTicketRoutesDeps,
): void {
/**
* @openapi
* /users/ws-ticket:
* post:
* summary: Get a WebSocket ticket
* description: Returns a token that is valid for one minute and only opens plugin WebSockets. The web app sends it as a subprotocol, so sockets work behind a proxy that rewrites the Host header. Only a page on this server can get one.
* tags:
* - Users
* responses:
* 200:
* description: The ticket.
* content:
* application/json:
* schema:
* type: object
* properties:
* ticket:
* type: string
* 401:
* description: Not authenticated, or not a login session.
* 403:
* description: The request came from a page on another site.
* 500:
* description: Failed to issue the ticket.
*/
router.post("/ws-ticket", authenticateJWT, async (req, res) => {
const authReq = req as AuthenticatedRequest;
const sessionId = authReq.sessionId;
if (!sessionId || authReq.apiKeyId) {
return res.status(401).json({ error: "A login session is required" });
}
// The cookie rides along from sibling subdomains too, so the ticket only
// goes to a page the browser says is this server.
if (req.cookies?.jwt && !isCookieOriginAllowed(req)) {
return res.status(403).json({ error: "Not allowed from this origin" });
}
try {
const ticket = await authManager.issueSocketTicket(
authReq.userId,
sessionId,
);
res.setHeader("Cache-Control", "no-store");
res.json({ ticket });
} catch (err) {
authLogger.error("Failed to issue a socket ticket", err);
res.status(500).json({ error: "Failed to issue a socket ticket" });
}
});
}
+2
View File
@@ -26,6 +26,7 @@ import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserSocketTicketRoutes } from "./user-socket-ticket-routes.js";
import { registerUserExternalAccountRoutes } from "./user-external-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
import { registerUserAdminRoutes } from "./user-admin-routes.js";
@@ -1530,6 +1531,7 @@ registerUserExternalAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
+28
View File
@@ -61,6 +61,34 @@ export class SSHHostKeyVerifier {
const keyType = this.getKeyType(hostkey);
const algorithm = "sha256";
// An unsaved host (Quick Connect) has nowhere to keep the key, so the
// user checks it for this session when there is a socket to ask on.
if (!hostId && ws) {
const accepted = await this.promptUserForNewKey(
ws,
ip,
port,
undefined,
fingerprint,
keyType,
algorithm,
);
sshLogger.info(
accepted
? "Quick connect host key accepted for this session"
: "Quick connect host key rejected",
{
operation: "host_key_quick_connect",
ip,
port,
fingerprint,
userId,
},
);
verify(accepted);
return;
}
if (!hostId) {
sshLogger.info(
"Host key verification skipped (no hostId - quick connect)",
@@ -495,6 +495,30 @@ function withDeclaredFields(
return out;
}
/**
* The credential a host inherits from its folder or the user's defaults, for
* a protocol login set to "credential" with none picked.
*/
async function inheritedCredentialId(
ownerId: string,
hostId: number,
): Promise<number | null> {
try {
const { resolveForEditor } = await import("../defaults/service.js");
const resolved = await resolveForEditor({ ownerId, hostId });
const auth = resolved["core.auth"]?.value as {
authType?: string;
credentialId?: unknown;
} | null;
return auth?.authType === "credential" &&
typeof auth.credentialId === "number"
? auth.credentialId
: null;
} catch {
return null;
}
}
/** The owner's own login: the stored one, or the credential it points at. */
export async function resolveOwnerProtocolLogin(
host: Record<string, unknown>,
@@ -512,8 +536,12 @@ export async function resolveOwnerProtocolLogin(
const authType = login?.authType || "direct";
let username = login?.username ?? "";
let password = login?.password ?? "";
if (authType === "credential" && login?.credentialId) {
const credential = await findUsableCredential(login.credentialId, ownerId);
const credentialId =
authType === "credential"
? (login?.credentialId ?? (await inheritedCredentialId(ownerId, hostId)))
: null;
if (credentialId) {
const credential = await findUsableCredential(credentialId, ownerId);
// Only the login itself comes from a stored credential, never the fields.
if (credential?.username) username = credential.username;
if (credential?.password) password = credential.password;
+58 -1
View File
@@ -251,6 +251,7 @@ export class PluginLoader {
async loadAll(): Promise<LoadedPlugin[]> {
this.plugins.clear();
this.bundledVersions.clear();
await removeStaleStaging();
const loaded: LoadedPlugin[] = [];
// Every bundled folder name, loaded or not: a bundled plugin that fails
// to load must not leave its id free for a user plugin to take.
@@ -361,6 +362,13 @@ export class PluginLoader {
);
}
const unsafe = await unsafeArchivePath(file);
if (unsafe) {
throw new Error(
`${path.basename(file)} has a file outside the plugin folder: ${unsafe}`,
);
}
const unpackedRoot = getUnpackedPluginsDir();
const staging = path.join(
unpackedRoot,
@@ -430,7 +438,15 @@ export class PluginLoader {
throw error;
}
} finally {
await fs.promises.rm(staging, { recursive: true, force: true });
// Never let a failed cleanup hide the error that got us here.
await fs.promises
.rm(staging, { recursive: true, force: true, maxRetries: 5 })
.catch((error) =>
pluginLogger.warn(`Could not remove ${staging}`, {
operation: "plugin_load",
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
@@ -884,3 +900,44 @@ export function isRealPathInside(root: string, target: string): boolean {
return false;
}
}
/**
* The first entry that would land outside the folder it is unpacked into,
* or null. tar skips these on its own, but its stream keeps writing after
* it rejects, so they are refused before anything is unpacked.
*/
export async function unsafeArchivePath(file: string): Promise<string | null> {
let unsafe: string | null = null;
await tar.t({
file,
onReadEntry: (entry) => {
const name = entry.path.replace(/\\/g, "/");
if (
!unsafe &&
(name.startsWith("/") ||
/^[a-zA-Z]:/.test(name) ||
name.split("/").includes(".."))
) {
unsafe = entry.path;
}
},
});
return unsafe;
}
/** Clears unpack folders an install left behind when the process died. */
export async function removeStaleStaging(): Promise<void> {
const root = getUnpackedPluginsDir();
let entries: fs.Dirent[];
try {
entries = await fs.promises.readdir(root, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith(".staging-")) continue;
await fs.promises
.rm(path.join(root, entry.name), { recursive: true, force: true })
.catch(() => {});
}
}
+29 -3
View File
@@ -551,6 +551,19 @@ function dependentChain(pluginId: string, enabled: Set<string>): string[] {
return result;
}
/** Marks a plugin that was turned off only because a dependency was. */
const STOPPED_WITH = "stopped with ";
async function stoppedWith(pluginId: string): Promise<string[]> {
const { createCurrentPluginRepository } = await repos();
const marker = STOPPED_WITH + pluginId;
return (await createCurrentPluginRepository().listAll())
.filter(
(record) => record.state === "disabled" && record.lastError === marker,
)
.map((record) => record.id);
}
export interface StateChangePlan {
enable: string[];
disable: string[];
@@ -614,13 +627,26 @@ export async function setPluginStateUnlocked(
return { ...plan, state: "blocked" };
}
}
return { ...plan, state: await startAndRecord(pluginId) };
const state = await startAndRecord(pluginId);
if (state === "active") {
// Bring back what turning this plugin off also turned off.
for (const id of await stoppedWith(pluginId)) {
const restore = await planStateChange(id, true);
if (restore.missing.length > 0) continue;
for (const dependency of restore.enable)
await startAndRecord(dependency);
await startAndRecord(id);
}
}
return { ...plan, state };
}
for (const id of [...plan.disable, pluginId]) {
await recordState(id, "disabled", null);
for (const id of plan.disable) {
await recordState(id, "disabled", STOPPED_WITH + pluginId);
await deactivatePlugin(id);
}
await recordState(pluginId, "disabled", null);
await deactivatePlugin(pluginId);
return { ...plan, state: "stopped" };
}
+14 -7
View File
@@ -29,6 +29,8 @@ export interface UserOwnedTable {
hostColumns: string[];
/** Columns that travel, as SQL names. The primary key and secrets do not. */
columns: string[];
/** The owner column is the primary key: one row per user. */
userIsKey: boolean;
definition: PluginTableDefinition;
}
@@ -50,14 +52,17 @@ export function listUserOwnedTables(): UserOwnedTable[] {
hostColumns: entries
.filter(([, column]) => column.type === "refHost")
.map(([property, column]) => columnName(property, column)),
// The owner column always travels, even as the key; import rewrites it.
columns: entries
.filter(
([, column]) =>
column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText",
column === user[1] ||
(column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText"),
)
.map(([property, column]) => columnName(property, column)),
userIsKey: !!user[1].primaryKey,
definition,
});
}
@@ -239,13 +244,15 @@ export async function importUserPluginRows(
summary.skipped++;
continue;
}
const matchOn = owned.userIsKey ? [owned.userColumn] : columns;
const existing = await selectRows(
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
columns.map((column, index) =>
values[index] === null
matchOn.map((column) => {
const value = values[columns.indexOf(column)];
return value === null
? sql`${sql.identifier(column)} IS NULL`
: sql`${sql.identifier(column)} = ${values[index]}`,
),
: sql`${sql.identifier(column)} = ${value}`;
}),
sql` AND `,
)} LIMIT 1`,
);
+3 -1
View File
@@ -113,7 +113,9 @@ async function verifyToken(request: IncomingMessage): Promise<string | null> {
if (!token) return null;
try {
const { AuthManager } = await import("../utils/auth-manager.js");
const payload = await AuthManager.getInstance().verifyJWTToken(token);
const payload = await AuthManager.getInstance().verifyJWTToken(token, {
allowSocketTicket: true,
});
if (!payload || payload.pendingTOTP) return null;
return payload.userId ?? null;
} catch {
@@ -37,6 +37,18 @@ describe("RDP domain across database boots", () => {
"utf8",
),
);
// 2.9 has opened it, so it has the table 2.9 added.
seed.exec(`
CREATE TABLE plugin_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
migration_id TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, migration_id),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
`);
seed.exec(`
INSERT INTO users (id, username, password_hash)
VALUES ('owner', 'alice', 'hash');
@@ -45,6 +45,15 @@ describe("dropping the columns 2.9.0 kept", () => {
auto_update INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL
);
CREATE TABLE plugin_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
migration_id TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, migration_id),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
@@ -34,7 +34,7 @@ describe("share_ssh_auth backfill", () => {
});
/**
* Hosts and shares exist, the column does not. The plugins table is there
* Hosts and shares exist, the column does not. The plugin tables are there
* because 26.10.0 only opens a database 2.9 has already upgraded.
*/
function writePreUpgradeDatabase(): void {
@@ -54,6 +54,15 @@ describe("share_ssh_auth backfill", () => {
auto_update INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL
);
CREATE TABLE plugin_migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_id TEXT NOT NULL,
migration_id TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (plugin_id, migration_id),
FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
@@ -7,15 +7,15 @@ import {
const tables = (...names: string[]) => (name: string) => names.includes(name);
describe("assertNotPre29Database", () => {
it("refuses a 2.8 database, which has hosts but no plugins table", async () => {
await expect(assertNotPre29Database(tables("ssh_data"))).rejects.toThrow(
PRE_29_DATABASE_ERROR,
);
it("refuses a 2.8 database, which has hosts and plugins but no plugin_migrations", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins")),
).rejects.toThrow(PRE_29_DATABASE_ERROR);
});
it("opens a database 2.9 has upgraded", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins")),
assertNotPre29Database(tables("ssh_data", "plugins", "plugin_migrations")),
).resolves.toBeUndefined();
});
@@ -23,3 +23,25 @@ describe("assertNotPre29Database", () => {
await expect(assertNotPre29Database(tables())).resolves.toBeUndefined();
});
});
describe("a real 2.8.0 schema", () => {
it("is refused", async () => {
const { default: Database } = await import("better-sqlite3");
const fs = await import("node:fs");
const db = new Database(":memory:");
db.exec(
fs.readFileSync(
new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url),
"utf8",
),
);
const has = (name: string) =>
!!db
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
await expect(assertNotPre29Database(has)).rejects.toThrow(
PRE_29_DATABASE_ERROR,
);
db.close();
});
});
@@ -1,8 +1,10 @@
import { describe, it, expect } from "vitest";
import {
importedHostUsername,
notifyImportedHosts,
parseSSHConfig,
} from "../../../database/routes/host-bulk-routes.js";
import { pluginEvents, TOPICS } from "../../../plugins/events.js";
describe("parseSSHConfig", () => {
it("parses a basic Host block", () => {
@@ -126,3 +128,21 @@ describe("importedHostUsername", () => {
);
});
});
describe("notifyImportedHosts", () => {
it("sends a host update for every imported host", () => {
const seen: unknown[] = [];
const off = pluginEvents.on(TOPICS.hostUpdated, (payload) => {
seen.push(payload);
});
try {
notifyImportedHosts("user-1", [4, 7]);
} finally {
off();
}
expect(seen).toEqual([
{ hostId: 4, userId: "user-1" },
{ hostId: 7, userId: "user-1" },
]);
});
});
@@ -4,6 +4,7 @@ import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeImportedHost,
@@ -505,3 +506,12 @@ it("preserves a shared host sync identity for connect-only recipients", () => {
);
expect(result).toEqual({ id: 9, syncId: "remote-host-41" });
});
describe("keepsStoredSshAuth", () => {
it("keeps stored secrets only when no auth type is sent", () => {
expect(keepsStoredSshAuth(undefined)).toBe(true);
expect(keepsStoredSshAuth(null)).toBe(true);
expect(keepsStoredSshAuth("password")).toBe(false);
expect(keepsStoredSshAuth("none")).toBe(false);
});
});
@@ -276,12 +276,52 @@ describe("plugins route", () => {
expect(body[0].contributes).toEqual({
guest: true,
guestViews: ["shared"],
tabs: [],
panels: [],
});
expect(body[0]).not.toHaveProperty("capabilities");
expect(body[0]).not.toHaveProperty("lastError");
expect(body[0].version).toBe("");
});
it("leaves out dependencies that do not load on guest pages", async () => {
state.plugins.set(
"sharing",
makePlugin({
id: "sharing",
manifestJson: JSON.stringify({
contributes: {
guest: true,
guestViews: ["shared"],
tabs: [{ id: "collab", titleKey: "nav.collab" }],
},
dependencies: { terminal: "^1.0.0", desktop: "^1.0.0" },
optionalDependencies: { recorder: "^1.0.0" },
}),
}),
);
state.plugins.set(
"desktop",
makePlugin({
id: "desktop",
manifestJson: JSON.stringify({ contributes: { guest: true } }),
}),
);
state.plugins.set("terminal", makePlugin({ id: "terminal" }));
const res = await fetch(`${baseUrl}/plugins/public`);
const body = await res.json();
const sharing = body.find(
(plugin: { id: string }) => plugin.id === "sharing",
);
expect(sharing.dependencies).toEqual({ desktop: "^1.0.0" });
expect(sharing.optionalDependencies).toEqual({});
expect(sharing.contributes.tabs).toEqual([
{ id: "collab", titleKey: "nav.collab" },
]);
});
it("skips a plugin whose manifest does not parse", async () => {
state.plugins.set(
"broken",
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Grant {
id: number;
userId: string;
grantedBy: string;
permissionLevel: string;
}
const state = vi.hoisted(() => ({
hostGrants: [] as Grant[],
credentialGrants: [] as Grant[],
}));
const canManage = (grants: Grant[], userId: string) =>
userId === "owner" ||
grants.some((g) => g.userId === userId && g.permissionLevel === "manage");
vi.mock("../../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
createAuthMiddleware: () => vi.fn(),
createDataAccessMiddleware: () => vi.fn(),
}),
},
}));
vi.mock("../../../utils/permission-manager.js", () => ({
SHARE_PERMISSION_LEVELS: ["connect", "view", "edit", "manage"],
PermissionManager: {
getInstance: () => ({
canAccessHost: async (userId: string) => ({
hasAccess: canManage(state.hostGrants, userId),
isOwner: userId === "owner",
}),
requirePermission: () => vi.fn(),
requireAdmin: () => vi.fn(),
invalidateUserPermissionCache: vi.fn(),
}),
},
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({
findHostOwnerId: async () => "owner",
}),
createCurrentRbacAccessRepository: () => ({
listHostAccess: async () => [...state.hostGrants],
revokeHostAccess: async (id: number) => {
state.hostGrants = state.hostGrants.filter((g) => g.id !== id);
},
listHostIdsGrantedBy: async (userId: string) =>
state.hostGrants.some((g) => g.grantedBy === userId) ? [1] : [],
}),
createCurrentCredentialRepository: () => ({
findById: async () => ({ id: 5, userId: "owner" }),
}),
createCurrentRoleRepository: () => ({ listUserRoleIds: async () => [] }),
createCurrentCredentialAccessRepository: () => ({
listForCredential: async () => [...state.credentialGrants],
findActiveGrant: async (_id: number, userId: string) =>
state.credentialGrants.find((g) => g.userId === userId) ?? null,
revoke: async (id: number) => {
state.credentialGrants = state.credentialGrants.filter(
(g) => g.id !== id,
);
},
listCredentialIdsGrantedBy: async () => [],
}),
}));
const { pruneHostGrants, pruneCredentialGrants } =
await import("../../../database/routes/rbac.js");
const chain = (): Grant[] => [
{ id: 2, userId: "bob", grantedBy: "alice", permissionLevel: "manage" },
{ id: 3, userId: "carol", grantedBy: "bob", permissionLevel: "view" },
{ id: 4, userId: "dave", grantedBy: "owner", permissionLevel: "view" },
];
describe("share pruning", () => {
beforeEach(() => {
state.hostGrants = chain();
state.credentialGrants = chain();
});
it("drops host shares passed on by someone who lost manage", async () => {
expect(await pruneHostGrants(1)).toBe(2);
expect(state.hostGrants.map((g) => g.userId)).toEqual(["dave"]);
});
it("keeps shares while the granter can still manage", async () => {
state.hostGrants.push({
id: 1,
userId: "alice",
grantedBy: "owner",
permissionLevel: "manage",
});
expect(await pruneHostGrants(1)).toBe(0);
expect(state.hostGrants).toHaveLength(4);
});
it("drops credential shares the same way", async () => {
expect(await pruneCredentialGrants(5)).toBe(2);
expect(state.credentialGrants.map((g) => g.userId)).toEqual(["dave"]);
});
});
@@ -0,0 +1,100 @@
/**
* The web app's socket ticket. The cookie rides along from a sibling
* subdomain too, so a ticket only goes to a page on this server.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import express, { type RequestHandler } from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import type { AuthManager } from "../../../utils/auth-manager.js";
const state = vi.hoisted(() => ({
sessionId: "s1" as string | undefined,
apiKeyId: undefined as string | undefined,
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const { registerUserSocketTicketRoutes } =
await import("../../../database/routes/user-socket-ticket-routes.js");
const issueSocketTicket = vi.fn(
async (userId: string, sessionId: string) => `ticket:${userId}:${sessionId}`,
);
const authenticate: RequestHandler = (req, _res, next) => {
Object.assign(req, {
userId: "u1",
sessionId: state.sessionId,
apiKeyId: state.apiKeyId,
});
next();
};
function app() {
const router = express.Router();
registerUserSocketTicketRoutes(router, {
authenticateJWT: authenticate,
authManager: { issueSocketTicket } as unknown as AuthManager,
});
const server = express();
server.use(cookieParser());
server.use("/users", router);
return server;
}
beforeEach(() => {
state.sessionId = "s1";
state.apiKeyId = undefined;
issueSocketTicket.mockClear();
});
describe("POST /users/ws-ticket", () => {
it("gives this server's own page a ticket for its session", async () => {
const res = await request(app())
.post("/users/ws-ticket")
.set("Host", "termix.example.com")
.set("Origin", "https://termix.example.com")
.set("Cookie", "jwt=session-token");
expect(res.status).toBe(200);
expect(res.body.ticket).toBe("ticket:u1:s1");
expect(res.headers["cache-control"]).toBe("no-store");
});
it("trusts the browser's same-origin mark behind a proxy that rewrites Host", async () => {
const res = await request(app())
.post("/users/ws-ticket")
.set("Host", "10.0.0.5:8080")
.set("Origin", "https://termix.example.com")
.set("Sec-Fetch-Site", "same-origin")
.set("Cookie", "jwt=session-token");
expect(res.status).toBe(200);
});
it("refuses a page on a sibling subdomain", async () => {
const res = await request(app())
.post("/users/ws-ticket")
.set("Host", "termix.example.com")
.set("Origin", "https://evil.example.com")
.set("Sec-Fetch-Site", "same-site")
.set("Cookie", "jwt=session-token");
expect(res.status).toBe(403);
expect(issueSocketTicket).not.toHaveBeenCalled();
});
it("refuses an API key, which has no session to bind to", async () => {
state.sessionId = undefined;
state.apiKeyId = "key-1";
const res = await request(app()).post("/users/ws-ticket");
expect(res.status).toBe(401);
expect(issueSocketTicket).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,59 @@
import { EventEmitter } from "node:events";
import type { WebSocket } from "ws";
import { describe, expect, it, vi } from "vitest";
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentHostResolutionRepository: () => ({}),
}));
vi.mock("../../utils/logger.js", () => ({
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
function socketAnswering(action: "accept" | "reject") {
const sent: string[] = [];
const socket = new EventEmitter() as EventEmitter & {
send: (data: string) => void;
};
socket.send = (data) => {
sent.push(data);
socket.emit(
"message",
Buffer.from(
JSON.stringify({
type: "host_key_verification_response",
data: { action },
}),
),
);
};
return { socket: socket as unknown as WebSocket, sent };
}
async function verifyUnsaved(socket: WebSocket | null) {
const verifier = await SSHHostKeyVerifier.createHostVerifier(
null,
"10.0.0.5",
22,
socket,
"user",
);
return new Promise<boolean>((resolve) =>
verifier(Buffer.from("quick-key"), resolve),
);
}
describe("quick connect host keys", () => {
it("asks the user and uses their answer", async () => {
const yes = socketAnswering("accept");
expect(await verifyUnsaved(yes.socket)).toBe(true);
expect(yes.sent[0]).toContain("host_key_verification_required");
const no = socketAnswering("reject");
expect(await verifyUnsaved(no.socket)).toBe(false);
});
it("still connects when there is no socket to ask on", async () => {
expect(await verifyUnsaved(null)).toBe(true);
});
});
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
login: null as Record<string, unknown> | null,
inherited: null as Record<string, unknown> | null,
}));
vi.mock("../../../database/db/index.js", () => ({
getDb: () => null,
getSqlite: () => null,
}));
vi.mock("../../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: () => Buffer.alloc(32) },
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostProtocolAuthRepository: () => ({
find: async () => state.login,
}),
}));
vi.mock("../../../hosts/usable-credential.js", () => ({
findUsableCredential: async (id: number) =>
id === 9 ? { id, username: "folder-user", password: "folder-pass" } : null,
}));
vi.mock("../../../hosts/defaults/service.js", () => ({
resolveForEditor: async () =>
state.inherited ? { "core.auth": { value: state.inherited } } : {},
}));
const { resolveOwnerProtocolLogin } =
await import("../../../hosts/protocol-auth/protocol-auth.js");
const RDP = { id: "rdp", pluginId: "remote-desktop", pluginName: "RD" };
const host = { id: 4, userId: "owner" };
describe("owner protocol login", () => {
it("uses the folder credential when none is picked", async () => {
state.login = { authType: "credential", credentialId: null };
state.inherited = { authType: "credential", credentialId: 9 };
const login = await resolveOwnerProtocolLogin(host, RDP as never);
expect(login).toMatchObject({
authType: "credential",
username: "folder-user",
password: "folder-pass",
});
});
it("prefers the credential picked on the host", async () => {
state.login = { authType: "credential", credentialId: 3 };
state.inherited = { authType: "credential", credentialId: 9 };
const login = await resolveOwnerProtocolLogin(host, RDP as never);
expect(login.username).toBe("");
});
it("leaves a direct login alone", async () => {
state.login = { authType: "direct", username: "me", password: "pw" };
state.inherited = { authType: "credential", credentialId: 9 };
const login = await resolveOwnerProtocolLogin(host, RDP as never);
expect(login).toMatchObject({ username: "me", password: "pw" });
});
});
+69 -1
View File
@@ -11,7 +11,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PluginLoader, assertBundledUpdate } from "../../plugins/loader.js";
import zlib from "node:zlib";
import {
PluginLoader,
assertBundledUpdate,
removeStaleStaging,
unsafeArchivePath,
} from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../database/repositories/factory.js", () => ({
@@ -549,3 +555,65 @@ describe("PluginLoader uninstall and pins", () => {
).toThrow(/signed/);
});
});
/** A gzipped tar with the given entry names, written by hand. */
function rawTar(names: string[]): Buffer {
const blocks: Buffer[] = [];
for (const name of names) {
const header = Buffer.alloc(512);
header.write(name, 0, 100);
header.write("0000644\0", 100);
header.write("00000000001\0", 124);
header.write(" ", 148);
header.write("0", 156);
header.write("ustar\0", 257);
header.write("00", 263);
let sum = 0;
for (const byte of header) sum += byte;
header.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
blocks.push(header, Buffer.concat([Buffer.from("x"), Buffer.alloc(511)]));
}
blocks.push(Buffer.alloc(1024));
return zlib.gzipSync(Buffer.concat(blocks));
}
describe("unsafeArchivePath", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-unsafe-"));
const write = (names: string[]) => {
const file = path.join(dir, `${names.length}-${Math.random()}.tmxplug`);
fs.writeFileSync(file, rawTar(names));
return file;
};
it("finds entries that escape the folder", async () => {
expect(
await unsafeArchivePath(write(["manifest.json", "../../etc/x"])),
).toBe("../../etc/x");
expect(await unsafeArchivePath(write(["/tmp/x"]))).not.toBeNull();
});
it("passes a normal archive", async () => {
expect(
await unsafeArchivePath(write(["manifest.json", "dist/backend.js"])),
).toBeNull();
});
});
describe("removeStaleStaging", () => {
it("removes unpack folders left by a crashed install", async () => {
const data = fs.mkdtempSync(path.join(os.tmpdir(), "termix-staging-"));
const previous = process.env.DATA_DIR;
process.env.DATA_DIR = data;
try {
const unpacked = path.join(data, "plugins", ".unpacked");
fs.mkdirSync(path.join(unpacked, ".staging-abc", "dist"), {
recursive: true,
});
fs.mkdirSync(path.join(unpacked, "serial"), { recursive: true });
await removeStaleStaging();
expect(fs.readdirSync(unpacked)).toEqual(["serial"]);
} finally {
process.env.DATA_DIR = previous;
}
});
});
+28
View File
@@ -540,6 +540,34 @@ describe("dependencies", () => {
expect(loader.get("base")?.state).toBe("active");
});
it("restarts dependents it stopped when turned back on", async () => {
createFixturePlugin({ id: "base", root: bundled });
createFixturePlugin({
id: "addon",
root: bundled,
manifestOverrides: { dependencies: { base: "^1.0.0" } },
});
createFixturePlugin({
id: "manual",
root: bundled,
manifestOverrides: { dependencies: { base: "^1.0.0" } },
});
const loader = await boot();
await manage.setPluginState("manual", false);
await manage.setPluginState("base", false);
expect(loader.get("addon")?.state).toBe("stopped");
await manage.setPluginState("base", true);
expect(loader.get("addon")?.state).toBe("active");
expect(db.rows.get("addon")).toMatchObject({
state: "enabled",
lastError: null,
});
expect(loader.get("manual")?.state).not.toBe("active");
expect(db.rows.get("manual")?.state).toBe("disabled");
});
it("refuses to uninstall a plugin a running one depends on", async () => {
createFixturePlugin({ id: "base", root: bundled });
createFixturePlugin({
@@ -117,6 +117,44 @@ describe("plugin user data", () => {
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("exports and imports a table keyed by its user column", async () => {
const keyed = defineTable("keyed", {
userId: refUser().primaryKey(),
secret: encryptedText(),
label: text(),
});
for (const statement of createTableSql("sqlite", "fixture", keyed)) {
sqlite.exec(statement);
}
registerTable("fixture", keyed);
sqlite.exec(
"INSERT INTO p_fixture_keyed (user_id, secret, label) VALUES ('u1', 'sealed', 'mine')",
);
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
await writeUserPluginTables(file, "u1");
expect(file.prepare("SELECT * FROM p_fixture_keyed").all()).toEqual([
{ user_id: "u1", secret: null, label: "mine" },
]);
const result = await importUserPluginRows(file, "u2");
expect(result.errors).toEqual([]);
expect(
sqlite
.prepare(
"SELECT user_id, label FROM p_fixture_keyed WHERE user_id = 'u2'",
)
.all(),
).toEqual([{ user_id: "u2", label: "mine" }]);
// The user already has their one row, so a second import skips it.
expect(await importUserPluginRows(file, "u2")).toMatchObject({
errors: [],
});
});
it("writes large tables in chunks and lets the event loop run between them", async () => {
const insert = sqlite.prepare(
"INSERT INTO p_fixture_bookmark (user_id, path) VALUES ('u1', ?)",
+28 -3
View File
@@ -14,6 +14,7 @@ import { WebSocket } from "ws";
const state = vi.hoisted(() => ({
validToken: "valid-token",
ticket: "socket-ticket",
userId: "user-1",
granted: new Set<string>(["network:serve"]),
}));
@@ -31,8 +32,14 @@ vi.mock("../../utils/logger.js", () => ({
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
verifyJWTToken: async (token: string) =>
token === state.validToken ? { userId: state.userId } : null,
verifyJWTToken: async (
token: string,
options: { allowSocketTicket?: boolean } = {},
) =>
token === state.validToken ||
(token === state.ticket && options.allowSocketTicket)
? { userId: state.userId }
: null,
}),
},
}));
@@ -94,9 +101,10 @@ function connect(
function connectWithHeaders(
url: string,
headers: Record<string, string>,
protocols: string[] = [],
): Promise<{ outcome: "open"; socket: WebSocket } | { outcome: number }> {
return new Promise((resolve) => {
const socket = new WebSocket(url, { headers });
const socket = new WebSocket(url, protocols, { headers });
socket.once("open", () => resolve({ outcome: "open", socket }));
socket.once("unexpected-response", (_req, res) =>
resolve({ outcome: res.statusCode ?? 0 }),
@@ -228,6 +236,23 @@ describe("ctx.ws authentication", () => {
expect(cookies).toEqual([undefined]);
});
it("accepts a socket ticket when the proxy hid the page's origin", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
const base = await startServer();
const result = await connectWithHeaders(
`${base}/plugin-ws/sample-plugin/socket`,
{
Cookie: `jwt=${state.validToken}`,
Origin: "https://termix.example.com",
},
authProtocols(state.ticket),
);
expect(result.outcome).toBe("open");
if (result.outcome === "open") result.socket.close();
});
it("rejects an upgrade with no token", async () => {
ws.registerPluginWsRoute("sample-plugin", "/socket", () => {}, DECLARED);
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
vi.mock("../../database/db/index.js", () => ({}));
import { registerCoreSyncEntities } from "../../sync/entities.js";
import {
listEntityTypes,
resetSyncRegistry,
} from "../../plugins/sync-registry.js";
afterEach(() => resetSyncRegistry());
describe("core sync entities", () => {
it("each has a label in the Sync panel", () => {
registerCoreSyncEntities();
const en = JSON.parse(
fs.readFileSync(
new URL("../../../ui/locales/en.json", import.meta.url),
"utf8",
),
);
const missing = listEntityTypes().filter((type) => !en.sync.entities[type]);
expect(missing).toEqual([]);
});
});
@@ -170,4 +170,42 @@ describe("AuthManager token handling", () => {
const payload = await authManager.verifyJWTToken(token);
expect(payload?.pendingTOTP).toBe(true);
});
it("issues a one minute socket ticket bound to the session", async () => {
const ticket = await authManager.issueSocketTicket("user-1", "s1");
const payload = jwt.decode(ticket) as Record<string, number | string>;
expect(payload.userId).toBe("user-1");
expect(payload.sessionId).toBe("s1");
expect(payload.purpose).toBe("ws");
expect(Number(payload.exp) - Number(payload.iat)).toBe(60);
});
it("only accepts a socket ticket where a socket asks for it", async () => {
const ticket = await authManager.issueSocketTicket("user-1", "s1");
expect(await authManager.verifyJWTToken(ticket)).toBeNull();
const payload = await authManager.verifyJWTToken(ticket, {
allowSocketTicket: true,
});
expect(payload?.userId).toBe("user-1");
});
it("rejects a socket ticket whose session has expired", async () => {
const ticket = await authManager.issueSocketTicket("user-1", "expired");
expect(
await authManager.verifyJWTToken(ticket, { allowSocketTicket: true }),
).toBeNull();
});
it("rejects a token with an unknown purpose", async () => {
const token = jwt.sign(
{ userId: "user-1", sessionId: "s1", purpose: "other" },
jwtSecret,
{ expiresIn: "1h" },
);
expect(
await authManager.verifyJWTToken(token, { allowSocketTicket: true }),
).toBeNull();
});
});
+25
View File
@@ -83,4 +83,29 @@ describe("the jwt cookie on a socket", () => {
),
).toBe(true);
});
it("trusts the browser's same-origin mark behind a proxy that rewrites Host", () => {
expect(
extractWebSocketToken(
request({
cookie: "jwt=cookie-token",
host: "10.0.0.5:8080",
origin: "https://termix.example.com",
"sec-fetch-site": "same-origin",
}),
),
).toBe("cookie-token");
});
it("still ignores the cookie from a sibling subdomain", () => {
expect(
isCookieOriginAllowed(
request({
host: "termix.example.com",
origin: "https://evil.example.com",
"sec-fetch-site": "same-site",
}),
),
).toBe(false);
});
});
+24 -1
View File
@@ -42,6 +42,8 @@ interface JWTPayload {
userId: string;
sessionId?: string;
pendingTOTP?: boolean;
/** Set on a short-lived socket ticket, which only a socket upgrade takes. */
purpose?: "ws";
dataKeyWrap?: WrappedDataKey;
iat?: number;
exp?: number;
@@ -393,11 +395,32 @@ class AuthManager {
}
}
async verifyJWTToken(token: string): Promise<JWTPayload | null> {
/**
* A one minute token for opening a WebSocket. A browser cannot read its
* HttpOnly cookie, and a proxy that rewrites Host makes the cookie look
* like it came from another site, so the page asks for one of these.
*/
async issueSocketTicket(userId: string, sessionId: string): Promise<string> {
const payload: JWTPayload = { userId, sessionId, purpose: "ws" };
return jwt.sign(payload, await this.systemCrypto.getJWTSecret(), {
expiresIn: 60,
} as jwt.SignOptions);
}
async verifyJWTToken(
token: string,
options: { allowSocketTicket?: boolean } = {},
): Promise<JWTPayload | null> {
try {
const jwtSecret = await this.systemCrypto.getJWTSecret();
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
if (
payload.purpose !== undefined &&
!(payload.purpose === "ws" && options.allowSocketTicket)
) {
return null;
}
if (payload.sessionId) {
try {
+4
View File
@@ -7,10 +7,14 @@ const JWT_PROTOCOL_PREFIX = "termix.jwt.";
* same site, including a sibling subdomain. The cookie only counts when the
* page that opened the socket is this server. A non-web origin (the desktop
* app) or no Origin at all is not a browser page on another host.
*
* Sec-Fetch-Site is set by the browser itself, so it holds even behind a
* proxy that rewrites Host.
*/
export function isCookieOriginAllowed(req: IncomingMessage): boolean {
const origin = req.headers.origin;
if (!origin) return true;
if (req.headers["sec-fetch-site"] === "same-origin") return true;
let parsed: URL;
try {
parsed = new URL(origin);
+14 -11
View File
@@ -20,6 +20,7 @@ import { createPortal } from "react-dom";
import { useIsMobile } from "@/hooks/use-mobile";
import { resetPermissionsCache } from "@/hooks/use-permissions";
import { MobileBar } from "@/shell/MobileBar";
import { isElectron } from "@/lib/electron";
import { AppRail, type RailView } from "@/sidebar/AppRail";
import { ComponentSlot } from "@/shell/ActionSlot";
import {
@@ -3101,17 +3102,19 @@ export function AppShell({
</div>
</SurfaceScope>
{/* Bottom nav bar, mobile only */}
<MobileBar
railView={railView}
sidebarOpen={sidebarOpen}
username={username}
onRailClick={handleRailClick}
onOpenTab={(type) => openSingletonTab(type)}
onOpenSettings={() => openSingletonTab("settings")}
onOpenPalette={() => setCommandPaletteOpen(true)}
onLogout={() => onLogout({ manual: true })}
/>
{/* Bottom nav bar, mobile only. The desktop app keeps the rail. */}
{!isElectron() && (
<MobileBar
railView={railView}
sidebarOpen={sidebarOpen}
username={username}
onRailClick={handleRailClick}
onOpenTab={(type) => openSingletonTab(type)}
onOpenSettings={() => openSingletonTab("settings")}
onOpenPalette={() => setCommandPaletteOpen(true)}
onLogout={() => onLogout({ manual: true })}
/>
)}
</div>
{/* Right dock, desktop only, holds a second reference panel */}
+4 -7
View File
@@ -1,5 +1,6 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time";
export interface LineChartSeries {
key: string;
@@ -23,12 +24,8 @@ const X_LABEL_H = 20;
const TOP_PAD = 6;
const TICK_COUNT = 5;
function parseTs(ts: string): Date {
return new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z");
}
function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseTs(ts);
const d = parseServerTime(ts);
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@@ -104,8 +101,8 @@ export function LineChart({
const rangeMs =
timestamps.length >= 2
? parseTs(timestamps[timestamps.length - 1]).getTime() -
parseTs(timestamps[0]).getTime()
? parseServerTime(timestamps[timestamps.length - 1]).getTime() -
parseServerTime(timestamps[0]).getTime()
: 0;
const yTicks = (() => {
+12 -2
View File
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, useRef } from "react";
import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -395,6 +395,16 @@ function ToggleButton({
* A run of peer facts with a rule between each pair, like "12 online | 40
* hosts". Never wraps; a fact that does not fit is truncated.
*/
/** Children with fragments opened up, so each fact gets its own divider. */
function flattenFacts(children: React.ReactNode): React.ReactNode[] {
return Children.toArray(children).flatMap((child) =>
isValidElement<{ children?: React.ReactNode }>(child) &&
child.type === Fragment
? flattenFacts(child.props.children)
: [child],
);
}
export function Facts({
className,
children,
@@ -402,7 +412,7 @@ export function Facts({
className?: string;
children: React.ReactNode;
}) {
const items = Children.toArray(children).filter(Boolean);
const items = flattenFacts(children);
if (items.length === 0) return null;
return (
+2 -4
View File
@@ -11,16 +11,14 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-[background-color,border-color,box-shadow,opacity] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent-brand data-[state=checked]:border-accent-brand data-[state=unchecked]:bg-muted data-[state=unchecked]:border-border",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
className="pointer-events-none block h-3 w-3 bg-background shadow-sm transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchPrimitive.Root>
);
+7 -1
View File
@@ -181,6 +181,7 @@ function Section({
onDrop,
onRemove,
onHeightChange,
autoHeight,
children,
}: {
slot: CardSlot;
@@ -192,6 +193,8 @@ function Section({
onDrop: () => void;
onRemove: () => void;
onHeightChange: (key: string, h: number) => void;
/** Phones stack cards, so each takes the height of its content. */
autoHeight?: boolean;
children: React.ReactNode;
}) {
const { t } = useTranslation();
@@ -227,7 +230,9 @@ function Section({
e.stopPropagation();
onDrop();
}}
style={{ height: flex ? undefined : (slot.height ?? undefined) }}
style={{
height: flex || autoHeight ? undefined : (slot.height ?? undefined),
}}
className={`relative flex shrink-0 flex-col border-b border-border last:border-b-0 ${
flex ? "min-h-48 flex-1" : ""
} ${dragging ? "opacity-40" : ""} ${editMode ? "cursor-grab select-none" : ""}`}
@@ -783,6 +788,7 @@ export function DashboardTab({
setSlots((prev) => prev.filter((s) => s.key !== slot.key))
}
onHeightChange={handleHeightChange}
autoHeight={isMobile}
>
{renderCard(slot.id)}
</Section>
+17 -8
View File
@@ -1,3 +1,4 @@
import { useIsMobile } from "@/hooks/use-mobile";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
@@ -25,6 +26,7 @@ import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
import type { RecentActivityItem } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import { quarterBorders } from "./quarter-borders";
import { parseServerTime } from "@/lib/server-time";
export type VersionStatus =
"up_to_date" | "requires_update" | "beta" | "unknown";
@@ -296,6 +298,8 @@ export function HostStatusList({
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
// A phone has no room for the metric bars next to the host name.
const isMobile = useIsMobile();
if (hosts.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
@@ -342,13 +346,16 @@ export function HostStatusList({
</Facts>
</span>
{/* Plugins add live details to a host row here. */}
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
online: availability === "online",
}}
/>
{!isMobile && (
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
host,
online: availability === "online",
}}
/>
)}
<span
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
>
@@ -380,7 +387,9 @@ export function RecentActivityList({
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const ago = (ts: string) => {
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000);
const diff = Math.floor(
(Date.now() - parseServerTime(ts).getTime()) / 1000,
);
if (diff < 60) return t("dashboard.justNow");
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
+6
View File
@@ -1,13 +1,19 @@
import * as React from "react";
import { isElectron } from "@/lib/electron";
const MOBILE_BREAKPOINT = 768;
/** Phone layout below the breakpoint. The desktop app always stays desktop. */
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
if (isElectron()) {
setIsMobile(false);
return;
}
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+16
View File
@@ -0,0 +1,16 @@
import { sha256 } from "@noble/hashes/sha2.js";
/**
* The backend sends the raw host key as hex. This turns it into the
* SHA256:... form ssh-keygen -lf prints, so users can compare the two.
*/
export function hostKeyFingerprint(hex: string): string {
if (!hex || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return hex;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
let binary = "";
for (const byte of sha256(bytes)) binary += String.fromCharCode(byte);
return `SHA256:${btoa(binary).replace(/=+$/, "")}`;
}
+21 -1
View File
@@ -126,6 +126,26 @@ export function pluginApiFor(
return client;
}
/**
* A one minute socket token for the web app. The HttpOnly cookie alone fails
* behind a proxy that rewrites Host. Null for a guest or an older server, and
* the socket then falls back to the cookie.
*/
async function fetchSocketTicket(): Promise<string | null> {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
try {
const response = await fetch(`${base}/users/ws-ticket`, {
method: "POST",
credentials: "include",
});
if (!response.ok) return null;
const data = (await response.json()) as { ticket?: unknown };
return typeof data.ticket === "string" ? data.ticket : null;
} catch {
return null;
}
}
/**
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
* carry the JWT.
@@ -151,7 +171,7 @@ export async function pluginWsUrl(
});
}
const token = localStorage.getItem("jwt");
const token = (await fetchSocketTicket()) ?? localStorage.getItem("jwt");
const protocols = websocketAuthProtocols(token);
// Dev without a configured API host goes through Vite's proxy, which is
+12
View File
@@ -0,0 +1,12 @@
import type { TFunction } from "i18next";
/** System roles store an i18n key as their display name. */
export function roleLabel<T extends string | null | undefined>(
t: TFunction,
displayName: T,
): T | string {
return typeof displayName === "string" &&
displayName.startsWith("rbac.roles.")
? t(displayName)
: displayName;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Parses a timestamp from the API. SQLite stores CURRENT_TIMESTAMP as UTC
* with no zone ("2026-10-09 23:11:54"), which browsers read as local time.
*/
export function parseServerTime(ts: string): Date {
const bare = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(ts);
return new Date(bare ? `${ts.replace(" ", "T")}Z` : ts);
}
+31 -5
View File
@@ -1079,7 +1079,7 @@
"sessionExpiredLogin": "Session expired. Please log in again."
},
"messages": {
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
"registrationDisabled": "You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
"userNotAllowed": "Your account is not authorized to register. Please contact an administrator.",
"databaseConnectionFailed": "Failed to connect to the database server",
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
@@ -1218,7 +1218,7 @@
"allowPasswordLogin": "Allow Password Login",
"allowPasswordLoginDesc": "Username/password login",
"externalAutoProvision": "Auto-create external accounts",
"externalAutoProvisionDesc": "Create an account the first time someone signs in through an external login such as SSO or LDAP, even when registration is off",
"externalAutoProvisionDesc": "Create an account the first time someone signs in through an external login such as SSO or LDAP. When off, only people who already have an account can sign in that way",
"secondFactorAfterExternalLogin": "Ask for a second factor after external logins (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.",
"allowPasswordReset": "Allow Password Reset",
@@ -1867,6 +1867,19 @@
"updated": "Recently updated",
"category": "Category"
},
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"table": {
"name": "Name",
"description": "Description",
"version": "Version",
"status": "Status",
"category": "Category",
"installs": "Installs"
},
"risk": {
"critical": "Critical",
"high": "High risk",
@@ -2220,7 +2233,12 @@
"dashboardServiceLinks": "Service links",
"homepageItems": "Homepage items",
"networkTopology": "Network map",
"workspaces": "Workspaces"
"workspaces": "Workspaces",
"hostDefaults": "Host defaults",
"hostDefaultsAdmin": "Server host defaults",
"aiProviders": "AI providers",
"homepageLayouts": "Homepage layouts",
"accountProfile": "Account profile"
},
"proxy": {
"title": "Reverse proxy",
@@ -2267,9 +2285,11 @@
"continue": "Continue",
"back": "Back",
"mergeTitle": "Add it to my account",
"mergeBody": "The {{count}} items on this device are added to your account. Items your account already has stay as they are on the server.",
"mergeBody_one": "The item on this device is added to your account. Items your account already has stay as they are on the server.",
"mergeBody_other": "The {{count}} items on this device are added to your account. Items your account already has stay as they are on the server.",
"replaceTitle": "Replace it with my account",
"replaceBody": "The {{count}} items on this device are removed, and this device takes everything from your account. Hosts kept on this device only stay.",
"replaceBody_one": "The item on this device is removed, and this device takes everything from your account. Hosts kept on this device only stay.",
"replaceBody_other": "The {{count}} items on this device are removed, and this device takes everything from your account. Hosts kept on this device only stay.",
"link": "Link",
"linking": "Linking and running the first sync...",
"linked": "Linked. Your data is syncing now.",
@@ -2369,5 +2389,11 @@
"pickCredential": "Pick a credential to edit",
"pickDefaults": "Pick a level of defaults to edit",
"pickHint": "Everything about a connection lives here, grouped so you can find it."
},
"rbac": {
"roles": {
"admin": "Admin",
"user": "User"
}
}
}
+15 -5
View File
@@ -370,9 +370,7 @@ function createApiInstance(
// The desktop app has no cookies with the server, so it carries the
// remember-this-device token itself, and only to login.
const trustToken = localStorage.getItem(TRUST_TOKEN_KEY);
const isLogin =
config.url?.includes("/users/login") ||
/\/users\/auth\/[^/]+\/verify$/.test(config.url ?? "");
const isLogin = isLoginRequestUrl(config.url);
if (trustToken && isLogin) {
if (config.headers.set) {
config.headers.set("X-Termix-Trust-Token", trustToken);
@@ -728,6 +726,19 @@ class ApiError extends Error {
}
}
/**
* Requests that sign someone in. A 401 from one of these means wrong
* credentials or a wrong code, not an expired session.
*/
export function isLoginRequestUrl(url: string | undefined): boolean {
if (!url) return false;
return (
url.includes("/users/login") ||
url.includes("/users/totp/verify-login") ||
/\/users\/auth\/(second-factor\/)?[^/?]+\/verify(\?|$)/.test(url)
);
}
export function handleApiError(error: unknown, operation: string): never {
const context: LogContext = {
operation: "error_handling",
@@ -759,8 +770,7 @@ export function handleApiError(error: unknown, operation: string): never {
errorContext,
);
const isLoginEndpoint = url?.includes("/users/login");
const errorMessage = isLoginEndpoint
const errorMessage = isLoginRequestUrl(url)
? message
: "Authentication required. Please log in again.";
+23 -8
View File
@@ -110,14 +110,29 @@ export function useScrollSpy(
lockUntil.current = Date.now() + 600;
setActive(id);
const area = scroller(container);
area.scrollTo?.({
top:
el.getBoundingClientRect().top -
area.getBoundingClientRect().top +
area.scrollTop -
8,
behavior: "smooth",
});
const target = () =>
el.getBoundingClientRect().top -
area.getBoundingClientRect().top +
area.scrollTop -
8;
let lastTop = target();
let lastHeight = area.scrollHeight;
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
// Sections above can still be mounting, which moves the target or
// cuts the scroll short. Follow it until the layout settles.
const started = Date.now();
const follow = () => {
if (!el.isConnected || Date.now() - started > 1500) return;
const top = target();
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
lastTop = top;
lastHeight = area.scrollHeight;
lockUntil.current = Date.now() + 600;
area.scrollTo?.({ top, behavior: "smooth" });
}
requestAnimationFrame(follow);
};
requestAnimationFrame(follow);
},
};
}
+3 -1
View File
@@ -64,6 +64,8 @@ export const CORE_STEPS: OnboardingStepDef[] = [
Component: SecurityStep,
audience: "all",
position: 90,
isRelevant: (ctx) => ctx.canEnrollSecondFactor === true,
// The desktop signs its local account in by itself, so a second factor
// set up there would never be asked for.
isRelevant: (ctx) => !ctx.isDesktop && ctx.canEnrollSecondFactor === true,
},
];
+6 -1
View File
@@ -277,13 +277,18 @@ async function loadNamespaces(summaries: PluginSummary[]): Promise<void> {
}
}
/** The server only serves assets of a running plugin. */
function serverServesAssets(summary: PluginSummary): boolean {
return summary.enabled && (!summary.state || summary.state === "active");
}
function namespaceVersion(summary: PluginSummary): string {
return `${summary.version}|${summary.assetVersion ?? ""}`;
}
async function reconcile(summaries: PluginSummary[]): Promise<void> {
setPluginSummaries(summaries);
await loadNamespaces(summaries);
await loadNamespaces(summaries.filter(serverServesAssets));
const { order, blocked } = orderForActivation(summaries);
const wanted = new Map(order.map((summary) => [summary.id, summary]));
+449 -43
View File
@@ -1,8 +1,9 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownWideNarrow,
Check,
Columns3,
Download,
Globe,
Puzzle,
@@ -31,7 +32,10 @@ import {
GroupHeading,
PANEL,
PanelShell,
ViewToggle,
type PanelViewMode,
} from "@/components/panel-layout";
import { DataView, type DataColumn } from "@/components/data-view";
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import {
@@ -58,6 +62,37 @@ import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates";
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number];
interface Layout {
view: PanelViewMode;
columns: GridSize;
}
function useStoredChoice<T extends string>(
key: string,
allowed: readonly T[],
fallback: T,
) {
const [value, setValue] = useState<T>(() => {
try {
const saved = localStorage.getItem(key);
return allowed.includes(saved as T) ? (saved as T) : fallback;
} catch {
return fallback;
}
});
useEffect(() => {
try {
localStorage.setItem(key, value);
} catch {
// blocked storage just means the choice won't stick
}
}, [key, value]);
return [value, setValue] as const;
}
export function PluginsTab() {
return (
<SurfaceScope kind="tab" className="h-full min-h-0">
@@ -72,6 +107,20 @@ function PluginsTabBody() {
const [section, setSection] = useState<Section>("installed");
const [query, setQuery] = useState("");
const [detailId, setDetailId] = useState<string | null>(null);
const [view, setView] = useStoredChoice<PanelViewMode>(
"pluginsTab.view",
["grid", "list"],
"grid",
);
const [columns, setColumns] = useStoredChoice<GridSize>(
"pluginsTab.columns",
GRID_SIZES,
"auto",
);
const layout: Layout = { view, columns };
const layoutControls = (
<LayoutControls layout={layout} onView={setView} onColumns={setColumns} />
);
const installed = manager.plugins.filter((p) => p.installed);
const available = manager.plugins.filter((p) => p.inRegistry);
@@ -155,6 +204,8 @@ function PluginsTabBody() {
query={query}
onQuery={setQuery}
onOpen={setDetailId}
layout={layout}
layoutControls={layoutControls}
/>
) : section === "browse" ? (
<BrowseSection
@@ -163,6 +214,8 @@ function PluginsTabBody() {
query={query}
onQuery={setQuery}
onOpen={setDetailId}
layout={layout}
layoutControls={layoutControls}
/>
) : (
<UpdatesSection
@@ -276,25 +329,152 @@ function Group({
return (
<section className="flex flex-col gap-2.5">
<GroupHeading title={title} count={count} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{children}
</div>
{children}
</section>
);
}
function LayoutControls({
layout,
onView,
onColumns,
}: {
layout: Layout;
onView: (next: PanelViewMode) => void;
onColumns: (next: GridSize) => void;
}) {
const { t } = useTranslation();
return (
<>
{layout.view === "grid" && (
<Select
value={layout.columns}
onValueChange={(v) => onColumns(v as GridSize)}
>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("plugins.manager.columns.title")}
aria-label={t("plugins.manager.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("plugins.manager.columns.auto")
: t("plugins.manager.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<ViewToggle view={layout.view} onView={onView} />
</>
);
}
// Sized off the tab's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
function gridStyle(columns: GridSize): React.CSSProperties {
if (columns === "auto") {
return {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))",
};
}
const n = Number(columns);
const gaps = (n - 1) * 12;
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`,
};
}
function PluginCollection({
plugins,
layout,
renderCard,
listColumns,
onOpen,
}: {
plugins: PluginEntry[];
layout: Layout;
renderCard: (plugin: PluginEntry) => React.ReactNode;
listColumns: DataColumn<PluginEntry>[];
onOpen: (id: string) => void;
}) {
if (layout.view === "list") {
return (
<DataView
items={plugins}
view="list"
getKey={(p) => p.id}
renderCard={renderCard}
columns={{}}
listColumns={listColumns}
onRowClick={(p) => onOpen(p.id)}
empty={null}
/>
);
}
return (
<div className="grid gap-3" style={gridStyle(layout.columns)}>
{plugins.map((p) => (
<div key={p.id} className="flex flex-col *:flex-1">
{renderCard(p)}
</div>
))}
</div>
);
}
function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) {
return (
<div className="flex min-w-0 items-center gap-2">
<PluginIconBox name={plugin.icon} size="sm" muted={muted} />
<span
className={cn(
"truncate font-semibold",
muted && "text-muted-foreground",
)}
>
{plugin.name}
</span>
{plugin.unverified && <UnverifiedBadge />}
{plugin.isBeta && <BetaBadge />}
</div>
);
}
function DescriptionCell({ plugin }: { plugin: PluginEntry }) {
return (
<span className="text-muted-foreground" title={plugin.description}>
{plugin.description}
</span>
);
}
function stop(e: React.MouseEvent) {
e.stopPropagation();
}
function InstalledSection({
plugins,
manager,
query,
onQuery,
onOpen,
layout,
layoutControls,
}: {
plugins: PluginEntry[];
manager: PluginsManager;
query: string;
onQuery: (v: string) => void;
onOpen: (id: string) => void;
layout: Layout;
layoutControls: React.ReactNode;
}) {
const { t } = useTranslation();
const [sort, setSort] = useState<SortKey>("name");
@@ -306,6 +486,59 @@ function InstalledSection({
const stopped = rows.filter((p) => p.status !== "running");
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
const listColumns: DataColumn<PluginEntry>[] = [
{
key: "name",
header: t("plugins.manager.table.name"),
width: "minmax(0,1.3fr)",
cell: (p) => <NameCell plugin={p} muted={p.status !== "running"} />,
},
{
key: "description",
header: t("plugins.manager.table.description"),
width: "minmax(0,2fr)",
hideBelow: "md",
cell: (p) => <DescriptionCell plugin={p} />,
},
{
key: "version",
header: t("plugins.manager.table.version"),
width: "80px",
cell: (p) => (
<span className="tabular-nums text-muted-foreground">v{p.version}</span>
),
},
{
key: "status",
header: t("plugins.manager.table.status"),
width: "140px",
hideBelow: "lg",
cell: (p) => <StatusCell plugin={p} />,
},
{
key: "actions",
header: "",
width: "150px",
align: "end",
cell: (p) => <InstalledRowActions plugin={p} manager={manager} />,
},
];
const collection = (items: PluginEntry[]) => (
<PluginCollection
plugins={items}
layout={layout}
renderCard={(p) => (
<InstalledCard
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
)}
listColumns={listColumns}
onOpen={onOpen}
/>
);
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<Toolbar
@@ -315,6 +548,7 @@ function InstalledSection({
>
<div className="ml-auto flex items-center gap-2">
<SortSelect value={sort} onChange={setSort} />
{layoutControls}
{manager.developerMode && !manager.signedOnly && (
<UploadButton manager={manager} />
)}
@@ -350,14 +584,7 @@ function InstalledSection({
title={t("plugins.manager.groups.running")}
count={running.length}
>
{running.map((p) => (
<InstalledCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
{collection(running)}
</Group>
)}
{stopped.length > 0 && (
@@ -365,14 +592,7 @@ function InstalledSection({
title={t("plugins.manager.groups.notRunning")}
count={stopped.length}
>
{stopped.map((p) => (
<InstalledCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
{collection(stopped)}
</Group>
)}
</>
@@ -577,18 +797,126 @@ function InstalledCard({
);
}
function StatusCell({ plugin }: { plugin: PluginEntry }) {
const { t } = useTranslation();
const running = plugin.status === "running";
const failed = plugin.status === "failed" || plugin.status === "blocked";
const label =
plugin.status === "blocked"
? t("plugins.manager.status.blocked")
: failed
? t("plugins.manager.status.failed")
: !running
? t("plugins.manager.status.disabled")
: plugin.pinnedVersion
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
: t("plugins.manager.status.running");
return (
<span
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
title={failed ? (plugin.lastError ?? undefined) : undefined}
>
{failed ? (
<TriangleAlert className="size-3 shrink-0 text-destructive" />
) : (
<span
className={cn(
"size-1.5 shrink-0",
running ? "bg-green-500" : "bg-muted-foreground/40",
)}
/>
)}
<span className="truncate">{label}</span>
</span>
);
}
function InstalledRowActions({
plugin,
manager,
}: {
plugin: PluginEntry;
manager: PluginsManager;
}) {
const { t } = useTranslation();
const failed = plugin.status === "failed" || plugin.status === "blocked";
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
const locked = manager.managedByServer;
const updateLabel =
plugin.addedCapabilities.length > 0
? t("plugins.manager.reviewUpdate")
: t("plugins.manager.updateTo", { version: plugin.latestVersion });
return (
<div className="flex items-center justify-end gap-1" onClick={stop}>
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
<Button
variant="ghost"
size="icon-xs"
title={updateLabel}
aria-label={updateLabel}
className="text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={busy || locked}
onClick={() => void manager.requestUpdate(plugin)}
>
<Download className="size-3" />
</Button>
)}
{failed ? (
<Button
variant="outline"
size="xs"
className="gap-1"
disabled={busy || locked}
onClick={() => void manager.retry(plugin)}
>
<RotateCcw className="size-3" />
{t("plugins.manager.restart")}
</Button>
) : (
<Button
variant="outline"
size="xs"
disabled={busy || locked}
onClick={() => void manager.toggle(plugin)}
>
{plugin.enabled
? t("plugins.manager.disable")
: t("plugins.manager.enable")}
</Button>
)}
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
<Button
variant="ghost"
size="icon-xs"
disabled={busy || locked}
title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")}
className="text-muted-foreground hover:text-destructive"
onClick={() => void manager.uninstall(plugin)}
>
<Trash2 className="size-3" />
</Button>
</div>
);
}
function BrowseSection({
plugins,
manager,
query,
onQuery,
onOpen,
layout,
layoutControls,
}: {
plugins: PluginEntry[];
manager: PluginsManager;
query: string;
onQuery: (v: string) => void;
onOpen: (id: string) => void;
layout: Layout;
layoutControls: React.ReactNode;
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<string | null>(null);
@@ -604,6 +932,62 @@ function BrowseSection({
sort,
);
const listColumns: DataColumn<PluginEntry>[] = [
{
key: "name",
header: t("plugins.manager.table.name"),
width: "minmax(0,1.3fr)",
cell: (p) => <NameCell plugin={p} />,
},
{
key: "description",
header: t("plugins.manager.table.description"),
width: "minmax(0,2fr)",
hideBelow: "md",
cell: (p) => <DescriptionCell plugin={p} />,
},
{
key: "category",
header: t("plugins.manager.table.category"),
width: "120px",
hideBelow: "lg",
cell: (p) => (
<span className="text-muted-foreground">{p.category ?? ""}</span>
),
},
{
key: "installs",
header: t("plugins.manager.table.installs"),
width: "110px",
hideBelow: "lg",
cell: (p) => (
<span className="text-muted-foreground">
<InstallCountFact
count={p.installCount}
source={p.installCountSource}
/>
</span>
),
},
{
key: "version",
header: t("plugins.manager.table.version"),
width: "80px",
cell: (p) => (
<span className="tabular-nums text-muted-foreground">
{p.latestVersion ? `v${p.latestVersion}` : ""}
</span>
),
},
{
key: "actions",
header: "",
width: "120px",
align: "end",
cell: (p) => <BrowseAction plugin={p} manager={manager} />,
},
];
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<Toolbar
@@ -613,6 +997,7 @@ function BrowseSection({
>
<div className="ml-auto flex items-center gap-2">
<SortSelect value={sort} onChange={setSort} />
{layoutControls}
</div>
</Toolbar>
@@ -647,16 +1032,19 @@ function BrowseSection({
}
/>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{rows.map((p) => (
<PluginCollection
plugins={rows}
layout={layout}
renderCard={(p) => (
<BrowseCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</div>
)}
listColumns={listColumns}
onOpen={onOpen}
/>
)}
<p className="text-[11px] text-muted-foreground">
@@ -680,7 +1068,6 @@ function BrowseCard({
}) {
const { t } = useTranslation();
const severe = orderByRisk(plugin.capabilities).filter(isSevere);
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
return (
<Card className="gap-0 overflow-hidden py-0">
@@ -708,28 +1095,47 @@ function BrowseCard({
})
: t("plugins.manager.noServerAccess")}
</span>
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
{plugin.installed ? (
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
<Check className="size-3" />
{t("plugins.manager.installedBadge")}
</span>
) : (
<Button
variant="outline"
size="xs"
disabled={busy || manager.managedByServer || !plugin.latestVersion}
onClick={() => manager.requestInstall(plugin)}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("plugins.manager.install")}
</Button>
)}
<BrowseAction plugin={plugin} manager={manager} />
</div>
</Card>
);
}
function BrowseAction({
plugin,
manager,
}: {
plugin: PluginEntry;
manager: PluginsManager;
}) {
const { t } = useTranslation();
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
return (
<div
className="flex shrink-0 items-center justify-end gap-2"
onClick={stop}
>
<DocsLink href={plugin.docs} variant="icon" className="size-6" />
{plugin.installed ? (
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
<Check className="size-3" />
{t("plugins.manager.installedBadge")}
</span>
) : (
<Button
variant="outline"
size="xs"
disabled={busy || manager.managedByServer || !plugin.latestVersion}
onClick={() => manager.requestInstall(plugin)}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("plugins.manager.install")}
</Button>
)}
</div>
);
}
function UpdatesSection({
plugins,
manager,
+2 -1
View File
@@ -50,6 +50,7 @@ import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
import { docsUrl } from "@/lib/docs";
import { parseServerTime } from "@/lib/server-time";
interface CommandPaletteProps {
/** Opens settings, optionally at one section. */
@@ -316,7 +317,7 @@ export function CommandPalette({
<span className="truncate">{item.hostName}</span>
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="size-3" />
{new Date(item.timestamp).toLocaleDateString()}
{parseServerTime(item.timestamp).toLocaleDateString()}
</span>
</CommandItem>
);
+2 -1
View File
@@ -21,6 +21,7 @@ import {
import { toast } from "sonner";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { parseServerTime } from "@/lib/server-time";
const RESOURCE_TYPES = [
"user",
@@ -177,7 +178,7 @@ export function AdminAuditLogSection({
function formatTimestamp(ts: string) {
try {
return new Date(ts).toLocaleString(undefined, {
return parseServerTime(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
+2 -1
View File
@@ -30,6 +30,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import { roleLabel } from "@/lib/role-label";
export type AdminUser = {
id: string;
@@ -637,7 +638,7 @@ export function AdminRolesSection({
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{role.displayName}
{roleLabel(t, role.displayName)}
</span>
{role.isSystem ? (
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
+2 -1
View File
@@ -14,6 +14,7 @@ import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
@@ -244,7 +245,7 @@ export function AdminEditUserDialog({
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
+2 -1
View File
@@ -56,6 +56,7 @@ import { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
@@ -481,7 +482,7 @@ export function AdminUserManagePanel({
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
+2 -1
View File
@@ -314,7 +314,8 @@ export function AppRail({
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
// The desktop app keeps the rail however narrow its window gets.
className={`${isElectron() ? "flex" : "hidden md:flex"} flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0`}
style={{ width: rem(railExpanded ? 172 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
+14 -9
View File
@@ -29,10 +29,11 @@ import {
type CredentialPermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { getUserList, type AccessRecord } from "@/main-axios";
import { getUserInfo, getUserList, type AccessRecord } from "@/main-axios";
import type { Credential } from "@/types/ui-types";
import { getErrorMessage } from "@/lib/error-message";
import { DocsLink } from "@/components/docs-link";
import { roleLabel } from "@/lib/role-label";
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
@@ -119,14 +120,15 @@ export function CredentialShareModal({
getCredentialAccess(credentialId).catch(() => ({ access: [] })),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes]) => {
.then(([accessRes, usersRes, rolesRes, me]) => {
setAccessList(accessRes.access ?? []);
// Nobody shares a credential with themselves.
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
(usersRes.users ?? [])
.map((u) => ({ id: String(u.userId), username: u.username }))
.filter((u) => u.id !== me?.userId),
);
setShareRoles(
(rolesRes.roles ?? [])
@@ -231,7 +233,10 @@ export function CredentialShareModal({
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
const who =
record.username ?? record.roleDisplayName ?? record.roleName ?? "";
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"";
const ok = await confirm({
title: t("sharing.revokeConfirm", { name: who }),
confirmLabel: t("sharing.revoke"),
@@ -376,7 +381,7 @@ export function CredentialShareModal({
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
{roleLabel(t, role.displayName || role.name)}
</span>
</button>
);
@@ -499,7 +504,7 @@ export function CredentialShareModal({
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
record.userId ??
record.roleId}
+16 -10
View File
@@ -32,6 +32,7 @@ import {
updateHostAccess,
revokeHostAccess,
getUserList,
getUserInfo,
getRoles,
type AccessRecord,
type SharePermissionLevel,
@@ -40,6 +41,7 @@ import {
import type { Host } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
import { roleLabel } from "@/lib/role-label";
const PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
@@ -112,14 +114,18 @@ export function HostShareModal({
: Promise.resolve({ accessList: [] }),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes]) => {
.then(([accessRes, usersRes, rolesRes, me]) => {
setAccessList(accessRes.accessList ?? []);
// Nobody shares with themselves.
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
})),
(usersRes.users ?? [])
.map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
}))
.filter((u) => u.id !== me?.userId),
);
setShareRoles(
(rolesRes.roles ?? [])
@@ -411,7 +417,7 @@ export function HostShareModal({
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
{roleLabel(t, role.displayName || role.name)}
</span>
</button>
);
@@ -526,7 +532,7 @@ export function HostShareModal({
>
<span className="flex-1 truncate">
{rule.targetType === "role"
? rule.roleDisplayName || rule.roleName
? roleLabel(t, rule.roleDisplayName) || rule.roleName
: rule.username}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
@@ -541,7 +547,7 @@ export function HostShareModal({
title: t("sharing.revokeConfirm", {
name:
rule.username ??
rule.roleDisplayName ??
roleLabel(t, rule.roleDisplayName) ??
rule.roleName ??
"",
}),
@@ -593,7 +599,7 @@ export function HostShareModal({
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
record.userId ??
record.roleId}
@@ -633,7 +639,7 @@ export function HostShareModal({
title: t("sharing.revokeConfirm", {
name:
record.username ??
record.roleDisplayName ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"",
}),
+2 -2
View File
@@ -77,6 +77,7 @@ import {
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
type UserProfileSection =
| "account"
@@ -1240,7 +1241,7 @@ export function UserProfilePanel({
key={r.roleId}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
>
{r.roleDisplayName}
{roleLabel(t, r.roleDisplayName)}
</span>
))}
</div>
@@ -1440,7 +1441,6 @@ export function UserProfilePanel({
</option>
))}
</Select2>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground pointer-events-none" />
</div>
<div className="flex gap-1 mt-0.5">
{THEMES.filter((th) => th.id !== "system").map((th) => (
@@ -8,6 +8,7 @@ import {
import { Shield, AlertTriangle, Copy, Check } from "lucide-react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { hostKeyFingerprint } from "@/lib/host-key-fingerprint";
interface HostKeyVerificationDialogProps {
isOpen: boolean;
@@ -25,8 +26,6 @@ interface HostKeyVerificationDialogProps {
backgroundColor?: string;
}
const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") || fp;
function FingerprintRow({
label,
value,
@@ -44,16 +43,16 @@ function FingerprintRow({
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</p>
<div className="flex items-center gap-2">
<div className="flex items-stretch gap-2">
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
{formatFingerprint(value)}
{value}
</div>
<button
type="button"
onClick={onCopy}
title={t("common.copy")}
aria-label={t("common.copy")}
className={`${PROMPT_BUTTON} size-8 shrink-0 px-0`}
className="flex w-11 shrink-0 items-center justify-center border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
>
{copied ? (
<Check className="size-4 text-accent-brand" />
@@ -74,7 +73,6 @@ export function HostKeyVerificationDialog({
hostname,
fingerprint,
oldFingerprint,
algorithm,
onAccept,
onReject,
backgroundColor,
@@ -97,6 +95,8 @@ export function HostKeyVerificationDialog({
};
const changed = scenario === "changed";
const newPrint = hostKeyFingerprint(fingerprint);
const oldPrint = hostKeyFingerprint(oldFingerprint || "");
return (
<PanePrompt
@@ -155,15 +155,15 @@ export function HostKeyVerificationDialog({
</div>
<FingerprintRow
label={t("hostKey.previousKey")}
value={oldFingerprint || ""}
value={oldPrint}
copied={copiedOldFingerprint}
onCopy={() => copyFingerprint(oldFingerprint || "", true)}
onCopy={() => copyFingerprint(oldPrint, true)}
/>
<FingerprintRow
label={t("hostKey.newFingerprint")}
value={fingerprint}
value={newPrint}
copied={copiedFingerprint}
onCopy={() => copyFingerprint(fingerprint)}
onCopy={() => copyFingerprint(newPrint)}
/>
</>
) : (
@@ -180,10 +180,10 @@ export function HostKeyVerificationDialog({
</div>
</div>
<FingerprintRow
label={`${t("hostKey.fingerprint")} (${algorithm.toUpperCase()})`}
value={fingerprint}
label={t("hostKey.fingerprint")}
value={newPrint}
copied={copiedFingerprint}
onCopy={() => copyFingerprint(fingerprint)}
onCopy={() => copyFingerprint(newPrint)}
/>
<p className="text-[10px] text-muted-foreground">
{t("hostKey.verifyInstructions")}
@@ -85,6 +85,20 @@ describe("Facts", () => {
);
expect(container.querySelectorAll("[aria-hidden]")).toHaveLength(1);
});
it("separates facts passed inside a fragment", () => {
const { container } = render(
<Facts>
<>
<span>Termix</span>
{false}
<span>v1.0.0</span>
<span>Monitoring</span>
</>
</Facts>,
);
expect(container.querySelectorAll("[aria-hidden]")).toHaveLength(2);
});
});
describe("PanelShell", () => {
@@ -0,0 +1,33 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
const mobile = vi.hoisted(() => ({ value: true }));
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => mobile.value }));
vi.mock("@/shell/ActionSlot", async (importOriginal) => ({
...(await importOriginal<typeof import("@/shell/ActionSlot")>()),
ComponentSlot: ({ slotId }: { slotId: string }) => <span>{slotId}</span>,
}));
import { HostStatusCard } from "@/dashboard/DashboardTab";
import type { Host } from "@/types/ui-types";
afterEach(cleanup);
const host = { id: "1", name: "web", ip: "10.0.0.1", online: true } as Host;
describe("HostStatusCard on a phone", () => {
it("leaves out plugin metrics so the host name has room", () => {
mobile.value = true;
render(<HostStatusCard hosts={[host]} onOpenTab={() => {}} />);
expect(screen.queryByText("dashboard.hostRow")).toBeNull();
});
it("keeps them on a wide screen", () => {
mobile.value = false;
render(<HostStatusCard hosts={[host]} onOpenTab={() => {}} />);
expect(screen.getByText("dashboard.hostRow")).toBeTruthy();
});
});
+8
View File
@@ -22,6 +22,7 @@ function setViewport(width: number) {
afterEach(() => {
vi.restoreAllMocks();
delete (window as { electronAPI?: unknown }).electronAPI;
});
describe("useIsMobile", () => {
@@ -42,4 +43,11 @@ describe("useIsMobile", () => {
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
it("keeps the desktop layout in the desktop app, however narrow", () => {
setViewport(500);
(window as { electronAPI?: unknown }).electronAPI = { isElectron: true };
const { result } = renderHook(() => useIsMobile());
expect(result.current).toBe(false);
});
});
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { hostKeyFingerprint } from "@/lib/host-key-fingerprint";
describe("hostKeyFingerprint", () => {
it("matches ssh-keygen -lf for an ed25519 key", () => {
const hex =
"0000000b7373682d656432353531390000002068baf48c33774769fc29e545d108119da4c6186dffb0b00fe887316935630379";
expect(hostKeyFingerprint(hex)).toBe(
"SHA256:4AQQyjAyA8CDb9y/vb1q02wyz6f8tagQ4qK41tmJ/d8",
);
});
it("leaves values that are not hex alone", () => {
expect(hostKeyFingerprint("")).toBe("");
expect(hostKeyFingerprint("SHA256:abc")).toBe("SHA256:abc");
});
});
+44 -1
View File
@@ -15,8 +15,13 @@ vi.mock("@/main-axios", () => ({
}));
vi.mock("@/lib/electron", () => ({ isElectron: () => state.electron }));
vi.mock("@/lib/device-id", () => ({ getDeviceId: () => "device-1" }));
vi.mock("@/lib/base-path", () => ({ getBasePath: () => "" }));
import { createPluginApi, pluginFetch } from "@/lib/plugin-transport";
import {
createPluginApi,
pluginFetch,
pluginWsUrl,
} from "@/lib/plugin-transport";
const fetchMock = vi.fn(async () => new Response("ok"));
@@ -84,3 +89,41 @@ describe("createPluginApi", () => {
);
});
});
describe("pluginWsUrl in the browser", () => {
it("sends a socket ticket, since the cookie can fail behind a proxy", async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ ticket: "ticket-1" })),
);
const target = await pluginWsUrl("ssh-terminal", "/terminal");
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(url).toBe("http://server:8080/users/ws-ticket");
expect(init.method).toBe("POST");
expect(init.credentials).toBe("include");
expect(target?.url).toBe(
`ws://${window.location.host}/plugin-ws/ssh-terminal/terminal`,
);
expect(target?.protocols).toEqual(["termix.jwt.ticket-1"]);
});
it("falls back to the cookie alone for a guest", async () => {
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 }));
const target = await pluginWsUrl("ssh-terminal", "/terminal");
expect(target?.protocols).toEqual([]);
});
it("falls back when the server is older and has no ticket route", async () => {
fetchMock.mockRejectedValueOnce(new Error("offline"));
const target = await pluginWsUrl("ssh-terminal", "/terminal");
expect(target?.protocols).toEqual([]);
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import type { TFunction } from "i18next";
import { roleLabel } from "@/lib/role-label";
const t = ((key: string) => `T(${key})`) as unknown as TFunction;
describe("roleLabel", () => {
it("translates system role keys", () => {
expect(roleLabel(t, "rbac.roles.admin")).toBe("T(rbac.roles.admin)");
});
it("keeps custom names and empty values", () => {
expect(roleLabel(t, "Ops team")).toBe("Ops team");
expect(roleLabel(t, null)).toBeNull();
expect(roleLabel(t, undefined)).toBeUndefined();
});
});
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more