mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
release-2.5.1 (#1067)
* chore(deps): bump node from 24-slim to 26-slim in /docker in the docker-major-updates group (#1021) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump node in /docker in the docker-major-updates group Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 24 updates (#1023) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps-dev): bump the dev-patch-updates group with 24 updates Bumps the dev-patch-updates group with 24 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.1` | `2.5.2` | | [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.3` | `6.10.4` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.1` | `6.43.5` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.14` | `1.2.15` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.17` | `1.1.18` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.5` | `1.3.6` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.18` | `2.1.19` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.10` | `2.1.11` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.17` | `1.1.18` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.10` | `1.1.11` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.12` | `1.2.13` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.1` | `2.3.2` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.10` | `1.1.11` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.1` | `1.4.2` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.1` | `1.3.2` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.15` | `1.1.16` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.10` | `1.2.11` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` | | [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.0` | `1.6.1` | | [sharp](https://github.com/lovell/sharp) | `0.35.2` | `0.35.3` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` | Updates `@biomejs/biome` from 2.5.1 to 2.5.2 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.2/packages/@biomejs/biome) Updates `@codemirror/commands` from 6.10.3 to 6.10.4 - [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/commands/commits) Updates `@codemirror/view` from 6.43.1 to 6.43.5 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@radix-ui/react-accordion` from 1.2.14 to 1.2.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.5 to 1.3.6 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.18 to 2.1.19 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.10 to 2.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.10 to 1.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.12 to 1.2.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.1 to 2.3.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.10 to 1.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.1 to 1.4.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-switch` from 1.3.1 to 1.3.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.15 to 1.1.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.10 to 1.2.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@tailwindcss/vite` from 4.3.1 to 4.3.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/@tailwindcss-vite) Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3 - [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.0.3/packages/plugin-react) Updates `i18next` from 26.3.1 to 26.3.4 - [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.3.1...v26.3.4) Updates `radix-ui` from 1.6.0 to 1.6.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `sharp` from 0.35.2 to 0.35.3 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.2...v0.35.3) Updates `tailwindcss` from 4.3.1 to 4.3.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/tailwindcss) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/commands" dependency-version: 6.10.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.19 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 2 updates (#1025) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump the prod-patch-updates group with 2 updates Bumps the prod-patch-updates group with 2 updates: [axios](https://github.com/axios/axios) and [nanoid](https://github.com/ai/nanoid). Updates `axios` from 1.18.0 to 1.18.1 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1) Updates `nanoid` from 5.1.15 to 5.1.16 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.15...5.1.16) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 5.1.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group with 3 updates (#1026) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump the prod-minor-updates group with 3 updates Bumps the prod-minor-updates group with 3 updates: [js-yaml](https://github.com/nodeca/js-yaml), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici). Updates `js-yaml` from 5.0.0 to 5.2.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.0.0...5.2.1) Updates `motion` from 12.40.0 to 12.42.2 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.40.0...v12.42.2) Updates `undici` from 8.5.0 to 8.7.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.7.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: motion dependency-version: 12.42.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump electron from 42.4.1 to 43.0.0 in the major-updates group (#1027) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps-dev): bump electron in the major-updates group Bumps the major-updates group with 1 update: [electron](https://github.com/electron/electron). Updates `electron` from 42.4.1 to 43.0.0 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.4.1...v43.0.0) --- updated-dependencies: - dependency-name: electron dependency-version: 43.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix MC syntax highlighting artifacts (#996) * Filter dashboard status hosts (#997) * Persist dashboard service link changes (#999) * Fix snippet text overflow (#1000) * Persist remote desktop credential auth (#1001) * Guard language switching failures (#1002) * Resolve tunnel source credentials (#1003) * Support Vault auth for monitors (#1004) * Fix Windows file delete command (#1005) * Fix release artifact checkout ref (#1006) * Fix command palette escape in fullscreen (#1008) * Fix alerts and audit log data normalization (#1010) * Fix macOS VNC protocol negotiation (#1012) * Fix port knocking before SSH connect (#1013) * Allow Escape to close link confirmation (#1014) * Prevent Electron modifier wheel zoom (#1016) * Fix credential auth optional password (#1009) * Retry transient terminal DNS lookups (#1011) * Retry transient terminal DNS lookups * Apply DNS retry to SSH entry points * Fix OIDC redirect forwarded port handling (#1007) * Preserve recent open tabs on startup (#1015) * Fix fish prompt OSC highlighting (#998) * Fix terminal font selection (#1018) * fix: font legibility (#1019) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * fix: font legibility Text was entirely unreadable in places for me. Especially with themes like Catppuccin. The muted-foreground text and the tags too similiar to the background. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: russell <git@0896c69e.com> * fix(file-manager): chunked uploads fail with 'Expected multipart/form-data request' (#1020) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * fix(file-manager): use postForm for chunked uploads so multipart content-type is sent The fileManagerApi axios instance defaults to Content-Type: application/json. Axios 1.x's default transformRequest converts a FormData body to JSON whenever the request content type is application/json, so every chunk POSTed to /ssh/uploadFileChunk arrived as a JSON body like {"chunk":{}} and the backend rejected it with 400 'Expected multipart/form-data request'. This breaks all uploads of files larger than the 1.5 GiB chunking threshold. The non-chunked path already uses postForm for /ssh/uploadFileStream; use it for the chunk path too so axios keeps the FormData intact and the browser sets the multipart boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat: implement OIDC back-channel logout support with session management (#1028) * feat: implement OIDC back-channel logout support with session management * Fix OIDC back-channel logout handling * Require logout token replay identifiers --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * Add API key host enrollment endpoint (#1029) * Fix tmux detection for non-POSIX shells (#1030) * Fix OPKSSH js-yaml ESM import (#1031) * Fix Android Vietnamese IME input (#1032) * Fix Firefox RDP clipboard paste (#1033) * Fix Proxmox discovery over HTTPS (#1041) * Fix external editor actions in file preview (#1042) * Allow pinned hosts with name sorting (#1043) * Fix Firefox desktop OIDC callback (#1044) * feat(session): add recording and replay (#1049) * Fix status checks through jump hosts (#1045) * Add terminal font size shortcuts (#1047) * feat: add Open File Manager to tab right-click menu (#1051) Co-authored-by: SankeerthNara <sankeerthnara@gmail.com> * perf: frontend request cache, poll pause, and code-split shell (#1052) Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * feat: save quick connect sessions as hosts (#1055) * fix: restore sudo password autofill settings (#1056) * fix: preserve file editor position on save (#1057) * fix: sync cloud preference storage mode (#1058) * fix: render RDP sessions at native pixel density (#1059) * fix: restore database import in embedded desktop mode (#1060) * Update Auto-complete.tsx (#1061) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore: donation bar reporting wrong result * feat: add Open File Manager to tab right-click menu (#1046) * Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050) This reverts commit0712fdd731. * Remove donation badge from README Removed donation badge from README. * Delete .github/workflows/donation-goal.yml * Update Auto-complete.tsx --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat(auth): opt-in OIDC DEK unlock for API-key requests (ALLOW_APIKEY_DATA_UNLOCK) (#1064) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore: donation bar reporting wrong result * feat: add Open File Manager to tab right-click menu (#1046) * Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050) This reverts commit0712fdd731. * Remove donation badge from README Removed donation badge from README. * Delete .github/workflows/donation-goal.yml * feat(auth): opt-in OIDC DEK unlock for API-key requests API keys authenticate but cannot touch the encrypted credential/host store ('User data not unlocked') unless the user has a live interactive session, making them unusable for headless automation. For OIDC users the DEK is server-derivable (deriveOIDCSystemKey), so handleApiKeyAuth can unlock it without a password. Gated behind ALLOW_APIKEY_DATA_UNLOCK (default off) because enabling it widens the blast radius of a leaked API key. OIDC-only; password users are untouched. Refs #1063 --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Sankeerth Nara <sankeerthnara@gmail.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * chore: package lock sync * Add Proxmox guest auto sync (#1053) * draft: database layer refactor (#1054) * feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' 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> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [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.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [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.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 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.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 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> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 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> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: start database layer refactor * docs: plan database layer refactor * docs: audit database layer refactor phase zero * chore: add database runtime adapter skeleton * chore: add settings repository skeleton * chore: add user session repository skeleton * chore: add host credential repository skeleton * chore: add field encryption boundary * chore: migrate settings route slice * chore: migrate user settings routes * chore: migrate host metrics settings routes * chore: migrate acme settings route * chore: migrate terminal settings route * chore: migrate tailscale settings read * chore: migrate guacamole settings reads * chore: migrate session timeout settings reads * chore: migrate auth route settings reads * chore: migrate host metrics settings reads * chore: migrate startup settings reads * chore: migrate user settings cleanup * chore: migrate password reset settings * chore: migrate oidc legacy settings read * chore: migrate user route settings slice * chore: migrate oidc state settings * chore: migrate user login settings reads * chore: migrate user crypto settings * chore: consolidate startup settings defaults * chore: consolidate database settings import export * chore: migrate core session auth paths * chore: migrate remaining session auth paths * chore: migrate admin user routes * chore: migrate user route admin checks * chore: migrate user lifecycle routes * chore: migrate auth user lookups * chore: migrate oidc user routes * chore: migrate api key repository paths * docs: add database gray rollout guide * chore: migrate trusted device paths * chore: migrate user session route user lookups * chore: add database repository rollout guard * chore: expose repository rollout status * chore: warn on repository rollout misconfiguration * chore: migrate remaining user lookup helpers * chore: migrate ssh user lookups * chore: migrate user settings admin lookups * chore: migrate acme ssl user lookups * chore: migrate audit log admin checks * chore: migrate oidc account user updates * chore: migrate password reset user updates * chore: migrate user deletion core records * chore: migrate snippet audit user lookups * chore: migrate ldap user sync paths * chore: migrate totp user updates * chore: migrate rbac user checks * chore: migrate rbac role paths * chore: migrate permission role lookups * chore: migrate rbac access list reads * chore: migrate shared rbac reads * chore: migrate rbac access writes * chore: migrate permission host access * chore: migrate role host access lookup * chore: migrate snippet access lookup * chore: migrate shared credential access lookups * chore: migrate host access cleanup writes * chore: migrate host list access checks * chore: migrate host access cleanup routes * chore: migrate shared credential role lookups * chore: migrate user role cleanup * chore: migrate admin role sync * chore: migrate ldap role sync * chore: migrate user role assignment * chore: migrate sso provider access * chore: migrate audit log access * chore: migrate user preference access * chore: migrate open tab access * chore: migrate dismissed alert access * chore: migrate homepage layout access * chore: migrate network topology access * chore: migrate dashboard service link access * chore: migrate command history access * chore: migrate recent activity cleanup * chore: migrate ssh credential usage access * chore: migrate transfer recent access * chore: migrate file manager bookmark access * chore: migrate c2s tunnel preset access * chore: migrate homepage item access * chore: migrate session recording access * chore: migrate tmux session tag access * chore: migrate opkssh token access * chore: migrate vault token access * chore: migrate vault profile access * chore: migrate host metrics preference access * chore: migrate host health access * chore: migrate host metrics history access * chore: migrate alert persistence access * chore: route alert host lookup through repository * chore: migrate user data export reads * chore: route host metrics stats sync through repository * chore: migrate host folder persistence * chore: migrate host resolution reads * chore: route jump host resolution reads * chore: route docker console jump host reads * chore: route docker ssh resolution reads * chore: route proxmox discovery resolution reads * chore: route file manager activity host reads * chore: route host metrics resolution reads * chore: route ssh auth credential reads * chore: route tunnel endpoint credential reads * chore: route credential deployment resolution reads * chore: route command history host flag reads * chore: route snippet execution resolution reads * chore: route terminal host resolution reads * chore: route vault oidc host resolution reads * chore: route wake on lan host reads * chore: route internal host list reads * chore: route host key verification persistence * chore: route credential read paths * chore: route credential host usage reads * chore: route credential folder rename * chore: route host owner access checks * chore: route shared credential source reads * chore: route user host credential cleanup * chore: route credential delete reads * chore: route credential update reads * chore: route host credential reads * chore: route host read paths * chore: route host projection reads * chore: route host list reads * chore: route snippet read paths * chore: route snippet folder writes * chore: route snippet crud paths * chore: route snippet bulk import * chore: route rbac ownership reads * chore: route user count reads * chore: route cleanup snippets folders * chore: route shared credential persistence * chore: route dashboard activity * chore: route guacamole host reads * chore: route host bulk lookups * chore: remove unlock-only simple db ops * chore: route host autostart persistence * chore: route ldap provisioning through users * chore: route credential encrypted writes * chore: route host encrypted writes * chore: route bulk host encrypted writes * chore: route termix id credentials * chore: route termix id ca persistence * chore: route termix identity persistence * chore: route credential system migration * chore: isolate user encryption migration storage * chore: remove legacy simple db ops * chore: isolate legacy sqlite migration copy * chore: route database settings import export * chore: route database host credential export * chore: route database host credential import * chore: route database file-manager import export * chore: route database alert usage import export * chore: route database user checks * chore: isolate auth lazy migration storage * chore: route explicit database saves * chore: initialize database save boundary * chore: route migration snapshot saves * chore: isolate sqlite import constraints * chore: route import sqlite boundary * chore: route user encryption migration store * chore: centralize current repository runtime * chore: route more current repositories * chore: route activity repository runtimes * chore: route token repository runtimes * chore: route health repository runtimes * chore: route identity repository runtimes * chore: route rbac repository runtime * chore: centralize current sqlite runtime access * chore: route user deletion key cleanup * chore: route user deletion vault cleanup * chore: route user deletion homepage cleanup * chore: route user deletion health cleanup * chore: route user deletion alert cleanup * chore: route user deletion identity cleanup * chore: add database layer preupgrade backup * Fix database repository type errors * fix: complete post-merge compile fixes for database refactor Restore missing DatabaseSaveTrigger/getDb imports, session log format fallback, OIDC provider resolution, guacamole recording insert, and passwordFallbackOnly typing after merging current dev. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> * refactor(db): collapse repository rollout scaffolding into single factory Repositories are now the only data path. Replaces the 41 current-*-repository wrapper files, the DATABASE_LAYER_REPOSITORY_ROLLOUT flag/alias map and the unused database/runtime adapter with repositories/factory.ts, a plain DatabaseContext type and an in-memory TestSqliteDatabase test harness. * refactor(db): route remaining raw DB access through repositories proxmox, session-log, oidc-utils, webauthn and guacamole recording now use repositories (new WebauthnCredentialRepository; SsoProviderRepository listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled). Remaining raw access: db boot code, simple-db-ops and docker.ts, which are removed/restructured in later phases. * feat(crypto): add UserKeyManager with system-wrapped per-user DEKs New utils/user-keys.ts: one random 32-byte DEK per user, wrapped AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY (per-user info string + AAD binding, versioned v3 wrap format stored in settings). Synchronous unwrap-on-demand with a 15-minute cache so the existing DataCrypto facade keeps its sync call sites. Not wired up yet. * feat(crypto): boot-time DEK migration to system-wrapped v3 format utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths (PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default fallback) and migrates every server-unwrappable DEK to the v3 wrap at startup. Password-wrapped DEKs migrate at next login or from a live session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup flips on once the new path is authoritative. * refactor(crypto): make system-wrapped DEKs the authoritative key path DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone. utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps. A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active password users migrate without re-login. Password login migrates legacy password-wrapped DEKs via migratePasswordUserAtLogin. * refactor(crypto): remove pending share queue and credential sharing key With server-unwrappable DEKs both sides of a share are always available, so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_* shadow columns on ssh_credentials are gone. A one-time boot cleanup re-creates legacy pending share copies where possible (dropping unresolvable ones with a warning) and drops the legacy columns. * feat(auth): non-destructive password resets and admin reset endpoint Password resets no longer destroy user data: the DEK is system-wrapped, so forgot-password and admin resets are just a hash update plus session revoke. The wipe branch survives only for accounts that never logged in since the encryption upgrade and now requires explicit confirmDataWipe (surfaced as a 409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds POST /users/admin/reset-password and removes the dead re-encryption paths. * refactor(ssh): consolidate four jump-host chain copies into one module terminal, host-metrics and docker now use ssh/jump-host-chain.ts (already shared by file-manager, tmux-monitor and docker-console); docker's inline copy also drops its raw SimpleDBOps host/credential lookups in favor of repositories. * refactor(ssh): single shared createConnectionLog helper file-manager-log.ts becomes ssh/connection-log.ts; the copies in docker.ts and host-metrics-helpers.ts are gone. * refactor(ssh): split docker module into layered directory ssh/docker/{index,routes,session-manager,container-routes,console}.ts: server boot and wiring in index, HTTP handlers in routes, SSH session registry and command execution in session-manager. Code motion only; port 30007/30009 and endpoints unchanged. Swagger now scans ssh subdirectories. * refactor(ssh): split tunnel module into layered directory ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers in routes, tunnel state and engine (connect/retry/autostart) in manager. Code motion only; port 30003 and endpoints unchanged. * refactor(backend): reorganize top-level layout - ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics) - serial/serial.ts and guacamole/ moved inside hosts/ - dashboard.ts and homepage.ts moved to services/ - swagger.ts moved to utils/ with adjusted scan globs Import paths and the generate:openapi script updated; ports and endpoints unchanged. * refactor(tests): move backend tests into src/backend/tests mirror tree Backend *.test.ts files (and the test-support harness) no longer sit next to source files; they live under src/backend/tests/ mirroring the source layout. Imports rewritten accordingly; CLAUDE.md convention updated. * refactor(hosts): group host modules into per-feature directories file-manager/, metrics/ (incl. widgets, managers, alert-engine), terminal/, tmux/ and tunnel/ each own their files; docker/ gains container-runtime. Genuinely shared helpers (jump-host chain, host resolver, connection pool, opkssh, vault, serial) stay at hosts/ root. Pure file moves with import path updates; mirrored test paths follow. * refactor(backend): final cleanup pass - re-register WebAuthn passkey routes (registration was dropped in the #1054 merge, breaking passkey login) and document all six endpoints - delete utils/simple-db-ops.ts (last caller migrated to DataCrypto) - starter: use the typed serverReady export, collapse the four-way version lookup to env then package.json candidates - add OpenAPI JSDoc to c2s-tunnel-presets endpoints - strip block-divider comment banners * feat: remove legacy "data_unlocked" field * feat: refactor rbac/sharing to support new permissions and auth types * feat: refactor rbac/sharing to support new permissions and auth types * feat: add "id" to user profile hide list * chore: root cleanup * feat: add more donation references and a 30-day donation reminder * chore: update readme * feat: automate beta tests * feat: add links to milestones * fix: hoist github/google SSO defaults to module scope (#1065) * fix(ssh-tools): allow clipboard paste in key recording field (#1066) The broadcast key-recording input was marked readOnly, which makes browsers block paste entirely (no context-menu Paste, Ctrl+V does nothing). handleKeyDown also called preventDefault() unconditionally, swallowing the Ctrl+V shortcut before a paste event could even fire. Let Ctrl/Cmd+V pass through in handleKeyDown, drop readOnly, and add an onPaste handler that reads the clipboard text and broadcasts it to the selected terminals like any other captured keystroke. Signed-off-by: emreumar <emreumar@users.noreply.github.com> Co-authored-by: emreumar <emreumar@users.noreply.github.com> * chore: write release notes * chore: update release notes * chore: update readmes * chore: add crypto only reminder in en.json * fix: macOS and cask errors on release workflow * chore: sync Crowdin translations for 2.5.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: emreumar <emreumar@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Russell Poovey <09.our_seekers@icloud.com> Co-authored-by: russell <git@0896c69e.com> Co-authored-by: Subedi Bibek <77529535+questbibek@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Alexander Elsner <101340634+Bensonheimer992@users.noreply.github.com> Co-authored-by: SankeerthNara <sankeerthnara@gmail.com> Co-authored-by: Stephan Groth <96803994+Kalvalax@users.noreply.github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: lhojun <ldgs3324@gmail.com> Co-authored-by: Yunus Emre Umar <77045015+emre155@users.noreply.github.com> Co-authored-by: emreumar <emreumar@users.noreply.github.com>
This commit is contained in:
562 files changed
+52303
-23645
No files matched your search
@@ -101,7 +101,7 @@ export function AdminAuditLogSection({
|
||||
.catch(() => {});
|
||||
fetchLogs(buildFilters(), 1);
|
||||
setPage(1);
|
||||
}, [open]);
|
||||
}, [open, buildFilters, fetchLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -114,6 +114,9 @@ export function AdminAuditLogSection({
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [
|
||||
open,
|
||||
buildFilters,
|
||||
fetchLogs,
|
||||
filterUserId,
|
||||
filterAction,
|
||||
filterResourceType,
|
||||
@@ -179,7 +182,7 @@ export function AdminAuditLogSection({
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("admin.auditLogFilterAll")}</option>
|
||||
{users.map((u) => (
|
||||
{(Array.isArray(users) ? users : []).map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
@@ -318,7 +321,7 @@ export function AdminAuditLogSection({
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-[10px] font-semibold text-foreground">
|
||||
{log.action.replace(/_/g, " ")}
|
||||
{(log.action || "unknown").replace(/_/g, " ")}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
|
||||
{log.resourceType}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
deleteRole,
|
||||
deleteUser,
|
||||
getPermissionsCatalog,
|
||||
revokeAllUserSessions,
|
||||
revokeSession,
|
||||
updateRole,
|
||||
} from "@/main-axios";
|
||||
import type { Role } from "@/main-axios";
|
||||
import type { PermissionCatalogEntry, Role } from "@/main-axios";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Share2,
|
||||
Trash2,
|
||||
Unlink,
|
||||
@@ -29,6 +33,8 @@ export type AdminUser = {
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash?: string;
|
||||
dataUnlocked?: boolean;
|
||||
totpEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
@@ -73,6 +79,7 @@ type UsersSectionProps = {
|
||||
SetStateAction<{ id: string; username: string } | null>
|
||||
>;
|
||||
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
|
||||
onManageUser: (user: AdminUser) => void;
|
||||
};
|
||||
|
||||
export function AdminUsersSection({
|
||||
@@ -88,6 +95,7 @@ export function AdminUsersSection({
|
||||
setLinkAccountOpen,
|
||||
setUnlinkAccountTarget,
|
||||
setUnlinkAccountOpen,
|
||||
onManageUser,
|
||||
}: UsersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -156,6 +164,15 @@ export function AdminUsersSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-accent-brand"
|
||||
title={t("admin.manageUserData")}
|
||||
onClick={() => onManageUser(user)}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -375,6 +392,69 @@ export function AdminRolesSection({
|
||||
createRoleLoading,
|
||||
}: RolesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
|
||||
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
||||
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [savingPermissions, setSavingPermissions] = useState(false);
|
||||
|
||||
function rolePermissions(role: Role): string[] {
|
||||
if (Array.isArray(role.permissions)) return role.permissions;
|
||||
if (typeof role.permissions === "string") {
|
||||
try {
|
||||
return JSON.parse(role.permissions) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function openPermissionsEditor(role: Role) {
|
||||
if (editingRoleId === role.id) {
|
||||
setEditingRoleId(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (catalog.length === 0) {
|
||||
const res = await getPermissionsCatalog();
|
||||
setCatalog(res.catalog ?? []);
|
||||
}
|
||||
setEditingPermissions(new Set(rolePermissions(role)));
|
||||
setEditingRoleId(role.id);
|
||||
} catch {
|
||||
toast.error(t("admin.rolePermissions.loadError"));
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(permission: string) {
|
||||
setEditingPermissions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(permission)) next.delete(permission);
|
||||
else next.add(permission);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function savePermissions(role: Role) {
|
||||
setSavingPermissions(true);
|
||||
try {
|
||||
const permissions = [...editingPermissions];
|
||||
await updateRole(role.id, { permissions });
|
||||
setRoles((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === role.id ? { ...entry, permissions } : entry,
|
||||
),
|
||||
);
|
||||
setEditingRoleId(null);
|
||||
toast.success(t("admin.rolePermissions.saved"));
|
||||
} catch {
|
||||
toast.error(t("admin.rolePermissions.saveError"));
|
||||
} finally {
|
||||
setSavingPermissions(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
@@ -467,52 +547,154 @@ export function AdminRolesSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{roles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{role.displayName}
|
||||
</span>
|
||||
{role.isSystem ? (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
||||
{t("admin.systemBadge")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||||
{t("admin.customBadge")}
|
||||
{roles.map((role) => {
|
||||
const permissionCount = rolePermissions(role).length;
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className="flex flex-col py-2.5 border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-xs font-semibold truncate">
|
||||
{role.displayName}
|
||||
</span>
|
||||
{role.isSystem ? (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
|
||||
{t("admin.systemBadge")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
|
||||
{t("admin.customBadge")}
|
||||
</span>
|
||||
)}
|
||||
{permissionCount > 0 && (
|
||||
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground">
|
||||
{t("admin.rolePermissions.count", {
|
||||
count: permissionCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{role.name}
|
||||
</span>
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-6 ${editingRoleId === role.id ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("admin.rolePermissions.editAction")}
|
||||
onClick={() => openPermissionsEditor(role)}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
await deleteRole(role.id);
|
||||
setRoles((prev) =>
|
||||
prev.filter((r) => r.id !== role.id),
|
||||
);
|
||||
toast.success(
|
||||
t("admin.deleteRoleSuccess", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground">
|
||||
{role.name}
|
||||
</span>
|
||||
</div>
|
||||
{!role.isSystem && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
await deleteRole(role.id);
|
||||
setRoles((prev) => prev.filter((r) => r.id !== role.id));
|
||||
toast.success(
|
||||
t("admin.deleteRoleSuccess", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
{editingRoleId === role.id && (
|
||||
<div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
|
||||
{catalog.map((entry) => {
|
||||
const wildcard = `${entry.group}.*`;
|
||||
const wildcardOn = editingPermissions.has(wildcard);
|
||||
return (
|
||||
<div key={entry.group} className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => togglePermission(wildcard)}
|
||||
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
|
||||
>
|
||||
<span
|
||||
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{wildcardOn && (
|
||||
<Check className="size-2 text-background" />
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
wildcardOn
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{entry.group}.*
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex flex-col gap-0.5 pl-4">
|
||||
{entry.permissions.map((permission) => {
|
||||
const checked =
|
||||
wildcardOn || editingPermissions.has(permission);
|
||||
return (
|
||||
<button
|
||||
key={permission}
|
||||
disabled={wildcardOn}
|
||||
onClick={() => togglePermission(permission)}
|
||||
className="flex items-center gap-1.5 text-[10px] text-left disabled:opacity-60"
|
||||
>
|
||||
<span
|
||||
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${checked ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{checked && (
|
||||
<Check className="size-2 text-background" />
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{permission}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
})}
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setEditingRoleId(null)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={savingPermissions}
|
||||
onClick={() => savePermissions(role)}
|
||||
>
|
||||
{savingPermissions
|
||||
? t("admin.rolePermissions.saving")
|
||||
: t("admin.rolePermissions.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getCommandHistoryEnabled,
|
||||
updateCommandHistoryEnabled,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
getUserRoles,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
@@ -69,7 +70,7 @@ import {
|
||||
type AdminUser,
|
||||
} from "./AdminManagementSections";
|
||||
import { toast } from "sonner";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import {
|
||||
AdminDatabaseSection,
|
||||
AdminGeneralSettingsSection,
|
||||
@@ -86,6 +87,8 @@ import {
|
||||
AdminLinkAccountDialog,
|
||||
AdminUnlinkAccountDialog,
|
||||
} from "./AdminUserDialogs";
|
||||
import { AdminUserManagePanel } from "./AdminUserManagePanel";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: {
|
||||
@@ -99,11 +102,18 @@ function apiErrorMessage(error: unknown, fallback: string) {
|
||||
return (error as ApiErrorLike).response?.data?.error || fallback;
|
||||
}
|
||||
|
||||
export function AdminSettingsPanel() {
|
||||
export function AdminSettingsPanel({
|
||||
onEditingChange,
|
||||
onOpenHostTab,
|
||||
}: {
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSection, setOpenSection] = useState<AdminSection | null>(
|
||||
"general",
|
||||
);
|
||||
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
|
||||
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
|
||||
@@ -205,6 +215,11 @@ export function AdminSettingsPanel() {
|
||||
loadSSOProviders();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onEditingChange?.(manageUser !== null);
|
||||
return () => onEditingChange?.(false);
|
||||
}, [manageUser, onEditingChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editUserOpen && editUserTarget) {
|
||||
setEditUserRoles([]);
|
||||
@@ -226,6 +241,8 @@ export function AdminSettingsPanel() {
|
||||
isAdmin: user.is_admin,
|
||||
isOidc: user.is_oidc,
|
||||
passwordHash: user.password_hash,
|
||||
dataUnlocked: user.data_unlocked,
|
||||
totpEnabled: user.totp_enabled,
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -717,17 +734,11 @@ export function AdminSettingsPanel() {
|
||||
async function handleExportDatabase() {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
(window.location.port === "5173" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${window.configuredServerUrl}/database/export`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/export`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/export`;
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
@@ -769,17 +780,11 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const isDev =
|
||||
!isElectron() &&
|
||||
(window.location.port === "5173" ||
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const apiUrl = isElectron()
|
||||
? `${window.configuredServerUrl}/database/import`
|
||||
: isDev
|
||||
? `http://localhost:30001/database/import`
|
||||
: `${window.location.protocol}//${window.location.host}${getBasePath()}/database/import`;
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
@@ -823,8 +828,31 @@ export function AdminSettingsPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
if (manageUser) {
|
||||
return (
|
||||
<AdminUserManagePanel
|
||||
key={manageUser.id}
|
||||
user={manageUser}
|
||||
roles={roles}
|
||||
onBack={() => setManageUser(null)}
|
||||
onOpenHostTab={onOpenHostTab}
|
||||
onUserDeleted={() => {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== manageUser.id));
|
||||
setManageUser(null);
|
||||
}}
|
||||
onTotpDisabled={() => {
|
||||
setUsers((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === manageUser.id ? { ...u, totpEnabled: false } : u,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSection === "general"}
|
||||
onToggle={() => toggle("general")}
|
||||
@@ -892,6 +920,7 @@ export function AdminSettingsPanel() {
|
||||
setLinkAccountOpen={setLinkAccountOpen}
|
||||
setUnlinkAccountTarget={setUnlinkAccountTarget}
|
||||
setUnlinkAccountOpen={setUnlinkAccountOpen}
|
||||
onManageUser={setManageUser}
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
|
||||
@@ -34,7 +34,7 @@ export function AccordionSection({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-border bg-card overflow-hidden">
|
||||
<div className="border border-border bg-card overflow-hidden shrink-0">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -37,6 +37,7 @@ const SEVERITY_CLASS: Record<string, string> = {
|
||||
|
||||
function timeAgo(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
@@ -321,7 +322,7 @@ export function AlertsPanel() {
|
||||
{rule.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{rule.triggerType.replace(/_/g, " ")}
|
||||
{(rule.triggerType || "").replace(/_/g, " ")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+36
-11
@@ -137,8 +137,8 @@ function buildRailButtons(
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center gap-2.5 h-7 rounded shrink-0 transition-colors";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 6px" };
|
||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
||||
const btnStyle = { margin: "0 4px", padding: "0 8px" };
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
@@ -171,18 +171,43 @@ export function AppRail({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const poll = () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
getAlertFirings({ acknowledged: false, limit: 50 })
|
||||
.then((firings) => {
|
||||
if (!cancelled) setUnreadAlerts(firings.length);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
intervalId = setInterval(poll, 30000);
|
||||
};
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
poll();
|
||||
start();
|
||||
};
|
||||
|
||||
poll();
|
||||
const iv = setInterval(poll, 30000);
|
||||
if (document.visibilityState !== "hidden") start();
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, []);
|
||||
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
|
||||
@@ -254,8 +279,8 @@ export function AppRail({
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
@@ -279,8 +304,8 @@ export function AppRail({
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0"
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
@@ -337,7 +362,7 @@ export function AppRail({
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
@@ -356,7 +381,7 @@ export function AppRail({
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</span>
|
||||
@@ -366,7 +391,7 @@ export function AppRail({
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 6px" }}
|
||||
style={{ padding: "0 8px" }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
|
||||
const CONNECTION_TAB_TYPES: TabType[] = [
|
||||
"terminal",
|
||||
@@ -49,6 +50,28 @@ function formatDuration(ms: number): string {
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function sessionsUnchanged(
|
||||
prev: ActiveSessionInfo[],
|
||||
next: ActiveSessionInfo[],
|
||||
): boolean {
|
||||
if (prev.length !== next.length) return false;
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
const a = prev[i];
|
||||
const b = next[i];
|
||||
if (
|
||||
a.sessionId !== b.sessionId ||
|
||||
a.hostId !== b.hostId ||
|
||||
a.hostName !== b.hostName ||
|
||||
a.tabInstanceId !== b.tabInstanceId ||
|
||||
a.isConnected !== b.isConnected ||
|
||||
a.createdAt !== b.createdAt
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatExpiry(updatedAt: string): string {
|
||||
const TTL_MS = 30 * 60 * 1000;
|
||||
const elapsed = Date.now() - new Date(updatedAt).getTime();
|
||||
@@ -285,7 +308,6 @@ export function ConnectionsPanel({
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Drag-to-reorder state
|
||||
const [dragTabId, setDragTabId] = useState<string | null>(null);
|
||||
@@ -322,17 +344,15 @@ export function ConnectionsPanel({
|
||||
})
|
||||
: backgroundTabs;
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
|
||||
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const sessions = await getActiveSessions();
|
||||
setActiveSessions((prev) => {
|
||||
const next = Array.isArray(sessions) ? sessions : [];
|
||||
if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
|
||||
if (sessionsUnchanged(prev, next)) return prev;
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
@@ -340,13 +360,10 @@ export function ConnectionsPanel({
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
pollTimerRef.current = setInterval(refresh, 5000);
|
||||
return () => {
|
||||
if (pollTimerRef.current) clearInterval(pollTimerRef.current);
|
||||
};
|
||||
}, [refresh]);
|
||||
// Initial fetch + visibility-aware poll (hook fires once on mount).
|
||||
usePageVisibleInterval(() => {
|
||||
void refresh();
|
||||
}, 5_000);
|
||||
|
||||
// Global pointer listeners for drag reorder
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
generateKeyPair,
|
||||
generatePublicKeyFromPrivate,
|
||||
updateCredential,
|
||||
adminCreateUserCredential,
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
|
||||
@@ -23,11 +25,15 @@ export function CredentialEditorView({
|
||||
activeTab,
|
||||
onBack,
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
onBack: () => void;
|
||||
onSave: (saved: Record<string, unknown>) => void;
|
||||
// When set, saves go to another user's credentials via the admin
|
||||
// impersonation endpoints.
|
||||
adminTargetUserId?: string;
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
@@ -92,15 +98,28 @@ export function CredentialEditorView({
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
};
|
||||
const saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
: await createCredential(data);
|
||||
let saved: Record<string, unknown>;
|
||||
if (adminTargetUserId) {
|
||||
saved = credential
|
||||
? await adminUpdateUserCredential(
|
||||
adminTargetUserId,
|
||||
Number(credential.id),
|
||||
data,
|
||||
)
|
||||
: await adminCreateUserCredential(adminTargetUserId, data);
|
||||
} else {
|
||||
saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
: await createCredential(data);
|
||||
}
|
||||
toast.success(
|
||||
credential
|
||||
? t("hosts.credentialUpdated")
|
||||
: t("hosts.credentialCreated"),
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
}
|
||||
onSave(saved);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePath, splitPath } from "./FolderPathPicker.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on canonical separator", () => {
|
||||
expect(splitPath("Homelab / Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash without spaces", () => {
|
||||
expect(splitPath("Homelab/Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash with only leading space", () => {
|
||||
expect(splitPath("Homelab /Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("splits on slash with only trailing space", () => {
|
||||
expect(splitPath("Homelab/ Debian")).toEqual(["Homelab", "Debian"]);
|
||||
});
|
||||
|
||||
it("handles three levels", () => {
|
||||
expect(splitPath("A/B/C")).toEqual(["A", "B", "C"]);
|
||||
});
|
||||
|
||||
it("filters empty segments from leading/trailing slashes", () => {
|
||||
expect(splitPath("/Debian/")).toEqual(["Debian"]);
|
||||
});
|
||||
|
||||
it("returns single segment for non-slash path", () => {
|
||||
expect(splitPath("Homelab")).toEqual(["Homelab"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePath", () => {
|
||||
it("normalizes slash-without-spaces to canonical form", () => {
|
||||
expect(normalizePath("Homelab/Debian")).toBe("Homelab / Debian");
|
||||
});
|
||||
|
||||
it("is idempotent on already-canonical input", () => {
|
||||
expect(normalizePath("Homelab / Debian")).toBe("Homelab / Debian");
|
||||
});
|
||||
|
||||
it("normalizes mixed spacing around slash", () => {
|
||||
expect(normalizePath("Homelab /Debian/ Servers")).toBe(
|
||||
"Homelab / Debian / Servers",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(normalizePath("")).toBe("");
|
||||
});
|
||||
});
|
||||
+1710
-1575
File diff suppressed because it is too large.
Load diff
@@ -1,123 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
createHostEditorForm,
|
||||
buildHostEditorPayload,
|
||||
type HostProtocols,
|
||||
} from "./HostEditorData";
|
||||
|
||||
const sshOnly: HostProtocols = {
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
};
|
||||
|
||||
describe("buildHostEditorPayload auth field isolation", () => {
|
||||
it("only sends the password when authType is password", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "hunter2",
|
||||
key: "PRIVATE KEY",
|
||||
keyPassword: "kp",
|
||||
credentialId: "5",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.password).toBe("hunter2");
|
||||
expect(payload.key).toBeNull();
|
||||
expect(payload.keyPassword).toBeNull();
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the credentialId when switching a cloned host away from credential auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "newpass",
|
||||
credentialId: "12",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.credentialId).toBeNull();
|
||||
expect(payload.password).toBe("newpass");
|
||||
});
|
||||
|
||||
it("only sends the credentialId when authType is credential", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "credential" as const,
|
||||
credentialId: "7",
|
||||
password: "leftover",
|
||||
key: "leftover-key",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.credentialId).toBe(7);
|
||||
expect(payload.password).toBeNull();
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sends key fields and optional password when authType is key", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "key" as const,
|
||||
key: "MY KEY",
|
||||
keyType: "ssh-ed25519",
|
||||
password: "leftover",
|
||||
credentialId: "3",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
|
||||
expect(payload.key).toBe("MY KEY");
|
||||
expect(payload.keyType).toBe("ssh-ed25519");
|
||||
expect(payload.password).toBe("leftover");
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves agentSocketPath in terminalConfig when authType is agent", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBe("/run/user/1000/gnupg/S.gpg-agent.ssh");
|
||||
expect(payload.password).toBeNull();
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sets agentSocketPath to null in payload when authType is agent but path is empty", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
|
||||
it("nulls out agentSocketPath when switching away from agent auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "mypass",
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,9 @@ export function createHostEditorForm(
|
||||
windowsPatterns: "win, windows",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "10., 192.168.",
|
||||
autoSyncEnabled: false,
|
||||
syncIntervalMinutes: 15,
|
||||
markMissingGuests: true,
|
||||
},
|
||||
enableTunnel: host?.enableTunnel ?? false,
|
||||
defaultPath: host?.defaultPath ?? "/",
|
||||
@@ -273,7 +276,8 @@ export function buildHostEditorPayload(
|
||||
pin: form.pin,
|
||||
authType: form.authType,
|
||||
useWarpgate: form.useWarpgate,
|
||||
password: usesPassword || usesKey ? form.password || null : null,
|
||||
password:
|
||||
usesPassword || usesKey || usesCredential ? form.password || null : null,
|
||||
key: usesKey
|
||||
? form.key === "existing_key"
|
||||
? undefined
|
||||
@@ -406,7 +410,7 @@ export function buildHostEditorPayload(
|
||||
agentForwarding: form.agentForwarding,
|
||||
autoMosh: form.autoMosh,
|
||||
autoTmux: form.autoTmux,
|
||||
sudoPasswordAutoFill: false,
|
||||
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
|
||||
sudoPassword: form.sudoPassword || null,
|
||||
keepaliveInterval: Number(form.keepaliveInterval),
|
||||
keepaliveCountMax: Number(form.keepaliveCountMax),
|
||||
|
||||
@@ -105,7 +105,22 @@ export function HostProxmoxTab({
|
||||
windowsPatterns: "win, windows",
|
||||
dockerPatterns: "docker",
|
||||
preferredPrefixes: "10., 192.168.",
|
||||
autoSyncEnabled: false,
|
||||
syncIntervalMinutes: 15,
|
||||
markMissingGuests: true,
|
||||
};
|
||||
const lastSyncResult = cfg.lastSyncResult;
|
||||
const lastSyncSummary = lastSyncResult
|
||||
? t("hosts.proxmoxLastSyncSummary", {
|
||||
created: lastSyncResult.created,
|
||||
updated: lastSyncResult.updated,
|
||||
markedMissing: lastSyncResult.markedMissing,
|
||||
skipped: lastSyncResult.skipped,
|
||||
})
|
||||
: t("hosts.proxmoxLastSyncNoResult");
|
||||
const lastSyncDescription = cfg.lastSyncAt
|
||||
? `${new Date(cfg.lastSyncAt).toLocaleString()} · ${lastSyncSummary}`
|
||||
: t("hosts.proxmoxLastSyncNever");
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
@@ -231,6 +246,71 @@ export function HostProxmoxTab({
|
||||
placeholder="10., 192.168."
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxAutoSync")}
|
||||
description={t("hosts.proxmoxAutoSyncDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={cfg.autoSyncEnabled === true}
|
||||
onChange={(v) =>
|
||||
setField("proxmoxConfig", {
|
||||
...cfg,
|
||||
autoSyncEnabled: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxSyncInterval")}
|
||||
description={t("hosts.proxmoxSyncIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
className="w-24 h-7 text-xs"
|
||||
type="number"
|
||||
min={5}
|
||||
value={cfg.syncIntervalMinutes ?? 15}
|
||||
onChange={(e) =>
|
||||
setField("proxmoxConfig", {
|
||||
...cfg,
|
||||
syncIntervalMinutes: Math.max(
|
||||
5,
|
||||
Number.parseInt(e.target.value || "15", 10),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxMarkMissing")}
|
||||
description={t("hosts.proxmoxMarkMissingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={cfg.markMissingGuests !== false}
|
||||
onChange={(v) =>
|
||||
setField("proxmoxConfig", {
|
||||
...cfg,
|
||||
markMissingGuests: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxLastSync")}
|
||||
description={lastSyncDescription}
|
||||
>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
cfg.lastSyncStatus === "error"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
title={cfg.lastSyncError ?? undefined}
|
||||
>
|
||||
{cfg.lastSyncStatus
|
||||
? t(`hosts.proxmoxLastSyncStatus.${cfg.lastSyncStatus}`)
|
||||
: t("hosts.proxmoxLastSyncStatus.pending")}
|
||||
</span>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -113,6 +113,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
socks5Password: h.socks5Password,
|
||||
socks5ProxyChain: parseJson(h.socks5ProxyChain) ?? [],
|
||||
overrideCredentialUsername: h.overrideCredentialUsername ?? false,
|
||||
isShared: h.isShared ?? false,
|
||||
permissionLevel: h.permissionLevel,
|
||||
sharedExpiresAt: h.sharedExpiresAt,
|
||||
ownerUsername: h.ownerUsername,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+461
-228
@@ -1,9 +1,11 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ListChecks,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
@@ -12,17 +14,43 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import {
|
||||
getHostAccess,
|
||||
shareHost,
|
||||
updateHostAccess,
|
||||
revokeHostAccess,
|
||||
getUserList,
|
||||
getRoles,
|
||||
type AccessRecord,
|
||||
type SharePermissionLevel,
|
||||
type ShareTarget,
|
||||
} from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
const PERMISSION_LEVELS: SharePermissionLevel[] = [
|
||||
"connect",
|
||||
"view",
|
||||
"edit",
|
||||
"manage",
|
||||
];
|
||||
|
||||
const EXPIRY_PRESETS = [
|
||||
{ key: "never", hours: null },
|
||||
{ key: "oneHour", hours: 1 },
|
||||
{ key: "oneDay", hours: 24 },
|
||||
{ key: "sevenDays", hours: 24 * 7 },
|
||||
{ key: "thirtyDays", hours: 24 * 30 },
|
||||
{ key: "custom", hours: undefined },
|
||||
] as const;
|
||||
|
||||
type ExpiryPresetKey = (typeof EXPIRY_PRESETS)[number]["key"];
|
||||
|
||||
export function HostShareModal({
|
||||
open,
|
||||
onClose,
|
||||
@@ -33,18 +61,28 @@ export function HostShareModal({
|
||||
host: Host | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [shareType, setShareType] = useState<"user" | "role">("user");
|
||||
const [shareGranteeId, setShareGranteeId] = useState("");
|
||||
const [shareExpiryHours, setShareExpiryHours] = useState("");
|
||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [permissionLevel, setPermissionLevel] =
|
||||
useState<SharePermissionLevel>("connect");
|
||||
const [expiryPreset, setExpiryPreset] = useState<ExpiryPresetKey>("never");
|
||||
const [customHours, setCustomHours] = useState("");
|
||||
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
|
||||
const [shareUsers, setShareUsers] = useState<
|
||||
{ id: string; username: string }[]
|
||||
>([]);
|
||||
const [shareRoles, setShareRoles] = useState<{ id: string; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [shareRoles, setShareRoles] = useState<
|
||||
{ id: number; name: string; displayName?: string }[]
|
||||
>([]);
|
||||
const [sharingLoaded, setSharingLoaded] = useState(false);
|
||||
const [sharingLoadError, setSharingLoadError] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !host) return;
|
||||
@@ -64,10 +102,13 @@ export function HostShareModal({
|
||||
})),
|
||||
);
|
||||
setShareRoles(
|
||||
(rolesRes.roles ?? []).map((r) => ({
|
||||
id: String(r.id),
|
||||
name: r.name,
|
||||
})),
|
||||
(rolesRes.roles ?? [])
|
||||
.filter((r) => !r.isSystem)
|
||||
.map((r) => ({
|
||||
id: Number(r.id),
|
||||
name: r.name,
|
||||
displayName: r.displayName,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setSharingLoadError(true));
|
||||
@@ -77,21 +118,102 @@ export function HostShareModal({
|
||||
setSharingLoaded(false);
|
||||
setSharingLoadError(false);
|
||||
setAccessList([]);
|
||||
setShareGranteeId("");
|
||||
setShareExpiryHours("");
|
||||
setShareType("user");
|
||||
setSearch("");
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
setPermissionLevel("connect");
|
||||
setExpiryPreset("never");
|
||||
setCustomHours("");
|
||||
setTargetTab("user");
|
||||
}, [host?.id]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? shareUsers.filter((u) => u.username.toLowerCase().includes(q))
|
||||
: shareUsers;
|
||||
}, [shareUsers, search]);
|
||||
|
||||
const filteredRoles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return q
|
||||
? shareRoles.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
(r.displayName ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
: shareRoles;
|
||||
}, [shareRoles, search]);
|
||||
|
||||
const selectedCount = selectedUserIds.size + selectedRoleIds.size;
|
||||
|
||||
const durationHours = (() => {
|
||||
if (expiryPreset === "never") return undefined;
|
||||
if (expiryPreset === "custom") {
|
||||
const hours = Number(customHours);
|
||||
return Number.isFinite(hours) && hours > 0 ? hours : undefined;
|
||||
}
|
||||
return (
|
||||
EXPIRY_PRESETS.find((p) => p.key === expiryPreset)?.hours ?? undefined
|
||||
);
|
||||
})();
|
||||
|
||||
async function refreshAccessList() {
|
||||
if (!host) return;
|
||||
const res = await getHostAccess(Number(host.id));
|
||||
setAccessList(res.accessList ?? []);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
async function handleShare() {
|
||||
if (!host || selectedCount === 0) return;
|
||||
const targets: ShareTarget[] = [
|
||||
...[...selectedUserIds].map(
|
||||
(id) => ({ type: "user", id }) as ShareTarget,
|
||||
),
|
||||
...[...selectedRoleIds].map(
|
||||
(id) => ({ type: "role", id }) as ShareTarget,
|
||||
),
|
||||
];
|
||||
|
||||
const hasCredential =
|
||||
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await shareHost(Number(host.id), {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToShareHost"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLevelChange(
|
||||
record: AccessRecord,
|
||||
level: SharePermissionLevel,
|
||||
) {
|
||||
if (!host || record.permissionLevel === level) return;
|
||||
try {
|
||||
await updateHostAccess(Number(host.id), record.id, {
|
||||
permissionLevel: level,
|
||||
});
|
||||
setAccessList((prev) =>
|
||||
prev.map((entry) =>
|
||||
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
|
||||
),
|
||||
);
|
||||
toast.success(t("hosts.sharing.accessUpdated"));
|
||||
} catch {
|
||||
toast.error(t("hosts.sharing.accessUpdateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
|
||||
@@ -101,228 +223,339 @@ export function HostShareModal({
|
||||
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>{t("hosts.shareHostTitle", { name: host?.name ?? "" })}</span>
|
||||
<span className="truncate">
|
||||
{t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto p-3 gap-3">
|
||||
{!hasCredential && host !== null && (
|
||||
<div className="flex items-start gap-3 p-3 border border-yellow-500/30 bg-yellow-500/5 text-xs text-yellow-500">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.sharing.requiresCredential")}</div>
|
||||
</div>
|
||||
)}
|
||||
{sharingLoadError && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.sharing.loadError")}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sharingLoadError && hasCredential && (
|
||||
<div className="flex items-start gap-3 p-3 border border-destructive/30 bg-destructive/5 text-xs text-destructive">
|
||||
<Shield className="size-3.5 shrink-0 mt-0.5" />
|
||||
<div>{t("hosts.guac.sharingLoadError")}</div>
|
||||
{/* Share form: fixed, non-scrolling */}
|
||||
<div className="flex flex-col gap-2 p-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<Users className="size-3.5" />
|
||||
{t("hosts.sharing.shareWithSection")}
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/rbac"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline shrink-0"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{hasCredential && (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.guac.shareHostSection")}
|
||||
icon={<Users className="size-3.5" />}
|
||||
action={
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/rbac"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline normal-case tracking-normal font-normal"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
}
|
||||
<div className="flex gap-1.5">
|
||||
{(["user", "role"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setTargetTab(tab)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${targetTab === tab ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{(["user", "role"] as const).map((shareTypeOpt) => (
|
||||
<button
|
||||
key={shareTypeOpt}
|
||||
onClick={() => {
|
||||
setShareType(shareTypeOpt);
|
||||
setShareGranteeId("");
|
||||
}}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${shareType === shareTypeOpt ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{shareTypeOpt === "user" ? (
|
||||
<>
|
||||
<User className="size-3 inline mr-1" />
|
||||
{t("hosts.guac.shareWithUser")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="size-3 inline mr-1" />
|
||||
{t("hosts.guac.shareWithRole")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{shareType === "user"
|
||||
? t("hosts.guac.selectUser")
|
||||
: t("hosts.guac.selectRole")}
|
||||
</label>
|
||||
<select
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={shareGranteeId}
|
||||
onChange={(e) => setShareGranteeId(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{shareType === "user"
|
||||
? t("hosts.guac.selectUserOption")
|
||||
: t("hosts.guac.selectRoleOption")}
|
||||
</option>
|
||||
{shareType === "user"
|
||||
? shareUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))
|
||||
: shareRoles.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.expiresInHours")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t("hosts.guac.noExpiryPlaceholder")}
|
||||
value={shareExpiryHours}
|
||||
onChange={(e) => setShareExpiryHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={!shareGranteeId}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await shareHost(Number(host!.id), {
|
||||
targetType: shareType,
|
||||
...(shareType === "user"
|
||||
? { targetUserId: shareGranteeId }
|
||||
: { targetRoleId: Number(shareGranteeId) }),
|
||||
permissionLevel: "view",
|
||||
...(shareExpiryHours
|
||||
? { durationHours: Number(shareExpiryHours) }
|
||||
: {}),
|
||||
});
|
||||
await refreshAccessList();
|
||||
setShareGranteeId("");
|
||||
setShareExpiryHours("");
|
||||
toast.success(t("hosts.hostSharedSuccessfully"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToShareHost"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1.5" />
|
||||
{t("hosts.guac.shareBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
{tab === "user" ? (
|
||||
<User className="size-3 shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 shrink-0" />
|
||||
)}
|
||||
{tab === "user"
|
||||
? t("hosts.sharing.usersTab")
|
||||
: t("hosts.sharing.rolesTab")}
|
||||
{tab === "user" && selectedUserIds.size > 0 && (
|
||||
<span>({selectedUserIds.size})</span>
|
||||
)}
|
||||
{tab === "role" && selectedRoleIds.size > 0 && (
|
||||
<span>({selectedRoleIds.size})</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder={t("hosts.sharing.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col border border-border h-28 overflow-y-auto">
|
||||
{targetTab === "user" &&
|
||||
(filteredUsers.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noMatches")}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.guac.currentAccess")}
|
||||
icon={<ListChecks className="size-3.5" />}
|
||||
>
|
||||
<div className="py-2">
|
||||
{accessList.length === 0 && (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.guac.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((r, i) => {
|
||||
const expired =
|
||||
r.expiresAt && new Date(r.expiresAt) < new Date();
|
||||
return (
|
||||
) : (
|
||||
filteredUsers.map((user) => {
|
||||
const isSelected = selectedUserIds.has(user.id);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() =>
|
||||
setSelectedUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(user.id)) next.delete(user.id);
|
||||
else next.add(user.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col gap-1 px-2 py-2.5 border-b border-border last:border-0 text-xs"
|
||||
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{r.targetType === "user" ? (
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold truncate">
|
||||
{r.username ??
|
||||
r.roleName ??
|
||||
r.roleDisplayName ??
|
||||
r.userId ??
|
||||
r.roleId}
|
||||
</span>
|
||||
<span className="text-muted-foreground capitalize shrink-0">
|
||||
({r.targetType})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10 shrink-0"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), r.id);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((_, idx) => idx !== i),
|
||||
);
|
||||
toast.success(t("hosts.accessRevoked"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRevokeAccess"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("hosts.guac.revokeBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||
<span>
|
||||
{t("hosts.guac.grantedByHeader")}:{" "}
|
||||
<span className="text-foreground/70">
|
||||
{r.grantedByUsername ?? "—"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={expired ? "text-destructive" : ""}>
|
||||
{t("hosts.guac.expiresHeader")}:{" "}
|
||||
{expired ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<X className="size-3" />
|
||||
{t("hosts.guac.expiredLabel")}
|
||||
</span>
|
||||
) : r.expiresAt ? (
|
||||
<span className="text-foreground/70">
|
||||
{new Date(r.expiresAt).toLocaleDateString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/70">
|
||||
{t("hosts.guac.neverLabel")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check className="size-2.5 text-background" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{user.username}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))}
|
||||
{targetTab === "role" &&
|
||||
(filteredRoles.length === 0 ? (
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noMatches")}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
) : (
|
||||
filteredRoles.map((role) => {
|
||||
const isSelected = selectedRoleIds.has(role.id);
|
||||
return (
|
||||
<button
|
||||
key={role.id}
|
||||
onClick={() =>
|
||||
setSelectedRoleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(role.id)) next.delete(role.id);
|
||||
else next.add(role.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs text-left border-b border-border/50 last:border-0 transition-colors shrink-0 ${isSelected ? "bg-accent-brand/10 text-accent-brand" : "hover:bg-muted/40"}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3.5 border flex items-center justify-center shrink-0 transition-colors ${isSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<Check className="size-2.5 text-background" />
|
||||
)}
|
||||
</div>
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">
|
||||
{role.displayName || role.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sharing.permissionLevelLabel")}
|
||||
</span>
|
||||
<select
|
||||
value={permissionLevel}
|
||||
onChange={(e) =>
|
||||
setPermissionLevel(e.target.value as SharePermissionLevel)
|
||||
}
|
||||
className="h-8 w-full px-2.5 text-xs border border-border bg-background hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(`hosts.sharing.levels.${level}.label`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex flex-col gap-1 shrink-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground text-left">
|
||||
{t("hosts.sharing.expiryLabel")}
|
||||
</span>
|
||||
<span className="h-8 flex items-center justify-center px-2.5 text-xs border border-border hover:bg-muted/40 transition-colors whitespace-nowrap">
|
||||
{t(`hosts.sharing.expiry.${expiryPreset}`)}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{EXPIRY_PRESETS.map((preset) => (
|
||||
<DropdownMenuItem
|
||||
key={preset.key}
|
||||
onClick={() => setExpiryPreset(preset.key)}
|
||||
>
|
||||
{expiryPreset === preset.key ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.expiry.${preset.key}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
disabled={
|
||||
selectedCount === 0 ||
|
||||
submitting ||
|
||||
(expiryPreset === "custom" && !durationHours)
|
||||
}
|
||||
onClick={handleShare}
|
||||
>
|
||||
<Share2 className="size-3.5 mr-1.5" />
|
||||
{selectedCount > 0
|
||||
? t("hosts.sharing.shareWithCount", { count: selectedCount })
|
||||
: t("hosts.sharing.shareButton")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(`hosts.sharing.levels.${permissionLevel}.description`)}
|
||||
</p>
|
||||
|
||||
{expiryPreset === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
autoFocus
|
||||
placeholder={t("hosts.sharing.customHoursPlaceholder")}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current access: takes remaining space, scrolls independently */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 border-b border-border text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
<ListChecks className="size-3.5" />
|
||||
{t("hosts.sharing.currentAccess")}
|
||||
{accessList.length > 0 && (
|
||||
<span className="text-muted-foreground/40">
|
||||
({accessList.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{accessList.length === 0 && (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground/50 text-center">
|
||||
{t("hosts.sharing.noAccessEntries")}
|
||||
</div>
|
||||
)}
|
||||
{accessList.map((record) => {
|
||||
const expired =
|
||||
record.expiresAt && new Date(record.expiresAt) < new Date();
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex flex-col gap-1 px-3 py-2 border-b border-border/60 last:border-0 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{record.targetType === "user" ? (
|
||||
<User className="size-3 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Shield className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="font-semibold truncate">
|
||||
{record.username ??
|
||||
record.roleDisplayName ??
|
||||
record.roleName ??
|
||||
record.userId ??
|
||||
record.roleId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-widest border border-accent-brand/30 bg-accent-brand/10 text-accent-brand transition-colors hover:bg-accent-brand/20">
|
||||
{t(
|
||||
`hosts.sharing.levels.${record.permissionLevel ?? "connect"}.label`,
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="text-xs">
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<DropdownMenuItem
|
||||
key={level}
|
||||
onClick={() => handleLevelChange(record, level)}
|
||||
>
|
||||
{record.permissionLevel === level ? (
|
||||
<Check className="size-3 mr-1.5" />
|
||||
) : (
|
||||
<span className="size-3 mr-1.5" />
|
||||
)}
|
||||
{t(`hosts.sharing.levels.${level}.label`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await revokeHostAccess(Number(host!.id), record.id);
|
||||
setAccessList((prev) =>
|
||||
prev.filter((entry) => entry.id !== record.id),
|
||||
);
|
||||
toast.success(t("hosts.accessRevoked"));
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToRevokeAccess"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("hosts.sharing.revoke")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground pl-4">
|
||||
<span>
|
||||
{t("hosts.sharing.grantedBy")}:{" "}
|
||||
<span className="text-foreground/70">
|
||||
{record.grantedByUsername ?? "?"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={expired ? "text-destructive" : ""}>
|
||||
{t("hosts.sharing.expires")}:{" "}
|
||||
{expired ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<X className="size-3" />
|
||||
{t("hosts.sharing.expired")}
|
||||
</span>
|
||||
) : record.expiresAt ? (
|
||||
<span className="text-foreground/70">
|
||||
{new Date(record.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-foreground/70">
|
||||
{t("hosts.sharing.never")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,16 +44,11 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
|
||||
type SortKey =
|
||||
| "default"
|
||||
| "name-asc"
|
||||
| "name-desc"
|
||||
| "ip-asc"
|
||||
| "ip-desc"
|
||||
| "status-online"
|
||||
| "status-offline"
|
||||
| "pinned";
|
||||
import {
|
||||
resolveHostSortPreferences,
|
||||
sortHostTree,
|
||||
type SortKey,
|
||||
} from "@/sidebar/host-sort";
|
||||
|
||||
type FilterState = {
|
||||
status: ("online" | "offline" | "pinned")[];
|
||||
@@ -127,43 +122,6 @@ function groupHosts(
|
||||
return { name: "root", children };
|
||||
}
|
||||
|
||||
function sortHostTree(folder: HostFolder, key: SortKey): HostFolder {
|
||||
if (key === "default") return folder;
|
||||
|
||||
const comparator = (a: Host | HostFolder, b: Host | HostFolder): number => {
|
||||
const aIsFolder = isFolder(a);
|
||||
const bIsFolder = isFolder(b);
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
if (aIsFolder && bIsFolder)
|
||||
return (a as HostFolder).name.localeCompare((b as HostFolder).name);
|
||||
const ha = a as Host,
|
||||
hb = b as Host;
|
||||
switch (key) {
|
||||
case "name-asc":
|
||||
return ha.name.localeCompare(hb.name);
|
||||
case "name-desc":
|
||||
return hb.name.localeCompare(ha.name);
|
||||
case "ip-asc":
|
||||
return ha.ip.localeCompare(hb.ip);
|
||||
case "ip-desc":
|
||||
return hb.ip.localeCompare(ha.ip);
|
||||
case "status-online":
|
||||
return (hb.online ? 1 : 0) - (ha.online ? 1 : 0);
|
||||
case "status-offline":
|
||||
return (ha.online ? 1 : 0) - (hb.online ? 1 : 0);
|
||||
case "pinned":
|
||||
return (hb.pin ? 1 : 0) - (ha.pin ? 1 : 0);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortedChildren = [...folder.children]
|
||||
.sort(comparator)
|
||||
.map((child) => (isFolder(child) ? sortHostTree(child, key) : child));
|
||||
return { ...folder, children: sortedChildren };
|
||||
}
|
||||
|
||||
function hostPassesFilters(host: Host, filters: FilterState): boolean {
|
||||
if (filters.status.length > 0) {
|
||||
const ok =
|
||||
@@ -255,9 +213,18 @@ export function HostsPanel({
|
||||
const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [sortKey, setSortKey] = useState<SortKey>(
|
||||
() => (localStorage.getItem("hostSortKey") as SortKey) ?? "default",
|
||||
);
|
||||
const [sortKey, setSortKey] = useState<SortKey>(() => {
|
||||
return resolveHostSortPreferences(
|
||||
localStorage.getItem("hostSortKey"),
|
||||
localStorage.getItem("hostPinnedFirst"),
|
||||
).sortKey;
|
||||
});
|
||||
const [pinnedFirst, setPinnedFirst] = useState(() => {
|
||||
return resolveHostSortPreferences(
|
||||
localStorage.getItem("hostSortKey"),
|
||||
localStorage.getItem("hostPinnedFirst"),
|
||||
).pinnedFirst;
|
||||
});
|
||||
const [groupKey, setGroupKey] = useState<GroupKey>(
|
||||
() => (localStorage.getItem("hostGroupKey") as GroupKey) ?? "folder",
|
||||
);
|
||||
@@ -280,6 +247,11 @@ export function HostsPanel({
|
||||
localStorage.setItem("hostSortKey", key);
|
||||
}
|
||||
|
||||
function handlePinnedFirstChange(enabled: boolean) {
|
||||
setPinnedFirst(enabled);
|
||||
localStorage.setItem("hostPinnedFirst", String(enabled));
|
||||
}
|
||||
|
||||
function handleGroupChange(key: GroupKey) {
|
||||
setGroupKey(key);
|
||||
localStorage.setItem("hostGroupKey", key);
|
||||
@@ -320,9 +292,25 @@ export function HostsPanel({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then(setRawHosts)
|
||||
.catch(() => {});
|
||||
let cancelled = false;
|
||||
const load = () => {
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
if (!cancelled) setRawHosts(hosts);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const onChanged = () => load();
|
||||
window.addEventListener("ssh-hosts:changed", onChanged);
|
||||
window.addEventListener("hosts:refresh", onChanged);
|
||||
window.addEventListener("termix:hosts-changed", onChanged);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener("ssh-hosts:changed", onChanged);
|
||||
window.removeEventListener("hosts:refresh", onChanged);
|
||||
window.removeEventListener("termix:hosts-changed", onChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleEditingChange(editing: boolean) {
|
||||
@@ -654,7 +642,7 @@ export function HostsPanel({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`size-7 ${sortKey !== "default" ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
className={`size-7 ${sortKey !== "default" || pinnedFirst ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("hosts.sortHosts")}
|
||||
>
|
||||
<ArrowUpDown className="size-3.5" />
|
||||
@@ -708,28 +696,32 @@ export function HostsPanel({
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{(["status-online", "status-offline", "pinned"] as const).map(
|
||||
(key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
key === "status-online"
|
||||
? "hosts.sortOnlineFirst"
|
||||
: key === "status-offline"
|
||||
? "hosts.sortOfflineFirst"
|
||||
: "hosts.sortPinnedFirst",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
{(["status-online", "status-offline"] as const).map((key) => (
|
||||
<DropdownMenuItem
|
||||
key={key}
|
||||
onClick={() => handleSortChange(key)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sortKey === key ? (
|
||||
<Check className="size-3 shrink-0 text-accent-brand" />
|
||||
) : (
|
||||
<span className="size-3 shrink-0 inline-block" />
|
||||
)}
|
||||
{t(
|
||||
key === "status-online"
|
||||
? "hosts.sortOnlineFirst"
|
||||
: "hosts.sortOfflineFirst",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={pinnedFirst}
|
||||
onCheckedChange={handlePinnedFirstChange}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
>
|
||||
{t("hosts.sortPinnedFirst")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
@@ -1017,7 +1009,10 @@ export function HostsPanel({
|
||||
children={
|
||||
hostTree
|
||||
? groupHosts(
|
||||
applyFilters(sortHostTree(hostTree, sortKey), filterState),
|
||||
applyFilters(
|
||||
sortHostTree(hostTree, sortKey, pinnedFirst),
|
||||
filterState,
|
||||
),
|
||||
groupKey,
|
||||
groupLabel,
|
||||
).children
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getCredentials } from "@/api/credentials-api";
|
||||
import { mapCredentials } from "./HostManagerData";
|
||||
import { createQuickConnectHost } from "./quick-connect-host";
|
||||
|
||||
interface QuickConnectPanelProps {
|
||||
onConnect: (host: Host, type: "terminal" | "files") => void;
|
||||
@@ -34,38 +35,15 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
|
||||
const connect = (type: "terminal" | "files") => {
|
||||
if (!host || !username) return;
|
||||
const hostConfig: Host = {
|
||||
id: `quick-connect-${Date.now()}`,
|
||||
name: `${username}@${host}`,
|
||||
const hostConfig = createQuickConnectHost({
|
||||
ip: host,
|
||||
port: parseInt(port) || 22,
|
||||
username,
|
||||
authType,
|
||||
password: authType === "password" ? password : undefined,
|
||||
key: authType === "key" ? privateKey : undefined,
|
||||
credentialId: authType === "credential" ? credentialId : undefined,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
pin: false,
|
||||
defaultPath: "",
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
enableTerminal: true,
|
||||
enableFileManager: true,
|
||||
enableTunnel: true,
|
||||
enableDocker: true,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: parseInt(port) || 22,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
};
|
||||
password,
|
||||
key: privateKey,
|
||||
credentialId,
|
||||
});
|
||||
onConnect(hostConfig, type);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,26 @@ const PROVIDER_TYPE_OPTIONS: { value: SSOProviderType; label: string }[] = [
|
||||
{ value: "ldap", label: "LDAP" },
|
||||
];
|
||||
|
||||
const githubDefaults = {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
scopes: "read:user user:email",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
};
|
||||
|
||||
const googleDefaults = {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
scopes: "openid email profile",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
};
|
||||
|
||||
type SSOProviderDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -408,24 +428,6 @@ function OIDCConfigFields({
|
||||
simplified: boolean;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const githubDefaults = {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
scopes: "read:user user:email",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
};
|
||||
const googleDefaults = {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
scopes: "openid email profile",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
};
|
||||
const docsHref = simplified
|
||||
? "https://docs.termix.site/features/authentication/github-google"
|
||||
: "https://docs.termix.site/features/authentication/oidc";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
Loader2,
|
||||
Save,
|
||||
ScrollText,
|
||||
Search,
|
||||
X,
|
||||
@@ -23,9 +25,13 @@ import { toast } from "sonner";
|
||||
import {
|
||||
getSessionLogs,
|
||||
getSessionLogContent,
|
||||
getSessionLogBlob,
|
||||
getSessionRecordingRetention,
|
||||
setSessionRecordingRetention,
|
||||
deleteSessionLog,
|
||||
type SessionLogRecord,
|
||||
} from "@/api/session-log-api";
|
||||
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) return "--";
|
||||
@@ -63,7 +69,13 @@ function buildFilename(log: SessionLogRecord): string {
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
const s = String(d.getSeconds()).padStart(2, "0");
|
||||
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.log`;
|
||||
const extension =
|
||||
log.format === "guacamole"
|
||||
? "guac"
|
||||
: log.format === "asciicast"
|
||||
? "cast"
|
||||
: "log";
|
||||
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`;
|
||||
}
|
||||
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
@@ -105,6 +117,9 @@ function LogRow({
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate">
|
||||
{formatDate(log.startedAt)}
|
||||
{" · "}
|
||||
{(log.protocol ?? "ssh").toUpperCase()}
|
||||
{log.username ? ` · ${log.username}` : ""}
|
||||
{" · "}
|
||||
{formatDuration(log.duration)}
|
||||
{" · "}
|
||||
{formatBytes(log.sizeBytes)}
|
||||
@@ -159,12 +174,15 @@ export function SessionLogsPanel() {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
|
||||
const [viewContent, setViewContent] = useState<string>("");
|
||||
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
|
||||
const [viewLoading, setViewLoading] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
|
||||
null,
|
||||
);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [retentionDays, setRetentionDays] = useState<number | null>(null);
|
||||
const [savingRetention, setSavingRetention] = useState(false);
|
||||
|
||||
const load = useCallback(
|
||||
(initial = false) => {
|
||||
@@ -173,10 +191,11 @@ export function SessionLogsPanel() {
|
||||
.then((fresh) => {
|
||||
setLogs((prev) => {
|
||||
if (
|
||||
JSON.stringify(prev.map((l) => l.id)) ===
|
||||
JSON.stringify(fresh.map((l) => l.id))
|
||||
)
|
||||
prev.length === fresh.length &&
|
||||
prev.every((log, i) => log.id === fresh[i]?.id)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return fresh;
|
||||
});
|
||||
})
|
||||
@@ -192,24 +211,40 @@ export function SessionLogsPanel() {
|
||||
|
||||
useEffect(() => {
|
||||
load(true);
|
||||
const interval = setInterval(() => load(false), 5000);
|
||||
return () => clearInterval(interval);
|
||||
getSessionRecordingRetention()
|
||||
.then(setRetentionDays)
|
||||
.catch(() => setRetentionDays(null));
|
||||
}, [load]);
|
||||
|
||||
usePageVisibleInterval(
|
||||
() => {
|
||||
void load(false);
|
||||
},
|
||||
5_000,
|
||||
true,
|
||||
{ runOnMount: false },
|
||||
);
|
||||
|
||||
const q = filter.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? logs.filter((l) =>
|
||||
(l.hostName ?? l.hostIp ?? "").toLowerCase().includes(q),
|
||||
`${l.hostName ?? ""} ${l.hostIp ?? ""} ${l.username ?? ""} ${l.protocol}`
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
)
|
||||
: logs;
|
||||
|
||||
const handleView = async (log: SessionLogRecord) => {
|
||||
setViewLog(log);
|
||||
setViewContent("");
|
||||
setViewBlob(null);
|
||||
setViewLoading(true);
|
||||
try {
|
||||
const content = await getSessionLogContent(log.id);
|
||||
setViewContent(content);
|
||||
if (log.format === "text") {
|
||||
setViewContent(await getSessionLogContent(log.id));
|
||||
} else {
|
||||
setViewBlob(await getSessionLogBlob(log.id));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("sessionLogs.loadError"));
|
||||
} finally {
|
||||
@@ -219,8 +254,7 @@ export function SessionLogsPanel() {
|
||||
|
||||
const handleDownload = async (log: SessionLogRecord) => {
|
||||
try {
|
||||
const content = await getSessionLogContent(log.id);
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const blob = await getSessionLogBlob(log.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -285,6 +319,7 @@ export function SessionLogsPanel() {
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{formatDate(viewLog.startedAt)}
|
||||
{` · ${viewLog.protocol.toUpperCase()}`}
|
||||
{viewLog.duration != null
|
||||
? ` · ${formatDuration(viewLog.duration)}`
|
||||
: ""}
|
||||
@@ -294,25 +329,27 @@ export function SessionLogsPanel() {
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{copied
|
||||
? t("sessionLogs.copied")
|
||||
: t("sessionLogs.copyContent")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{viewLog.format === "text" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{copied
|
||||
? t("sessionLogs.copied")
|
||||
: t("sessionLogs.copyContent")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -336,6 +373,8 @@ export function SessionLogsPanel() {
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : viewBlob ? (
|
||||
<SessionRecordingPlayer log={viewLog} blob={viewBlob} />
|
||||
) : (
|
||||
<pre className="p-3 text-[11px] font-mono whitespace-pre-wrap break-all text-foreground/80 leading-relaxed">
|
||||
{viewContent || "(empty)"}
|
||||
@@ -349,6 +388,44 @@ export function SessionLogsPanel() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{retentionDays != null && (
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<span className="flex-1">Recording retention</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={3650}
|
||||
value={retentionDays}
|
||||
onChange={(event) => setRetentionDays(Number(event.target.value))}
|
||||
className="h-7 w-20 text-xs"
|
||||
aria-label="Recording retention days"
|
||||
/>
|
||||
<span>days</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingRetention}
|
||||
onClick={async () => {
|
||||
setSavingRetention(true);
|
||||
try {
|
||||
await setSessionRecordingRetention(retentionDays);
|
||||
toast.success("Recording retention updated");
|
||||
} catch {
|
||||
toast.error("Failed to update recording retention");
|
||||
} finally {
|
||||
setSavingRetention(false);
|
||||
}
|
||||
}}
|
||||
className="flex size-7 items-center justify-center hover:bg-muted disabled:opacity-50"
|
||||
aria-label="Save recording retention"
|
||||
>
|
||||
{savingRetention ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
|
||||
<div className="size-10 bg-muted/40 flex items-center justify-center">
|
||||
|
||||
+276
-138
@@ -1,6 +1,13 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useState, useEffect, type MouseEvent } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
Box,
|
||||
Boxes,
|
||||
@@ -29,6 +36,7 @@ import {
|
||||
Share2,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Users,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -57,12 +65,21 @@ import type { SSHHostData } from "@/types/index";
|
||||
import { FolderIconEl } from "@/components/folder-style";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import {
|
||||
canDeleteHost,
|
||||
canEditHost,
|
||||
canShareHost,
|
||||
} from "@/sidebar/host-permissions";
|
||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
useHostStatus,
|
||||
useServerStatus,
|
||||
useServerStatusMeta,
|
||||
} from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -255,8 +272,8 @@ function folderHostCount(folder: HostFolder): {
|
||||
export function HostItem({
|
||||
host,
|
||||
onOpenTab,
|
||||
onEditHost,
|
||||
onShareHost,
|
||||
onEditHost: onEditHostProp,
|
||||
onShareHost: onShareHostProp,
|
||||
onProxmoxDiscover,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -271,6 +288,7 @@ export function HostItem({
|
||||
onTrayOpenChange,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
depth = 0,
|
||||
}: {
|
||||
host: Host;
|
||||
onOpenTab: (type: TabType) => void;
|
||||
@@ -290,8 +308,14 @@ export function HostItem({
|
||||
onProxmoxDiscover?: () => void;
|
||||
onDragStart?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
/** Nesting level when rendered in a flattened virtual list. */
|
||||
depth?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Shared hosts expose actions matching the recipient's permission level.
|
||||
const onEditHost = canEditHost(host) ? onEditHostProp : undefined;
|
||||
const onShareHost = canShareHost(host) ? onShareHostProp : undefined;
|
||||
const allowDelete = canDeleteHost(host);
|
||||
const metricsEnabled =
|
||||
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
@@ -305,16 +329,17 @@ export function HostItem({
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
);
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const { initialLoadComplete, getStatus } = useServerStatus();
|
||||
const { initialLoadComplete } = useServerStatusMeta();
|
||||
const statusCheckOn = statusCheckEnabled(host);
|
||||
const statusLoading = !initialLoadComplete && statusCheckOn;
|
||||
const liveStatus = statusCheckOn ? getStatus(Number(host.id)) : null;
|
||||
// Per-host subscription — status polls only re-render rows that flipped.
|
||||
const liveStatus = useHostStatus(Number(host.id), statusCheckOn);
|
||||
const isOnline = liveStatus != null ? liveStatus === "online" : host.online;
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
||||
const showPasswordCopy = canCopyHostPassword(host);
|
||||
const showSudoPasswordCopy = canCopyHostSudoPassword(host);
|
||||
const showPasswordCopy = !host.isShared && canCopyHostPassword(host);
|
||||
const showSudoPasswordCopy = !host.isShared && canCopyHostSudoPassword(host);
|
||||
|
||||
async function handleCopyPassword(
|
||||
e: MouseEvent,
|
||||
@@ -372,6 +397,9 @@ export function HostItem({
|
||||
|
||||
if (query && !hostMatchesQuery(host, query)) return null;
|
||||
|
||||
const depthStyle =
|
||||
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
|
||||
|
||||
if (compactHostView) {
|
||||
return (
|
||||
<div
|
||||
@@ -381,6 +409,7 @@ export function HostItem({
|
||||
onDragStart?.();
|
||||
}}
|
||||
onDragEnd={() => onDragEnd?.()}
|
||||
style={depthStyle}
|
||||
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
|
||||
selected
|
||||
? "bg-accent-brand/5"
|
||||
@@ -446,6 +475,26 @@ export function HostItem({
|
||||
<span className="text-[13px] font-medium truncate text-foreground leading-none">
|
||||
{host.name}
|
||||
</span>
|
||||
{host.isShared && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||
<Users className="size-2.5" />
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("hosts.sharing.sharedBadgeTooltip", {
|
||||
owner: host.ownerUsername || "?",
|
||||
level: t(
|
||||
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
|
||||
),
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!selectionMode && shouldUseClickTray && (
|
||||
<button
|
||||
title={
|
||||
@@ -647,27 +696,31 @@ export function HostItem({
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
{allowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -843,27 +896,31 @@ export function HostItem({
|
||||
{t("nav.copySudoPassword")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
{allowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -882,6 +939,7 @@ export function HostItem({
|
||||
onDragStart?.();
|
||||
}}
|
||||
onDragEnd={() => onDragEnd?.()}
|
||||
style={depthStyle}
|
||||
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
|
||||
selected
|
||||
? "bg-accent-brand/5"
|
||||
@@ -954,6 +1012,26 @@ export function HostItem({
|
||||
{host.pin && (
|
||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||
)}
|
||||
{host.isShared && (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-0.5 text-[9px] px-1 py-px border border-accent-brand/30 bg-accent-brand/10 text-accent-brand shrink-0 leading-none uppercase tracking-wider">
|
||||
<Users className="size-2.5" />
|
||||
{t("hosts.sharing.sharedBadge")}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("hosts.sharing.sharedBadgeTooltip", {
|
||||
owner: host.ownerUsername || "?",
|
||||
level: t(
|
||||
`hosts.sharing.levels.${host.permissionLevel ?? "connect"}.label`,
|
||||
),
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{!selectionMode && shouldUseClickTray && (
|
||||
<button
|
||||
title={
|
||||
@@ -1425,27 +1503,31 @@ export function HostItem({
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
{allowDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDuplicate();
|
||||
}}
|
||||
>
|
||||
<CopyPlus className="size-3.5 mr-2" />
|
||||
{t("hosts.cloneHostAction")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-2" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -1483,6 +1565,9 @@ export function FolderItem({
|
||||
draggedHostIds,
|
||||
onDragHostStart,
|
||||
onDragEnd,
|
||||
/** When true, only render the folder header (children come from the virtual list). */
|
||||
flat = false,
|
||||
stripeIndex: stripeIndexProp,
|
||||
}: {
|
||||
folder: HostFolder;
|
||||
depth?: number;
|
||||
@@ -1493,7 +1578,7 @@ export function FolderItem({
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onProxmoxDiscover?: (host: Host) => void;
|
||||
query?: string;
|
||||
stripeMap: Map<Host | HostFolder, number>;
|
||||
stripeMap?: Map<Host | HostFolder, number>;
|
||||
openFolders: Set<string>;
|
||||
onToggleFolder: (name: string) => void;
|
||||
selectionMode: boolean;
|
||||
@@ -1510,6 +1595,8 @@ export function FolderItem({
|
||||
draggedHostIds: string[] | null;
|
||||
onDragHostStart: (hostId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
flat?: boolean;
|
||||
stripeIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { getStatus, initialLoadComplete } = useServerStatus();
|
||||
@@ -1525,13 +1612,14 @@ export function FolderItem({
|
||||
|
||||
const folderPath = folder.path ?? folder.name;
|
||||
const isOpen = query ? true : openFolders.has(folderPath);
|
||||
const stripeIndex = stripeMap.get(folder) ?? 0;
|
||||
const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0;
|
||||
// Synthetic group headers (group-by tag/status/etc.) are not real folders, so
|
||||
// they can't be edited, deleted, or used as drop targets.
|
||||
const isGroup = folderPath.startsWith("__group__:");
|
||||
|
||||
return (
|
||||
<div
|
||||
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
|
||||
onDragOver={(e) => {
|
||||
if (draggedHostIds && !isGroup) {
|
||||
e.preventDefault();
|
||||
@@ -1611,7 +1699,7 @@ export function FolderItem({
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
{isOpen && (
|
||||
{!flat && isOpen && (
|
||||
<div className="border-l border-border/40 ml-[30px]">
|
||||
{folder.children.map((child, i) =>
|
||||
isFolder(child) ? (
|
||||
@@ -1657,7 +1745,7 @@ export function FolderItem({
|
||||
onDelete={() => onDeleteHost(child)}
|
||||
onDuplicate={() => onDuplicateHost(child)}
|
||||
query={query}
|
||||
stripeIndex={stripeMap.get(child) ?? 0}
|
||||
stripeIndex={stripeMap?.get(child) ?? 0}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedHostIds.has(child.id)}
|
||||
onToggleSelect={() => onToggleSelect(child.id)}
|
||||
@@ -1961,9 +2049,33 @@ export function SidebarTree({
|
||||
const allFolderPaths = collectAllFolderPaths(children);
|
||||
|
||||
const visibleRows = collectVisibleRows(children, query, openFolders);
|
||||
const stripeMap = new Map<Host | HostFolder, number>(
|
||||
visibleRows.map((r, i) => [r.item, i]),
|
||||
);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: visibleRows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return 36;
|
||||
if (isFolder(row.item)) return 36;
|
||||
// Expanded action tray is taller than a single host row.
|
||||
if (openTrayHostId === row.item.id) return 88;
|
||||
return 40;
|
||||
},
|
||||
overscan: 12,
|
||||
getItemKey: (index) => {
|
||||
const row = visibleRows[index];
|
||||
if (!row) return index;
|
||||
return isFolder(row.item)
|
||||
? `folder:${row.item.path ?? row.item.name}`
|
||||
: `host:${row.item.id}`;
|
||||
},
|
||||
});
|
||||
|
||||
// Remeasure when tray open state or tree shape changes (variable row heights).
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [virtualizer, openTrayHostId, openFolders, query, visibleRows.length]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -1993,6 +2105,7 @@ export function SidebarTree({
|
||||
return (
|
||||
<div className="relative flex flex-col flex-1 min-h-0">
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
if (draggedHostIds) {
|
||||
@@ -2019,66 +2132,91 @@ export function SidebarTree({
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
children.map((child, i) =>
|
||||
isFolder(child) ? (
|
||||
<FolderItem
|
||||
key={i}
|
||||
folder={child}
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
onShareHost={onShareHost}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onProxmoxDiscover={onProxmoxDiscover}
|
||||
query={query}
|
||||
stripeMap={stripeMap}
|
||||
openFolders={openFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
selectionMode={selectionMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
onToggleSelect={toggleSelect}
|
||||
openMenuHostId={openMenuHostId}
|
||||
onMenuOpenChange={setOpenMenuHostId}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={setOpenTrayHostId}
|
||||
onManageFolder={handleManageFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onOpenAllSessions={handleOpenAllSessions}
|
||||
onMoveHostsToFolder={handleMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={handleDragHostStart}
|
||||
onDragEnd={() => setDraggedHostIds(null)}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
key={i}
|
||||
host={child}
|
||||
onOpenTab={(type) => onOpenTab(child, type)}
|
||||
onEditHost={() => onEditHost(child)}
|
||||
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
|
||||
onProxmoxDiscover={
|
||||
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined
|
||||
}
|
||||
onDelete={() => handleDeleteHost(child)}
|
||||
onDuplicate={() => handleDuplicateHost(child)}
|
||||
query={query}
|
||||
stripeIndex={stripeMap.get(child) ?? 0}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedHostIds.has(child.id)}
|
||||
onToggleSelect={() => toggleSelect(child.id)}
|
||||
isMenuOpen={openMenuHostId === child.id}
|
||||
onMenuOpenChange={(open) =>
|
||||
setOpenMenuHostId(open ? child.id : null)
|
||||
}
|
||||
isTrayOpen={openTrayHostId === child.id}
|
||||
onTrayOpenChange={(open) =>
|
||||
setOpenTrayHostId(open ? child.id : null)
|
||||
}
|
||||
onDragStart={() => handleDragHostStart(child.id)}
|
||||
onDragEnd={() => setDraggedHostIds(null)}
|
||||
/>
|
||||
),
|
||||
)
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const row = visibleRows[vItem.index];
|
||||
if (!row) return null;
|
||||
const { item, depth } = row;
|
||||
return (
|
||||
<div
|
||||
key={vItem.key}
|
||||
data-index={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${vItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
{isFolder(item) ? (
|
||||
<FolderItem
|
||||
folder={item}
|
||||
depth={depth}
|
||||
flat
|
||||
onOpenTab={onOpenTab}
|
||||
onEditHost={onEditHost}
|
||||
onShareHost={onShareHost}
|
||||
onDeleteHost={handleDeleteHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onProxmoxDiscover={onProxmoxDiscover}
|
||||
query={query}
|
||||
openFolders={openFolders}
|
||||
onToggleFolder={toggleFolder}
|
||||
selectionMode={selectionMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
onToggleSelect={toggleSelect}
|
||||
openMenuHostId={openMenuHostId}
|
||||
onMenuOpenChange={setOpenMenuHostId}
|
||||
openTrayHostId={openTrayHostId}
|
||||
onTrayOpenChange={setOpenTrayHostId}
|
||||
onManageFolder={handleManageFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onOpenAllSessions={handleOpenAllSessions}
|
||||
onMoveHostsToFolder={handleMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={handleDragHostStart}
|
||||
onDragEnd={() => setDraggedHostIds(null)}
|
||||
stripeIndex={vItem.index}
|
||||
/>
|
||||
) : (
|
||||
<HostItem
|
||||
host={item}
|
||||
depth={depth}
|
||||
onOpenTab={(type) => onOpenTab(item, type)}
|
||||
onEditHost={() => onEditHost(item)}
|
||||
onShareHost={
|
||||
onShareHost ? () => onShareHost(item) : undefined
|
||||
}
|
||||
onProxmoxDiscover={
|
||||
onProxmoxDiscover
|
||||
? () => onProxmoxDiscover(item)
|
||||
: undefined
|
||||
}
|
||||
onDelete={() => handleDeleteHost(item)}
|
||||
onDuplicate={() => handleDuplicateHost(item)}
|
||||
query={query}
|
||||
stripeIndex={vItem.index}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedHostIds.has(item.id)}
|
||||
onToggleSelect={() => toggleSelect(item.id)}
|
||||
isMenuOpen={openMenuHostId === item.id}
|
||||
onMenuOpenChange={(open) =>
|
||||
setOpenMenuHostId(open ? item.id : null)
|
||||
}
|
||||
isTrayOpen={openTrayHostId === item.id}
|
||||
onTrayOpenChange={(open) =>
|
||||
setOpenTrayHostId(open ? item.id : null)
|
||||
}
|
||||
onDragStart={() => handleDragHostStart(item.id)}
|
||||
onDragEnd={() => setDraggedHostIds(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -908,12 +908,14 @@ function SnippetCard({
|
||||
onDragEnd={onDragEnd}
|
||||
className={`border bg-background p-2.5 flex flex-col gap-2 group/card transition-opacity ${isDragging ? "opacity-40" : "opacity-100"} border-border`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<GripVertical className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/30 group-hover/card:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors" />
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="text-xs font-semibold">{snippet.name}</span>
|
||||
<span className="text-xs font-semibold break-words">
|
||||
{snippet.name}
|
||||
</span>
|
||||
{snippet.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground break-words">
|
||||
{snippet.description}
|
||||
</span>
|
||||
)}
|
||||
@@ -925,7 +927,7 @@ function SnippetCard({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono px-1">
|
||||
<span className="text-xs text-muted-foreground font-mono px-1 min-w-0 break-all whitespace-pre-wrap">
|
||||
{snippet.content}
|
||||
</span>
|
||||
{targetHosts.length > 0 && (
|
||||
@@ -936,7 +938,7 @@ function SnippetCard({
|
||||
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 bg-accent-brand/10 text-accent-brand border border-accent-brand/20"
|
||||
>
|
||||
<Server className="size-2.5" />
|
||||
{host.name || host.ip}
|
||||
<span className="min-w-0 truncate">{host.name || host.ip}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -105,13 +105,26 @@ export function SshToolsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (text) broadcast(text);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
const ctrl = e.ctrlKey;
|
||||
const { key } = e;
|
||||
|
||||
// Let Ctrl/Cmd+V fall through so the browser fires a paste event
|
||||
// instead of being swallowed by the preventDefault() below.
|
||||
if ((ctrl || e.metaKey) && key.toLowerCase() === "v") {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (ctrl) {
|
||||
const ctrlMap: Record<string, string> = {
|
||||
c: "\x03",
|
||||
@@ -280,8 +293,15 @@ export function SshToolsPanel({
|
||||
{keyRecording && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
readOnly
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onChange={(e) => {
|
||||
// Keystrokes are broadcast directly via handleKeyDown; the
|
||||
// field itself must stay empty. This also catches paste
|
||||
// insertion on browsers that fire "input" before we can
|
||||
// intercept it in onPaste.
|
||||
e.target.value = "";
|
||||
}}
|
||||
placeholder={t("newUi.sidebar.sshTools.broadcastInputPlaceholder")}
|
||||
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
|
||||
/>
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
@@ -78,7 +79,7 @@ import type { ApiKey } from "@/main-axios";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import type { FontSizeId, ThemeId } from "@/types/ui-types";
|
||||
import { toast } from "sonner";
|
||||
import i18n from "@/i18n/i18n";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
|
||||
type UserProfileSection =
|
||||
| "account"
|
||||
@@ -445,7 +446,10 @@ export function UserProfilePanel({
|
||||
hiddenRailTabs?: string | null;
|
||||
statusColorScheme?: string | null;
|
||||
};
|
||||
onPrefsChange?: (prefs: { reopenTabsOnLogin: boolean }) => void;
|
||||
onPrefsChange?: (prefs: {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
storageMode?: "local" | "cloud";
|
||||
}) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const themeLabel: Record<ThemeId, string> = {
|
||||
@@ -515,13 +519,19 @@ export function UserProfilePanel({
|
||||
const [fontSize, setFontSize] = useState<FontSizeId>(
|
||||
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
|
||||
);
|
||||
const [language, setLanguage] = useState(
|
||||
() => localStorage.getItem("i18nextLng") ?? "en",
|
||||
const [language, setLanguage] = useState(() =>
|
||||
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||
);
|
||||
const [storageMode, setStorageMode] = useState<"local" | "cloud">(() =>
|
||||
userPrefs?.storageMode === "cloud" ? "cloud" : "local",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (userPrefs?.storageMode) {
|
||||
setStorageMode(userPrefs.storageMode === "cloud" ? "cloud" : "local");
|
||||
}
|
||||
}, [userPrefs?.storageMode]);
|
||||
|
||||
// Settings toggles — all backed by localStorage
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState(
|
||||
() => localStorage.getItem("commandAutocomplete") === "true",
|
||||
@@ -623,6 +633,7 @@ export function UserProfilePanel({
|
||||
|
||||
async function handleStorageModeChange(mode: "local" | "cloud") {
|
||||
setStorageMode(mode);
|
||||
onPrefsChange?.({ storageMode: mode });
|
||||
if (mode === "cloud") {
|
||||
// Snapshot current browser localStorage values so any tab can restore them later
|
||||
const SNAPSHOT_KEYS = [
|
||||
@@ -662,9 +673,8 @@ export function UserProfilePanel({
|
||||
applyAccentColor(prefs.accentColor);
|
||||
}
|
||||
if (prefs.language) {
|
||||
setLanguage(prefs.language);
|
||||
localStorage.setItem("i18nextLng", prefs.language);
|
||||
void i18n.changeLanguage(prefs.language);
|
||||
const language = await changeAppLanguage(prefs.language);
|
||||
setLanguage(language);
|
||||
}
|
||||
if (prefs.commandAutocomplete != null) {
|
||||
setCommandAutocomplete(prefs.commandAutocomplete);
|
||||
@@ -772,8 +782,7 @@ export function UserProfilePanel({
|
||||
localStorage.setItem("termix-accent", DEFAULT_ACCENT);
|
||||
applyAccentColor(DEFAULT_ACCENT);
|
||||
setLanguage("en");
|
||||
localStorage.setItem("i18nextLng", "en");
|
||||
void i18n.changeLanguage("en");
|
||||
void changeAppLanguage("en");
|
||||
setCommandAutocomplete(false);
|
||||
localStorage.setItem("commandAutocomplete", "false");
|
||||
setCommandPaletteEnabled(true);
|
||||
@@ -862,10 +871,9 @@ export function UserProfilePanel({
|
||||
localStorage.setItem("termix-accent", restoredAccent);
|
||||
applyAccentColor(restoredAccent);
|
||||
|
||||
const restoredLang = restore("i18nextLng", "en") ?? "en";
|
||||
const restoredLang = normalizeLanguageCode(restore("i18nextLng", "en"));
|
||||
setLanguage(restoredLang);
|
||||
localStorage.setItem("i18nextLng", restoredLang);
|
||||
void i18n.changeLanguage(restoredLang);
|
||||
void changeAppLanguage(restoredLang);
|
||||
|
||||
const restoredAutocomplete =
|
||||
restore("commandAutocomplete", "false") === "true";
|
||||
@@ -984,10 +992,12 @@ export function UserProfilePanel({
|
||||
}
|
||||
|
||||
function handleLanguageChange(code: string) {
|
||||
setLanguage(code);
|
||||
localStorage.setItem("i18nextLng", code);
|
||||
i18n.changeLanguage(code);
|
||||
if (storageMode === "cloud") saveToCloud({ language: code });
|
||||
void changeAppLanguage(code)
|
||||
.then((language) => {
|
||||
setLanguage(language);
|
||||
if (storageMode === "cloud") saveToCloud({ language });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function toggle(id: UserProfileSection) {
|
||||
@@ -1125,6 +1135,27 @@ export function UserProfilePanel({
|
||||
userId={userId}
|
||||
/>
|
||||
|
||||
{/* Donate banner */}
|
||||
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.donateTitle")}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateDescription")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateMilestones")}
|
||||
</p>
|
||||
<a
|
||||
href="https://donate.termix.site/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.donateButton")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Storage mode toggle */}
|
||||
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
@@ -1255,6 +1286,35 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.betaProgramTitle")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Support/issues/new?template=beta_feedback.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.betaProgramFeedback")}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="https://docs.termix.site/install/server/docker#beta-builds"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isElectron() && onChangeServer && (
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1666,6 +1726,11 @@ export function UserProfilePanel({
|
||||
icon: <KeyRound size={12} />,
|
||||
label: t("nav.credentials"),
|
||||
},
|
||||
{
|
||||
id: "termix-id",
|
||||
icon: <Fingerprint size={12} />,
|
||||
label: t("nav.termixId"),
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
icon: <Plug size={12} />,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Host, SharePermissionLevel } from "@/types/ui-types";
|
||||
|
||||
const LEVEL_RANK: Record<SharePermissionLevel, number> = {
|
||||
connect: 1,
|
||||
view: 2,
|
||||
edit: 3,
|
||||
manage: 4,
|
||||
};
|
||||
|
||||
function sharedLevelRank(host: Host): number {
|
||||
return LEVEL_RANK[host.permissionLevel ?? "connect"] ?? 1;
|
||||
}
|
||||
|
||||
export function canViewHostConfig(host: Host): boolean {
|
||||
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.view;
|
||||
}
|
||||
|
||||
export function canEditHost(host: Host): boolean {
|
||||
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.edit;
|
||||
}
|
||||
|
||||
export function canShareHost(host: Host): boolean {
|
||||
return !host.isShared || sharedLevelRank(host) >= LEVEL_RANK.manage;
|
||||
}
|
||||
|
||||
export function canDeleteHost(host: Host): boolean {
|
||||
return !host.isShared;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Host, HostFolder } from "@/types/ui-types";
|
||||
|
||||
export type SortKey =
|
||||
| "default"
|
||||
| "name-asc"
|
||||
| "name-desc"
|
||||
| "ip-asc"
|
||||
| "ip-desc"
|
||||
| "status-online"
|
||||
| "status-offline";
|
||||
|
||||
const SORT_KEYS = new Set<SortKey>([
|
||||
"default",
|
||||
"name-asc",
|
||||
"name-desc",
|
||||
"ip-asc",
|
||||
"ip-desc",
|
||||
"status-online",
|
||||
"status-offline",
|
||||
]);
|
||||
|
||||
export function resolveHostSortPreferences(
|
||||
savedSortKey: string | null,
|
||||
savedPinnedFirst: string | null,
|
||||
): { sortKey: SortKey; pinnedFirst: boolean } {
|
||||
const legacyPinned = savedSortKey === "pinned";
|
||||
return {
|
||||
sortKey: SORT_KEYS.has(savedSortKey as SortKey)
|
||||
? (savedSortKey as SortKey)
|
||||
: "default",
|
||||
pinnedFirst:
|
||||
savedPinnedFirst === null ? legacyPinned : savedPinnedFirst === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function isFolder(item: Host | HostFolder): item is HostFolder {
|
||||
return "children" in item;
|
||||
}
|
||||
|
||||
export function sortHostTree(
|
||||
folder: HostFolder,
|
||||
key: SortKey,
|
||||
pinnedFirst = false,
|
||||
): HostFolder {
|
||||
if (key === "default" && !pinnedFirst) return folder;
|
||||
|
||||
const comparator = (a: Host | HostFolder, b: Host | HostFolder): number => {
|
||||
const aIsFolder = isFolder(a);
|
||||
const bIsFolder = isFolder(b);
|
||||
if (aIsFolder && !bIsFolder) return -1;
|
||||
if (!aIsFolder && bIsFolder) return 1;
|
||||
if (aIsFolder && bIsFolder) return a.name.localeCompare(b.name);
|
||||
|
||||
if (pinnedFirst && !!a.pin !== !!b.pin) return b.pin ? 1 : -1;
|
||||
|
||||
switch (key) {
|
||||
case "name-asc":
|
||||
return a.name.localeCompare(b.name);
|
||||
case "name-desc":
|
||||
return b.name.localeCompare(a.name);
|
||||
case "ip-asc":
|
||||
return a.ip.localeCompare(b.ip);
|
||||
case "ip-desc":
|
||||
return b.ip.localeCompare(a.ip);
|
||||
case "status-online":
|
||||
return Number(b.online) - Number(a.online);
|
||||
case "status-offline":
|
||||
return Number(a.online) - Number(b.online);
|
||||
case "default":
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...folder,
|
||||
children: [...folder.children]
|
||||
.sort(comparator)
|
||||
.map((child) =>
|
||||
isFolder(child) ? sortHostTree(child, key, pinnedFirst) : child,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { SSHHostData } from "@/types";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
type QuickConnectInput = Pick<
|
||||
Host,
|
||||
"ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId"
|
||||
>;
|
||||
|
||||
export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||
return {
|
||||
id: `quick-connect-${Date.now()}`,
|
||||
name: `${input.username}@${input.ip}`,
|
||||
ip: input.ip,
|
||||
port: input.port,
|
||||
username: input.username,
|
||||
authType: input.authType,
|
||||
password: input.authType === "password" ? input.password : undefined,
|
||||
key: input.authType === "key" ? input.key : undefined,
|
||||
credentialId:
|
||||
input.authType === "credential" ? input.credentialId : undefined,
|
||||
folder: "",
|
||||
online: false,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
lastAccess: new Date().toISOString(),
|
||||
pin: false,
|
||||
defaultPath: "",
|
||||
serverTunnels: [],
|
||||
quickActions: [],
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableFileManager: true,
|
||||
enableTunnel: true,
|
||||
enableDocker: true,
|
||||
enableProxmox: false,
|
||||
enableTmuxMonitor: false,
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: input.port,
|
||||
rdpPort: 3389,
|
||||
vncPort: 5900,
|
||||
telnetPort: 23,
|
||||
};
|
||||
}
|
||||
|
||||
export function quickConnectHostToPayload(host: Host): SSHHostData {
|
||||
return {
|
||||
name: host.name,
|
||||
ip: host.ip,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
authType: host.authType,
|
||||
password: host.password,
|
||||
key: host.key,
|
||||
credentialId: host.credentialId
|
||||
? Number.parseInt(host.credentialId, 10)
|
||||
: null,
|
||||
folder: host.folder,
|
||||
pin: host.pin,
|
||||
defaultPath: host.defaultPath,
|
||||
enableTerminal: true,
|
||||
enableSessionLogging: true,
|
||||
enableCommandHistory: host.enableCommandHistory,
|
||||
enableFileManager: host.enableFileManager,
|
||||
enableTunnel: host.enableTunnel,
|
||||
enableDocker: host.enableDocker,
|
||||
enableProxmox: host.enableProxmox,
|
||||
enableTmuxMonitor: host.enableTmuxMonitor,
|
||||
showTerminalInSidebar: true,
|
||||
showFileManagerInSidebar: true,
|
||||
showTunnelInSidebar: true,
|
||||
showDockerInSidebar: true,
|
||||
showServerStatsInSidebar: true,
|
||||
connectionType: "ssh",
|
||||
enableSsh: true,
|
||||
enableRdp: false,
|
||||
enableVnc: false,
|
||||
enableTelnet: false,
|
||||
sshPort: host.sshPort,
|
||||
rdpPort: host.rdpPort,
|
||||
vncPort: host.vncPort,
|
||||
telnetPort: host.telnetPort,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user