mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
release-2.6.0 (#1085)
* fix: general bug fixes * fix: general qol additions * ci(deps): bump actions/setup-node in the github-actions group (#1068) Bumps the github-actions group with 1 update: [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-node 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 28 updates (#1069) Bumps the dev-patch-updates group with 28 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.2` | `2.5.4` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.5` | `6.43.6` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.15` | `1.2.17` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.18` | `1.1.20` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.6` | `1.3.8` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.20` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.19` | `2.1.21` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.11` | `2.1.12` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.18` | `1.1.20` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.11` | `1.1.13` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.13` | `1.2.15` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.2` | `2.3.4` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.11` | `1.1.12` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.2` | `1.4.4` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.2` | `1.3.4` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.16` | `1.1.18` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.11` | `1.2.13` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.2` | `4.3.3` | | [@uiw/codemirror-extensions-langs](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@uiw/codemirror-theme-github](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@uiw/react-codemirror](https://github.com/uiwjs/react-codemirror) | `4.25.10` | `4.25.11` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.9` | `4.1.10` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.9` | `4.1.10` | | [i18next](https://github.com/i18next/i18next) | `26.3.4` | `26.3.6` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.1` | `1.6.3` | | [react-i18next](https://github.com/i18next/react-i18next) | `17.0.8` | `17.0.10` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.2` | `4.3.3` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` | Updates `@biomejs/biome` from 2.5.2 to 2.5.4 - [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.4/packages/@biomejs/biome) Updates `@codemirror/view` from 6.43.5 to 6.43.6 - [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.15 to 1.2.17 - [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.18 to 1.1.20 - [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.6 to 1.3.8 - [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.18 to 1.1.20 - [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.19 to 2.1.21 - [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.11 to 2.1.12 - [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.18 to 1.1.20 - [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.11 to 1.1.13 - [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.13 to 1.2.15 - [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.2 to 2.3.4 - [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.11 to 1.1.12 - [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.2 to 1.4.4 - [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.2 to 1.3.4 - [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.16 to 1.1.18 - [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.11 to 1.2.13 - [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.2 to 4.3.3 - [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.3/packages/@tailwindcss-vite) Updates `@uiw/codemirror-extensions-langs` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@uiw/codemirror-theme-github` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@uiw/react-codemirror` from 4.25.10 to 4.25.11 - [Release notes](https://github.com/uiwjs/react-codemirror/releases) - [Commits](https://github.com/uiwjs/react-codemirror/compare/v4.25.10...v4.25.11) Updates `@vitest/coverage-v8` from 4.1.9 to 4.1.10 - [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.10/packages/coverage-v8) Updates `@vitest/ui` from 4.1.9 to 4.1.10 - [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.10/packages/ui) Updates `i18next` from 26.3.4 to 26.3.6 - [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.4...v26.3.6) Updates `radix-ui` from 1.6.1 to 1.6.3 - [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 `react-i18next` from 17.0.8 to 17.0.10 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.10) Updates `tailwindcss` from 4.3.2 to 4.3.3 - [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.3/packages/tailwindcss) Updates `vitest` from 4.1.9 to 4.1.10 - [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.10/packages/vitest) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.6 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.17 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.20 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.8 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.20 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.21 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.12 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.20 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.13 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.15 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.4 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.12 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.4 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.4 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.18 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.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/codemirror-extensions-langs" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/codemirror-theme-github" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@uiw/react-codemirror" dependency-version: 4.25.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-i18next dependency-version: 17.0.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.10 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 ws in the prod-patch-updates group (#1071) Bumps the prod-patch-updates group with 1 update: [ws](https://github.com/websockets/ws). Updates `ws` from 8.21.0 to 8.21.1 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.21.0...8.21.1) --- updated-dependencies: - dependency-name: ws dependency-version: 8.21.1 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 2 updates (#1072) Bumps the major-updates group with 2 updates: [nanoid](https://github.com/ai/nanoid) and [typescript](https://github.com/microsoft/TypeScript). Updates `nanoid` from 5.1.16 to 6.0.0 - [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.16...6.0.0) Updates `typescript` from 6.0.3 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: nanoid dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: typescript dependency-version: 7.0.2 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> * fix: general qol additions and new analytics/telemetrics feature * fix: incorrect version sent to posthog * feat: add multiplayer/shared sessions for terminal and guacd * feat: rework Electron desktop app to run standalone-first with optional two-way sync to a remote Termix server * Fix Guacamole tab visibility lifecycle (#1074) Co-authored-by: default-student <default-student@github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix(alerts): send channel config as object payload instead of JSON string (#1075) * fix tmux-monitor tailscale issue (#1076) * Initial plan * fix(tmux-monitor): explicitly handle tailscale auth in PanePreview hostConfig For Tailscale-auth hosts the pane-preview attach path was building the Terminal hostConfig with only the generic spread of host fields. This could omit or mismap auth-critical details and trigger a plain TCP/SSH reachability path that doesn't work with Tailscale-only SSH endpoints. The fix branches on `host.authType === "tailscale"` and: - Carries `authType: "tailscale"` explicitly so the backend always selects the Tailscale-aware PTY path regardless of how the host object evolves. - Derives `port` from `host.sshPort ?? host.port` so Tailscale SSH endpoints on a non-default SSH port are reached correctly. - Leaves all non-tailscale auth types on the unchanged code path. Reattach (bumping instanceId + attachNonce) continues to work because terminalHostConfig is recomputed on every render with the latest instanceIdRef.current value. * refactor(tmux-monitor): simplify tailscale port logic with extracted variable Address code review feedback: extract resolvedPort into a local variable to avoid the duplicated `host.sshPort ?? host.port` expression that was assigned to both `port` and `sshPort`. Restructure as an if/else block instead of an IIFE for readability. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: run prettier * chore: update beta release text * fix: cant update credential of an RDP host * feat: add custom key shortcuts * feat: add support for MFA over SSH * fix: Invalid websocket frame causing code 10006 crash triggering restart loop * fix(net): correct SSRF blocklist false-positive blocking all outbound IPv4 (#1079) * fix: correct IPv4-mapped-IPv6 blocklist entry blocking all outbound IPv4 ::ffff:0:0/96 in the IPv6 blocklist matches every IPv4 address once mapped, since Node's BlockList compares addresses in their mapped form internally regardless of the declared family. This caused safeOutboundFetch to reject all IPv4-resolved destinations as private, breaking outbound requests (e.g. ntfy/webhook notifications) whenever DNS resolved to IPv4. Replaced with individual mapped ranges mirroring the existing IPv4 blocklist. * test: cover isBlockedAddress and link the Node BlockList citation Exports isBlockedAddress so its family-crossing behavior around IPv4-mapped-IPv6 addresses can actually be asserted, instead of relying on manual container debugging to notice a regression. Also swaps the prior "Node's BlockList compares addresses in mapped form" comment for one citing the documented example in the Node docs (https://nodejs.org/api/net.html#class-netblocklist), since that behavior isn't otherwise obvious from the addSubnet/check call sites. Related: Termix-SSH/Support#1024 * refactor: derive IPv6 mirror from IPv4 list, split DNS error messages Two follow-ups from review: - The IPv6 blocklist previously hand-duplicated each IPv4 range as its IPv4-mapped-IPv6 equivalent. Nothing enforced the two stayed in sync, which is exactly how the original bug (a mismatched ::ffff:0:0/96 entry blocking all IPv4) was introduced in the first place. Now derived from a single blockedIpv4Ranges list in one loop. - The connect.lookup hook threw the same "Private destinations are not allowed" for both an empty DNS result and an actually-blocked address. An empty result is a resolution failure, not a privacy decision, and conflating the two is the same kind of opaque-error problem that made this bug slow to diagnose in production. Split into distinct messages. Also extracted the lookup hook itself (createDnsLookupHook) so it can be unit-tested against a fake resolver directly, instead of only through a real fetch()/Agent call — the bug lived entirely in this callback, and undici wraps any error thrown here as a generic "fetch failed" TypeError, which is why isolating it matters for testability. --------- Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com> * fix(ssh): do not offer chacha20-poly1305 without the native ssh2 binding (#1081) The availability probe treated a working OpenSSL "chacha20" cipher as proof that chacha20-poly1305@openssh.com is usable. It is not: ssh2 pure-JS chacha20-poly1305 corrupts the transport, so the peer aborts the KEX ("incomplete message [preauth]") and the connection times out. Easy to hit on jump-host connections whose target sshd negotiates chacha20-poly1305 first. Only trust the native binding (sshcrypto.node); otherwise leave the cipher disabled so filterCiphers() drops it and AES-GCM is negotiated instead. Co-authored-by: XtraLarge <> * fix: add Swiss German server layout (#1078) * chore: update release notes * feat: continue improving desktop app 2-way sync with logic fixes and a migration dialog * fix: dekstop app showing auth form without syncing * feat: create desktop auto sessions for existing setups * feat: add electron backend killing * fix: electron login and session related bugs and updated readme for v2.6.0 * chore: finalize release notes * fix: click to expand hosts including extra bottom margin * fix: desktop auth modal failing to log users in * fix: desktop app failing to sync * fix: reverse proxy causing sync error * chore: lint, format, and bump version to 2.6.0 * chore: sync Crowdin translations for 2.6.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: L.H. <117188168+default-student@users.noreply.github.com> Co-authored-by: default-student <default-student@github.com> Co-authored-by: Brad Baker <xyzulu@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Brennan Neoh <brennanneoh@users.noreply.github.com> Co-authored-by: brennanneoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: XtraLarge <eMail@WilliWerres.de> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
232 files changed
+50585
-32016
No files matched your search
@@ -33,7 +33,6 @@ import {
|
||||
getCommandHistoryEnabled,
|
||||
updateCommandHistoryEnabled,
|
||||
isElectron,
|
||||
getConfiguredServerUrl,
|
||||
getUserRoles,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
@@ -41,12 +40,19 @@ import {
|
||||
updateTailscaleSettings,
|
||||
getHostDefaults,
|
||||
updateHostDefaults,
|
||||
getAnalyticsEnabled,
|
||||
updateAnalyticsEnabled,
|
||||
type HostDefaults,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
getSessionSharingGloballyEnabled,
|
||||
updateSessionSharingGloballyEnabled,
|
||||
} from "@/api/session-sharing-api";
|
||||
import {
|
||||
getAcmeSslSettings,
|
||||
updateAcmeSslSettings,
|
||||
requestAcmeCertificate,
|
||||
uploadManualSslCertificate,
|
||||
type AcmeSettings,
|
||||
} from "@/api/acme-ssl-api";
|
||||
import {
|
||||
@@ -110,8 +116,8 @@ export function AdminSettingsPanel({
|
||||
onOpenHostTab?: (host: Host) => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const [openSection, setOpenSection] = useState<AdminSection | null>(
|
||||
"general",
|
||||
const [openSections, setOpenSections] = useState<Set<AdminSection>>(
|
||||
() => new Set(["general"]),
|
||||
);
|
||||
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
|
||||
const [allowRegistration, setAllowRegistration] = useState(true);
|
||||
@@ -126,6 +132,9 @@ export function AdminSettingsPanel({
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
|
||||
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
|
||||
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
|
||||
useState(true);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
|
||||
// SSO / auto-provision state
|
||||
@@ -200,6 +209,9 @@ export function AdminSettingsPanel({
|
||||
useState<AcmeSettings>(defaultAcmeSettings);
|
||||
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
|
||||
const [acmeRequesting, setAcmeRequesting] = useState(false);
|
||||
const [manualCertDraft, setManualCertDraft] = useState("");
|
||||
const [manualKeyDraft, setManualKeyDraft] = useState("");
|
||||
const [manualUploading, setManualUploading] = useState(false);
|
||||
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||
@@ -281,6 +293,8 @@ export function AdminSettingsPanel({
|
||||
oidcSilent,
|
||||
tailscale,
|
||||
cmdHistory,
|
||||
analytics,
|
||||
sessionSharingEnabled,
|
||||
] = await Promise.allSettled([
|
||||
getRegistrationAllowed(),
|
||||
getPasswordLoginAllowed(),
|
||||
@@ -293,6 +307,8 @@ export function AdminSettingsPanel({
|
||||
getOidcSilentLoginDefault(),
|
||||
getTailscaleSettings(),
|
||||
getCommandHistoryEnabled(),
|
||||
getAnalyticsEnabled(),
|
||||
getSessionSharingGloballyEnabled(),
|
||||
]);
|
||||
|
||||
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
|
||||
@@ -324,6 +340,12 @@ export function AdminSettingsPanel({
|
||||
if (cmdHistory.status === "fulfilled") {
|
||||
setCommandHistoryEnabled(cmdHistory.value.enabled);
|
||||
}
|
||||
if (analytics.status === "fulfilled") {
|
||||
setAnalyticsEnabled(analytics.value.enabled);
|
||||
}
|
||||
if (sessionSharingEnabled.status === "fulfilled") {
|
||||
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
|
||||
}
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
@@ -347,7 +369,12 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
|
||||
function toggle(id: AdminSection) {
|
||||
setOpenSection((prev) => (prev === id ? null : id));
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveHostDefaults() {
|
||||
@@ -426,6 +453,28 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleAnalytics() {
|
||||
const newVal = !analyticsEnabled;
|
||||
setAnalyticsEnabled(newVal);
|
||||
try {
|
||||
await updateAnalyticsEnabled(newVal);
|
||||
} catch {
|
||||
setAnalyticsEnabled(!newVal);
|
||||
toast.error(t("admin.updateAnalyticsFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSessionSharingGloballyEnabled() {
|
||||
const newVal = !sessionSharingGloballyEnabled;
|
||||
setSessionSharingGloballyEnabled(newVal);
|
||||
try {
|
||||
await updateSessionSharingGloballyEnabled(newVal);
|
||||
} catch {
|
||||
setSessionSharingGloballyEnabled(!newVal);
|
||||
toast.error(t("admin.updateSessionSharingFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSessionTimeout() {
|
||||
const hours = parseInt(sessionTimeout, 10);
|
||||
if (isNaN(hours) || hours < 1 || hours > 720) {
|
||||
@@ -587,6 +636,28 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleManualSslUpload() {
|
||||
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
|
||||
toast.error(t("admin.sslManualRequiresFields"));
|
||||
return;
|
||||
}
|
||||
setManualUploading(true);
|
||||
try {
|
||||
const result = await uploadManualSslCertificate({
|
||||
certificate: manualCertDraft,
|
||||
privateKey: manualKeyDraft,
|
||||
});
|
||||
setAcmeSettings(result);
|
||||
setManualCertDraft("");
|
||||
setManualKeyDraft("");
|
||||
toast.success(t("admin.sslManualUploadSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
|
||||
} finally {
|
||||
setManualUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSaved(saved: SSOProvider) {
|
||||
setSsoProviders((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === saved.id);
|
||||
@@ -736,7 +807,7 @@ export function AdminSettingsPanel({
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
@@ -782,7 +853,7 @@ export function AdminSettingsPanel({
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: getConfiguredServerUrl(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
@@ -854,8 +925,14 @@ export function AdminSettingsPanel({
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
|
||||
<AdminGeneralSettingsSection
|
||||
open={openSection === "general"}
|
||||
open={openSections.has("general")}
|
||||
onToggle={() => toggle("general")}
|
||||
analyticsEnabled={analyticsEnabled}
|
||||
handleToggleAnalytics={handleToggleAnalytics}
|
||||
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
|
||||
handleToggleSessionSharingGloballyEnabled={
|
||||
handleToggleSessionSharingGloballyEnabled
|
||||
}
|
||||
allowRegistration={allowRegistration}
|
||||
handleToggleRegistration={handleToggleRegistration}
|
||||
allowPasswordLogin={allowPasswordLogin}
|
||||
@@ -891,7 +968,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSSOSection
|
||||
open={openSection === "sso"}
|
||||
open={openSections.has("sso")}
|
||||
onToggle={() => toggle("sso")}
|
||||
providers={ssoProviders}
|
||||
onAddProvider={handleAddProvider}
|
||||
@@ -908,7 +985,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminUsersSection
|
||||
open={openSection === "users"}
|
||||
open={openSections.has("users")}
|
||||
onToggle={() => toggle("users")}
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
@@ -924,7 +1001,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSessionsSection
|
||||
open={openSection === "sessions"}
|
||||
open={openSections.has("sessions")}
|
||||
onToggle={() => toggle("sessions")}
|
||||
sessions={sessions}
|
||||
setSessions={setSessions}
|
||||
@@ -932,7 +1009,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminRolesSection
|
||||
open={openSection === "roles"}
|
||||
open={openSections.has("roles")}
|
||||
onToggle={() => toggle("roles")}
|
||||
roles={roles}
|
||||
setRoles={setRoles}
|
||||
@@ -949,7 +1026,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminHostDefaultsSection
|
||||
open={openSection === "host-defaults"}
|
||||
open={openSections.has("host-defaults")}
|
||||
onToggle={() => toggle("host-defaults")}
|
||||
defaults={hostDefaults}
|
||||
setDefaults={setHostDefaults}
|
||||
@@ -957,7 +1034,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminDatabaseSection
|
||||
open={openSection === "database"}
|
||||
open={openSections.has("database")}
|
||||
onToggle={() => toggle("database")}
|
||||
importFile={importFile}
|
||||
setImportFile={setImportFile}
|
||||
@@ -968,7 +1045,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminSSLSection
|
||||
open={openSection === "ssl"}
|
||||
open={openSections.has("ssl")}
|
||||
onToggle={() => toggle("ssl")}
|
||||
settings={acmeSettings}
|
||||
setSettings={setAcmeSettings}
|
||||
@@ -977,10 +1054,16 @@ export function AdminSettingsPanel({
|
||||
requesting={acmeRequesting}
|
||||
handleSave={handleSaveAcmeSettings}
|
||||
handleRequest={handleRequestAcmeCertificate}
|
||||
manualCertDraft={manualCertDraft}
|
||||
setManualCertDraft={setManualCertDraft}
|
||||
manualKeyDraft={manualKeyDraft}
|
||||
setManualKeyDraft={setManualKeyDraft}
|
||||
manualUploading={manualUploading}
|
||||
handleManualUpload={handleManualSslUpload}
|
||||
/>
|
||||
|
||||
<AdminApiKeysSection
|
||||
open={openSection === "api-keys"}
|
||||
open={openSections.has("api-keys")}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
apiKeys={apiKeys}
|
||||
setApiKeys={setApiKeys}
|
||||
@@ -1001,7 +1084,7 @@ export function AdminSettingsPanel({
|
||||
/>
|
||||
|
||||
<AdminAuditLogSection
|
||||
open={openSection === "audit-log"}
|
||||
open={openSections.has("audit-log")}
|
||||
onToggle={() => toggle("audit-log")}
|
||||
users={users}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,10 @@ import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
|
||||
type GeneralSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
analyticsEnabled: boolean;
|
||||
handleToggleAnalytics: () => void;
|
||||
sessionSharingGloballyEnabled: boolean;
|
||||
handleToggleSessionSharingGloballyEnabled: () => void;
|
||||
allowRegistration: boolean;
|
||||
handleToggleRegistration: () => void;
|
||||
allowPasswordLogin: boolean;
|
||||
@@ -67,6 +71,10 @@ type GeneralSettingsSectionProps = {
|
||||
export function AdminGeneralSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
analyticsEnabled,
|
||||
handleToggleAnalytics,
|
||||
sessionSharingGloballyEnabled,
|
||||
handleToggleSessionSharingGloballyEnabled,
|
||||
allowRegistration,
|
||||
handleToggleRegistration,
|
||||
allowPasswordLogin,
|
||||
@@ -110,6 +118,21 @@ export function AdminGeneralSettingsSection({
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
<SettingRow
|
||||
label={t("admin.analyticsEnabled")}
|
||||
description={t("admin.analyticsEnabledDesc")}
|
||||
>
|
||||
<AdminToggle on={analyticsEnabled} onToggle={handleToggleAnalytics} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.sessionSharingGloballyEnabled")}
|
||||
description={t("admin.sessionSharingGloballyEnabledDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={sessionSharingGloballyEnabled}
|
||||
onToggle={handleToggleSessionSharingGloballyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("admin.allowRegistration")}
|
||||
description={t("admin.allowRegistrationDesc")}
|
||||
@@ -1038,6 +1061,12 @@ type AdminSSLSectionProps = {
|
||||
requesting: boolean;
|
||||
handleSave: () => void;
|
||||
handleRequest: () => void;
|
||||
manualCertDraft: string;
|
||||
setManualCertDraft: Dispatch<SetStateAction<string>>;
|
||||
manualKeyDraft: string;
|
||||
setManualKeyDraft: Dispatch<SetStateAction<string>>;
|
||||
manualUploading: boolean;
|
||||
handleManualUpload: () => void;
|
||||
};
|
||||
|
||||
export function AdminSSLSection({
|
||||
@@ -1050,6 +1079,12 @@ export function AdminSSLSection({
|
||||
requesting,
|
||||
handleSave,
|
||||
handleRequest,
|
||||
manualCertDraft,
|
||||
setManualCertDraft,
|
||||
manualKeyDraft,
|
||||
setManualKeyDraft,
|
||||
manualUploading,
|
||||
handleManualUpload,
|
||||
}: AdminSSLSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -1107,34 +1142,6 @@ export function AdminSSLSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslDomain")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.domain}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, domain: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslDomainPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslEmail")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.email}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, email: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslEmailPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslChallengeType")}
|
||||
@@ -1158,6 +1165,9 @@ export function AdminSSLSection({
|
||||
<SelectItem value="dns-cloudflare" className="text-xs">
|
||||
DNS (Cloudflare)
|
||||
</SelectItem>
|
||||
<SelectItem value="manual" className="text-xs">
|
||||
{t("admin.sslManualOption")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
@@ -1165,6 +1175,38 @@ export function AdminSSLSection({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{settings.challengeType !== "manual" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslDomain")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.domain}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, domain: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslDomainPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslEmail")}
|
||||
</label>
|
||||
<Input
|
||||
value={settings.email}
|
||||
onChange={(e) =>
|
||||
setSettings((p) => ({ ...p, email: e.target.value }))
|
||||
}
|
||||
placeholder={t("admin.sslEmailPlaceholder")}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{settings.challengeType === "dns-cloudflare" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
@@ -1185,34 +1227,86 @@ export function AdminSSLSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.challengeType === "manual" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslManualCert")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={manualCertDraft}
|
||||
onChange={(e) => setManualCertDraft(e.target.value)}
|
||||
placeholder={t("admin.sslManualCertPlaceholder")}
|
||||
spellCheck={false}
|
||||
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.sslManualKey")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={5}
|
||||
value={manualKeyDraft}
|
||||
onChange={(e) => setManualKeyDraft(e.target.value)}
|
||||
placeholder={t("admin.sslManualKeyPlaceholder")}
|
||||
spellCheck={false}
|
||||
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.sslManualDesc")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleManualUpload}
|
||||
disabled={manualUploading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{manualUploading
|
||||
? t("admin.sslManualUploadLoading")
|
||||
: t("admin.sslManualUpload")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
|
||||
{t("admin.sslInfoNote")}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("admin.sslSave")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleRequest}
|
||||
disabled={requesting}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${requesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{requesting
|
||||
? t("admin.sslRequestCertLoading")
|
||||
: t("admin.sslRequestCert")}
|
||||
</Button>
|
||||
</div>
|
||||
{settings.challengeType !== "manual" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("admin.sslSave")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleRequest}
|
||||
disabled={requesting}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${requesting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{requesting
|
||||
? t("admin.sslRequestCertLoading")
|
||||
: t("admin.sslRequestCert")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
|
||||
@@ -446,6 +446,13 @@ export function AdminUserManagePanel({
|
||||
key={editor.credential ? editor.credential.id : "new-cred"}
|
||||
credential={editor.credential}
|
||||
activeTab={editorTab}
|
||||
existingFolders={Array.from(
|
||||
new Set(
|
||||
credentials
|
||||
.map((c) => c.folder)
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
onBack={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { getAlertFirings } from "@/api/alerts-api";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export type RailView =
|
||||
| "hosts"
|
||||
@@ -247,8 +248,44 @@ export function AppRail({
|
||||
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
|
||||
}, []);
|
||||
|
||||
// Termix ID publishes SSH public keys under a claimed public handle for
|
||||
// other servers to fetch -- meaningless for a standalone desktop install
|
||||
// with no synced multi-device account, so it stays hidden until a remote
|
||||
// server is actually connected.
|
||||
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
|
||||
() => !isElectron(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
let cancelled = false;
|
||||
const refreshSyncStatus = () => {
|
||||
window.electronAPI
|
||||
?.invoke?.("get-remote-sync-config")
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setIsRemoteSyncConnected(
|
||||
!!(config as { serverUrl?: string } | null)?.serverUrl,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refreshSyncStatus();
|
||||
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
|
||||
refreshSyncStatus(),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const railButtons = buildRailButtons(splitMode, t, hiddenTabs);
|
||||
const effectiveHiddenTabs = isRemoteSyncConnected
|
||||
? hiddenTabs
|
||||
: new Set([...hiddenTabs, "termix-id"]);
|
||||
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { Badge } from "@/components/badge";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -64,7 +65,11 @@ function sessionsUnchanged(
|
||||
a.hostName !== b.hostName ||
|
||||
a.tabInstanceId !== b.tabInstanceId ||
|
||||
a.isConnected !== b.isConnected ||
|
||||
a.createdAt !== b.createdAt
|
||||
a.createdAt !== b.createdAt ||
|
||||
a.isOwnSession !== b.isOwnSession ||
|
||||
a.sharedByUsername !== b.sharedByUsername ||
|
||||
a.permissionLevel !== b.permissionLevel ||
|
||||
a.shareId !== b.shareId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -289,6 +294,7 @@ export function ConnectionsPanel({
|
||||
onForgetBackground,
|
||||
onRenameTab,
|
||||
onReorderTabs,
|
||||
onJoinSharedSession,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
@@ -303,6 +309,7 @@ export function ConnectionsPanel({
|
||||
onForgetBackground: (recordId: string) => void;
|
||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||
onReorderTabs?: (tabs: Tab[]) => void;
|
||||
onJoinSharedSession?: (session: ActiveSessionInfo) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [now, setNow] = useState(Date.now());
|
||||
@@ -344,6 +351,22 @@ export function ConnectionsPanel({
|
||||
})
|
||||
: backgroundTabs;
|
||||
|
||||
const joinedInstanceIds = new Set(
|
||||
tabs
|
||||
.map((t) => (t.joinSharedSessionId ? t.instanceId : null))
|
||||
.filter(Boolean),
|
||||
);
|
||||
const sharedWithMe = activeSessions.filter(
|
||||
(s) => s.isOwnSession === false && !joinedInstanceIds.has(s.sessionId),
|
||||
);
|
||||
const filteredSharedWithMe = q
|
||||
? sharedWithMe.filter(
|
||||
(s) =>
|
||||
s.hostName.toLowerCase().includes(q) ||
|
||||
(s.sharedByUsername ?? "").toLowerCase().includes(q),
|
||||
)
|
||||
: sharedWithMe;
|
||||
|
||||
// Duration labels only need minute-level freshness; 1s ticks re-render the whole panel.
|
||||
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
|
||||
|
||||
@@ -432,9 +455,12 @@ export function ConnectionsPanel({
|
||||
activeSessions.map((s) => [s.tabInstanceId, s]),
|
||||
);
|
||||
|
||||
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
|
||||
const hasAnything =
|
||||
openTabs.length > 0 || backgroundTabs.length > 0 || sharedWithMe.length > 0;
|
||||
const hasResults =
|
||||
filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
|
||||
filteredOpenTabs.length > 0 ||
|
||||
filteredBackgroundTabs.length > 0 ||
|
||||
filteredSharedWithMe.length > 0;
|
||||
|
||||
if (!hasAnything) {
|
||||
return (
|
||||
@@ -593,6 +619,78 @@ export function ConnectionsPanel({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredSharedWithMe.length > 0 && (
|
||||
<div
|
||||
className={`flex flex-col ${filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0 ? "mt-2" : ""}`}
|
||||
>
|
||||
<SectionHeader
|
||||
label={t("connections.sectionSharedWithMe")}
|
||||
count={filteredSharedWithMe.length}
|
||||
/>
|
||||
{filteredSharedWithMe.map((session) => (
|
||||
<SharedWithMeRow
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
onJoin={() => onJoinSharedSession?.(session)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SharedWithMeRow({
|
||||
session,
|
||||
onJoin,
|
||||
}: {
|
||||
session: ActiveSessionInfo;
|
||||
onJoin: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isReadWrite = session.permissionLevel === "read-write";
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 last:border-b-0">
|
||||
<div className="shrink-0 flex items-center justify-center size-7 rounded bg-muted/60 text-muted-foreground">
|
||||
{tabIcon("terminal")}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`shrink-0 size-1.5 rounded-full ${
|
||||
session.isConnected ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-semibold truncate flex-1 text-foreground">
|
||||
{session.hostName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[9px] px-1 py-0 h-4 font-mono shrink-0 border-border/60 ${
|
||||
isReadWrite ? "text-accent-brand" : "text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{isReadWrite
|
||||
? t("sessionSharing.permissionLevel.readWrite")
|
||||
: t("sessionSharing.permissionLevel.readOnly")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
|
||||
{t("connections.sharedBy", {
|
||||
username: session.sharedByUsername ?? "?",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={onJoin}
|
||||
>
|
||||
{t("connections.join")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
adminUpdateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
|
||||
@@ -26,14 +27,14 @@ export function CredentialEditorView({
|
||||
onBack,
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
existingFolders = [],
|
||||
}: {
|
||||
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;
|
||||
existingFolders?: string[];
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
@@ -151,10 +152,10 @@ export function CredentialEditorView({
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.folder")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Server Keys"
|
||||
<FolderPathPicker
|
||||
value={credForm.folder}
|
||||
onChange={(e) => setCredField("folder", e.target.value)}
|
||||
onChange={(path) => setCredField("folder", path)}
|
||||
folderPaths={existingFolders}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
|
||||
@@ -18,13 +18,17 @@ import {
|
||||
IconPicker,
|
||||
} from "@/components/folder-style";
|
||||
import { normalizePath, splitPath } from "./FolderPathPicker";
|
||||
import { getCredentials } from "@/main-axios";
|
||||
|
||||
export type FolderMetadataValue = {
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
credentialId: number | null;
|
||||
};
|
||||
|
||||
type CredentialOption = { id: string; name: string; username?: string };
|
||||
|
||||
export function FolderMetadataDialog({
|
||||
open,
|
||||
mode,
|
||||
@@ -34,7 +38,12 @@ export function FolderMetadataDialog({
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
initial?: { name: string; color?: string; icon?: string };
|
||||
initial?: {
|
||||
name: string;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
credentialId?: number | null;
|
||||
};
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onSubmit: (value: FolderMetadataValue) => void;
|
||||
}) {
|
||||
@@ -42,19 +51,45 @@ export function FolderMetadataDialog({
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState(DEFAULT_FOLDER_COLOR);
|
||||
const [icon, setIcon] = useState(DEFAULT_FOLDER_ICON);
|
||||
const [credentialId, setCredentialId] = useState<string>("");
|
||||
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial?.name ?? "");
|
||||
setColor(initial?.color ?? DEFAULT_FOLDER_COLOR);
|
||||
setIcon(initial?.icon ?? DEFAULT_FOLDER_ICON);
|
||||
setCredentialId(
|
||||
initial?.credentialId ? String(initial.credentialId) : "",
|
||||
);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
getCredentials()
|
||||
.then((data) => {
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setCredentials(
|
||||
list.map((c) => ({
|
||||
id: String(c.id),
|
||||
name: String(c.name ?? ""),
|
||||
username: c.username ? String(c.username) : undefined,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => setCredentials([]));
|
||||
}, [open]);
|
||||
|
||||
function handleSubmit() {
|
||||
const normalized = normalizePath(name);
|
||||
if (!normalized) return;
|
||||
onSubmit({ name: normalized, color, icon });
|
||||
onSubmit({
|
||||
name: normalized,
|
||||
color,
|
||||
icon,
|
||||
credentialId: credentialId ? Number(credentialId) : null,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
@@ -101,6 +136,26 @@ export function FolderMetadataDialog({
|
||||
</label>
|
||||
<IconPicker value={icon} color={color} onChange={setIcon} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={credentialId}
|
||||
onChange={(e) => setCredentialId(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("hosts.folderCredentialNone")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderCredentialHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderPreview")}
|
||||
|
||||
@@ -11,6 +11,10 @@ import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Slider } from "@/components/slider";
|
||||
import {
|
||||
TERMINAL_FONT_ZOOM_MIN,
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
} from "@/features/terminal/terminal-font-zoom";
|
||||
import {
|
||||
Globe,
|
||||
Layers, // --- tmux-monitor ---
|
||||
@@ -39,8 +43,16 @@ import {
|
||||
adminUpdateUserHost,
|
||||
adminGetHostPassword,
|
||||
adminGetUserSnippets,
|
||||
createCredential,
|
||||
adminCreateUserCredential,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import {
|
||||
getUserPreferences,
|
||||
saveUserPreferences,
|
||||
parseCustomThemes,
|
||||
type SavedCustomTheme,
|
||||
} from "@/api/open-tabs-api";
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelStatus } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
@@ -127,6 +139,65 @@ export function HostEditor({
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
const [quickCredentialName, setQuickCredentialName] = useState("");
|
||||
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
|
||||
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
|
||||
useState(false);
|
||||
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
const reloadSavedThemes = () => {
|
||||
getUserPreferences()
|
||||
.then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadSavedThemes();
|
||||
}, []);
|
||||
|
||||
const handleSaveAsGlobalTheme = async () => {
|
||||
const colors = form.customThemeColors;
|
||||
if (!colors) return;
|
||||
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
|
||||
if (!name || !name.trim()) return;
|
||||
setSavingTheme(true);
|
||||
try {
|
||||
const newTheme: SavedCustomTheme = {
|
||||
id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: name.trim(),
|
||||
colors,
|
||||
};
|
||||
const updated = [...savedThemes, newTheme];
|
||||
await saveUserPreferences({
|
||||
customThemes: JSON.stringify(updated),
|
||||
});
|
||||
setSavedThemes(updated);
|
||||
toast.success(t("hosts.saveGlobalThemeSuccess"));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGlobalTheme = async (id: string) => {
|
||||
const updated = savedThemes.filter((theme) => theme.id !== id);
|
||||
try {
|
||||
await saveUserPreferences({
|
||||
customThemes: JSON.stringify(updated),
|
||||
});
|
||||
setSavedThemes(updated);
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalTheme = (id: string) => {
|
||||
const theme = savedThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return;
|
||||
setField("customThemeColors", { ...theme.colors });
|
||||
};
|
||||
|
||||
const reloadVaultProfiles = () => {
|
||||
getVaultProfiles()
|
||||
@@ -223,6 +294,77 @@ export function HostEditor({
|
||||
(c) => c.id === form.credentialId,
|
||||
);
|
||||
|
||||
const canQuickCreateCredential =
|
||||
(authMethod === "password" || authMethod === "key") &&
|
||||
(authMethod === "password"
|
||||
? !!form.password || !!host?.hasPassword
|
||||
: (!!form.key && form.key !== "existing_key") || !!host?.hasKey);
|
||||
|
||||
const openQuickCredentialDialog = () => {
|
||||
setQuickCredentialName(form.name || form.username || "");
|
||||
setShowQuickCredentialDialog(true);
|
||||
};
|
||||
|
||||
const handleQuickCreateCredential = async () => {
|
||||
if (!quickCredentialName.trim()) {
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return;
|
||||
}
|
||||
setCreatingQuickCredential(true);
|
||||
try {
|
||||
const fetchField = (field: "password" | "key" | "keyPassword") =>
|
||||
adminTargetUserId
|
||||
? adminGetHostPassword(adminTargetUserId, Number(host?.id), field)
|
||||
: getHostPassword(Number(host?.id), field);
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
name: quickCredentialName,
|
||||
username: form.username || null,
|
||||
folder: form.folder || null,
|
||||
};
|
||||
|
||||
if (authMethod === "password") {
|
||||
data.authType = "password";
|
||||
data.password =
|
||||
form.password ||
|
||||
(host?.hasPassword ? await fetchField("password") : null);
|
||||
} else {
|
||||
const key =
|
||||
form.key && form.key !== "existing_key"
|
||||
? form.key
|
||||
: host?.hasKey
|
||||
? await fetchField("key")
|
||||
: null;
|
||||
const keyPassword =
|
||||
form.keyPassword && form.keyPassword !== "existing_key_password"
|
||||
? form.keyPassword
|
||||
: host?.hasKeyPassword
|
||||
? await fetchField("keyPassword")
|
||||
: null;
|
||||
data.authType = "key";
|
||||
data.key = key;
|
||||
data.keyPassword = keyPassword;
|
||||
data.password = form.password || null;
|
||||
}
|
||||
|
||||
if (adminTargetUserId) {
|
||||
await adminCreateUserCredential(adminTargetUserId, data);
|
||||
} else {
|
||||
await createCredential(data);
|
||||
}
|
||||
toast.success(t("hosts.credentialCreated"));
|
||||
if (!adminTargetUserId) {
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
}
|
||||
setShowQuickCredentialDialog(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : null;
|
||||
toast.error(msg || t("hosts.failedToSaveCredential"));
|
||||
} finally {
|
||||
setCreatingQuickCredential(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared hosts: view-level recipients see a read-only editor; edit-level
|
||||
// recipients may change the host but never its credential/vault references
|
||||
// or auth type (owner-only, enforced server-side too).
|
||||
@@ -317,6 +459,20 @@ export function HostEditor({
|
||||
<SectionCard
|
||||
title={t("hosts.authenticationLabel")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
action={
|
||||
canQuickCreateCredential && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
onClick={openQuickCredentialDialog}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
{t("hosts.createCredentialFromHostBtn")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -385,6 +541,11 @@ export function HostEditor({
|
||||
{t("hosts.oidcUsernameHint")}
|
||||
</p>
|
||||
)}
|
||||
{authMethod === "tailscale" && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.tailscaleUsernameHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{authMethod === "password" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -950,8 +1111,8 @@ export function HostEditor({
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={8}
|
||||
max={24}
|
||||
min={TERMINAL_FONT_ZOOM_MIN}
|
||||
max={TERMINAL_FONT_ZOOM_MAX}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v)}
|
||||
@@ -1051,6 +1212,64 @@ export function HostEditor({
|
||||
</div>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.savedThemesLabel")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={savingTheme || !form.customThemeColors}
|
||||
onClick={handleSaveAsGlobalTheme}
|
||||
>
|
||||
{t("hosts.saveAsGlobalTheme")}
|
||||
</Button>
|
||||
</div>
|
||||
{savedThemes.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.noSavedThemes")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{savedThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.applyGlobalThemeTooltip")}
|
||||
onClick={() =>
|
||||
handleApplyGlobalTheme(theme.id)
|
||||
}
|
||||
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
|
||||
>
|
||||
<span
|
||||
className="size-3.5 shrink-0 border border-border"
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{theme.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.deleteGlobalThemeTooltip")}
|
||||
onClick={() =>
|
||||
handleDeleteGlobalTheme(theme.id)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.customThemeColors")}
|
||||
@@ -1380,6 +1599,15 @@ export function HostEditor({
|
||||
onChange={(v) => setField("enableSessionLogging", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.allowSessionSharing")}
|
||||
description={t("hosts.allowSessionSharingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.allowSessionSharing}
|
||||
onChange={(v) => setField("allowSessionSharing", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.enableCommandHistory")}
|
||||
description={
|
||||
@@ -2103,6 +2331,58 @@ export function HostEditor({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showQuickCredentialDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold">
|
||||
{t("hosts.createCredentialFromHostTitle")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowQuickCredentialDialog(false)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("hosts.createCredentialFromHostDesc")}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Production SSH Key"
|
||||
value={quickCredentialName}
|
||||
onChange={(e) => setQuickCredentialName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowQuickCredentialDialog(false)}
|
||||
disabled={creatingQuickCredential}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleQuickCreateCredential}
|
||||
disabled={creatingQuickCredential}
|
||||
>
|
||||
{creatingQuickCredential
|
||||
? t("hosts.savingBtn")
|
||||
: t("hosts.addCredentialBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,10 @@ export function createHostEditorForm(
|
||||
? "chain"
|
||||
: "single") as "single" | "chain",
|
||||
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
|
||||
connectionOrigin: (host?.connectionOrigin ?? null) as
|
||||
| "local"
|
||||
| "remote"
|
||||
| null,
|
||||
enableTerminal: host?.enableTerminal ?? true,
|
||||
enableSessionLogging:
|
||||
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
|
||||
@@ -107,6 +111,7 @@ export function createHostEditorForm(
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||
allowSessionSharing: host?.allowSessionSharing ?? true,
|
||||
enableProxmox: host?.enableProxmox ?? false,
|
||||
proxmoxConfig: host?.proxmoxConfig ?? {
|
||||
defaultCredentialId: null as number | null,
|
||||
@@ -191,21 +196,28 @@ export function createHostEditorForm(
|
||||
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
|
||||
rdpCredentialId: host?.rdpCredentialId ?? "",
|
||||
rdpUser: host?.rdpUser ?? "",
|
||||
rdpPassword: host?.rdpPassword ?? "",
|
||||
rdpPassword: host?.hasRdpPassword
|
||||
? "existing_rdp_password"
|
||||
: (host?.rdpPassword ?? ""),
|
||||
domain: host?.domain ?? "",
|
||||
security: host?.security ?? "",
|
||||
ignoreCert: host?.ignoreCert ?? false,
|
||||
vncCredentialId: host?.vncCredentialId ?? "",
|
||||
vncPassword: host?.vncPassword ?? "",
|
||||
vncPassword: host?.hasVncPassword
|
||||
? "existing_vnc_password"
|
||||
: (host?.vncPassword ?? ""),
|
||||
vncUser: host?.vncUser ?? "",
|
||||
telnetUser: host?.telnetUser ?? "",
|
||||
telnetPassword: host?.telnetPassword ?? "",
|
||||
telnetPassword: host?.hasTelnetPassword
|
||||
? "existing_telnet_password"
|
||||
: (host?.telnetPassword ?? ""),
|
||||
telnetCredentialId:
|
||||
host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "",
|
||||
rdpAuthType: (host?.rdpAuthType ??
|
||||
(host?.rdpCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
| "credential",
|
||||
| "credential"
|
||||
| "none",
|
||||
vncAuthType: (host?.vncAuthType ??
|
||||
(host?.vncCredentialId ? "credential" : "direct")) as
|
||||
| "direct"
|
||||
@@ -305,6 +317,7 @@ export function buildHostEditorPayload(
|
||||
enableDocker: form.enableDocker,
|
||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||
allowSessionSharing: form.allowSessionSharing,
|
||||
enableProxmox: form.enableProxmox,
|
||||
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
|
||||
defaultPath: form.defaultPath || "/",
|
||||
@@ -319,6 +332,7 @@ export function buildHostEditorPayload(
|
||||
form.socks5ProxyMode === "single" ? form.socks5Password || null : null,
|
||||
socks5ProxyChain:
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
connectionOrigin: form.connectionOrigin,
|
||||
enableSsh: protocols.enableSsh,
|
||||
enableRdp: protocols.enableRdp,
|
||||
enableVnc: protocols.enableVnc,
|
||||
@@ -340,7 +354,9 @@ export function buildHostEditorPayload(
|
||||
? form.rdpUser || null
|
||||
: null,
|
||||
rdpPassword:
|
||||
protocols.enableRdp && form.rdpAuthType === "direct"
|
||||
protocols.enableRdp &&
|
||||
form.rdpAuthType === "direct" &&
|
||||
form.rdpPassword !== "existing_rdp_password"
|
||||
? form.rdpPassword || null
|
||||
: null,
|
||||
rdpDomain: form.domain || null,
|
||||
@@ -354,7 +370,9 @@ export function buildHostEditorPayload(
|
||||
? Number(form.vncCredentialId)
|
||||
: null,
|
||||
vncPassword:
|
||||
protocols.enableVnc && form.vncAuthType === "direct"
|
||||
protocols.enableVnc &&
|
||||
form.vncAuthType === "direct" &&
|
||||
form.vncPassword !== "existing_vnc_password"
|
||||
? form.vncPassword || null
|
||||
: null,
|
||||
vncUser:
|
||||
@@ -373,7 +391,9 @@ export function buildHostEditorPayload(
|
||||
? form.telnetUser || null
|
||||
: null,
|
||||
telnetPassword:
|
||||
protocols.enableTelnet && form.telnetAuthType === "direct"
|
||||
protocols.enableTelnet &&
|
||||
form.telnetAuthType === "direct" &&
|
||||
form.telnetPassword !== "existing_telnet_password"
|
||||
? form.telnetPassword || null
|
||||
: null,
|
||||
jumpHosts: form.jumpHosts,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { getSSHFolders } from "@/main-axios";
|
||||
import { getSSHFolders, isElectron } from "@/main-axios";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
@@ -717,6 +717,31 @@ export function HostEditorGeneralTab({
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{isElectron() && protocols.enableSsh && (
|
||||
<SettingRow
|
||||
label={t("hosts.connectionOrigin")}
|
||||
description={t("hosts.connectionOriginDesc")}
|
||||
>
|
||||
<select
|
||||
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.connectionOrigin ?? ""}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"connectionOrigin",
|
||||
(e.target.value || null) as "local" | "remote" | null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">{t("hosts.connectionOriginDefault")}</option>
|
||||
<option value="local">
|
||||
{t("hosts.connectionOriginLocal")}
|
||||
</option>
|
||||
<option value="remote">
|
||||
{t("hosts.connectionOriginRemote")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
|
||||
@@ -174,88 +174,114 @@ export function HostEditorRdpTab({
|
||||
icon={<Shield className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{credentials && credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["direct", "credential"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("rdpAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.rdpAuthType === m
|
||||
? "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-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.authMethod")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
"direct",
|
||||
...(credentials && credentials.length > 0
|
||||
? (["credential"] as const)
|
||||
: []),
|
||||
"none",
|
||||
] as const
|
||||
).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setField("rdpAuthType", m)}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
|
||||
form.rdpAuthType === m
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{form.rdpAuthType === "none" ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.guac.authTypeNoneDesc")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.rdpAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5 col-span-full">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.rdpCredentialId}
|
||||
onChange={(e) =>
|
||||
setField("rdpCredentialId", e.target.value)
|
||||
}
|
||||
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"
|
||||
>
|
||||
{t(
|
||||
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.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.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Administrator"
|
||||
value={form.rdpUser}
|
||||
onChange={(e) => setField("rdpUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
form.rdpPassword === "existing_rdp_password"
|
||||
? t("hosts.guac.passwordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.rdpPassword === "existing_rdp_password"
|
||||
? ""
|
||||
: form.rdpPassword
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.rdpPassword === "existing_rdp_password")
|
||||
setField("rdpPassword", "");
|
||||
}}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={form.domain}
|
||||
onChange={(e) => setField("domain", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{form.rdpAuthType === "credential" &&
|
||||
credentials &&
|
||||
credentials.length > 0 ? (
|
||||
<div className="flex flex-col gap-1.5 col-span-full">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.storedCredential")}
|
||||
</label>
|
||||
<select
|
||||
value={form.rdpCredentialId}
|
||||
onChange={(e) => setField("rdpCredentialId", e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">{t("hosts.guac.selectCredential")}</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.username ? `${c.name} (${c.username})` : c.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.username")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Administrator"
|
||||
value={form.rdpUser}
|
||||
onChange={(e) => setField("rdpUser", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.rdpPassword}
|
||||
onChange={(e) => setField("rdpPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.guac.domain")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="WORKGROUP"
|
||||
value={form.domain}
|
||||
onChange={(e) => setField("domain", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -650,6 +676,7 @@ export function HostEditorRdpTab({
|
||||
<option>en-us-qwerty</option>
|
||||
<option>en-gb-qwerty</option>
|
||||
<option>de-de-qwertz</option>
|
||||
<option>de-ch-qwertz</option>
|
||||
<option>fr-fr-azerty</option>
|
||||
<option>it-it-qwerty</option>
|
||||
<option>sv-se-qwerty</option>
|
||||
@@ -1154,8 +1181,20 @@ export function HostEditorVncTab({
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.vncPassword}
|
||||
placeholder={
|
||||
form.vncPassword === "existing_vnc_password"
|
||||
? t("hosts.guac.passwordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.vncPassword === "existing_vnc_password"
|
||||
? ""
|
||||
: form.vncPassword
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.vncPassword === "existing_vnc_password")
|
||||
setField("vncPassword", "");
|
||||
}}
|
||||
onChange={(e) => setField("vncPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1294,6 +1333,7 @@ export function HostEditorVncTab({
|
||||
<option>en-us-qwerty</option>
|
||||
<option>en-gb-qwerty</option>
|
||||
<option>de-de-qwertz</option>
|
||||
<option>de-ch-qwertz</option>
|
||||
<option>fr-fr-azerty</option>
|
||||
<option>it-it-qwerty</option>
|
||||
<option>sv-se-qwerty</option>
|
||||
@@ -1708,8 +1748,20 @@ export function HostEditorTelnetTab({
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.telnetPassword}
|
||||
placeholder={
|
||||
form.telnetPassword === "existing_telnet_password"
|
||||
? t("hosts.guac.passwordSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
form.telnetPassword === "existing_telnet_password"
|
||||
? ""
|
||||
: form.telnetPassword
|
||||
}
|
||||
onFocus={() => {
|
||||
if (form.telnetPassword === "existing_telnet_password")
|
||||
setField("telnetPassword", "");
|
||||
}}
|
||||
onChange={(e) => setField("telnetPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -431,6 +431,13 @@ export function HostManager({
|
||||
: (editingCredential as Credential)
|
||||
}
|
||||
activeTab={activeCredentialTab}
|
||||
existingFolders={Array.from(
|
||||
new Set(
|
||||
credentials
|
||||
.map((c) => c.folder)
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
onBack={() => {
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
|
||||
@@ -4,6 +4,9 @@ import type { Host, Credential } from "@/types/ui-types";
|
||||
type RawSSHHost = SSHHostWithStatus & {
|
||||
hasKey?: boolean;
|
||||
hasKeyPassword?: boolean;
|
||||
hasRdpPassword?: boolean;
|
||||
hasVncPassword?: boolean;
|
||||
hasTelnetPassword?: boolean;
|
||||
};
|
||||
type HostQuickAction = Host["quickActions"][number];
|
||||
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
|
||||
@@ -81,17 +84,33 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
|
||||
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
|
||||
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
|
||||
rdpAuthType:
|
||||
(h.rdpAuthType as "direct" | "credential") ??
|
||||
(h.rdpCredentialId ? "credential" : "direct"),
|
||||
rdpCredentialId:
|
||||
h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined,
|
||||
rdpUser: h.rdpUser,
|
||||
rdpPassword: h.rdpPassword ?? "",
|
||||
hasRdpPassword: !!host.hasRdpPassword || !!h.rdpPassword,
|
||||
domain: h.rdpDomain,
|
||||
security: h.rdpSecurity,
|
||||
ignoreCert: h.rdpIgnoreCert ?? false,
|
||||
vncAuthType: h.vncAuthType ?? (h.vncCredentialId ? "credential" : "direct"),
|
||||
vncCredentialId: h.vncCredentialId ?? null,
|
||||
vncAuthType:
|
||||
(h.vncAuthType as "direct" | "credential") ??
|
||||
(h.vncCredentialId ? "credential" : "direct"),
|
||||
vncCredentialId:
|
||||
h.vncCredentialId != null ? String(h.vncCredentialId) : undefined,
|
||||
vncPassword: h.vncPassword ?? "",
|
||||
hasVncPassword: !!host.hasVncPassword || !!h.vncPassword,
|
||||
vncUser: h.vncUser,
|
||||
telnetAuthType:
|
||||
(h.telnetAuthType as "direct" | "credential") ??
|
||||
(h.telnetCredentialId ? "credential" : "direct"),
|
||||
telnetCredentialId:
|
||||
h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined,
|
||||
telnetUser: h.telnetUser,
|
||||
telnetPassword: h.telnetPassword ?? "",
|
||||
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
|
||||
quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({
|
||||
name: a.name,
|
||||
snippetId: String(a.snippetId),
|
||||
|
||||
+178
-129
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
getHostAccess,
|
||||
shareHost,
|
||||
shareFolder,
|
||||
updateHostAccess,
|
||||
revokeHostAccess,
|
||||
getUserList,
|
||||
@@ -55,12 +56,15 @@ export function HostShareModal({
|
||||
open,
|
||||
onClose,
|
||||
host,
|
||||
folder,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
host: Host | null;
|
||||
folder?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isFolderShare = !host && !!folder;
|
||||
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(
|
||||
@@ -83,13 +87,19 @@ export function HostShareModal({
|
||||
const [sharingLoaded, setSharingLoaded] = useState(false);
|
||||
const [sharingLoadError, setSharingLoadError] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [folderShareSummary, setFolderShareSummary] = useState<{
|
||||
hostsShared: number;
|
||||
hostsTotal: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !host) return;
|
||||
if (!open || (!host && !folder)) return;
|
||||
if (sharingLoaded) return;
|
||||
setSharingLoaded(true);
|
||||
Promise.all([
|
||||
getHostAccess(Number(host.id)).catch(() => ({ accessList: [] })),
|
||||
host
|
||||
? getHostAccess(Number(host.id)).catch(() => ({ accessList: [] }))
|
||||
: Promise.resolve({ accessList: [] }),
|
||||
getUserList().catch(() => ({ users: [] })),
|
||||
getRoles().catch(() => ({ roles: [] })),
|
||||
])
|
||||
@@ -112,7 +122,7 @@ export function HostShareModal({
|
||||
);
|
||||
})
|
||||
.catch(() => setSharingLoadError(true));
|
||||
}, [open, host, sharingLoaded]);
|
||||
}, [open, host, folder, sharingLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
setSharingLoaded(false);
|
||||
@@ -125,7 +135,8 @@ export function HostShareModal({
|
||||
setExpiryPreset("never");
|
||||
setCustomHours("");
|
||||
setTargetTab("user");
|
||||
}, [host?.id]);
|
||||
setFolderShareSummary(null);
|
||||
}, [host?.id, folder]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -165,7 +176,7 @@ export function HostShareModal({
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
if (!host || selectedCount === 0) return;
|
||||
if ((!host && !folder) || selectedCount === 0) return;
|
||||
const targets: ShareTarget[] = [
|
||||
...[...selectedUserIds].map(
|
||||
(id) => ({ type: "user", id }) as ShareTarget,
|
||||
@@ -177,17 +188,40 @@ export function HostShareModal({
|
||||
|
||||
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"));
|
||||
if (isFolderShare && folder) {
|
||||
const result = await shareFolder(folder, {
|
||||
targets,
|
||||
permissionLevel,
|
||||
...(durationHours ? { durationHours } : {}),
|
||||
});
|
||||
setFolderShareSummary({
|
||||
hostsShared: result.hostsShared,
|
||||
hostsTotal: result.hostsTotal,
|
||||
});
|
||||
setSelectedUserIds(new Set());
|
||||
setSelectedRoleIds(new Set());
|
||||
toast.success(
|
||||
t("hosts.folderSharedSuccessfully", {
|
||||
count: result.hostsShared,
|
||||
}),
|
||||
);
|
||||
} else if (host) {
|
||||
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"));
|
||||
toast.error(
|
||||
isFolderShare
|
||||
? t("hosts.failedToShareFolder")
|
||||
: t("hosts.failedToShareHost"),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -224,7 +258,9 @@ export function HostShareModal({
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
{isFolderShare
|
||||
? t("hosts.shareFolderTitle", { name: folder ?? "" })
|
||||
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -441,121 +477,134 @@ export function HostShareModal({
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
{/* Folder share summary */}
|
||||
{isFolderShare && folderShareSummary && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 shrink-0 text-xs text-muted-foreground">
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
{t("hosts.sharing.folderShareSummary", {
|
||||
shared: folderShareSummary.hostsShared,
|
||||
total: folderShareSummary.hostsTotal,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current access: takes remaining space, scrolls independently */}
|
||||
{!isFolderShare && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getUserPreferences,
|
||||
parseCustomKeybindings,
|
||||
} from "@/api/open-tabs-api";
|
||||
import { saveUserPreferences, getSnippets } from "@/main-axios";
|
||||
import { BUILT_IN_DEFAULTS } from "@/lib/default-keybindings";
|
||||
import type {
|
||||
CustomKeybinding,
|
||||
KeyCombo,
|
||||
KeybindingActionType,
|
||||
} from "@/types/keybindings";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
|
||||
interface SnippetOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function formatCombo(combo: KeyCombo): string {
|
||||
const parts: string[] = [];
|
||||
if (combo.ctrl) parts.push("Ctrl");
|
||||
if (combo.alt) parts.push("Alt");
|
||||
if (combo.shift) parts.push("Shift");
|
||||
if (combo.meta) parts.push("Cmd");
|
||||
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
|
||||
return parts.join(" + ");
|
||||
}
|
||||
|
||||
const ACTION_LABEL_KEYS: Record<KeybindingActionType, string> = {
|
||||
copy: "newUi.sidebar.keybindings.actionCopy",
|
||||
paste: "newUi.sidebar.keybindings.actionPaste",
|
||||
sendControlCode: "newUi.sidebar.keybindings.actionSendControlCode",
|
||||
sendText: "newUi.sidebar.keybindings.actionSendText",
|
||||
runSnippet: "newUi.sidebar.keybindings.actionRunSnippet",
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
return typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function KeybindingsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [snippets, setSnippets] = useState<SnippetOption[]>([]);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [overridesDefaultId, setOverridesDefaultId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
|
||||
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [actionType, setActionType] =
|
||||
useState<KeybindingActionType>("sendText");
|
||||
const [text, setText] = useState("");
|
||||
const [appendEnter, setAppendEnter] = useState(false);
|
||||
const [controlCode, setControlCode] = useState("");
|
||||
const [snippetId, setSnippetId] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
getUserPreferences().catch(() => null),
|
||||
getSnippets().catch(() => []),
|
||||
])
|
||||
.then(([prefs, snippetList]) => {
|
||||
setBindings(
|
||||
prefs ? parseCustomKeybindings(prefs.customKeybindings) : [],
|
||||
);
|
||||
const list = Array.isArray(snippetList) ? snippetList : [];
|
||||
setSnippets(
|
||||
list.map((s: { id: number; name: string }) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
})),
|
||||
);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const conflictWarning = useMemo(() => {
|
||||
if (!recordedCombo) return null;
|
||||
const conflict = bindings.find(
|
||||
(kb) =>
|
||||
kb.id !== editingId &&
|
||||
kb.enabled &&
|
||||
kb.combo.ctrl === recordedCombo.ctrl &&
|
||||
kb.combo.alt === recordedCombo.alt &&
|
||||
kb.combo.shift === recordedCombo.shift &&
|
||||
kb.combo.meta === recordedCombo.meta &&
|
||||
kb.combo.key === recordedCombo.key,
|
||||
);
|
||||
if (conflict) {
|
||||
return t("newUi.sidebar.keybindings.conflictWarning", {
|
||||
combo: formatCombo(conflict.combo),
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [recordedCombo, bindings, editingId, t]);
|
||||
|
||||
function resetForm() {
|
||||
setEditingId(null);
|
||||
setOverridesDefaultId(undefined);
|
||||
setRecordedCombo(null);
|
||||
setRecording(false);
|
||||
setActionType("sendText");
|
||||
setText("");
|
||||
setAppendEnter(false);
|
||||
setControlCode("");
|
||||
setSnippetId("");
|
||||
}
|
||||
|
||||
function openAddForm() {
|
||||
resetForm();
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openEditForm(kb: CustomKeybinding) {
|
||||
setEditingId(kb.id);
|
||||
setOverridesDefaultId(kb.overridesDefaultId);
|
||||
setRecordedCombo(kb.combo);
|
||||
setRecording(false);
|
||||
setActionType(kb.action.type);
|
||||
setText(kb.action.text ?? "");
|
||||
setAppendEnter(kb.action.appendEnter ?? false);
|
||||
setControlCode(kb.action.controlCode ?? "");
|
||||
setSnippetId(kb.action.snippetId ?? "");
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
|
||||
resetForm();
|
||||
setOverridesDefaultId(defaultId);
|
||||
setRecordedCombo(combo);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function persist(updated: CustomKeybinding[]) {
|
||||
try {
|
||||
await saveUserPreferences({
|
||||
customKeybindings: JSON.stringify(updated),
|
||||
});
|
||||
setBindings(updated);
|
||||
window.dispatchEvent(new Event("customKeybindingsChanged"));
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.keybindings.saveError"));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(id: string) {
|
||||
persist(bindings.filter((kb) => kb.id !== id));
|
||||
}
|
||||
|
||||
function handleResetAll() {
|
||||
persist([]);
|
||||
}
|
||||
|
||||
function startRecording(inputEl: HTMLInputElement) {
|
||||
setRecording(true);
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
|
||||
const combo: KeyCombo = {
|
||||
key: isAlnum ? e.key.toLowerCase() : e.code,
|
||||
isCode: !isAlnum,
|
||||
ctrl: e.ctrlKey,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
meta: e.metaKey,
|
||||
};
|
||||
setRecordedCombo(combo);
|
||||
setRecording(false);
|
||||
inputEl.removeEventListener("keydown", handler, true);
|
||||
inputEl.blur();
|
||||
};
|
||||
inputEl.addEventListener("keydown", handler, true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!recordedCombo) {
|
||||
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
|
||||
return;
|
||||
}
|
||||
if (actionType === "sendText" && !text.trim()) {
|
||||
toast.error(t("newUi.sidebar.keybindings.textRequiredError"));
|
||||
return;
|
||||
}
|
||||
if (actionType === "sendControlCode" && !/^[a-zA-Z]$/.test(controlCode)) {
|
||||
toast.error(t("newUi.sidebar.keybindings.controlCodeRequiredError"));
|
||||
return;
|
||||
}
|
||||
if (actionType === "runSnippet" && !snippetId) {
|
||||
toast.error(t("newUi.sidebar.keybindings.snippetRequiredError"));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existing = editingId
|
||||
? bindings.find((kb) => kb.id === editingId)
|
||||
: undefined;
|
||||
|
||||
const newBinding: CustomKeybinding = {
|
||||
id: editingId ?? generateId(),
|
||||
combo: recordedCombo,
|
||||
action: {
|
||||
type: actionType,
|
||||
text: actionType === "sendText" ? text : undefined,
|
||||
controlCode:
|
||||
actionType === "sendControlCode"
|
||||
? controlCode.toLowerCase()
|
||||
: undefined,
|
||||
snippetId: actionType === "runSnippet" ? snippetId : undefined,
|
||||
appendEnter:
|
||||
actionType === "runSnippet"
|
||||
? true
|
||||
: actionType === "sendText"
|
||||
? appendEnter
|
||||
: undefined,
|
||||
},
|
||||
enabled: true,
|
||||
overridesDefaultId,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const updated = editingId
|
||||
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
|
||||
: [...bindings, newBinding];
|
||||
|
||||
persist(updated);
|
||||
setFormOpen(false);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{t("newUi.sidebar.keybindings.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.defaultsHeading")}
|
||||
</span>
|
||||
{BUILT_IN_DEFAULTS.map((def) => {
|
||||
const override = bindings.find(
|
||||
(kb) => kb.overridesDefaultId === def.id,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={def.id}
|
||||
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-mono">
|
||||
{override
|
||||
? formatCombo(override.combo)
|
||||
: formatCombo(def.combo)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{def.description}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{override ? (
|
||||
<>
|
||||
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
|
||||
{t("newUi.sidebar.keybindings.customizedBadge")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => openEditForm(override)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(override.id)}
|
||||
title={t(
|
||||
"newUi.sidebar.keybindings.resetToDefault",
|
||||
)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground border border-border px-1">
|
||||
{t("newUi.sidebar.keybindings.defaultBadge")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() =>
|
||||
openOverrideDefaultForm(def.id, def.combo)
|
||||
}
|
||||
title={t("newUi.sidebar.keybindings.customize")}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.customHeading")}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={openAddForm}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("newUi.sidebar.keybindings.addBinding")}
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.loading")}
|
||||
</span>
|
||||
) : customOnlyBindings.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{t("newUi.sidebar.keybindings.noCustomBindings")}
|
||||
</span>
|
||||
) : (
|
||||
customOnlyBindings.map((kb) => {
|
||||
const orphaned =
|
||||
kb.action.type === "runSnippet" &&
|
||||
!snippets.some((s) => String(s.id) === kb.action.snippetId);
|
||||
return (
|
||||
<div
|
||||
key={kb.id}
|
||||
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-mono">
|
||||
{formatCombo(kb.combo)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{t(ACTION_LABEL_KEYS[kb.action.type])}
|
||||
{orphaned && (
|
||||
<span className="text-destructive">
|
||||
{" "}
|
||||
(
|
||||
{t(
|
||||
"newUi.sidebar.keybindings.orphanedSnippetWarning",
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={() => openEditForm(kb)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={handleResetAll}
|
||||
disabled={bindings.length === 0}
|
||||
>
|
||||
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("newUi.sidebar.keybindings.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
{editingId || overridesDefaultId
|
||||
? t("newUi.sidebar.keybindings.editBindingTitle")
|
||||
: t("newUi.sidebar.keybindings.addBindingTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
readOnly
|
||||
value={
|
||||
recording
|
||||
? t("newUi.sidebar.keybindings.recording")
|
||||
: recordedCombo
|
||||
? formatCombo(recordedCombo)
|
||||
: ""
|
||||
}
|
||||
placeholder={t(
|
||||
"newUi.sidebar.keybindings.pressKeysPlaceholder",
|
||||
)}
|
||||
onFocus={(e) => startRecording(e.currentTarget)}
|
||||
className="font-mono cursor-pointer"
|
||||
/>
|
||||
{conflictWarning && (
|
||||
<span className="text-xs text-yellow-500">
|
||||
{conflictWarning}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.actionLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={actionType}
|
||||
onChange={(e) =>
|
||||
setActionType(e.target.value as KeybindingActionType)
|
||||
}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="copy">
|
||||
{t("newUi.sidebar.keybindings.actionCopy")}
|
||||
</option>
|
||||
<option value="paste">
|
||||
{t("newUi.sidebar.keybindings.actionPaste")}
|
||||
</option>
|
||||
<option value="sendControlCode">
|
||||
{t("newUi.sidebar.keybindings.actionSendControlCode")}
|
||||
</option>
|
||||
<option value="sendText">
|
||||
{t("newUi.sidebar.keybindings.actionSendText")}
|
||||
</option>
|
||||
<option value="runSnippet">
|
||||
{t("newUi.sidebar.keybindings.actionRunSnippet")}
|
||||
</option>
|
||||
</select>
|
||||
{actionType === "paste" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.keybindings.clipboardPermissionNote")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actionType === "sendControlCode" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.controlCodeLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={controlCode}
|
||||
maxLength={1}
|
||||
onChange={(e) => setControlCode(e.target.value)}
|
||||
placeholder="w"
|
||||
className="w-16 font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === "sendText" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.textLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full h-24 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appendEnter}
|
||||
onChange={(e) => setAppendEnter(e.target.checked)}
|
||||
/>
|
||||
{t("newUi.sidebar.keybindings.appendEnterLabel")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionType === "runSnippet" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.snippetLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={snippetId}
|
||||
onChange={(e) => setSnippetId(e.target.value)}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">
|
||||
{t("newUi.sidebar.keybindings.selectSnippetPlaceholder")}
|
||||
</option>
|
||||
{snippets.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 mt-2">
|
||||
<Button variant="ghost" onClick={() => setFormOpen(false)}>
|
||||
{t("newUi.sidebar.keybindings.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("newUi.sidebar.keybindings.saveBinding")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -84,15 +84,15 @@ export function NotificationChannelDialog({
|
||||
toast.error(t("alerts.channelNameRequired", "Name is required"));
|
||||
return;
|
||||
}
|
||||
let config: string;
|
||||
let config: WebhookConfig | NtfyConfig;
|
||||
if (type === "webhook") {
|
||||
if (!webhookUrl.trim()) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
return;
|
||||
}
|
||||
config = JSON.stringify({
|
||||
config = {
|
||||
url: webhookUrl.trim(),
|
||||
} satisfies WebhookConfig);
|
||||
} satisfies WebhookConfig;
|
||||
} else {
|
||||
if (!ntfyTopic.trim()) {
|
||||
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
|
||||
@@ -100,7 +100,7 @@ export function NotificationChannelDialog({
|
||||
}
|
||||
const cfg: NtfyConfig = { url: ntfyUrl.trim(), topic: ntfyTopic.trim() };
|
||||
if (ntfyToken.trim()) cfg.token = ntfyToken.trim();
|
||||
config = JSON.stringify(cfg);
|
||||
config = cfg;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
@@ -154,7 +154,7 @@ export function NotificationChannelDialog({
|
||||
<button
|
||||
key={ct}
|
||||
onClick={() => setType(ct)}
|
||||
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30"}`}
|
||||
>
|
||||
{ct === "webhook" ? "Webhook" : "ntfy"}
|
||||
</button>
|
||||
|
||||
@@ -32,30 +32,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) {
|
||||
const [loadingPorts, setLoadingPorts] = useState(false);
|
||||
|
||||
const buildWsUrl = () => {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
if (
|
||||
isDev ||
|
||||
(isElectron() &&
|
||||
!(window as { configuredServerUrl?: string }).configuredServerUrl)
|
||||
) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
}
|
||||
const configuredUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!configuredUrl) return null;
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
// Serial is always local -- the device is physically attached to this
|
||||
// desktop machine, so it never routes through a remote server.
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
};
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
canShareHost,
|
||||
} from "@/sidebar/host-permissions";
|
||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||
import { HostShareModal } from "@/sidebar/HostShareModal";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
@@ -319,7 +320,7 @@ export function HostItem({
|
||||
const metricsEnabled =
|
||||
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
@@ -362,7 +363,7 @@ export function HostItem({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true");
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false");
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("hostTrayOnClickChanged", handler);
|
||||
return () => {
|
||||
@@ -514,12 +515,12 @@ export function HostItem({
|
||||
</button>
|
||||
)}
|
||||
{!selectionMode && !shouldUseClickTray && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
{selectionMode && (
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto shrink-0">
|
||||
{host.ip}
|
||||
</span>
|
||||
)}
|
||||
@@ -528,7 +529,7 @@ export function HostItem({
|
||||
{/* Click-tray mode: always-visible action buttons */}
|
||||
{shouldUseClickTray && !selectionMode && (
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[72px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
@@ -729,7 +730,7 @@ export function HostItem({
|
||||
|
||||
{/* Hover tray (non-click-tray mode) */}
|
||||
{!shouldUseClickTray && !selectionMode && (
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[200px] group-hover:opacity-100">
|
||||
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[72px] group-hover:opacity-100">
|
||||
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
@@ -1053,7 +1054,7 @@ export function HostItem({
|
||||
</div>
|
||||
|
||||
{/* Address — always visible */}
|
||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
|
||||
<span className="text-[11px] text-muted-foreground/70 truncate leading-none pl-3">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
|
||||
@@ -1078,7 +1079,7 @@ export function HostItem({
|
||||
|
||||
{/* Connection buttons — always visible in click-tray mode, inside hover tray otherwise */}
|
||||
{shouldUseClickTray && !selectionMode && (
|
||||
<div className="flex items-center flex-wrap gap-1 pl-2 pb-1">
|
||||
<div className="flex items-center flex-wrap gap-1 pl-2">
|
||||
{getSshActions(host).map(({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
@@ -1157,7 +1158,7 @@ export function HostItem({
|
||||
|
||||
{/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[130px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[130px] !opacity-100" : ""}`}
|
||||
>
|
||||
{isOnline &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
@@ -1194,7 +1195,7 @@ export function HostItem({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-0.5 pt-1.5 pl-2 pb-1">
|
||||
<div className="flex flex-col gap-0.5 pl-2">
|
||||
{/* Connection buttons — only shown here in hover mode */}
|
||||
{!shouldUseClickTray && (
|
||||
<div className="flex items-center flex-wrap gap-1">
|
||||
@@ -1561,6 +1562,7 @@ export function FolderItem({
|
||||
onManageFolder,
|
||||
onDeleteFolder,
|
||||
onOpenAllSessions,
|
||||
onShareFolder,
|
||||
onMoveHostsToFolder,
|
||||
draggedHostIds,
|
||||
onDragHostStart,
|
||||
@@ -1591,6 +1593,7 @@ export function FolderItem({
|
||||
onManageFolder: (folder: HostFolder) => void;
|
||||
onDeleteFolder: (folder: HostFolder) => void;
|
||||
onOpenAllSessions: (folder: HostFolder) => void;
|
||||
onShareFolder?: (folder: HostFolder) => void;
|
||||
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
|
||||
draggedHostIds: string[] | null;
|
||||
onDragHostStart: (hostId: string) => void;
|
||||
@@ -1674,6 +1677,18 @@ export function FolderItem({
|
||||
>
|
||||
<FolderOpen className="size-2.5" />
|
||||
</span>
|
||||
{onShareFolder && (
|
||||
<span
|
||||
title={t("hosts.shareFolder")}
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onShareFolder(folder);
|
||||
}}
|
||||
>
|
||||
<Share2 className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
title={t("hosts.editFolder")}
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
@@ -1727,6 +1742,7 @@ export function FolderItem({
|
||||
onManageFolder={onManageFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenAllSessions={onOpenAllSessions}
|
||||
onShareFolder={onShareFolder}
|
||||
onMoveHostsToFolder={onMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={onDragHostStart}
|
||||
@@ -1813,11 +1829,14 @@ export function SidebarTree({
|
||||
mode: "create" | "edit";
|
||||
folder?: HostFolder;
|
||||
} | null>(null);
|
||||
const [shareFolderTarget, setShareFolderTarget] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [compactHostView, setCompactHostView] = useState(
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
);
|
||||
const [trayOnClick, setTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1833,7 +1852,7 @@ export function SidebarTree({
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true");
|
||||
setTrayOnClick(localStorage.getItem("hostTrayOnClick") !== "false");
|
||||
window.addEventListener("storage", handler);
|
||||
window.addEventListener("hostTrayOnClickChanged", handler);
|
||||
return () => {
|
||||
@@ -1886,6 +1905,7 @@ export function SidebarTree({
|
||||
name: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
credentialId: number | null;
|
||||
}) {
|
||||
const existing = folderDialog?.folder;
|
||||
try {
|
||||
@@ -1898,9 +1918,19 @@ export function SidebarTree({
|
||||
if (newPath !== oldPath) {
|
||||
await renameFolder(oldPath, newPath);
|
||||
}
|
||||
await updateFolderMetadata(newPath, value.color, value.icon);
|
||||
await updateFolderMetadata(
|
||||
newPath,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.credentialId,
|
||||
);
|
||||
} else {
|
||||
await updateFolderMetadata(value.name, value.color, value.icon);
|
||||
await updateFolderMetadata(
|
||||
value.name,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.credentialId,
|
||||
);
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
toast.success(t("hosts.folderSaved"));
|
||||
@@ -2224,6 +2254,9 @@ export function SidebarTree({
|
||||
onManageFolder={handleManageFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onOpenAllSessions={handleOpenAllSessions}
|
||||
onShareFolder={(folder) =>
|
||||
setShareFolderTarget(folder.path ?? folder.name)
|
||||
}
|
||||
onMoveHostsToFolder={handleMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={handleDragHostStart}
|
||||
@@ -2541,12 +2574,20 @@ export function SidebarTree({
|
||||
name: folderDialog.folder.name,
|
||||
color: folderDialog.folder.color,
|
||||
icon: folderDialog.folder.icon,
|
||||
credentialId: folderDialog.folder.credentialId,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onOpenChange={(v) => !v && setFolderDialog(null)}
|
||||
onSubmit={handleSaveFolderMetadata}
|
||||
/>
|
||||
|
||||
<HostShareModal
|
||||
open={shareFolderTarget !== null}
|
||||
onClose={() => setShareFolderTarget(null)}
|
||||
host={null}
|
||||
folder={shareFolderTarget}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
@@ -29,6 +29,7 @@ import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { FolderPathPicker } from "./FolderPathPicker";
|
||||
import { Separator } from "@/components/separator";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -150,6 +151,14 @@ function SnippetFormDialog({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const folderMeta = useMemo(() => {
|
||||
const map = new Map<string, { color?: string; icon?: string }>();
|
||||
for (const f of folders) {
|
||||
map.set(f.name, { color: f.color ?? undefined, icon: f.icon });
|
||||
}
|
||||
return map;
|
||||
}, [folders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(snippet?.name ?? "");
|
||||
@@ -238,20 +247,12 @@ function SnippetFormDialog({
|
||||
({t("newUi.sidebar.snippets.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<select
|
||||
<FolderPathPicker
|
||||
value={folder ?? ""}
|
||||
onChange={(e) =>
|
||||
setFolder(e.target.value === "" ? null : e.target.value)
|
||||
}
|
||||
className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("newUi.sidebar.snippets.noFolder")}</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(path) => setFolder(path === "" ? null : path)}
|
||||
folderPaths={folders.map((f) => f.name)}
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
saveUserPreferences,
|
||||
getUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
|
||||
import {
|
||||
deleteWebAuthnCredential,
|
||||
listWebAuthnCredentials,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import type { UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { RemoteSyncPanel } from "@/settings/RemoteSyncPanel.tsx";
|
||||
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Copy,
|
||||
Database,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
@@ -69,6 +72,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import { KeybindingsDialog } from "./KeybindingsDialog";
|
||||
import {
|
||||
ACCENT_PRESET_COLORS,
|
||||
applyAccentColor,
|
||||
@@ -86,6 +90,7 @@ type UserProfileSection =
|
||||
| "appearance"
|
||||
| "security"
|
||||
| "api-keys"
|
||||
| "data"
|
||||
| "c2s-tunnels";
|
||||
|
||||
const THEMES: { id: ThemeId; preview: string }[] = [
|
||||
@@ -422,13 +427,13 @@ function PasswordChangeSection({
|
||||
export function UserProfilePanel({
|
||||
username,
|
||||
onLogout,
|
||||
onChangeServer,
|
||||
userPrefs,
|
||||
onPrefsChange,
|
||||
remoteSyncInitialServerUrl,
|
||||
}: {
|
||||
username?: string;
|
||||
onLogout?: () => void;
|
||||
onChangeServer?: () => void;
|
||||
remoteSyncInitialServerUrl?: string;
|
||||
userPrefs?: {
|
||||
reopenTabsOnLogin: boolean;
|
||||
storageMode?: string | null;
|
||||
@@ -464,8 +469,8 @@ export function UserProfilePanel({
|
||||
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
|
||||
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
|
||||
};
|
||||
const [openSection, setOpenSection] = useState<UserProfileSection | null>(
|
||||
"account",
|
||||
const [openSections, setOpenSections] = useState<Set<UserProfileSection>>(
|
||||
() => new Set(["account"]),
|
||||
);
|
||||
|
||||
// User info
|
||||
@@ -502,6 +507,11 @@ export function UserProfilePanel({
|
||||
const [deletePassword, setDeletePassword] = useState("");
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
// Data export/import
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
|
||||
// UI state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [newKeyOpen, setNewKeyOpen] = useState(false);
|
||||
@@ -532,6 +542,46 @@ export function UserProfilePanel({
|
||||
}
|
||||
}, [userPrefs?.storageMode]);
|
||||
|
||||
// Remote sync is not connected by default on the desktop app (it's an
|
||||
// opt-in feature configured from this same panel). "cloud" storage mode
|
||||
// and Termix ID both assume a real multi-device server account, so they
|
||||
// stay hidden/forced-off until the user actually connects one.
|
||||
const [isRemoteSyncConnected, setIsRemoteSyncConnected] = useState(
|
||||
() => !isElectron(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isElectron()) return;
|
||||
let cancelled = false;
|
||||
const refreshSyncStatus = () => {
|
||||
window.electronAPI
|
||||
?.invoke?.("get-remote-sync-config")
|
||||
.then((config) => {
|
||||
if (!cancelled) {
|
||||
setIsRemoteSyncConnected(
|
||||
!!(config as { serverUrl?: string } | null)?.serverUrl,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
refreshSyncStatus();
|
||||
const unsubscribe = window.electronAPI?.onRemoteSyncStatusChanged?.(() =>
|
||||
refreshSyncStatus(),
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron() && !isRemoteSyncConnected && storageMode === "cloud") {
|
||||
setStorageMode("local");
|
||||
onPrefsChange?.({ storageMode: "local" });
|
||||
}
|
||||
}, [isRemoteSyncConnected, storageMode, onPrefsChange]);
|
||||
|
||||
// Settings toggles — all backed by localStorage
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState(
|
||||
() => localStorage.getItem("commandAutocomplete") === "true",
|
||||
@@ -543,12 +593,13 @@ export function UserProfilePanel({
|
||||
const v = localStorage.getItem("commandPaletteShortcutEnabled");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
|
||||
const [showHostTags, setShowHostTags] = useState(() => {
|
||||
const v = localStorage.getItem("showHostTags");
|
||||
return v !== null ? v === "true" : true;
|
||||
});
|
||||
const [hostTrayOnClick, setHostTrayOnClick] = useState(
|
||||
() => localStorage.getItem("hostTrayOnClick") === "true",
|
||||
() => localStorage.getItem("hostTrayOnClick") !== "false",
|
||||
);
|
||||
const [compactHostView, setCompactHostView] = useState(
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
@@ -790,8 +841,8 @@ export function UserProfilePanel({
|
||||
setShowHostTags(true);
|
||||
localStorage.setItem("showHostTags", "true");
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
setHostTrayOnClick(false);
|
||||
localStorage.setItem("hostTrayOnClick", "false");
|
||||
setHostTrayOnClick(true);
|
||||
localStorage.setItem("hostTrayOnClick", "true");
|
||||
setCompactHostView(false);
|
||||
localStorage.setItem("compactHostView", "false");
|
||||
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
|
||||
@@ -824,7 +875,7 @@ export function UserProfilePanel({
|
||||
commandAutocomplete: false,
|
||||
commandPaletteEnabled: true,
|
||||
showHostTags: true,
|
||||
hostTrayOnClick: false,
|
||||
hostTrayOnClick: true,
|
||||
compactHostView: false,
|
||||
pinAppRail: false,
|
||||
expandAppRailOnHover: true,
|
||||
@@ -893,7 +944,7 @@ export function UserProfilePanel({
|
||||
localStorage.setItem("showHostTags", String(restoredHostTags));
|
||||
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
|
||||
|
||||
const restoredTrayOnClick = restore("hostTrayOnClick", "false") === "true";
|
||||
const restoredTrayOnClick = restore("hostTrayOnClick", "true") !== "false";
|
||||
setHostTrayOnClick(restoredTrayOnClick);
|
||||
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
|
||||
|
||||
@@ -1001,7 +1052,12 @@ export function UserProfilePanel({
|
||||
}
|
||||
|
||||
function toggle(id: UserProfileSection) {
|
||||
setOpenSection((prev) => (prev === id ? null : id));
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStartTotpSetup() {
|
||||
@@ -1124,6 +1180,114 @@ export function UserProfilePanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportData() {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("export", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get("content-disposition");
|
||||
const filename =
|
||||
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
"termix-export.sqlite";
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("newUi.sidebar.userProfile.exportSuccess"));
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(err.error || t("newUi.sidebar.userProfile.exportFailed"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("newUi.sidebar.userProfile.exportFailed"));
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportData() {
|
||||
if (!importFile) {
|
||||
toast.error(t("newUi.sidebar.userProfile.importSelectFile"));
|
||||
return;
|
||||
}
|
||||
setImportLoading(true);
|
||||
try {
|
||||
const apiUrl = getDatabaseTransferUrl("import", {
|
||||
electron: isElectron(),
|
||||
configuredServerUrl: null,
|
||||
location: window.location,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", importFile);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const s = result.summary;
|
||||
const total =
|
||||
(s.sshHostsImported || 0) +
|
||||
(s.sshCredentialsImported || 0) +
|
||||
(s.fileManagerItemsImported || 0) +
|
||||
(s.dismissedAlertsImported || 0) +
|
||||
(s.settingsImported || 0);
|
||||
toast.success(
|
||||
t("newUi.sidebar.userProfile.importCompleted", {
|
||||
total,
|
||||
skipped: s.skippedItems || 0,
|
||||
}),
|
||||
);
|
||||
setImportFile(null);
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} else {
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: result.summary?.errors?.join(", ") || "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: err.error || "Unknown error",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
t("newUi.sidebar.userProfile.importFailed", {
|
||||
error: "Unknown error",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canChangePasword = !isOidc || isDualAuth;
|
||||
|
||||
return (
|
||||
@@ -1156,51 +1320,55 @@ export function UserProfilePanel({
|
||||
</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">
|
||||
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
||||
</span>
|
||||
<div className="flex border border-border overflow-hidden w-full">
|
||||
{/* Storage mode toggle — only meaningful once a remote server is
|
||||
connected; with no sync there's nowhere for "cloud" to sync to,
|
||||
so this stays forced to local storage and hidden. */}
|
||||
{(!isElectron() || isRemoteSyncConnected) && (
|
||||
<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">
|
||||
{t("newUi.sidebar.userProfile.storageModeSwitch")}
|
||||
</span>
|
||||
<div className="flex border border-border overflow-hidden w-full">
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("local")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "local"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeLocal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("cloud")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "cloud"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeCloud")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.storageModeDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("local")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "local"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
onClick={resetToDefaults}
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeLocal")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStorageModeChange("cloud")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "cloud"
|
||||
? "bg-accent-brand text-white"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.storageModeCloud")}
|
||||
<RotateCcw className="size-3" />
|
||||
{t("newUi.sidebar.userProfile.resetToDefaults")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.storageModeDescription")}
|
||||
</p>
|
||||
<button
|
||||
onClick={resetToDefaults}
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("newUi.sidebar.userProfile.resetToDefaults")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account */}
|
||||
<AccordionSection
|
||||
id="account"
|
||||
label={t("newUi.sidebar.userProfile.sectionAccount")}
|
||||
icon={<User className="size-3.5" />}
|
||||
open={openSection === "account"}
|
||||
open={openSections.has("account")}
|
||||
onToggle={() => toggle("account")}
|
||||
>
|
||||
<div className="flex flex-col gap-0 pt-2">
|
||||
@@ -1315,27 +1483,9 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isElectron() && onChangeServer && (
|
||||
{isElectron() && (
|
||||
<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("serverConfig.changeServer")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.changeServerDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 ml-3 text-[10px] h-7"
|
||||
onClick={onChangeServer}
|
||||
>
|
||||
<Server className="size-3" />
|
||||
{t("serverConfig.changeServer")}
|
||||
</Button>
|
||||
</div>
|
||||
<RemoteSyncPanel initialServerUrl={remoteSyncInitialServerUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1367,7 +1517,7 @@ export function UserProfilePanel({
|
||||
id="appearance"
|
||||
label={t("newUi.sidebar.userProfile.sectionAppearance")}
|
||||
icon={<Palette className="size-3.5" />}
|
||||
open={openSection === "appearance"}
|
||||
open={openSections.has("appearance")}
|
||||
onToggle={() => toggle("appearance")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -1528,6 +1678,20 @@ export function UserProfilePanel({
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.keyboardShortcuts")}
|
||||
description={t(
|
||||
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setKeybindingsDialogOpen(true)}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.manageShortcuts")}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
@@ -1873,7 +2037,7 @@ export function UserProfilePanel({
|
||||
id="security"
|
||||
label={t("newUi.sidebar.userProfile.sectionSecurity")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
open={openSection === "security"}
|
||||
open={openSections.has("security")}
|
||||
onToggle={() => toggle("security")}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
@@ -2204,7 +2368,7 @@ export function UserProfilePanel({
|
||||
id="api-keys"
|
||||
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
open={openSection === "api-keys"}
|
||||
open={openSections.has("api-keys")}
|
||||
onToggle={() => toggle("api-keys")}
|
||||
>
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
@@ -2305,12 +2469,86 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection
|
||||
id="data"
|
||||
label={t("newUi.sidebar.userProfile.sectionData")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
open={openSections.has("data")}
|
||||
onToggle={() => toggle("data")}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.exportData")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.exportDataDesc")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
|
||||
onClick={handleExportData}
|
||||
disabled={exportLoading}
|
||||
>
|
||||
{exportLoading
|
||||
? t("newUi.sidebar.userProfile.exporting")
|
||||
: t("newUi.sidebar.userProfile.export")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.importData")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{importFile
|
||||
? t("newUi.sidebar.userProfile.importDataSelected", {
|
||||
name: importFile.name,
|
||||
})
|
||||
: t("newUi.sidebar.userProfile.importDataDesc")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept=".sqlite,.db"
|
||||
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="pointer-events-none text-xs"
|
||||
>
|
||||
{importFile
|
||||
? t("newUi.sidebar.userProfile.changeFile")
|
||||
: t("newUi.sidebar.userProfile.selectFile")}
|
||||
</Button>
|
||||
</div>
|
||||
{importFile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleImportData}
|
||||
disabled={importLoading}
|
||||
>
|
||||
{importLoading
|
||||
? t("newUi.sidebar.userProfile.importing")
|
||||
: t("newUi.sidebar.userProfile.import")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{isElectron() && (
|
||||
<AccordionSection
|
||||
id="c2s-tunnels"
|
||||
label={t("newUi.sidebar.userProfile.sectionC2sTunnels")}
|
||||
icon={<Activity className="size-3.5" />}
|
||||
open={openSection === "c2s-tunnels"}
|
||||
open={openSections.has("c2s-tunnels")}
|
||||
onToggle={() => toggle("c2s-tunnels")}
|
||||
>
|
||||
<C2STunnelPresetManager />
|
||||
@@ -2380,6 +2618,10 @@ export function UserProfilePanel({
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<KeybindingsDialog
|
||||
open={keybindingsDialogOpen}
|
||||
onOpenChange={setKeybindingsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user