Compare commits

..
409 Commits
Author SHA1 Message Date
LukeGus 931761190f fix: plugin validate accepts beta versions 2026-10-09 14:37:54 -05:00
LukeGus 28fe98e787 feat: note in the plugins tab that community plugins are coming 2026-10-09 14:16:29 -05:00
LukeGus 2ba11652eb fix: sso only accounts cannot sign in with a passkey or other local method 2026-10-09 11:14:56 -05:00
LukeGus 2e3c242e34 chore: default mobile download links to v tags 2026-10-09 10:33:32 -05:00
LukeGus 4c68d35c21 fix: linked desktops keep a plugin when its update from the server fails 2026-10-09 10:07:10 -05:00
LukeGus 0ce378ec55 feat: plugin text columns are longtext on mysql so they are not capped at 64KB 2026-10-09 09:39:08 -05:00
LukeGus 83cce344e8 fix: link server wizard always opens inline, onboarding card grows for it 2026-10-09 09:29:27 -05:00
LukeGus 6f4198cc83 docs: note the removed donation reminder 2026-10-09 08:06:37 -05:00
LukeGus abdbb34ce2 feat: remove the donation reminder and its column 2026-10-09 08:06:22 -05:00
LukeGus 0b41672a3c chore: stop pushing the release- docker tag 2026-10-09 08:06:19 -05:00
LukeGus 0f5c2ac862 fix: current version in the version route example 2026-10-09 07:38:28 -05:00
LukeGus 6f7fc05892 docs: 26.10.0 changelog 2026-10-09 07:37:59 -05:00
LukeGus 4eac6d977d fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms 2026-10-09 07:37:56 -05:00
LukeGus 406766eddd fix: load plugin strings on guest pages, plugin wording and drop unused keys 2026-10-09 07:37:51 -05:00
LukeGus f803bfc140 fix: bundled plugins path for source runs and backend cleanup 2026-10-09 07:37:46 -05:00
LukeGus b0a0ee3e00 fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks 2026-10-09 07:37:42 -05:00
LukeGus af09aa1028 fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 2026-10-09 07:37:38 -05:00
LukeGus 45908b5df3 chore: merge dev-2.9.2 into dev-26.10.0 2026-10-08 22:56:41 -05:00
LukeGus b156829ef3 feat: upload banner 2026-10-08 21:58:19 -05:00
LukeGus 399835d8bc fix: recent activity rows use the same separator as host status 2026-10-08 18:15:44 -05:00
LukeGus e1d7b3e7c6 chore: repin bundled plugins after the 1.0.0 re-release 2026-10-08 17:53:50 -05:00
LukeGus 970aae4740 fix: repin remote-desktop after its 1.0.0 re-release 2026-10-08 17:41:51 -05:00
LukeGus 0324d27f13 fix: folder rows no longer nest action buttons inside a button 2026-10-08 15:28:11 -05:00
LukeGus ab2307bab4 fix: tab buttons like share show as soon as the terminal is ready 2026-10-08 14:04:27 -05:00
LukeGus 5e7f69456b fix: manage tab refetches before giving up on a host or credential it cannot find 2026-10-08 14:04:23 -05:00
LukeGus 2f975f433e fix: keep plugin tailwind variants in core's layer so overrides keep their order 2026-10-08 13:54:37 -05:00
LukeGus 4511cc0a42 fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK 2026-10-08 13:38:56 -05:00
LukeGus 878f1ebaa3 fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 2026-10-08 13:38:52 -05:00
LukeGus d6c4ec01b5 fix: lighter confirm prompt overlay to stop lag when it opens 2026-10-08 00:51:45 -05:00
LukeGus f09865ed04 fix: header separators render as borders and center the status divider 2026-10-08 00:49:11 -05:00
LukeGus cfab6f764c feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 2026-10-08 00:34:50 -05:00
LukeGus 7fcda3bb01 chore: update translation guidance, only en.json needs editing 2026-10-07 22:38:33 -05:00
LukeGus 7161f37984 chore: add LICENSE and use Node 24 from .nvmrc 2026-10-07 22:33:18 -05:00
LukeGus f57c7e8966 chore: remove unused knip config 2026-10-07 22:25:47 -05:00
LukeGus 5774f7cd0e chore: standardize repo setup files 2026-10-07 22:19:20 -05:00
LukeGus 5819348064 feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) 2026-10-07 21:39:31 -05:00
LukeGus c3624cdf62 fix: refuse sessionless tokens and keep the secrets file owner only 2026-10-07 21:20:03 -05:00
LukeGus 740d2adaf3 fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) 2026-10-07 21:18:08 -05:00
LukeGus 8a45d952fd fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) 2026-10-07 21:17:07 -05:00
LukeGus ad3a18224f fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) 2026-10-07 21:14:34 -05:00
LukeGus cd29e1d087 chore: name core and sdk releases Termix vX and Plugin SDK vX 2026-10-07 20:28:54 -05:00
LukeGus 10d0c732be fix: drop the ./ from the plugin sdk bin path so npm stops warning 2026-10-07 20:16:42 -05:00
LukeGus 64eafa0032 feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 2026-10-07 20:09:42 -05:00
LukeGus ea678c5602 fix: import sshpk as a default so the backend starts under ESM 2026-10-07 19:16:15 -05:00
LukeGus fbf7598399 fix: refresh cached plugin translations when only a translation changes (Support#1411) 2026-10-07 16:59:50 -05:00
LukeGus 18a594a92d fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) 2026-10-07 16:56:52 -05:00
LukeGus 679b61da9b fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) 2026-10-07 16:55:38 -05:00
LukeGus f783e6d51d fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) 2026-10-07 16:50:51 -05:00
LukeGus db8305e393 fix: keep client tunnels open when one of their connections fails (Support#1417) 2026-10-07 16:50:07 -05:00
LukeGus 1466c7bcab fix: ask for a push/TOTP menu choice as text instead of a push confirm 2026-10-07 16:40:18 -05:00
ZacharyZcR 42c5682243 fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) 2026-10-07 16:37:58 -05:00
ZacharyZcR bb4b345feb fix(sidebar): reclaim space from hidden folder actions (#1571) 2026-10-07 16:37:54 -05:00
ZacharyZcR 9c31dd41c7 fix(ssh): normalize PKCS8 private keys before connecting (#1570) 2026-10-07 16:37:50 -05:00
ZacharyZcR f4164a74b2 fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) 2026-10-07 16:37:45 -05:00
ZacharyZcR f412a28bb8 fix(sync): resolve shared hosts for connect-only recipients (#1568) 2026-10-07 16:37:41 -05:00
LukeGus f73c4b5ad6 feat: beta channels for core and plugins with one stable/beta release workflow 2026-10-07 16:25:58 -05:00
LukeGus 6a855c0fa8 feat: request a feature link for plugins and use the bug form for unverified plugins 2026-10-07 10:39:52 -05:00
LukeGus 8666533162 fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling 2026-10-06 23:08:21 -05:00
LukeGus 27819c477c fix: retry failed or blocked plugins on restart instead of leaving them disabled 2026-10-06 23:08:10 -05:00
LukeGus b6bb3ffc73 feat: show plugin features from the manifest on the plugin page 2026-10-06 22:38:13 -05:00
LukeGus 60024bdd4e fix: open settings goes to the plugin's page and hides when it has none 2026-10-06 22:26:35 -05:00
LukeGus d1ddcd3ff9 feat: add report a bug link to the your account settings section 2026-10-06 22:23:30 -05:00
LukeGus ac19183f40 feat: per-repo issue forms and prefilled bug reports instead of the support repo 2026-10-06 22:18:01 -05:00
LukeGus 15058119a3 chore: drop optional from ai plugin row in readme 2026-10-06 21:16:48 -05:00
LukeGus 522753fab5 chore: rewrite readme and taglines for plugin-based termix 2026-10-06 21:03:37 -05:00
LukeGus 77c19345b3 chore: general file cleanup 2026-10-06 20:09:18 -05:00
LukeGus df3c762b3d chore: remove dependabot 2026-10-06 20:09:03 -05:00
LukeGus 3b1a85b697 chore: run sdk publish on github hosted runner 2026-10-06 20:06:31 -05:00
LukeGus c828716d6e chore: drop docs job from release workflow 2026-10-06 20:01:12 -05:00
LukeGus 6280e0787c chore: renew mac app store provisioning profile 2026-10-06 19:55:42 -05:00
LukeGus 5a5fb66e9d chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN 2026-10-06 19:44:42 -05:00
LukeGus a608f5561a chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub 2026-10-06 19:41:44 -05:00
LukeGus 7804d4cc8d feat: show only the latest plugin release notes and cap long ones 2026-10-06 19:25:36 -05:00
LukeGus 1d3e77227c chore: release the plugin sdk as 1.0.1 2026-10-06 18:21:24 -05:00
LukeGus ed1ac06cca chore: add temp changelog 2026-10-06 18:18:10 -05:00
LukeGus 9bc2be721b feat: changelog.md release notes, v tags, plugin release notes and videos 2026-10-06 18:13:39 -05:00
LukeGus b9dd3488a5 feat: full screen onboarding with a plugin picker and per step versions 2026-10-06 17:44:25 -05:00
LukeGus 1e74ce4827 chore: update the plugin sdk ui export snapshot 2026-10-06 17:44:22 -05:00
LukeGus fb904554e8 fix: stop the dev runner rebuilding plugins when windows reports file reads as changes 2026-10-06 17:44:17 -05:00
LukeGus 2d71e886ac fix: settle quick connect panel loads in tests so they stop flaking 2026-10-06 16:05:13 -05:00
LukeGus 3ee98664f0 chore: clean up npm scripts, make npm run dev the full runner and drop concurrently 2026-10-06 15:58:27 -05:00
LukeGus ec80a8ef8d feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev 2026-10-06 15:55:00 -05:00
LukeGus 49fc241e06 fix: unify connection failure screens and rebuild stale local plugins 2026-10-06 15:21:44 -05:00
LukeGus a72aa5b57b feat: row detail options for hosts and credentials, open manage from sidebar header 2026-10-06 14:41:57 -05:00
LukeGus 08db1fcdde feat: consent check, developer mode file installs, install counts and issue links for plugins 2026-10-06 14:30:56 -05:00
LukeGus 26a474bb3d fix: open plain settings from the profile button, navigation only from the rail menu 2026-10-06 14:30:49 -05:00
LukeGus dbf6c0117f chore: split dashboard quick actions into quarters and drop host status row lines 2026-10-06 14:30:45 -05:00
LukeGus 46b3adeb57 chore: run all workflows on blacksmith runners 2026-10-06 13:47:24 -05:00
LukeGus 58c3434e58 feat: shared list kit, settings search, rail order and ui consistency fixes 2026-10-06 13:02:05 -05:00
LukeGus 3ed5775ed5 feat: implement plugin management UI 2026-10-06 10:35:56 -05:00
LukeGus 88d7bc5525 feat: pin the official plugins in the docker image 2026-10-05 21:46:16 -05:00
LukeGus 4fd9aebee4 fix: use TERMIX_PAT in workflows 2026-10-05 20:14:22 -05:00
LukeGus acc9484280 fix: restore release video link in 2.9.2 notes 2026-10-05 20:12:12 -05:00
LukeGus 853615af09 fix: stop full database rewrites on every plugin db call 2026-10-05 20:10:09 -05:00
LukeGus 7508a45f36 fix: stop full database rewrites on every plugin db call 2026-10-05 20:06:32 -05:00
LukeGus 040fbfe315 chore: add a 2.9.1 upgrade check for postgres and mysql 2026-10-05 19:50:28 -05:00
LukeGus 5e54d95e71 fix: harden plugin sockets, public routes, prebuilt binaries and login fallback 2026-10-05 19:50:19 -05:00
LukeGus efb4259438 chore: cleanup 2026-10-05 18:33:59 -05:00
LukeGus 5d67d76cc0 feat: add a publish workflow for the plugin sdk 2026-10-05 18:32:48 -05:00
LukeGus e608505b39 chore: rename the plugin sdk to @termix-ssh/plugin-sdk 2026-10-05 18:23:03 -05:00
LukeGus 0c4f673318 chore: pin the plugin signing key and use TERMIX_PAT in workflows 2026-10-05 17:35:39 -05:00
LukeGus 35c954b79a feat: port the demo redesign with Manage and Settings tabs 2026-10-05 16:23:21 -05:00
LukeGus f8151cb885 chore: remove the 2.9 finish list 2026-10-05 10:32:48 -05:00
LukeGus 61f46d2517 feat: drop the 2.8 columns, moves and aliases kept for a downgrade 2026-10-05 10:21:11 -05:00
dependabot[bot] d3d8269cb7 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>
2026-10-05 01:30:44 -05:00
LukeGus e02b5fe6e4 chore: move plugins to their own repos and drop the plugins folder 2026-10-04 22:40:06 -05:00
LukeGus 29a946d02e chore: rework plugin readmes, json changelogs and standalone repo setup 2026-10-04 21:25:16 -05:00
LukeGus a8f383d2b3 Merge remote-tracking branch 'origin/main' into dev-26.10.0
# Conflicts:
#	package-lock.json
#	package.json
#	plugins/ai/locales/en.json
#	plugins/ai/locales/translated/af_ZA.json
#	plugins/ai/locales/translated/ar_SA.json
#	plugins/ai/locales/translated/bg_BG.json
#	plugins/ai/locales/translated/bn_BD.json
#	plugins/ai/locales/translated/ca_ES.json
#	plugins/ai/locales/translated/cs_CZ.json
#	plugins/ai/locales/translated/da_DK.json
#	plugins/ai/locales/translated/de_DE.json
#	plugins/ai/locales/translated/el_GR.json
#	plugins/ai/locales/translated/es_ES.json
#	plugins/ai/locales/translated/fi_FI.json
#	plugins/ai/locales/translated/fr_FR.json
#	plugins/ai/locales/translated/he_IL.json
#	plugins/ai/locales/translated/hi_IN.json
#	plugins/ai/locales/translated/hu_HU.json
#	plugins/ai/locales/translated/id_ID.json
#	plugins/ai/locales/translated/it_IT.json
#	plugins/ai/locales/translated/ja_JP.json
#	plugins/ai/locales/translated/ko_KR.json
#	plugins/ai/locales/translated/nl_NL.json
#	plugins/ai/locales/translated/no_NO.json
#	plugins/ai/locales/translated/pl_PL.json
#	plugins/ai/locales/translated/pt_BR.json
#	plugins/ai/locales/translated/pt_PT.json
#	plugins/ai/locales/translated/ro_RO.json
#	plugins/ai/locales/translated/ru_RU.json
#	plugins/ai/locales/translated/sr_SP.json
#	plugins/ai/locales/translated/sv_SE.json
#	plugins/ai/locales/translated/th_TH.json
#	plugins/ai/locales/translated/tr_TR.json
#	plugins/ai/locales/translated/uk_UA.json
#	plugins/ai/locales/translated/vi_VN.json
#	plugins/ai/locales/translated/zh_CN.json
#	plugins/ai/locales/translated/zh_TW.json
2026-10-04 17:34:25 -05:00
LukeGus 6b91c3a460 Merge branch 'dev-2.9.1' into dev-26.10.0
# Conflicts:
#	package-lock.json
#	package.json
#	plugins/ai/locales/translated/af_ZA.json
#	plugins/ai/locales/translated/ar_SA.json
#	plugins/ai/locales/translated/bg_BG.json
#	plugins/ai/locales/translated/bn_BD.json
#	plugins/ai/locales/translated/ca_ES.json
#	plugins/ai/locales/translated/cs_CZ.json
#	plugins/ai/locales/translated/da_DK.json
#	plugins/ai/locales/translated/de_DE.json
#	plugins/ai/locales/translated/el_GR.json
#	plugins/ai/locales/translated/es_ES.json
#	plugins/ai/locales/translated/fi_FI.json
#	plugins/ai/locales/translated/fr_FR.json
#	plugins/ai/locales/translated/he_IL.json
#	plugins/ai/locales/translated/hi_IN.json
#	plugins/ai/locales/translated/hu_HU.json
#	plugins/ai/locales/translated/id_ID.json
#	plugins/ai/locales/translated/it_IT.json
#	plugins/ai/locales/translated/ja_JP.json
#	plugins/ai/locales/translated/ko_KR.json
#	plugins/ai/locales/translated/nl_NL.json
#	plugins/ai/locales/translated/no_NO.json
#	plugins/ai/locales/translated/pl_PL.json
#	plugins/ai/locales/translated/pt_BR.json
#	plugins/ai/locales/translated/pt_PT.json
#	plugins/ai/locales/translated/ro_RO.json
#	plugins/ai/locales/translated/ru_RU.json
#	plugins/ai/locales/translated/sr_SP.json
#	plugins/ai/locales/translated/sv_SE.json
#	plugins/ai/locales/translated/th_TH.json
#	plugins/ai/locales/translated/tr_TR.json
#	plugins/ai/locales/translated/uk_UA.json
#	plugins/ai/locales/translated/vi_VN.json
#	plugins/ai/locales/translated/zh_TW.json
2026-10-04 17:34:09 -05:00
LukeGus 8520076192 fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi 2026-10-04 15:40:57 -05:00
LukeGus e083f09658 chore: sync Crowdin translations for 2.9.1 2026-10-04 20:11:19 +00:00
LukeGus dd66aa57e4 Merge remote-tracking branch 'origin/main' into dev-2.9.1 2026-10-04 20:10:22 +00:00
LukeGus 0a436ab3d8 fix: simplify host status to online/offline and keep it live without a refresh 2026-10-04 14:18:50 -05:00
LukeGus 2646b649d7 fix: remote desktop logins missing from shared hosts on desktop 2026-10-04 13:55:55 -05:00
LukeGus 08e1eea2f3 fix: tunnels and host settings missing from shared hosts on desktop 2026-10-04 13:54:09 -05:00
LukeGus 5a373c3963 chore: add sso/ldap dialog and audit log fixes to release notes 2026-10-04 13:53:38 -05:00
ZacharyZcR abbfca2e94 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.
2026-10-04 18:40:13 +08:00
LukeGus 4c9e12f3b7 chore: increment ver 2026-10-03 16:56:19 -05:00
LukeGus 58577eafe9 fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles 2026-10-03 16:48:03 -05:00
LukeGus e489d804f9 Merge branch 'dev-2.9.1' into dev-26.10.0 2026-10-03 16:36:59 -05:00
LukeGus 0f4cdcbdfa chore: add host key and status label fixes to release notes 2026-10-03 16:36:54 -05:00
LukeGus 99d9db04d4 fix: clearer host login failed status label and fix its translations (#1396) 2026-10-03 16:36:37 -05:00
LukeGus b3e220acf1 fix: host key silently accepted when the host is missing from the database (#1397) 2026-10-03 16:35:09 -05:00
LukeGus 44be18988b fix: restore space to add host tags and redesign predefined tags editor 2026-10-03 16:22:13 -05:00
LukeGus 7305a44b19 test: run the posix-only ai agent tests on linux only 2026-10-03 16:12:38 -05:00
ZacharyZcR bf817d8bd1 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
2026-10-03 16:01:47 -05:00
LukeGus 77bcbdb3d6 chore: merge dev-2.9.1 into dev-26.10.0 2026-10-03 16:01:42 -05:00
LukeGus 69a03f6205 chore: update release notes for 2.9.1 2026-10-03 16:00:16 -05:00
LukeGus f52f07f975 chore: drop em dash from host row comment 2026-10-03 15:59:50 -05:00
LukeGus fa21a7a458 test: compare download stream buffers directly so it stops timing out 2026-10-03 15:59:46 -05:00
LukeGus 7156ac1af3 fix: add openapi docs and error handling to host tag routes 2026-10-03 15:45:41 -05:00
LukeGus a1b37c6829 fix: slow file deletes from the trash lookups on every delete (#1390) 2026-10-03 15:45:01 -05:00
LukeGus b03000e396 fix: op:// secret references rejected as ssh keys on credentials (#1394) 2026-10-03 15:41:44 -05:00
LukeGus 86840f7642 fix: user data export freezing the server (#1393) 2026-10-03 15:38:31 -05:00
LukeGus 79bd10a1b0 fix: high cpu from status probes and full database saves on every sample (#1300) 2026-10-03 15:37:11 -05:00
LukeGus 11a22d30eb fix: host imports running a defaults pass and metrics restart per host (#1384) 2026-10-03 15:37:07 -05:00
LukeGus 5e31642379 fix: fleets, proxmox and automations not getting the host sudo password 2026-10-03 15:31:05 -05:00
LukeGus 6f442c5863 fix: plugins losing the saved ssh login when copying a host (#1391) 2026-10-03 15:31:01 -05:00
LukeGus d433718c35 fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) 2026-10-03 15:27:15 -05:00
ZacharyZcR 0214a81e5d 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
2026-10-03 21:37:00 +08:00
ZacharyZcR 13f5736d8f test(database): advance fake timers past the save yield (#1555) 2026-10-03 21:28:28 +08:00
ZacharyZcR b00551c394 feat(hosts): add instance-wide predefined tag suggestions (#1548)
* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes
2026-10-03 21:04:50 +08:00
ZacharyZcR 94a9f815c9 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.
2026-10-03 21:04:33 +08:00
ZacharyZcR 59083aac00 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".
2026-10-03 21:04:21 +08:00
ZacharyZcR 57151c0e84 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.
2026-10-03 21:04:04 +08:00
ZacharyZcR ab7a68aa84 fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) 2026-10-03 21:03:47 +08:00
ZacharyZcR 7b2198e18c fix(auth): reject unresolved legacy identity provisioning (#1550) 2026-10-03 21:03:31 +08:00
ZacharyZcR c9a1348836 fix(auth): allow retrying unavailable second-factor interfaces (#1549) 2026-10-03 21:03:13 +08:00
ZacharyZcR c66702deb1 feat(hosts): keep compact row actions inline (#1547) 2026-10-03 21:02:54 +08:00
ZacharyZcR 2c4de5f705 fix(file-manager): render transfer toasts without plugin hooks (#1546) 2026-10-03 21:02:38 +08:00
ZacharyZcR 4785bf5dfa fix(docker): retain stored SSH credential association (#1543) 2026-10-03 21:02:21 +08:00
ZacharyZcR 117e125d55 fix(snippets): repair missing note column on SQLite upgrades (#1541) 2026-10-03 21:02:04 +08:00
ZacharyZcR d3568e484d docs(api): describe cookie and API key authentication (#1540) 2026-10-03 21:01:47 +08:00
ZacharyZcR c78bc37eb2 fix(database): batch session activity persistence (#1539) 2026-10-03 21:01:30 +08:00
ZacharyZcR dc5d57c012 fix(status): skip TCP probes while host sessions are active (#1538) 2026-10-03 21:01:12 +08:00
ZacharyZcR bd0858b5a2 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
2026-10-03 21:00:55 +08:00
ZacharyZcR 021670ca1d test(remote-desktop): preserve sessions beyond one hour (#1536) 2026-10-03 21:00:38 +08:00
ZacharyZcR dc6c522782 fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) 2026-10-03 21:00:20 +08:00
ZacharyZcR 93db4799c0 fix(sso): send the PKCE verifier for GitHub login (#1534) 2026-10-03 21:00:03 +08:00
LukeGus d4b68ea5a2 chore: switch to calver, bump version to 26.10.0 2026-10-01 21:47:45 -05:00
LukeGus 29719dbb87 chore: skip crowdin pre-translate in release for now 2026-10-01 16:05:50 -05:00
LukeGus 36325b38ec Merge remote-tracking branch 'origin/main' into dev-2.9.0 2026-10-01 20:23:47 +00:00
LukeGus b02f16ea0a chore: lint, format, and bump version to 2.9.0 2026-10-01 20:23:09 +00:00
LukeGus 1c9f561f7f fix: build plugin sdk before linting in release workflow 2026-10-01 15:12:42 -05:00
LukeGus 7dbdf36557 fix: remote desktop tabs reconnecting on every tab switch 2026-10-01 14:38:49 -05:00
LukeGus e4ffebb69c chore: update release notes 2026-10-01 14:38:41 -05:00
LukeGus 30da0850da fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag 2026-10-01 13:14:11 -05:00
LukeGus 3b58415f8f fix: maintenance index name, shared host read only view and badge polling 2026-10-01 13:14:07 -05:00
LukeGus 5ff5ae19d9 fix: network graph lag when adding hosts and on every render 2026-10-01 13:14:03 -05:00
LukeGus 744510520d fix: session log player scrolling off screen horizontally 2026-10-01 13:14:00 -05:00
LukeGus deb86d11b6 fix: fleet detail buttons overflowing the sidebar 2026-10-01 13:13:55 -05:00
LukeGus f9ad5a0189 fix: onboarding rerunning for existing users after upgrading 2026-10-01 13:13:52 -05:00
LukeGus 95a5e75495 fix: file manager and sftp text showing raw i18n keys 2026-10-01 13:13:45 -05:00
LukeGus 9015d5dccb fix: redesign session log player controls, fix tooltip overlap and drop dot separators 2026-10-01 00:09:32 -05:00
LukeGus b3c8f37c0d fix: remove duplicate top border on split screen menu 2026-09-30 23:58:45 -05:00
LukeGus f3ed947a7c fix: recent activity card no longer cuts off host names early 2026-09-30 23:56:20 -05:00
LukeGus 6217b91ae2 feat: move telemetry out of core into a usage statistics plugin 2026-09-30 23:54:38 -05:00
LukeGus f8edb5f299 fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release 2026-09-30 23:27:34 -05:00
LukeGus 1c4b057fd8 fix: add description to the web endpoint enable switch 2026-09-30 23:14:26 -05:00
LukeGus 7f0a825583 fix: remote desktop auth method labels and notes for vnc and telnet 2026-09-30 23:14:21 -05:00
LukeGus d2a1be2eb0 fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default 2026-09-30 23:14:17 -05:00
LukeGus f101d29d8a fix: drop the top border on the split layout menu 2026-09-30 22:48:19 -05:00
LukeGus 49acec4770 fix: host status shows online when the port answers and only warns on a failed login 2026-09-30 18:18:38 -05:00
LukeGus c28cb1bfc6 fix: hide the built-in source pill in the host editor 2026-09-30 17:50:42 -05:00
LukeGus f97f35de1f fix: count plugin ssh logins toward host status and stop showing reachable as a warning 2026-09-30 17:49:09 -05:00
LukeGus 69b1b9805b fix: proxmox stats tab reads its switch from plugin host settings 2026-09-30 17:48:37 -05:00
LukeGus ba05b6a610 chore: fix dependabot alerts and apply safe npm upgrades 2026-09-30 17:18:26 -05:00
LukeGus 90c6540f85 fix: docker container logs use the base background color 2026-09-30 17:06:45 -05:00
LukeGus 14cf0946b3 fix: quick connect fills the host address from plugin auth editors like tailscale 2026-09-30 16:50:58 -05:00
LukeGus e1f2cc975d feat: restore the alert feed homepage widget 2026-09-30 16:21:22 -05:00
LukeGus 0d94ad5e5f revert: drop the alert feed widget, the notification center replaces it 2026-09-30 16:19:40 -05:00
LukeGus 84c94bf9a6 fix: declare terminal.listSessions use for snippet targets 2026-09-30 14:56:51 -05:00
LukeGus de9b4a064c chore: openapi docs for undocumented routes and fix two broken doc blocks 2026-09-30 14:55:13 -05:00
LukeGus 5ecf8156e6 chore: openapi docs for host metrics manager routes 2026-09-30 14:49:56 -05:00
LukeGus d8fb2e75af fix: show the remote desktop note under connection origin again 2026-09-30 14:45:18 -05:00
LukeGus 9ce63d65d3 fix: toolbar files button opens at the shell directory again 2026-09-30 14:42:29 -05:00
LukeGus 20f19ff13b fix: translate leftover hardcoded ui text 2026-09-30 14:39:28 -05:00
LukeGus 1b342322dc fix: translate hardcoded toasts and drop em dashes from ui text 2026-09-30 14:25:51 -05:00
LukeGus 3c66931245 feat: list the fixed app shortcuts in keyboard shortcuts 2026-09-30 14:19:17 -05:00
LukeGus 25cbd40283 feat: recent servers list when linking the desktop app 2026-09-30 14:17:31 -05:00
LukeGus cb12541c16 feat: share button on connection tabs again 2026-09-30 14:16:11 -05:00
LukeGus f2c9358ecf feat: pick target terminals for snippets again 2026-09-30 14:12:52 -05:00
LukeGus 80f089268a feat: bring back the alert feed homepage widget 2026-09-30 14:10:07 -05:00
LukeGus 5f2b10fed8 feat: tighter compact host rows with inline tags (#1325) 2026-09-30 14:03:56 -05:00
LukeGus 911fc3bc69 feat: optional auto reconnect for dropped ssh sessions (#1321) 2026-09-30 14:02:42 -05:00
LukeGus 41d0c0ab85 fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312) 2026-09-30 13:58:26 -05:00
LukeGus 09259de01c fix: cover the gnome close path in the desktop quit test (#1332) 2026-09-30 12:16:00 -05:00
LukeGus 71bbec7084 fix: load the linux tray icon reliably and log tray failures (#1332) 2026-09-30 12:15:35 -05:00
LukeGus da588f8fa1 fix: show disk usage for the mount being browsed (#1347) 2026-09-30 12:14:26 -05:00
LukeGus e3eada899c fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335) 2026-09-30 12:12:41 -05:00
LukeGus f896e121e2 fix: show meeting guests to members and hide the roster from guests (#1353) 2026-09-30 12:09:05 -05:00
LukeGus ab43563247 fix: build desktop session share links on the linked server (#1350) 2026-09-30 12:04:25 -05:00
LukeGus d30875483d fix: readable terminal selection with the light theme (#1319) 2026-09-30 11:58:35 -05:00
LukeGus e88df7827b fix: prefer ipv4 when resolving ssh host names (#1365) 2026-09-30 11:56:14 -05:00
LukeGus 4076eb66ab fix: ship the plugin sdk and serialport in desktop builds 2026-09-30 11:21:16 -05:00
ZacharyZcR b45d0f9ed2 feat(installer): make the Windows desktop shortcut optional (#1517) 2026-09-30 11:20:44 -05:00
LukeGus f4c9090b88 fix: type errors and lint failures from merged plugin prs 2026-09-30 10:04:17 -05:00
ZacharyZcR 9b690b9935 fix(tunnels): support TOTP for client tunnels and jump hosts (#1508) 2026-09-30 09:55:34 -05:00
ZacharyZcR ec7c2c2835 fix(session-sharing): use the linked server for desktop meetings and links (#1507) 2026-09-30 09:54:23 -05:00
ZacharyZcR 9801cc7fc7 feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513) 2026-09-30 09:53:17 -05:00
ZacharyZcR 99cffc9ba6 feat(automations): add per-host maintenance mode and recurring schedules (#1521) 2026-09-30 09:51:51 -05:00
ZacharyZcR fd91d4925a feat(sidebar): add keyboard type-ahead search for hosts (#1516) 2026-09-30 09:51:10 -05:00
ZacharyZcR 5b0423c8e0 fix(database): preserve RDP domain during startup backfill (#1497) 2026-09-30 09:50:32 -05:00
ZacharyZcR 1f4b1ff6a4 fix(hosts): preserve recording and sharing settings in the dev editor (#1509) 2026-09-30 09:49:13 -05:00
ZacharyZcR e161ad22e0 feat: reconnect all disconnected terminal tabs (#1523) 2026-09-30 09:48:15 -05:00
ZacharyZcR 5860921cbe fix(electron): allow explicit quit with active connections (#1522) 2026-09-30 09:47:26 -05:00
ZacharyZcR 4c2f54dac0 feat(tmux-monitor): add a unified multi-host view (#1520) 2026-09-30 09:47:21 -05:00
ZacharyZcR cbbb80bc82 fix(shortcuts): prevent accidental double-Shift palette activation (#1519) 2026-09-30 09:47:16 -05:00
ZacharyZcR 7d06686f62 feat(homepage): synchronize canvas layouts across devices (#1515) 2026-09-30 09:47:08 -05:00
ZacharyZcR fdb2f74259 feat(file-manager): default to the remote login directory (#1512) 2026-09-30 09:47:02 -05:00
ZacharyZcR e69029fbf7 feat(file-manager): allow disabling move-to-trash confirmation (#1511) 2026-09-30 09:46:56 -05:00
ZacharyZcR fef33142b3 feat(ssh-terminal): add a duplicate tab action (#1510) 2026-09-30 09:46:50 -05:00
ZacharyZcR 55d7c8837b fix(ai): sync provider configuration and keys across linked devices (#1506) 2026-09-30 09:46:45 -05:00
ZacharyZcR 4ed9b69aab fix(session-sharing): show participant presence in member terminals (#1504) 2026-09-30 09:46:40 -05:00
ZacharyZcR 0f6e48a95c fix(session-sharing): synchronize shared SSH terminal dimensions (#1502) 2026-09-30 09:46:35 -05:00
ZacharyZcR c265758cdd fix(ssh-terminal): close owner sockets when presentations unmount (#1501) 2026-09-30 09:46:29 -05:00
ZacharyZcR 24def2b613 fix(hosts): preserve plugin settings when reopening the editor (#1499) 2026-09-30 09:46:23 -05:00
ZacharyZcR 718717554d fix(sidebar): keep compact host addresses visible (#1524) 2026-09-30 09:46:00 -05:00
ZacharyZcR 64b9b18fe6 fix(file-manager): restore text selection and copying in Markdown previews (#1518) 2026-09-30 09:45:54 -05:00
ZacharyZcR a1f72b9e53 feat(remote-desktop): expose display zoom controls for RDP (#1514) 2026-09-30 09:45:49 -05:00
ZacharyZcR 12a373e598 fix(ssh-terminal): preserve input order during local echo reconciliation (#1505) 2026-09-30 09:45:44 -05:00
ZacharyZcR 126a8c433b fix(file-manager): refresh disk usage with directory listing (#1503) 2026-09-30 09:45:39 -05:00
ZacharyZcR f6ea218100 fix(session-sharing): replace local presentation after takeover (#1500) 2026-09-30 09:45:34 -05:00
ZacharyZcR fb28cd1f21 fix(remote-desktop): release held keys on focus loss (#1498) 2026-09-30 09:45:29 -05:00
LukeGus a4a4d4f27b feat: redesign user host defaults system 2026-09-30 06:19:56 -05:00
LukeGus 3028ab1176 fix: load and save plugin host settings in the editor and tighten settings row spacing 2026-09-29 22:40:01 -05:00
LukeGus 1a6f9ffad6 fix: keep plugin css below core utilities and version plugin assets by their css 2026-09-29 19:06:57 -05:00
LukeGus 80944301dd feat: harden plugin api for separate repos, signed updates and generic external login 2026-09-29 18:11:59 -05:00
LukeGus fb9c168b6b feat: plugins only see their own host settings in the browser 2026-09-29 13:38:11 -05:00
LukeGus 50d43e29fb feat: replace core homepage widget registry with generic extension points 2026-09-29 13:23:51 -05:00
LukeGus b8bbcd2196 feat: type the plugin host record, move plugin strings out of core and add lint checks 2026-09-29 13:08:39 -05:00
LukeGus bba06f7ff6 feat: store rdp, vnc and telnet logins in a generic per-protocol table 2026-09-29 12:33:09 -05:00
LukeGus e0c30a4e6f feat: move snippets, keybinding actions and macros out of core into plugins 2026-09-29 11:39:14 -05:00
LukeGus d9bba10612 feat: move terminal settings, look and ssh tools into ssh-terminal plugin 2026-09-29 02:38:37 -05:00
LukeGus ffa872ebd3 fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport 2026-09-28 21:59:37 -05:00
LukeGus 44062e30d1 feat: improve quick connect system using plugin api 2026-09-28 21:23:06 -05:00
LukeGus b4457b6121 fix(ai): stop streams getting cut off and keep tool steps across messages 2026-09-28 21:08:48 -05:00
LukeGus c5b2b880b6 feat(ssh-terminal): add host setting to turn off password prompt auto-fill 2026-09-28 20:51:16 -05:00
LukeGus f0d720cf05 feat(ssh-terminal): add toolbar position, start state, display and fade host settings 2026-09-28 20:45:23 -05:00
LukeGus 1fd89acd90 feat(snippets): bring back sharing, target hosts and drag reorder 2026-09-28 20:34:51 -05:00
LukeGus 796db8f0a8 fix(session-recording): stop session logs panel from refetching in a loop 2026-09-28 20:14:54 -05:00
LukeGus 75c3542ba0 feat(snippets): redesign panel to match the host list 2026-09-28 20:13:08 -05:00
LukeGus 278fc7e32a feat(sidebar): add host click behavior setting, default to always opening a new tab 2026-09-28 19:56:52 -05:00
LukeGus 81e7aa6274 feat(split-screen): redesign split screen with free-form panes and pane picker 2026-09-28 19:49:04 -05:00
ZacharyZcR 5f3372f9f6 fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation
2026-09-27 17:36:11 -05:00
ZacharyZcR 107cf55d8c fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation
2026-09-27 17:36:01 -05:00
ZacharyZcR 4e652720af fix(electron): signal backend readiness over IPC (#1488) 2026-09-27 17:35:40 -05:00
ZacharyZcR 407d358f6b fix(file-manager): show download progress in file viewers (#1487) 2026-09-27 17:35:33 -05:00
LukeGus 7b1e0294aa feat: add descriptions to plugin host enable toggles 2026-09-27 14:14:51 -05:00
LukeGus c7fd900732 feat: merge host editor terminal tabs into one owned by ssh-terminal 2026-09-27 14:03:02 -05:00
LukeGus fff373d5bd chore: remove references to plugin sdk ARCHITECTURE.md 2026-09-27 14:02:40 -05:00
LukeGus 4ce551822b chore: untrack plugin sdk ARCHITECTURE.md 2026-09-27 14:00:49 -05:00
LukeGus bbeed088ad fix: compute beta changes across dev branches and move the beta tag 2026-09-27 01:12:39 -05:00
ZacharyZcR 378be3405a fix(ldap): preserve the directory-provided bind DN (#1486) 2026-09-27 00:51:18 -05:00
LukeGus 4897024324 fix: make link-store secret check immune to random base64 matches 2026-09-27 00:50:14 -05:00
LukeGus 25d11b1edb Merge remote-tracking branch 'origin/dev-2.9.0' into dev-2.9.0 2026-09-27 00:45:11 -05:00
LukeGus 50a263a8dc feat: add plugin sdk build to beta release 2026-09-27 00:45:04 -05:00
ZacharyZcR 0c46950d4c fix(file-manager): pipeline SFTP download reads (#1485) 2026-09-27 00:39:03 -05:00
ZacharyZcR 98cf62547f fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test
2026-09-27 00:38:55 -05:00
LukeGus 4423c4c8ef fix: keep plugins loaded after signing in from the login screen 2026-09-27 00:38:06 -05:00
LukeGus 8043d6dbdd chore: simplify plugin readmes, changelogs and manifest descriptions 2026-09-27 00:24:48 -05:00
ZacharyZcR 7423e33184 fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test
2026-09-27 00:12:08 -05:00
ZacharyZcR 2824b49ba9 fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:11:41 -05:00
ZacharyZcR 3b9a1bacf0 fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures
2026-09-27 00:11:09 -05:00
ZacharyZcR c98003f90e fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test
2026-09-27 00:10:51 -05:00
ZacharyZcR 44f751a5e5 fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:10:41 -05:00
ZacharyZcR 322f811f2c fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:10:28 -05:00
ZacharyZcR 2a2ba2f790 fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain
2026-09-27 00:10:19 -05:00
LukeGus 26d52c7631 fix: keep rounded-full circular in production builds 2026-09-26 18:49:48 -05:00
ZacharyZcR 2770f14a25 fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F
2026-09-26 18:19:35 -05:00
ZacharyZcR 5e02e30d0f fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown
2026-09-26 18:19:25 -05:00
ZacharyZcR 8192886a69 docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields
2026-09-26 18:19:15 -05:00
ZacharyZcR 4a1880acb8 fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels
2026-09-26 18:19:05 -05:00
ZacharyZcR 488e940c1a fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent
2026-09-26 18:18:40 -05:00
ZacharyZcR f05f626507 fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:30 -05:00
ZacharyZcR ec982aadb5 fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:21 -05:00
ZacharyZcR 710f545e34 fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL
2026-09-26 18:18:12 -05:00
LukeGus c38c2e84b8 fix: link every plugin workspace before npm ci in docker deps stage 2026-09-26 13:05:16 -05:00
LukeGus 2ca7a36dbc fix: link plugin-sdk workspace before npm ci in docker deps stage 2026-09-26 12:56:31 -05:00
LukeGus e14c0b32e0 fix: set blacksmith builder cache-key to stop broken gha cache fallback 2026-09-26 12:53:21 -05:00
LukeGus 6cd9a2846d feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling 2026-09-26 00:41:25 -05:00
LukeGus 7c02daccf4 feat: unify alerts into one plugin, channels, rules and inbox 2026-09-25 16:26:37 -05:00
LukeGus fbe736bc20 feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning 2026-09-25 13:06:33 -05:00
LukeGus c21bca8cf0 feat: show credential sharing inline in the sidebar like host sharing 2026-09-25 12:03:41 -05:00
LukeGus 4a320a2258 feat: improve UI loading time and jitter 2026-09-25 11:33:24 -05:00
LukeGus 0f8956dcf3 fix: give bundled plugin backends a real require for CJS deps 2026-09-25 10:13:52 -05:00
LukeGus f779d6a08e test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught 2026-09-25 09:58:45 -05:00
LukeGus 2ee9b3592c feat: add plugin packing, signing, pinned trust and tmxplug bundling 2026-09-25 09:21:28 -05:00
LukeGus c6a44b7ff3 fix: harden the plugin runtime after the 2.9.0 security review 2026-09-25 08:48:54 -05:00
LukeGus 2961297989 chore: lock the plugin boundary and remove 2.9.0 leftovers 2026-09-25 07:24:46 -05:00
LukeGus 6489b62ece fix: finish 2.9.0 plugin moves and get every check green 2026-09-24 23:35:18 -05:00
LukeGus 8c1e02af89 feat: move acme certificate automation into a plugin (C7) 2026-09-24 21:19:27 -05:00
LukeGus 4dbce08379 feat: move termix identity into a plugin (C6) 2026-09-24 20:50:54 -05:00
LukeGus 6e56c90aac feat: move vault ssh signing into a plugin (C5) 2026-09-24 20:24:09 -05:00
LukeGus b027717c46 feat: move step-ca into a plugin (C4) 2026-09-24 19:55:28 -05:00
LukeGus 98d367d7e2 feat: move opkssh and warpgate into plugins (C3) 2026-09-24 19:28:19 -05:00
LukeGus 925da4a661 feat: move sso and ldap logins into plugins (C2) 2026-09-24 18:23:20 -05:00
LukeGus 5c18435105 feat: move totp and passkeys into plugins (C1) 2026-09-24 16:57:24 -05:00
LukeGus f4f26a9b8b feat: convert secret-sources into a plugin (B20) 2026-09-24 16:20:20 -05:00
LukeGus 2ac7779eda feat: convert wake-on-lan into a plugin (B20) 2026-09-24 15:49:55 -05:00
LukeGus d4ec444558 feat: finish the homepage plugin, move service links and canvas out of core (B19) 2026-09-24 15:07:25 -05:00
LukeGus 21937a6b02 feat: finish the ai plugin (B18) 2026-09-24 13:43:26 -05:00
LukeGus 6b4d1432ab feat: finish the automations plugin (B17) 2026-09-24 13:05:44 -05:00
LukeGus 993a91c899 feat: finish the docker plugin (B15) 2026-09-24 11:58:20 -05:00
LukeGus 0a3f05d99d feat: finish the host-metrics plugin, move host status into core (B16) 2026-09-24 11:21:23 -05:00
LukeGus 14eb558b85 feat: finish the remote-desktop plugin (B14) 2026-09-24 01:02:45 -05:00
LukeGus 0af2c94983 feat: turn session recording into a plugin (B13) 2026-09-24 00:09:48 -05:00
LukeGus a276f6898c feat: move session sharing and collab rooms into a plugin (B12) 2026-09-23 23:42:46 -05:00
LukeGus 791d8656a1 feat: convert serial console into the serial plugin 2026-09-23 23:06:37 -05:00
LukeGus 708eedb861 feat: convert tmux monitoring into a plugin (B10) 2026-09-23 22:42:56 -05:00
LukeGus 79d77678c9 feat: move the terminal fully into the ssh-terminal plugin (B9) 2026-09-23 21:41:22 -05:00
LukeGus 535126fd1b feat: finish the web-endpoint plugin, add ctx.desktop (B8) 2026-09-23 18:36:41 -05:00
LukeGus 1682861853 feat: convert ssh tunnels into a plugin 2026-09-23 17:27:28 -05:00
LukeGus c85c1fdb86 feat: convert file manager into a plugin 2026-09-23 16:27:29 -05:00
LukeGus b76da9ab52 Merge remote-tracking branch 'origin/dev-2.9.0' into dev-2.9.0 2026-09-23 13:10:24 -05:00
LukeGus c381955ed0 feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4) 2026-09-23 13:10:18 -05:00
LukeGus bf147096eb feat: drop proxmox host columns, use plugin settings instead 2026-09-23 13:06:43 -05:00
LukeGus 37c015a760 feat: move proxmox node stats and collectors into the proxmox plugin 2026-09-23 13:06:18 -05:00
LukeGus 218c5088e7 feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox 2026-09-23 13:06:06 -05:00
LukeGus f9283ec83f chore: regenerate drizzle migration journals for B5 2026-09-23 13:05:55 -05:00
LukeGus 115bc515e1 chore: update proxmox and host-metrics locale files for B5 2026-09-23 13:05:19 -05:00
LukeGus 3b2c849514 feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4) 2026-09-23 11:24:16 -05:00
LukeGus d2ff5fc78f feat: finish the tailscale plugin, including SSH auth (B3) 2026-09-23 09:41:20 -05:00
LukeGus c77301577f feat: wire ai, automations, fleets and ssh-terminal to snippets (B2) 2026-09-23 08:59:24 -05:00
LukeGus 3656caf168 feat: add the snippets plugin (B2) 2026-09-23 08:59:16 -05:00
LukeGus 1eac0f54b0 feat: point core frontend snippets pickers at the plugin (B2) 2026-09-23 08:59:06 -05:00
LukeGus e07221eaf3 feat: move snippets backend and tables into a plugin (B2) 2026-09-23 08:58:53 -05:00
LukeGus fdedca39fd feat: convert network-topology to a full plugin (B1) 2026-09-23 07:36:39 -05:00
LukeGus 95abe24798 fix: gate second factors after SSO/LDAP behind an admin setting 2026-09-23 07:17:03 -05:00
LukeGus f217d8db89 feat: rebuild workspaces as the reference plugin 2026-09-23 07:04:03 -05:00
LukeGus d774df8b41 feat: add auth extension points and one ssh connect pipeline 2026-09-23 00:44:33 -05:00
LukeGus d3c209af13 fix: mock the ai status fetch in its activate test 2026-09-22 20:20:47 -05:00
LukeGus e88d5a52e3 feat: load plugin frontends at runtime and remove plugin ids from the shell 2026-09-22 20:15:52 -05:00
LukeGus 49f0286a14 feat: give plugins their own settings and one settings screen 2026-09-22 18:31:19 -05:00
LukeGus cc15559ea1 feat: namespace plugin permissions and keep them valid when a plugin is off 2026-09-22 17:30:00 -05:00
LukeGus 8431570ff7 feat: serve plugin http and websockets from one mount instead of ports 2026-09-22 16:39:41 -05:00
LukeGus c496a093ca feat: give plugins their own tables, migrations and sync entities 2026-09-22 15:49:54 -05:00
LukeGus 5796186a1c feat: make each plugin an npm workspace built by a termix-plugin CLI 2026-09-22 15:09:50 -05:00
LukeGus 4758016f41 feat: collapse plugin runtime to one tier behind a plugin SDK 2026-09-22 14:11:50 -05:00
LukeGus 6e058f6e9e feat: move web endpoint into a first-party plugin 2026-09-22 12:30:35 -05:00
LukeGus 6f0dff58a3 feat: move workspaces into a first-party plugin 2026-09-22 10:45:15 -05:00
LukeGus 8742c5d381 feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
2026-09-22 10:45:15 -05:00
LukeGus 5fbd5fb7e2 feat: move network topology into a first-party plugin 2026-09-22 10:45:15 -05:00
LukeGus e372b0eda7 feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
2026-09-22 10:45:12 -05:00
LukeGus e0f14f2ecf feat: move automations into a first-party plugin 2026-09-22 08:14:03 -05:00
LukeGus a6f6a0439c feat: move fleets into a first-party plugin 2026-09-21 20:44:00 -05:00
LukeGus 76b2e5739a feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
2026-09-21 20:26:09 -05:00
LukeGus c6bcfdb80d feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
2026-09-21 20:25:52 -05:00
LukeGus d440ce9073 feat: move proxmox discovery/import/sync into a first-party plugin 2026-09-21 19:20:17 -05:00
LukeGus 9db66d5780 feat: add cross-plugin secret references gated by the provider's RBAC permission 2026-09-21 17:09:28 -05:00
LukeGus 2d6c1cdecb feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
2026-09-21 16:56:15 -05:00
LukeGus 225f618337 feat: add a permission-gated backend service registry for plugins 2026-09-21 16:23:00 -05:00
LukeGus ee2d6b4697 fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
2026-09-21 15:25:17 -05:00
LukeGus 504710b065 feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
2026-09-21 15:20:05 -05:00
LukeGus c58ccbfc3f fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
2026-09-21 14:32:59 -05:00
LukeGus 19b70430a1 feat: ship host metrics as a first-party in-process plugin 2026-09-21 11:30:46 -05:00
LukeGus a2479c19b0 feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
2026-09-21 09:55:37 -05:00
LukeGus c94a453f1e feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
2026-09-21 08:28:50 -05:00
LukeGus e040b12045 fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
2026-09-21 07:23:48 -05:00
LukeGus 4e1dec8435 feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
2026-09-21 07:19:02 -05:00
LukeGus 4311a0e98a feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
2026-09-21 07:19:01 -05:00
LukeGus 1393fa7581 feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
2026-09-21 07:19:01 -05:00
LukeGus 0a771c9600 feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
2026-09-21 07:19:01 -05:00
LukeGus 9f4a2d3951 feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
2026-09-21 07:19:01 -05:00
LukeGus 248698f62d feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
2026-09-21 07:19:01 -05:00
dependabot[bot] a1805cb7ae chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:27:00 -05:00
dependabot[bot] f92ad3a3f7 chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

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

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:25:56 -05:00
dependabot[bot] ae011e6282 ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:25:17 -05:00
LukeGus 7f1beddccc feat: add category and icon fields to plugin manifest schema 2026-09-20 21:06:16 -05:00
LukeGus 5a76d30637 chore: increment ver 2026-09-20 21:00:38 -05:00
LukeGus 6b56accec8 fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
2026-09-20 20:24:43 -05:00
LukeGus f694023a65 fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
2026-09-20 19:46:04 -05:00
LukeGus beb5145475 fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
2026-09-20 19:12:34 -05:00
LukeGus a9a3bbc5f4 fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
2026-09-20 19:06:59 -05:00
LukeGus 7727fa09fb fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
2026-09-20 19:06:54 -05:00
LukeGus e9fd092ebd fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
2026-09-20 19:06:07 -05:00
LukeGus afae2889cb feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
2026-09-20 18:59:33 -05:00
LukeGus f8a8139031 feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
2026-09-20 18:53:38 -05:00
LukeGus 4052d63594 feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
2026-09-20 18:48:40 -05:00
LukeGus 0b7e616419 feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
2026-09-20 18:17:57 -05:00
LukeGus d0c25cf183 feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
2026-09-20 18:17:29 -05:00
LukeGus 6ce5e78165 feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
2026-09-20 18:16:45 -05:00
LukeGus 42e7861073 feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
2026-09-20 18:15:45 -05:00
LukeGus 2c8d13323b feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
2026-09-20 18:12:35 -05:00
LukeGus da840b925c feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
2026-09-20 18:12:10 -05:00
LukeGus 6837144ad5 feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
2026-09-20 18:10:56 -05:00
LukeGus db2c90838f feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
2026-09-20 18:10:13 -05:00
Koi 0ab6a3e674 Fix/backend optional sharp (#1455)
Fix/backend optional sharp
2026-09-20 17:58:29 -05:00
Koi d603d560c3 fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
2026-09-20 17:58:20 -05:00
107 changed files with 569 additions and 2317 deletions

No files matched your search

+65 -13
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 }}"
sync-main:
crowdin:
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,27 +265,79 @@ 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
# -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
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
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, 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') }}
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') }}
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.2"
sha256 "808d876b37ab802c511e964c232619b1ba2949ec39ccd2b1f97e264544739172"
version "2.9.1"
sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
-1
View File
@@ -1,4 +1,3 @@
Copyright 2026 Luke Gustafson
Apache License
Version 2.0, January 2004
+9
View File
@@ -0,0 +1,9 @@
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": "34aa172b2f6bc6a773ebdff2ec0f590d411ce258fd43176bdddb4c30d1fe30da"
"sha256": "05b7b4c27897135437af6340c5c31501d2e96ac013dca0672940b74c76085433"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.0/ai-1.0.0.tmxplug",
"sha256": "ad291a2c6f6f01b72ebd275e8d202b78f55bc13a1e1a40f419d04e71b2fbd06b"
"sha256": "ed7a64a4be5978238427d4abbd2153ef2b44c17ebfec78fcb5df579967d01eed"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.0/alerts-1.0.0.tmxplug",
"sha256": "3fe008f9d2a32e2b7111399054a2264ea5cf5d71264cc31e85cdab3e909cc62f",
"sha256": "97e7be293ace897cd45615a8764e8395cf613546b86de82601ab49669dc36591",
"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": "76d4cff6e78d40ec67e899f0c7b2aca43d35a47b9a15071d0a8f5a8d939af9a9"
"sha256": "0972aef1c14917321c035f7497db81fe95f6b9e47d854b95b1c762444d9cb2a2"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.0/docker-1.0.0.tmxplug",
"sha256": "641e564ba915a72efed4236272d63f6fff43904309833870f3554d3e9bd28dfa",
"sha256": "ca9b8cf573d27a71adb544fd6ff4d19278349f10fdca98f9cc63d7fec63bc441",
"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": "1cafccfe86a29e2f318026ca7bc20629df94331c81209607976dafd7feac0ca4",
"sha256": "d1d92ae051c79c7ce6f05c7dbf33c96de9d8b58083eaccb104d1d21a6bd8b58f",
"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": "1ebeccd6b2a8e9510ab56658374d0b87f652149101d14956752a696ca920f611"
"sha256": "410b19f3a6f47e2a292821b11876b8b5a0bdf779f8d852d5ac04a7501d0f12c0"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.0/homepage-1.0.0.tmxplug",
"sha256": "65cd21802f6a71f3b3c582bc0007bfc91160abc161b2390bce715fd2ed987f99"
"sha256": "5652e378dbb208c7bb349ba2262034077f1cfca95b2d6d029f4186c70ed27999"
},
{
"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": "1afce5489038e5e30b55210e8e43ba93e0446fe087e2952af75a402dbf809ea9",
"sha256": "ca50e27f06bc7a275bd1bfb1deb5ac97f19023c84567105fe74e075602ec86da",
"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": "1df08a931457043ba500689a73b4b5ab2bfb43630f377557e0fb13280b996334"
"sha256": "44d3f9e9be913b9791a084aff588d7d4217dd5cd3f61178006ed4fa2455cad23"
},
{
"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": "dc3ad943c1753b352b4d80c09836a293a8767293061ddf2e641575bae97124e9"
"sha256": "ba3db2a6bc87533c8273648549c466b95f2c7538700407b2d62e48f813526bac"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.0/opkssh-1.0.0.tmxplug",
"sha256": "91cc1575aa1ff84632e0b6a4da68df3a7df4b8e18431b9c0378d314b886ed55d"
"sha256": "05884e2244b4cc7f7f0465fe4b775ff95a99183ce6aba60848b68f1f3ec4078f"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.0/proxmox-1.0.0.tmxplug",
"sha256": "09891a3ab67a5b6589d8ab9e0fc88d8c63ff9d4e52ed8e7cbd0801a5e64e593f"
"sha256": "3a304ba327352093f4bf5dff1a7d6fc981a83180ffc2e06fe1c63e59600e8e3c"
},
{
"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": "7b24691953550458ee1609372443eeb5580d20091b02c95011c0427268350efc",
"sha256": "2dd13c79066c99816f78b3c8aa5f3e31e17d3d2568f2e48575ef1e01f1195c28",
"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": "04e9cfca70ffafc41546ddf0f1f6411743a2e0e9835b26ad5c0b91ac11028485"
"sha256": "fdab87ca97ea05c60d7008b22da4f56c4897abf11505b6fa8b5c7bd8bc1a53e3"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.0/serial-1.0.0.tmxplug",
"sha256": "2470acbd6c4c343c3d89dada972051f1ee7aace88af1e6719b92f19a4a6e46ea",
"sha256": "a645144a11e591f9668f2e7b23b8423274006180862be9c5f3e59df361c27e5e",
"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": "6b630ca0e08d79be7cb49366c898586ee3416a518f8e200157a6191c2c7c9ab7"
"sha256": "3a9ab3a3638d0cf13a4c7debbcd315d9a324b9991faea1c192ae1e8fda1e525b"
},
{
"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": "0c0d3f2d63184d4a4b2a90b11a1ee247ddc937abba76af74eb39e27048b412f9"
"sha256": "f838e53401f6d75923821797829806b9ca03f0dd64fe87f8524d14a2e05f684e"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.0/snippets-1.0.0.tmxplug",
"sha256": "47872d08cdf4a15b50c8f90ca125276a92579147a27cb6b5639a84a01160abe5",
"sha256": "a434dd442c54fd7a124f4ebc9579a0af1aa4b07b1a90e3875f8ae72c35ced410",
"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": "5b5a357bbf9a503c6b68725245712a99fa8ea0eec0baff64319be28950acac79",
"sha256": "64031970a7b6eb1c860aac79c1587a582ccd38b2ffcd5974b51a716240a8474d",
"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": "d9f69f7f52b778d1b144c58e5a65006780498d6fa171b5aeb18e15b7b033ca6b",
"sha256": "2045d1da03dcd47089cb6566a7f25fe021326cb556cbc36b01563a2c234d1230",
"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": "05209afbaeb104ff77fecb5cf26a7823b9ed66031eac7557d3216fc3b861f7a6"
"sha256": "fc38ac6dcf8c43c550b7a7c65836a095ec5a8bae76c8797b5563b2c6c8e8dad1"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.0/tailscale-1.0.0.tmxplug",
"sha256": "e9840bddef696b24354b9f04f2c97e164c3879f3fd1b337347de728db522874e"
"sha256": "f7732afe584044258cc46205fee327223cdd290e1ba6e6858ddd22be6b3ef12d"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.0/telemetry-1.0.0.tmxplug",
"sha256": "b41210d638b93470891ab238e3d1180a0e0d0f0c697b1c2079156416dec05966",
"sha256": "103e2ef2d52993bf15fdf2cda0addfa4aeba5817b4340f28e3fdea634aba6af0",
"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": "facd9f1c6036202e7c6b1020408ce49c7ba4bc687a8f8b93ccf60e97f473f897"
"sha256": "f1f712178b5d64ae263227640b61d1f5ea5a301eae3ff8adb8b52de5f20892fe"
},
{
"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": "d453e0c5f1bf54160615d5df4e12eac0d97fea3c78c0aad5ebe8552e1f2de42c"
"sha256": "d6a98bcf99dbb31201d7079baf348100bca1c0eda0928aec4aeb075ea373a8d5"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.0/totp-1.0.0.tmxplug",
"sha256": "a2d389048d16e31c089cf211fe2fb37fcfb33369497b58ee445e8626e9ec494d",
"sha256": "aed42422ccc38c661f1aac2f7409f7872acf271552d6623b79bbcc43813d4fca",
"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": "dc6c2942490c07ec1f9a4065850288cbb00c42dae64397937d2551d689314939",
"sha256": "215eae643d61d032bb6190bab7ed42140fd91c2165e2c4de079ff541e1522763",
"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": "bdad88ed576ed30b464c9cbddab4d9bb60696581dc1b815b76e1b4a08500f870"
"sha256": "0bbd95002866807ddd469f102d09bff9cf1b39e005dec6b96530066c6c3b0d19"
},
{
"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": "3e7d4c8a9a20cde95d8594d4ae8e1fa0469d0751be9f00249f6431b10ada9ea9"
"sha256": "39b97642e44e7b128df3632b10962bff6a26313f2e989e5acd29760cf91c4e04"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.0/warpgate-1.0.0.tmxplug",
"sha256": "18ed1c8238ecf57b5776ad93c6533d7040b988ae765012c5da591b85faa02d2e"
"sha256": "a7b7ed6d39639d0d6050958417d33d646a0304f99dec4eb9da71a46e42ae80f1"
},
{
"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": "7f69e608eccd8cc6fa2436bffb3e2c3c8aae6cebebf7341913f15e7f9be58e42"
"sha256": "22f5f276f6dab85e9b211f8e990f82fcbf63ebb8fa90379463f64d0fa38905ab"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.0/webauthn-1.0.0.tmxplug",
"sha256": "dafcd7bfffc39b60a14333164dfd8ca70986da1390285359669d0293cd050121",
"sha256": "50994ae148b2a5e68cf924b922eb2fb22cf6215905049dd5b61a16e30a376a50",
"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": "1ee3a07bd8d61b46b6fbe118d2a424f8027575282287622b467e83a4356e6412",
"sha256": "23c6d2b0845b0e854dc6dde0ab4b60b9a58af020099fd4f6fcc1463a48d9ee62",
"onboarding": {
"recommended": true,
"consent": false
+1 -1
View File
@@ -12,7 +12,6 @@
"packages/*"
],
"dependencies": {
"@noble/hashes": "^2.4.0",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/semver": "^7.8.0",
@@ -2877,6 +2876,7 @@
"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,7 +47,6 @@
"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, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, 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: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

-7
View File
@@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 528 B

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

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 39 KiB

+6 -6
View File
@@ -1,7 +1,7 @@
<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"/>
<?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>

Before

Width:  |  Height:  |  Size: 537 B

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 383 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 B

After

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 683 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 574 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+126
View File
@@ -0,0 +1,126 @@
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
@@ -0,0 +1,21 @@
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);
});
});
+99 -73
View File
@@ -1,5 +1,5 @@
import sharp from "sharp";
import { readFileSync, writeFileSync, mkdirSync, copyFileSync } from "fs";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
@@ -10,98 +10,124 @@ const iconsDir = join(publicDir, "icons");
mkdirSync(iconsDir, { recursive: true });
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 svgBuffer = readFileSync(join(publicDir, "icon.svg"));
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)])),
);
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],
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`)),
),
);
const winSizes = [16, 24, 32, 48, 64, 128, 256];
writeFileSync(
join(publicDir, "icon.ico"),
buildIco(
winSizes.map((s) => png[s]),
winSizes,
),
// 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()),
);
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, "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.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"));
copyFileSync(join(publicDir, "icon.icns"), join(iconsDir, "icon.icns"));
console.log(" ✓ icons/icon.ico");
console.log("Icons generated from public/icon-square.svg");
console.log(
"\nDone! Note: icon.icns requires macOS tools (iconutil). Use electron-builder on macOS to generate it.",
);
/**
* 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;
let offset = headerSize + pngBuffers.length * dirEntrySize;
const dirSize = headerSize + count * dirEntrySize;
let offset = dirSize;
const entries = pngBuffers.map((buf, i) => {
const entry = { size: sizes[i], buf, offset };
const size = sizes[i];
const entry = {
size,
width: size > 255 ? 0 : size,
height: size > 255 ? 0 : size,
buf,
offset,
};
offset += buf.length;
return entry;
});
const ico = Buffer.alloc(offset);
ico.writeUInt16LE(0, 0);
ico.writeUInt16LE(1, 2);
ico.writeUInt16LE(entries.length, 4);
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
entries.forEach((e, i) => {
const base = headerSize + i * dirEntrySize;
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);
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
});
// 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(
"You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
"Registration is disabled",
403,
"registration_disabled",
);
+2 -3
View File
@@ -3,8 +3,7 @@
* 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.
*
* 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.
* A 2.9 database has the plugins table; a 2.8 one has hosts but no plugins.
*/
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.";
@@ -12,7 +11,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("plugin_migrations"))) {
if ((await hasTable("ssh_data")) && !(await hasTable("plugins"))) {
throw new Error(PRE_29_DATABASE_ERROR);
}
}
@@ -152,15 +152,6 @@ 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,15 +173,6 @@ 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,7 +23,6 @@ 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,
@@ -136,16 +135,6 @@ 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,
@@ -828,7 +817,6 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -1034,7 +1022,6 @@ export function registerHostBulkRoutes(
}
await applyDefaultsAfterHostWrites(writtenHostIds);
notifyImportedHosts(userId, writtenHostIds);
res.json({
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
@@ -80,14 +80,6 @@ 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,7 +31,6 @@ import {
import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
keepsStoredSshAuth,
isNonEmptyString,
isOptionalBoolean,
isValidPort,
@@ -922,8 +921,6 @@ 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;
+25 -43
View File
@@ -82,55 +82,37 @@ router.get("/public", async (_req: Request, res: Response) => {
try {
const records = await createCurrentPluginRepository().listAll();
const { loader } = getPluginRuntime();
type GuestManifest = {
contributes?: {
guest?: boolean;
guestViews?: string[];
tabs?: unknown[];
panels?: unknown[];
};
dependencies?: Record<string, string>;
optionalDependencies?: Record<string, string>;
};
const guests = records.flatMap((record) => {
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>;
};
try {
const manifest = JSON.parse(record.manifestJson) as GuestManifest;
return manifest?.contributes?.guest === true
? [{ record, manifest }]
: [];
manifest = JSON.parse(record.manifestJson);
} catch {
return [];
}
});
// 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 ?? [],
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),
},
dependencies: onlyGuests(manifest.dependencies),
optionalDependencies: onlyGuests(manifest.optionalDependencies),
...describePluginFrontend(loaded),
};
];
});
res.json(plugins);
} catch (error) {
-75
View File
@@ -75,74 +75,6 @@ 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;
@@ -368,7 +300,6 @@ router.post(
});
}
await pruneHostGrants(hostId);
databaseLogger.success("Host shared successfully", {
operation: "rbac_host_share_success",
userId,
@@ -849,7 +780,6 @@ router.patch(
hostId,
update,
);
await pruneHostGrants(hostId);
databaseLogger.info("Host access grant updated", {
operation: "rbac_host_access_update",
@@ -933,7 +863,6 @@ router.delete(
accessId,
hostId,
);
await pruneHostGrants(hostId);
databaseLogger.info("Permission revoked", {
operation: "rbac_permission_revoke",
adminId: userId,
@@ -1454,7 +1383,6 @@ router.delete(
for (const userId of deletedUserIds) {
permissionManager.invalidateUserPermissionCache(userId);
}
await pruneGrantsBy(deletedUserIds);
res.json({
success: true,
@@ -1747,7 +1675,6 @@ router.delete(
}
permissionManager.invalidateUserPermissionCache(targetUserId);
await pruneGrantsBy([targetUserId]);
databaseLogger.info("Role removed from user", {
operation: "rbac_role_remove",
adminId: req.userId!,
@@ -1961,7 +1888,6 @@ router.post(
success: true,
});
await pruneCredentialGrants(credentialId);
res.json({ success: true, permissionLevel, expiresAt });
} catch (error) {
databaseLogger.error("Failed to share credential", error, {
@@ -2045,7 +1971,6 @@ 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, {
@@ -1,64 +0,0 @@
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,7 +26,6 @@ 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";
@@ -1531,7 +1530,6 @@ registerUserExternalAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerUserSocketTicketRoutes(router, { authenticateJWT, authManager });
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
-28
View File
@@ -61,34 +61,6 @@ 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,30 +495,6 @@ 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>,
@@ -536,12 +512,8 @@ export async function resolveOwnerProtocolLogin(
const authType = login?.authType || "direct";
let username = login?.username ?? "";
let password = login?.password ?? "";
const credentialId =
authType === "credential"
? (login?.credentialId ?? (await inheritedCredentialId(ownerId, hostId)))
: null;
if (credentialId) {
const credential = await findUsableCredential(credentialId, ownerId);
if (authType === "credential" && login?.credentialId) {
const credential = await findUsableCredential(login.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;
+1 -58
View File
@@ -251,7 +251,6 @@ 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.
@@ -362,13 +361,6 @@ 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,
@@ -438,15 +430,7 @@ export class PluginLoader {
throw error;
}
} finally {
// 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),
}),
);
await fs.promises.rm(staging, { recursive: true, force: true });
}
}
@@ -900,44 +884,3 @@ 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(() => {});
}
}
+3 -29
View File
@@ -551,19 +551,6 @@ 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[];
@@ -627,26 +614,13 @@ export async function setPluginStateUnlocked(
return { ...plan, state: "blocked" };
}
}
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 };
return { ...plan, state: await startAndRecord(pluginId) };
}
for (const id of plan.disable) {
await recordState(id, "disabled", STOPPED_WITH + pluginId);
for (const id of [...plan.disable, pluginId]) {
await recordState(id, "disabled", null);
await deactivatePlugin(id);
}
await recordState(pluginId, "disabled", null);
await deactivatePlugin(pluginId);
return { ...plan, state: "stopped" };
}
+7 -14
View File
@@ -29,8 +29,6 @@ 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;
}
@@ -52,17 +50,14 @@ 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 === user[1] ||
(column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText"),
column.type !== "id" &&
!column.primaryKey &&
column.type !== "encryptedText",
)
.map(([property, column]) => columnName(property, column)),
userIsKey: !!user[1].primaryKey,
definition,
});
}
@@ -244,15 +239,13 @@ 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(
matchOn.map((column) => {
const value = values[columns.indexOf(column)];
return value === null
columns.map((column, index) =>
values[index] === null
? sql`${sql.identifier(column)} IS NULL`
: sql`${sql.identifier(column)} = ${value}`;
}),
: sql`${sql.identifier(column)} = ${values[index]}`,
),
sql` AND `,
)} LIMIT 1`,
);
+1 -3
View File
@@ -113,9 +113,7 @@ 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, {
allowSocketTicket: true,
});
const payload = await AuthManager.getInstance().verifyJWTToken(token);
if (!payload || payload.pendingTOTP) return null;
return payload.userId ?? null;
} catch {
@@ -37,18 +37,6 @@ 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,15 +45,6 @@ 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 plugin tables are there
* Hosts and shares exist, the column does not. The plugins table is there
* because 26.10.0 only opens a database 2.9 has already upgraded.
*/
function writePreUpgradeDatabase(): void {
@@ -54,15 +54,6 @@ 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 and plugins but no plugin_migrations", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins")),
).rejects.toThrow(PRE_29_DATABASE_ERROR);
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("opens a database 2.9 has upgraded", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins", "plugin_migrations")),
assertNotPre29Database(tables("ssh_data", "plugins")),
).resolves.toBeUndefined();
});
@@ -23,25 +23,3 @@ 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,10 +1,8 @@
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", () => {
@@ -128,21 +126,3 @@ 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,7 +4,6 @@ import {
applyHostKeyTypeUpdate,
containsOwnerPrivateAuthUpdate,
isNonEmptyString,
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeImportedHost,
@@ -506,12 +505,3 @@ 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,52 +276,12 @@ 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",
@@ -1,107 +0,0 @@
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"]);
});
});
@@ -1,100 +0,0 @@
/**
* 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();
});
});
@@ -1,59 +0,0 @@
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);
});
});
@@ -1,60 +0,0 @@
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" });
});
});
+1 -69
View File
@@ -11,13 +11,7 @@ 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 zlib from "node:zlib";
import {
PluginLoader,
assertBundledUpdate,
removeStaleStaging,
unsafeArchivePath,
} from "../../plugins/loader.js";
import { PluginLoader, assertBundledUpdate } from "../../plugins/loader.js";
import { createFixturePlugin } from "./fixture-plugin.js";
vi.mock("../../database/repositories/factory.js", () => ({
@@ -555,65 +549,3 @@ 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,34 +540,6 @@ 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,44 +117,6 @@ 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', ?)",
+3 -28
View File
@@ -14,7 +14,6 @@ import { WebSocket } from "ws";
const state = vi.hoisted(() => ({
validToken: "valid-token",
ticket: "socket-ticket",
userId: "user-1",
granted: new Set<string>(["network:serve"]),
}));
@@ -32,14 +31,8 @@ vi.mock("../../utils/logger.js", () => ({
vi.mock("../../utils/auth-manager.js", () => ({
AuthManager: {
getInstance: () => ({
verifyJWTToken: async (
token: string,
options: { allowSocketTicket?: boolean } = {},
) =>
token === state.validToken ||
(token === state.ticket && options.allowSocketTicket)
? { userId: state.userId }
: null,
verifyJWTToken: async (token: string) =>
token === state.validToken ? { userId: state.userId } : null,
}),
},
}));
@@ -101,10 +94,9 @@ 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, protocols, { headers });
const socket = new WebSocket(url, { headers });
socket.once("open", () => resolve({ outcome: "open", socket }));
socket.once("unexpected-response", (_req, res) =>
resolve({ outcome: res.statusCode ?? 0 }),
@@ -236,23 +228,6 @@ 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);
@@ -1,26 +0,0 @@
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,42 +170,4 @@ 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,29 +83,4 @@ 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);
});
});
+1 -24
View File
@@ -42,8 +42,6 @@ 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;
@@ -395,32 +393,11 @@ class AuthManager {
}
}
/**
* 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> {
async verifyJWTToken(token: string): 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,14 +7,10 @@ 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);
+11 -14
View File
@@ -20,7 +20,6 @@ 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 {
@@ -3102,19 +3101,17 @@ export function AppShell({
</div>
</SurfaceScope>
{/* 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 })}
/>
)}
{/* 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 })}
/>
</div>
{/* Right dock, desktop only, holds a second reference panel */}
+7 -4
View File
@@ -1,6 +1,5 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time";
export interface LineChartSeries {
key: string;
@@ -24,8 +23,12 @@ 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 = parseServerTime(ts);
const d = parseTs(ts);
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@@ -101,8 +104,8 @@ export function LineChart({
const rangeMs =
timestamps.length >= 2
? parseServerTime(timestamps[timestamps.length - 1]).getTime() -
parseServerTime(timestamps[0]).getTime()
? parseTs(timestamps[timestamps.length - 1]).getTime() -
parseTs(timestamps[0]).getTime()
: 0;
const yTicks = (() => {
+2 -12
View File
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react";
import { Children, Fragment, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -395,16 +395,6 @@ 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,
@@ -412,7 +402,7 @@ export function Facts({
className?: string;
children: React.ReactNode;
}) {
const items = flattenFacts(children);
const items = Children.toArray(children).filter(Boolean);
if (items.length === 0) return null;
return (
+4 -2
View File
@@ -11,14 +11,16 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"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",
"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",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
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"
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",
)}
/>
</SwitchPrimitive.Root>
);
+1 -7
View File
@@ -181,7 +181,6 @@ function Section({
onDrop,
onRemove,
onHeightChange,
autoHeight,
children,
}: {
slot: CardSlot;
@@ -193,8 +192,6 @@ 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();
@@ -230,9 +227,7 @@ function Section({
e.stopPropagation();
onDrop();
}}
style={{
height: flex || autoHeight ? undefined : (slot.height ?? undefined),
}}
style={{ height: flex ? 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" : ""}`}
@@ -788,7 +783,6 @@ export function DashboardTab({
setSlots((prev) => prev.filter((s) => s.key !== slot.key))
}
onHeightChange={handleHeightChange}
autoHeight={isMobile}
>
{renderCard(slot.id)}
</Section>
+8 -17
View File
@@ -1,4 +1,3 @@
import { useIsMobile } from "@/hooks/use-mobile";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
@@ -26,7 +25,6 @@ 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";
@@ -298,8 +296,6 @@ 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">
@@ -346,16 +342,13 @@ export function HostStatusList({
</Facts>
</span>
{/* Plugins add live details to a host row here. */}
{!isMobile && (
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
host,
online: availability === "online",
}}
/>
)}
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
online: availability === "online",
}}
/>
<span
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
>
@@ -387,9 +380,7 @@ export function RecentActivityList({
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const ago = (ts: string) => {
const diff = Math.floor(
(Date.now() - parseServerTime(ts).getTime()) / 1000,
);
const diff = Math.floor((Date.now() - new Date(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,19 +1,13 @@
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
@@ -1,16 +0,0 @@
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(/=+$/, "")}`;
}
+1 -21
View File
@@ -126,26 +126,6 @@ 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.
@@ -171,7 +151,7 @@ export async function pluginWsUrl(
});
}
const token = (await fetchSocketTicket()) ?? localStorage.getItem("jwt");
const token = localStorage.getItem("jwt");
const protocols = websocketAuthProtocols(token);
// Dev without a configured API host goes through Vite's proxy, which is
-12
View File
@@ -1,12 +0,0 @@
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
@@ -1,8 +0,0 @@
/**
* 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);
}
+5 -31
View File
@@ -1079,7 +1079,7 @@
"sessionExpiredLogin": "Session expired. Please log in again."
},
"messages": {
"registrationDisabled": "You have no Termix account yet. Ask an admin to turn on Auto-create external accounts.",
"registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.",
"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. When off, only people who already have an account can sign in that way",
"externalAutoProvisionDesc": "Create an account the first time someone signs in through an external login such as SSO or LDAP, even when registration is off",
"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,19 +1867,6 @@
"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",
@@ -2233,12 +2220,7 @@
"dashboardServiceLinks": "Service links",
"homepageItems": "Homepage items",
"networkTopology": "Network map",
"workspaces": "Workspaces",
"hostDefaults": "Host defaults",
"hostDefaultsAdmin": "Server host defaults",
"aiProviders": "AI providers",
"homepageLayouts": "Homepage layouts",
"accountProfile": "Account profile"
"workspaces": "Workspaces"
},
"proxy": {
"title": "Reverse proxy",
@@ -2285,11 +2267,9 @@
"continue": "Continue",
"back": "Back",
"mergeTitle": "Add it to my account",
"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.",
"mergeBody": "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_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.",
"replaceBody": "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.",
@@ -2389,11 +2369,5 @@
"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"
}
}
}
+5 -15
View File
@@ -370,7 +370,9 @@ 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 = isLoginRequestUrl(config.url);
const isLogin =
config.url?.includes("/users/login") ||
/\/users\/auth\/[^/]+\/verify$/.test(config.url ?? "");
if (trustToken && isLogin) {
if (config.headers.set) {
config.headers.set("X-Termix-Trust-Token", trustToken);
@@ -726,19 +728,6 @@ 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",
@@ -770,7 +759,8 @@ export function handleApiError(error: unknown, operation: string): never {
errorContext,
);
const errorMessage = isLoginRequestUrl(url)
const isLoginEndpoint = url?.includes("/users/login");
const errorMessage = isLoginEndpoint
? message
: "Authentication required. Please log in again.";
+8 -23
View File
@@ -110,29 +110,14 @@ export function useScrollSpy(
lockUntil.current = Date.now() + 600;
setActive(id);
const area = scroller(container);
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);
area.scrollTo?.({
top:
el.getBoundingClientRect().top -
area.getBoundingClientRect().top +
area.scrollTop -
8,
behavior: "smooth",
});
},
};
}
+1 -3
View File
@@ -64,8 +64,6 @@ export const CORE_STEPS: OnboardingStepDef[] = [
Component: SecurityStep,
audience: "all",
position: 90,
// 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,
isRelevant: (ctx) => ctx.canEnrollSecondFactor === true,
},
];
+1 -6
View File
@@ -277,18 +277,13 @@ 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.filter(serverServesAssets));
await loadNamespaces(summaries);
const { order, blocked } = orderForActivation(summaries);
const wanted = new Map(order.map((summary) => [summary.id, summary]));
+43 -449
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownWideNarrow,
Check,
Columns3,
Download,
Globe,
Puzzle,
@@ -32,10 +31,7 @@ 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 {
@@ -62,37 +58,6 @@ 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">
@@ -107,20 +72,6 @@ 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);
@@ -204,8 +155,6 @@ function PluginsTabBody() {
query={query}
onQuery={setQuery}
onOpen={setDetailId}
layout={layout}
layoutControls={layoutControls}
/>
) : section === "browse" ? (
<BrowseSection
@@ -214,8 +163,6 @@ function PluginsTabBody() {
query={query}
onQuery={setQuery}
onOpen={setDetailId}
layout={layout}
layoutControls={layoutControls}
/>
) : (
<UpdatesSection
@@ -329,152 +276,25 @@ function Group({
return (
<section className="flex flex-col gap-2.5">
<GroupHeading title={title} count={count} />
{children}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{children}
</div>
</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");
@@ -486,59 +306,6 @@ 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
@@ -548,7 +315,6 @@ function InstalledSection({
>
<div className="ml-auto flex items-center gap-2">
<SortSelect value={sort} onChange={setSort} />
{layoutControls}
{manager.developerMode && !manager.signedOnly && (
<UploadButton manager={manager} />
)}
@@ -584,7 +350,14 @@ function InstalledSection({
title={t("plugins.manager.groups.running")}
count={running.length}
>
{collection(running)}
{running.map((p) => (
<InstalledCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</Group>
)}
{stopped.length > 0 && (
@@ -592,7 +365,14 @@ function InstalledSection({
title={t("plugins.manager.groups.notRunning")}
count={stopped.length}
>
{collection(stopped)}
{stopped.map((p) => (
<InstalledCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</Group>
)}
</>
@@ -797,126 +577,18 @@ 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);
@@ -932,62 +604,6 @@ 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
@@ -997,7 +613,6 @@ function BrowseSection({
>
<div className="ml-auto flex items-center gap-2">
<SortSelect value={sort} onChange={setSort} />
{layoutControls}
</div>
</Toolbar>
@@ -1032,19 +647,16 @@ function BrowseSection({
}
/>
) : (
<PluginCollection
plugins={rows}
layout={layout}
renderCard={(p) => (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{rows.map((p) => (
<BrowseCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
)}
listColumns={listColumns}
onOpen={onOpen}
/>
))}
</div>
)}
<p className="text-[11px] text-muted-foreground">
@@ -1068,6 +680,7 @@ 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">
@@ -1095,47 +708,28 @@ function BrowseCard({
})
: t("plugins.manager.noServerAccess")}
</span>
<BrowseAction plugin={plugin} manager={manager} />
<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>
</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,
+1 -2
View File
@@ -50,7 +50,6 @@ 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. */
@@ -317,7 +316,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" />
{parseServerTime(item.timestamp).toLocaleDateString()}
{new Date(item.timestamp).toLocaleDateString()}
</span>
</CommandItem>
);
+1 -2
View File
@@ -21,7 +21,6 @@ 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",
@@ -178,7 +177,7 @@ export function AdminAuditLogSection({
function formatTimestamp(ts: string) {
try {
return parseServerTime(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
+1 -2
View File
@@ -30,7 +30,6 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import { roleLabel } from "@/lib/role-label";
export type AdminUser = {
id: string;
@@ -638,7 +637,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">
{roleLabel(t, role.displayName)}
{role.displayName}
</span>
{role.isSystem ? (
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
+1 -2
View File
@@ -14,7 +14,6 @@ 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?: {
@@ -245,7 +244,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"
>
{roleLabel(t, ur.roleDisplayName)}
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
+1 -2
View File
@@ -56,7 +56,6 @@ 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?: {
@@ -482,7 +481,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"
>
{roleLabel(t, ur.roleDisplayName)}
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
+1 -2
View File
@@ -314,8 +314,7 @@ export function AppRail({
return (
<div
// 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`}
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"
style={{ width: rem(railExpanded ? 172 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
+9 -14
View File
@@ -29,11 +29,10 @@ import {
type CredentialPermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { getUserInfo, getUserList, type AccessRecord } from "@/main-axios";
import { 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"];
@@ -120,15 +119,14 @@ export function CredentialShareModal({
getCredentialAccess(credentialId).catch(() => ({ access: [] })),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes, me]) => {
.then(([accessRes, usersRes, rolesRes]) => {
setAccessList(accessRes.access ?? []);
// Nobody shares a credential with themselves.
setShareUsers(
(usersRes.users ?? [])
.map((u) => ({ id: String(u.userId), username: u.username }))
.filter((u) => u.id !== me?.userId),
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
);
setShareRoles(
(rolesRes.roles ?? [])
@@ -233,10 +231,7 @@ export function CredentialShareModal({
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
const who =
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"";
record.username ?? record.roleDisplayName ?? record.roleName ?? "";
const ok = await confirm({
title: t("sharing.revokeConfirm", { name: who }),
confirmLabel: t("sharing.revoke"),
@@ -381,7 +376,7 @@ export function CredentialShareModal({
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{roleLabel(t, role.displayName || role.name)}
{role.displayName || role.name}
</span>
</button>
);
@@ -504,7 +499,7 @@ export function CredentialShareModal({
)}
<span className="font-semibold truncate">
{record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleDisplayName ??
record.roleName ??
record.userId ??
record.roleId}
+10 -16
View File
@@ -32,7 +32,6 @@ import {
updateHostAccess,
revokeHostAccess,
getUserList,
getUserInfo,
getRoles,
type AccessRecord,
type SharePermissionLevel,
@@ -41,7 +40,6 @@ 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",
@@ -114,18 +112,14 @@ export function HostShareModal({
: Promise.resolve({ accessList: [] }),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes, me]) => {
.then(([accessRes, usersRes, rolesRes]) => {
setAccessList(accessRes.accessList ?? []);
// Nobody shares with themselves.
setShareUsers(
(usersRes.users ?? [])
.map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
}))
.filter((u) => u.id !== me?.userId),
(usersRes.users ?? []).map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
})),
);
setShareRoles(
(rolesRes.roles ?? [])
@@ -417,7 +411,7 @@ export function HostShareModal({
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{roleLabel(t, role.displayName || role.name)}
{role.displayName || role.name}
</span>
</button>
);
@@ -532,7 +526,7 @@ export function HostShareModal({
>
<span className="flex-1 truncate">
{rule.targetType === "role"
? roleLabel(t, rule.roleDisplayName) || rule.roleName
? rule.roleDisplayName || rule.roleName
: rule.username}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
@@ -547,7 +541,7 @@ export function HostShareModal({
title: t("sharing.revokeConfirm", {
name:
rule.username ??
roleLabel(t, rule.roleDisplayName) ??
rule.roleDisplayName ??
rule.roleName ??
"",
}),
@@ -599,7 +593,7 @@ export function HostShareModal({
)}
<span className="font-semibold truncate">
{record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleDisplayName ??
record.roleName ??
record.userId ??
record.roleId}
@@ -639,7 +633,7 @@ export function HostShareModal({
title: t("sharing.revokeConfirm", {
name:
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleDisplayName ??
record.roleName ??
"",
}),
+2 -2
View File
@@ -77,7 +77,6 @@ import {
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
type UserProfileSection =
| "account"
@@ -1241,7 +1240,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"
>
{roleLabel(t, r.roleDisplayName)}
{r.roleDisplayName}
</span>
))}
</div>
@@ -1441,6 +1440,7 @@ 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,7 +8,6 @@ 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;
@@ -26,6 +25,8 @@ interface HostKeyVerificationDialogProps {
backgroundColor?: string;
}
const formatFingerprint = (fp: string) => fp.match(/.{1,2}/g)?.join(":") || fp;
function FingerprintRow({
label,
value,
@@ -43,16 +44,16 @@ function FingerprintRow({
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</p>
<div className="flex items-stretch gap-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted/50 border border-border p-3 font-mono text-xs break-all">
{value}
{formatFingerprint(value)}
</div>
<button
type="button"
onClick={onCopy}
title={t("common.copy")}
aria-label={t("common.copy")}
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"
className={`${PROMPT_BUTTON} size-8 shrink-0 px-0`}
>
{copied ? (
<Check className="size-4 text-accent-brand" />
@@ -73,6 +74,7 @@ export function HostKeyVerificationDialog({
hostname,
fingerprint,
oldFingerprint,
algorithm,
onAccept,
onReject,
backgroundColor,
@@ -95,8 +97,6 @@ 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={oldPrint}
value={oldFingerprint || ""}
copied={copiedOldFingerprint}
onCopy={() => copyFingerprint(oldPrint, true)}
onCopy={() => copyFingerprint(oldFingerprint || "", true)}
/>
<FingerprintRow
label={t("hostKey.newFingerprint")}
value={newPrint}
value={fingerprint}
copied={copiedFingerprint}
onCopy={() => copyFingerprint(newPrint)}
onCopy={() => copyFingerprint(fingerprint)}
/>
</>
) : (
@@ -180,10 +180,10 @@ export function HostKeyVerificationDialog({
</div>
</div>
<FingerprintRow
label={t("hostKey.fingerprint")}
value={newPrint}
label={`${t("hostKey.fingerprint")} (${algorithm.toUpperCase()})`}
value={fingerprint}
copied={copiedFingerprint}
onCopy={() => copyFingerprint(newPrint)}
onCopy={() => copyFingerprint(fingerprint)}
/>
<p className="text-[10px] text-muted-foreground">
{t("hostKey.verifyInstructions")}
@@ -85,20 +85,6 @@ 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", () => {
@@ -1,33 +0,0 @@
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,7 +22,6 @@ function setViewport(width: number) {
afterEach(() => {
vi.restoreAllMocks();
delete (window as { electronAPI?: unknown }).electronAPI;
});
describe("useIsMobile", () => {
@@ -43,11 +42,4 @@ 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);
});
});
@@ -1,17 +0,0 @@
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");
});
});
+1 -44
View File
@@ -15,13 +15,8 @@ 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,
pluginWsUrl,
} from "@/lib/plugin-transport";
import { createPluginApi, pluginFetch } from "@/lib/plugin-transport";
const fetchMock = vi.fn(async () => new Response("ok"));
@@ -89,41 +84,3 @@ 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
@@ -1,17 +0,0 @@
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