mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +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:
co-authored by
XtraLarge
dependabot[bot]
L.H.
default-student
Brad Baker
copilot-swe-agent[bot]
Copilot Autofix powered by AI
Brennan Neoh
brennanneoh
XtraLarge
ZacharyZcR
parent
cf3e2cb499
commit
1a26628a48
+124
-157
@@ -29,17 +29,13 @@ import {
|
||||
completePasswordReset,
|
||||
getOIDCAuthorizeUrl,
|
||||
verifyTOTPLogin,
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
isElectron,
|
||||
getEmbeddedServerStatus,
|
||||
getCurrentToken,
|
||||
getOidcSilentLoginDefault,
|
||||
requestDesktopAutoSession,
|
||||
} from "@/main-axios";
|
||||
import { getSSOProviders, ldapLogin } from "@/api/sso-provider-api";
|
||||
import type { SSOProviderPublic } from "@/types/index";
|
||||
import { ElectronServerConfig as ServerConfigComponent } from "@/auth/ElectronServerConfig";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import {
|
||||
@@ -263,13 +259,23 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
const [firstUser, setFirstUser] = useState(false);
|
||||
const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
|
||||
const [dbHealthChecking, setDbHealthChecking] = useState(true);
|
||||
|
||||
const [showServerConfig, setShowServerConfig] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [currentServerUrl, setCurrentServerUrl] = useState("");
|
||||
const [webviewAuthSuccess, setWebviewAuthSuccess] = useState(false);
|
||||
|
||||
// Electron, non-iframed only: the desktop app never shows a login form
|
||||
// when running standalone -- the embedded backend auto-provisions a
|
||||
// single local user on first boot, and this component silently exchanges
|
||||
// that for a session instead of rendering login/register.
|
||||
// null = probe still in flight (Electron only, blocks rendering below).
|
||||
// true = probe settled with no auto-login (not applicable outside
|
||||
// Electron, multiple users exist, or setup is genuinely required) --
|
||||
// safe to fall through to the normal form/health-check flow.
|
||||
// Auto-login success never sets this; it calls onLogin directly and this
|
||||
// component unmounts.
|
||||
const [desktopAutoSessionDone, setDesktopAutoSessionDone] = useState<
|
||||
boolean | null
|
||||
>(!isElectron() || isInElectronWebView() ? true : null);
|
||||
const [desktopAutoSessionRetries, setDesktopAutoSessionRetries] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("rememberMe", rememberMe.toString());
|
||||
@@ -320,7 +326,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (showServerConfig) return;
|
||||
// Runs once the auto-session probe has settled (immediately outside
|
||||
// Electron, since it starts at true there; after the probe resolves in
|
||||
// Electron). Waiting avoids flashing a login screen the user is about
|
||||
// to skip past via auto-login.
|
||||
if (desktopAutoSessionDone !== true) return;
|
||||
setDbHealthChecking(true);
|
||||
getSetupRequired()
|
||||
.then((res) => {
|
||||
@@ -332,53 +342,59 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
})
|
||||
.catch(() => setDbConnectionFailed(true))
|
||||
.finally(() => setDbHealthChecking(false));
|
||||
}, [showServerConfig]);
|
||||
}, [desktopAutoSessionDone]);
|
||||
|
||||
// A cold first launch spawns the embedded backend as a separate process
|
||||
// that can take anywhere from a couple seconds to much longer to finish
|
||||
// booting (DB init, SSL, antivirus scanning a freshly-unpacked binary,
|
||||
// slow disks, etc.) -- well after the renderer has already mounted. The
|
||||
// embedded backend is bundled, always-on infrastructure, not something
|
||||
// that can be "not there" -- it always eventually comes up. So a
|
||||
// "retry" outcome (connection error, not a real verdict) is retried
|
||||
// forever with capped backoff rather than ever giving up and falling
|
||||
// through to the login form: that form is not a valid destination for a
|
||||
// standalone install with no remote sync configured, since the only
|
||||
// local account has no password to log in with. Only a definitive
|
||||
// "declined" (backend reachable and says no -- multiple users, or the
|
||||
// sole local user has a real credential) stops retrying and shows the
|
||||
// real form.
|
||||
useEffect(() => {
|
||||
const checkElectron = async () => {
|
||||
if (isInElectronWebView()) {
|
||||
setShowServerConfig(false);
|
||||
return;
|
||||
}
|
||||
if (isElectron()) {
|
||||
const forceShow = localStorage.getItem("termix_show_server_config");
|
||||
if (forceShow === "true") {
|
||||
localStorage.removeItem("termix_show_server_config");
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
setCurrentServerUrl(config?.serverUrl || "");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setShowServerConfig(true);
|
||||
if (desktopAutoSessionDone !== null) return;
|
||||
let cancelled = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
requestDesktopAutoSession()
|
||||
.then((outcome) => {
|
||||
if (cancelled) return;
|
||||
if (outcome.kind === "success") {
|
||||
storeAuth(outcome.data.username || "");
|
||||
onLogin(
|
||||
outcome.data.username || "",
|
||||
outcome.data.userId || undefined,
|
||||
!!outcome.data.is_admin,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [config, status] = await Promise.all([
|
||||
getServerConfig(),
|
||||
getEmbeddedServerStatus(),
|
||||
]);
|
||||
if (
|
||||
status?.embedded &&
|
||||
status?.running &&
|
||||
config &&
|
||||
!config.serverUrl
|
||||
) {
|
||||
setShowServerConfig(false);
|
||||
setCurrentServerUrl("");
|
||||
return;
|
||||
}
|
||||
setCurrentServerUrl(config?.serverUrl || "");
|
||||
setShowServerConfig(!config || !config.serverUrl);
|
||||
} catch {
|
||||
setShowServerConfig(true);
|
||||
if (outcome.kind === "retry") {
|
||||
const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1);
|
||||
}, delay);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setShowServerConfig(false);
|
||||
}
|
||||
setDesktopAutoSessionDone(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const delay = Math.min(1000 * 2 ** desktopAutoSessionRetries, 10000);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!cancelled) setDesktopAutoSessionRetries((c) => c + 1);
|
||||
}, delay);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
checkElectron();
|
||||
}, []);
|
||||
}, [desktopAutoSessionDone, desktopAutoSessionRetries, onLogin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "totp" && totpInputRef.current) totpInputRef.current.focus();
|
||||
@@ -474,36 +490,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
}
|
||||
}, [onLogin, t]);
|
||||
|
||||
const handleElectronAuthSuccess = useCallback(
|
||||
async (token: string | null) => {
|
||||
try {
|
||||
if (!token) {
|
||||
// No token in postMessage — fall back to waiting for the HttpOnly cookie
|
||||
const cookieReady = await window.electronAPI?.waitForSessionCookie?.(
|
||||
"jwt",
|
||||
currentServerUrl,
|
||||
null,
|
||||
5000,
|
||||
);
|
||||
if (cookieReady && !cookieReady.success)
|
||||
throw new Error(cookieReady.error || "Auth cookie not ready");
|
||||
}
|
||||
const meRes = await getUserInfo();
|
||||
if (!meRes) throw new Error("Failed to get user info");
|
||||
storeAuth(meRes.username || "");
|
||||
onLogin(
|
||||
meRes.username || "",
|
||||
meRes.userId || undefined,
|
||||
!!meRes.is_admin,
|
||||
);
|
||||
toast.success(t("messages.loginSuccess"));
|
||||
} catch {
|
||||
toast.error(t("errors.failedUserInfo"));
|
||||
}
|
||||
},
|
||||
[onLogin, currentServerUrl, t],
|
||||
);
|
||||
|
||||
function resetAll() {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
@@ -549,11 +535,19 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
return;
|
||||
}
|
||||
if (isInElectronWebView()) {
|
||||
// The iframe's login request never carries the X-Electron-App header
|
||||
// (only the top-level Electron renderer's axios instances do), so the
|
||||
// backend never includes the JWT in the login response body -- it
|
||||
// only lands in an HttpOnly cookie scoped to this iframe's origin.
|
||||
// Read it back via /users/me/token, same as the mobile-webview OIDC
|
||||
// callback below does, so the parent window can persist it.
|
||||
const token = res?.token ?? (await getCurrentToken());
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "auth_component",
|
||||
platform: "desktop",
|
||||
token: token ?? null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
@@ -607,6 +601,35 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
setView("totp");
|
||||
return;
|
||||
}
|
||||
if (isInMobileWebView()) {
|
||||
// Native-app requests get the JWT in the login response body.
|
||||
const token = res?.token ?? "";
|
||||
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
|
||||
JSON.stringify({ type: "AUTH_SUCCESS", token }),
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
}
|
||||
if (isInElectronWebView()) {
|
||||
// Registration inside the Remote Sync iframe must hand off to the
|
||||
// parent window the same way handleLogin does -- otherwise this
|
||||
// component's own onLogin() below fires on the iframe's own,
|
||||
// independent copy of the app, rendering the full remote AppShell
|
||||
// inside the small login dialog instead of closing it.
|
||||
const token = res?.token ?? (await getCurrentToken());
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "auth_component",
|
||||
platform: "desktop",
|
||||
token: token ?? null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
);
|
||||
setWebviewAuthSuccess(true);
|
||||
return;
|
||||
}
|
||||
const meRes = await getUserInfo();
|
||||
storeAuth(meRes.username || username.trim());
|
||||
toast.success(t("messages.registrationSuccess"));
|
||||
@@ -650,11 +673,16 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
return;
|
||||
}
|
||||
if (isInElectronWebView()) {
|
||||
// See the equivalent branch in handleLogin: the iframe never sends
|
||||
// X-Electron-App, so the JWT never lands in the response body here
|
||||
// either -- read it back from the HttpOnly cookie that was just set.
|
||||
const token = res?.token ?? (await getCurrentToken());
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "AUTH_SUCCESS",
|
||||
source: "totp_auth_component",
|
||||
platform: "desktop",
|
||||
token: token ?? null,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
"*",
|
||||
@@ -935,46 +963,19 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
oidcSilentLoginDefaultLoaded,
|
||||
]);
|
||||
|
||||
// Electron server config / webview auth success screens
|
||||
if (isElectron() && !isInElectronWebView()) {
|
||||
if (showServerConfig === null)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
if (showServerConfig)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<ServerConfigComponent
|
||||
onServerConfigured={() => window.location.reload()}
|
||||
onUseEmbedded={async () => {
|
||||
await saveServerConfig({
|
||||
serverUrl: "",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
});
|
||||
setShowServerConfig(false);
|
||||
setCurrentServerUrl("");
|
||||
}}
|
||||
onCancel={() => setShowServerConfig(false)}
|
||||
isFirstTime={!currentServerUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
if (!webviewAuthSuccess && showServerConfig === false && currentServerUrl)
|
||||
return (
|
||||
<div className="w-full h-screen flex items-center justify-center p-4 bg-background">
|
||||
<div className="w-full max-w-4xl h-[90vh]">
|
||||
<ElectronLoginForm
|
||||
serverUrl={currentServerUrl}
|
||||
onAuthSuccess={handleElectronAuthSuccess}
|
||||
onChangeServer={() => setShowServerConfig(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Electron, non-iframed: wait for the auto-session probe before rendering
|
||||
// anything, so a standalone desktop install never flashes a login form
|
||||
// it's about to skip past.
|
||||
if (
|
||||
isElectron() &&
|
||||
!isInElectronWebView() &&
|
||||
desktopAutoSessionDone === null
|
||||
) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (webviewAuthSuccess || (isInElectronWebView() && webviewAuthSuccess))
|
||||
@@ -1018,30 +1019,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isElectron() && currentServerUrl && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.serverUrl")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate max-w-[180px]">
|
||||
{currentServerUrl}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
>
|
||||
{t("common.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (dbHealthChecking && showServerConfig === false)
|
||||
if (dbHealthChecking)
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
@@ -1068,21 +1050,6 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
|
||||
{isElectron() && !isInElectronWebView() && showServerConfig === false && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setShowServerConfig(true)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
{t("serverConfig.changeServer")}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.localServer")}
|
||||
</span>
|
||||
<div className="w-20" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left decorative panel */}
|
||||
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
|
||||
|
||||
@@ -7,6 +7,12 @@ interface ElectronLoginFormProps {
|
||||
serverUrl: string;
|
||||
onAuthSuccess: (token: string | null) => void | Promise<void>;
|
||||
onChangeServer: () => void;
|
||||
// "local" (default): the app's own login, JWT goes to localStorage like
|
||||
// every other client. "remoteSync": this iframe is authenticating a
|
||||
// Settings-triggered connection to a remote Termix server for the sync
|
||||
// engine -- the JWT is handed to the Electron main process's encrypted
|
||||
// store instead, never exposed to the renderer's localStorage.
|
||||
targetPurpose?: "local" | "remoteSync";
|
||||
}
|
||||
|
||||
const AUTH_MESSAGE_SOURCES = new Set([
|
||||
@@ -19,6 +25,7 @@ export function ElectronLoginForm({
|
||||
serverUrl,
|
||||
onAuthSuccess,
|
||||
onChangeServer,
|
||||
targetPurpose = "local",
|
||||
}: ElectronLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,7 +50,11 @@ export function ElectronLoginForm({
|
||||
|
||||
try {
|
||||
if (token) {
|
||||
localStorage.setItem("jwt", token);
|
||||
if (targetPurpose === "remoteSync") {
|
||||
await window.electronAPI?.invoke?.("save-remote-sync-jwt", token);
|
||||
} else {
|
||||
localStorage.setItem("jwt", token);
|
||||
}
|
||||
}
|
||||
await onAuthSuccessRef.current(token);
|
||||
} catch {
|
||||
@@ -53,7 +64,7 @@ export function ElectronLoginForm({
|
||||
hasAuthenticatedRef.current = false;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[t, targetPurpose],
|
||||
);
|
||||
|
||||
// postMessage from server Auth.tsx after the backend has set the HttpOnly cookie.
|
||||
@@ -204,7 +215,7 @@ export function ElectronLoginForm({
|
||||
const isEmbeddedServer = serverUrl.includes("localhost:30001");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen bg-background flex flex-col">
|
||||
<div className="relative w-full h-full bg-background flex flex-col">
|
||||
{isAuthenticating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-50">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
@@ -212,7 +223,7 @@ export function ElectronLoginForm({
|
||||
)}
|
||||
|
||||
{!isAuthenticating && (
|
||||
<div className="flex items-center justify-between p-4 bg-background border-b border-border">
|
||||
<div className="flex items-center justify-between p-4 pr-12 bg-background border-b border-border">
|
||||
<button
|
||||
onClick={onChangeServer}
|
||||
className="flex items-center gap-2 text-foreground hover:text-primary transition-colors"
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Label } from "@/components/label.tsx";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Switch } from "@/components/switch.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getServerConfig,
|
||||
saveServerConfig,
|
||||
getEmbeddedServerStatus,
|
||||
setEmbeddedMode,
|
||||
type ServerConfig,
|
||||
} from "@/main-axios.ts";
|
||||
import { Server, Monitor, Loader2, ChevronDown, X } from "lucide-react";
|
||||
|
||||
const SAVED_URLS_KEY = "termix_saved_server_urls";
|
||||
const MAX_SAVED_URLS = 5;
|
||||
|
||||
function getSavedUrls(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVED_URLS_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function addSavedUrl(url: string) {
|
||||
const urls = getSavedUrls().filter((u) => u !== url);
|
||||
urls.unshift(url);
|
||||
localStorage.setItem(
|
||||
SAVED_URLS_KEY,
|
||||
JSON.stringify(urls.slice(0, MAX_SAVED_URLS)),
|
||||
);
|
||||
}
|
||||
|
||||
function removeSavedUrl(url: string) {
|
||||
const urls = getSavedUrls().filter((u) => u !== url);
|
||||
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
|
||||
}
|
||||
|
||||
interface ServerConfigProps {
|
||||
onServerConfigured: (serverUrl: string) => void;
|
||||
onUseEmbedded?: () => void;
|
||||
onCancel?: () => void;
|
||||
isFirstTime?: boolean;
|
||||
}
|
||||
|
||||
export function ElectronServerConfig({
|
||||
onServerConfigured,
|
||||
onUseEmbedded,
|
||||
onCancel,
|
||||
isFirstTime = false,
|
||||
}: ServerConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [serverUrl, setServerUrl] = useState("");
|
||||
const [allowInvalidCertificate, setAllowInvalidCertificate] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [embeddedLoading, setEmbeddedLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [embeddedAvailable, setEmbeddedAvailable] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [savedUrls, setSavedUrls] = useState<string[]>([]);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadServerConfig();
|
||||
checkEmbeddedBackend();
|
||||
setSavedUrls(getSavedUrls());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
try {
|
||||
const config = await getServerConfig();
|
||||
if (config?.serverUrl) {
|
||||
setServerUrl(config.serverUrl);
|
||||
}
|
||||
setAllowInvalidCertificate(!!config?.allowInvalidCertificate);
|
||||
} catch (error) {
|
||||
console.error("Server config operation failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkEmbeddedBackend = async () => {
|
||||
try {
|
||||
const status = await getEmbeddedServerStatus();
|
||||
setEmbeddedAvailable(!!status?.embedded);
|
||||
} catch {
|
||||
setEmbeddedAvailable(true);
|
||||
}
|
||||
};
|
||||
|
||||
const probeBackend = async (): Promise<boolean> => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 3000);
|
||||
try {
|
||||
const res = await fetch("http://localhost:30001/health", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
return res.ok;
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const controller2 = new AbortController();
|
||||
const timer2 = setTimeout(() => controller2.abort(), 3000);
|
||||
try {
|
||||
await fetch("http://localhost:30001/version", {
|
||||
signal: controller2.signal,
|
||||
});
|
||||
clearTimeout(timer2);
|
||||
return true;
|
||||
} catch {
|
||||
clearTimeout(timer2);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseEmbedded = async () => {
|
||||
setEmbeddedLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const maxRetries = 15;
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
if (await probeBackend()) {
|
||||
setEmbeddedMode(true);
|
||||
if (onUseEmbedded) {
|
||||
onUseEmbedded();
|
||||
} else {
|
||||
onServerConfigured("http://localhost:30001");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (i < maxRetries - 1) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
setError(t("serverConfig.embeddedNotReady"));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : t("serverConfig.embeddedNotReady"),
|
||||
);
|
||||
} finally {
|
||||
setEmbeddedLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!serverUrl.trim()) {
|
||||
setError(t("serverConfig.enterServerUrl"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const normalizedUrl = serverUrl.trim();
|
||||
|
||||
if (
|
||||
!normalizedUrl.startsWith("http://") &&
|
||||
!normalizedUrl.startsWith("https://")
|
||||
) {
|
||||
setError(t("serverConfig.mustIncludeProtocol"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const config: ServerConfig = {
|
||||
serverUrl: normalizedUrl,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
allowInvalidCertificate:
|
||||
normalizedUrl.startsWith("https://") && allowInvalidCertificate,
|
||||
};
|
||||
|
||||
const success = await saveServerConfig(config);
|
||||
|
||||
if (success) {
|
||||
addSavedUrl(normalizedUrl);
|
||||
setSavedUrls(getSavedUrls());
|
||||
onServerConfigured(normalizedUrl);
|
||||
} else {
|
||||
setError(t("serverConfig.saveFailed"));
|
||||
}
|
||||
} catch {
|
||||
setError(t("serverConfig.saveError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (value: string) => {
|
||||
setServerUrl(value);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
|
||||
<div className="flex flex-col gap-5 p-6 border border-border bg-card max-w-md w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Server className="size-4 text-accent-brand shrink-0" />
|
||||
<p className="font-bold">{t("serverConfig.title")}</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("serverConfig.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{embeddedAvailable !== false && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleUseEmbedded}
|
||||
disabled={embeddedLoading || loading}
|
||||
>
|
||||
{embeddedLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t("serverConfig.embeddedConnecting")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Monitor className="size-4" />
|
||||
{t("serverConfig.useEmbedded")}
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border border-yellow-500/30">
|
||||
BETA
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground -mt-3">
|
||||
{t("serverConfig.embeddedDesc")}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("common.or") || "OR"}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="server-url">{t("serverConfig.serverUrl")}</Label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Input
|
||||
id="server-url"
|
||||
type="text"
|
||||
placeholder="https://your-server.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
disabled={loading || embeddedLoading}
|
||||
className={savedUrls.length > 0 ? "pr-9" : ""}
|
||||
onFocus={() => {
|
||||
if (savedUrls.length > 0) setDropdownOpen(true);
|
||||
}}
|
||||
/>
|
||||
{savedUrls.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setDropdownOpen((o) => !o)}
|
||||
disabled={loading || embeddedLoading}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("serverConfig.savedServers")}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{dropdownOpen && savedUrls.length > 0 && (
|
||||
<div className="absolute z-50 w-full top-full mt-1 border border-border bg-card shadow-md">
|
||||
<p className="px-2.5 py-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground border-b border-border">
|
||||
{t("serverConfig.savedServers")}
|
||||
</p>
|
||||
{savedUrls.map((url) => (
|
||||
<div
|
||||
key={url}
|
||||
className="flex items-center justify-between group hover:bg-muted transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left px-2.5 py-2 text-sm font-mono truncate"
|
||||
onClick={() => {
|
||||
handleUrlChange(url);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-2 text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
title={t("serverConfig.removeServer")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeSavedUrl(url);
|
||||
const updated = getSavedUrls();
|
||||
setSavedUrls(updated);
|
||||
if (updated.length === 0) setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverUrl.trim().startsWith("https://") && (
|
||||
<div className="flex items-start justify-between gap-3 border border-border bg-muted/20 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="allow-invalid-certificate">
|
||||
{t("serverConfig.allowInvalidCertificate")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("serverConfig.allowInvalidCertificateDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="allow-invalid-certificate"
|
||||
checked={allowInvalidCertificate}
|
||||
onCheckedChange={setAllowInvalidCertificate}
|
||||
disabled={loading || embeddedLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t("common.error")}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onCancel && !isFirstTime && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={onCancel}
|
||||
disabled={loading || embeddedLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
className={`bg-accent-brand hover:bg-accent-brand/90 text-background font-bold ${onCancel && !isFirstTime ? "flex-1" : "w-full"}`}
|
||||
onClick={handleSaveConfig}
|
||||
disabled={loading || embeddedLoading || !serverUrl.trim()}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-background border-t-transparent rounded-full animate-spin" />
|
||||
{t("serverConfig.saving")}
|
||||
</span>
|
||||
) : (
|
||||
t("serverConfig.saveConfig")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t("serverConfig.helpText")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Auth } from "@/auth/LoginPage.tsx";
|
||||
|
||||
interface LoginScreenProps {
|
||||
authLoading: boolean;
|
||||
onAuthSuccess: (authData: {
|
||||
isAdmin: boolean;
|
||||
username: string | null;
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function LoginScreen({
|
||||
authLoading,
|
||||
onAuthSuccess,
|
||||
}: LoginScreenProps): React.ReactElement {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [, setIsAdmin] = useState(false);
|
||||
const [, setUsername] = useState<string | null>(null);
|
||||
const [, setUserId] = useState<string | null>(null);
|
||||
const [, setDbError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Auth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
setUsername={setUsername}
|
||||
setUserId={setUserId}
|
||||
loggedIn={loggedIn}
|
||||
authLoading={authLoading}
|
||||
setDbError={setDbError}
|
||||
onAuthSuccess={onAuthSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user