From 33a91fd512255a045f72c74d7137bbb28fb8fc75 Mon Sep 17 00:00:00 2001 From: Eric Date: Sun, 11 Oct 2026 10:04:47 +0800 Subject: [PATCH] feat(client): enforce bidirectional Core compatibility Check the selected site in the Electron main process before login, account restoration or switching. Require both version minimums, preserve the active session on failed checks and ignore stale attempts. Offer retry or the private site's download page according to the failure. Default Luna and its minimum Core requirement to 5.1.0. Preserve web login and release version injection, and leave WebLite unchanged. Reject failed login profiles before the renderer can close the previous site's workspace. Validate SemVer boundaries, unknown responses, session isolation and races, legacy protocol behavior, download URLs, and web session behavior. --- electron/README.md | 32 +++ electron/package.json | 4 +- electron/src/auth/compatibility.ts | 149 +++++++++++ electron/src/auth/service.ts | 113 ++++++-- electron/src/desktop/main.ts | 3 +- electron/tests/auth-compatibility.test.ts | 268 +++++++++++++++++++ electron/tests/client-compatibility.test.ts | 209 +++++++++++++++ electron/tests/client-protocol.test.ts | 26 +- i18n/locales/en.json | 22 +- i18n/locales/es.json | 22 +- i18n/locales/fr.json | 22 +- i18n/locales/ja.json | 22 +- i18n/locales/ko.json | 22 +- i18n/locales/pt_br.json | 22 +- i18n/locales/ru.json | 22 +- i18n/locales/vi.json | 22 +- i18n/locales/zh.json | 22 +- i18n/locales/zh_hant.json | 22 +- package.json | 2 +- ui/app.config.ts | 2 +- ui/app.vue | 1 + ui/components/ClientCompatibilityDialog.vue | 61 +++++ ui/components/Main/main.vue | 33 --- ui/components/SideBar/profile.vue | 155 +++-------- ui/composables/useAuthSession.test.ts | 101 ++++++- ui/composables/useAuthSession.ts | 61 +++-- ui/composables/useClientCompatibility.ts | 28 ++ ui/composables/useEventBus.ts | 4 - ui/pages/setting/user.vue | 8 +- ui/store/modules/userInfo.test.ts | 278 +++++++++++++++++++- ui/store/modules/userInfo.ts | 121 ++++++--- ui/types/clientCompatibility.ts | 27 ++ 32 files changed, 1628 insertions(+), 278 deletions(-) create mode 100644 electron/src/auth/compatibility.ts create mode 100644 electron/tests/auth-compatibility.test.ts create mode 100644 electron/tests/client-compatibility.test.ts create mode 100644 ui/components/ClientCompatibilityDialog.vue create mode 100644 ui/composables/useClientCompatibility.ts create mode 100644 ui/types/clientCompatibility.ts diff --git a/electron/README.md b/electron/README.md index 08d92fdd7..fe202978c 100644 --- a/electron/README.md +++ b/electron/README.md @@ -2,6 +2,38 @@ This directory contains the JumpServer desktop runtime. +Desktop connections first check anonymous +`GET /api/v1/settings/client/compatibility/` in the main process. Both +`app.getVersion() >= Core.MIN_CLIENT_VERSION` and +`Core.VERSION >= MIN_CORE_VERSION` must hold. The client's minimum lives in +`src/auth/compatibility.ts` and starts at `5.1.0`. Core represents the whole +server release, including Koko, Chen and Kael. Developers decide whether a +contract change breaks old clients; reviewers check that decision. Compatible +releases leave the minimums unchanged. There is no component version matrix, +automatic updater or automated compatibility gate. + +Release builds already run `scripts/set-version.js` to inject the complete +version into the Electron package. Core and Luna development versions default to +`5.1.0`; release builds still inject their actual versions. Do not reuse an +existing release number for new capabilities. +Comparisons follow SemVer, including patch, prerelease and metadata. A lowercase +`X.Y.Z-lts` stable channel label (also before `+metadata`) equals `X.Y.Z`; +other suffixes remain prereleases. A leading `v` is accepted. + +Login, saved-account restoration and site switching share the same preflight. +Failed or obsolete checks cannot activate a session. Missing endpoints, invalid +responses, timeouts and TLS failures pause the new connection and offer retry. +They never fall back to the legacy version array. Old clients get a button that +opens the selected site's `/core/download/` in the system browser, retaining the +deployment prefix. Old Core versions require an administrator upgrade. + +Run `pnpm test:electron`, `pnpm test:web`, `pnpm lint:check` and `pnpm typecheck`. +For desktop review, use local test servers and release-versioned test builds: +check both minimum boundaries, each single failure and both failures, an old Core +returning 404, retry after a network failure, and a prefixed download URL. Keep A +connected while B fails a check, then rapidly switch B/C and verify only the +latest result can activate. These checks must not access production accounts. + Run the current development shell with: ```sh diff --git a/electron/package.json b/electron/package.json index ea1d53486..7507288cc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,7 +1,7 @@ { "name": "jumpserver-client-electron", "productName": "JumpServer", - "version": "5.0.0", + "version": "5.1.0", "private": true, "description": "Electron runtime for JumpServer", "author": "JumpServer", @@ -11,7 +11,7 @@ "start": "electron-forge start", "package": "electron-forge package", "make": "electron-forge make", - "test": "node --import tsx --test tests/auth-language.test.ts tests/auth-request-site.test.ts tests/application-config.test.ts tests/client-protocol.test.ts tests/debug-log.test.ts tests/executable-path.test.ts tests/face-manager.test.ts tests/face-socket-policy.test.ts tests/ffmpeg-plugin.test.ts tests/forge-artifacts.test.ts tests/menu-command.test.ts tests/oauth-callback.test.ts tests/product-name.test.ts tests/replay-codec.test.ts tests/replay-transcoder-archive.test.ts tests/ssh-helper.test.ts tests/url.test.ts tests/web-proxy.test.ts && go -C ssh-helper test ./...", + "test": "node --import tsx --test tests/auth-language.test.ts tests/auth-request-site.test.ts tests/auth-compatibility.test.ts tests/client-compatibility.test.ts tests/application-config.test.ts tests/client-protocol.test.ts tests/debug-log.test.ts tests/executable-path.test.ts tests/face-manager.test.ts tests/face-socket-policy.test.ts tests/ffmpeg-plugin.test.ts tests/forge-artifacts.test.ts tests/menu-command.test.ts tests/oauth-callback.test.ts tests/product-name.test.ts tests/replay-codec.test.ts tests/replay-transcoder-archive.test.ts tests/ssh-helper.test.ts tests/url.test.ts tests/web-proxy.test.ts && go -C ssh-helper test ./...", "test:web-proxy:interaction": "electron tests/fixtures/web-proxy-interaction-app", "typecheck": "tsc -p tsconfig.json --pretty false", "postinstall": "node ../scripts/fix-electron-permissions.mjs" diff --git a/electron/src/auth/compatibility.ts b/electron/src/auth/compatibility.ts new file mode 100644 index 000000000..921aca6ab --- /dev/null +++ b/electron/src/auth/compatibility.ts @@ -0,0 +1,149 @@ +import type { SiteCompatibility } from "../../../ui/types/clientCompatibility"; +import { parseUrl } from "../shared/url"; + +// Raise only when the client starts depending on a breaking server change. +export const MIN_CORE_VERSION = "5.1.0"; +const COMPATIBILITY_PATH = "/api/v1/settings/client/compatibility/"; + +function parseVersion(value: unknown) { + if (typeof value !== "string" || value.length > 256) return null; + // JumpServer's terminal -lts suffix labels the stable release channel. + // Other suffixes retain their SemVer prerelease meaning (including rc1/beta8). + const normalized = value + .trim() + .replace(/^v/, "") + .replace(/^(\d+\.\d+\.\d+)-lts(?=\+|$)/, "$1"); + const match = normalized.match( + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/ + ); + if (!match) return null; + const prerelease = match[4]?.split(".") || []; + if (prerelease.some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith("0"))) return null; + return { release: match.slice(1, 4).map(BigInt), prerelease }; +} + +export function compareVersions(left: unknown, right: unknown): number | null { + const a = parseVersion(left); + const b = parseVersion(right); + if (!a || !b) return null; + for (let i = 0; i < 3; i += 1) { + if (a.release[i] !== b.release[i]) return a.release[i] > b.release[i] ? 1 : -1; + } + if (!a.prerelease.length || !b.prerelease.length) { + return Number(!a.prerelease.length) - Number(!b.prerelease.length); + } + for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) { + const x = a.prerelease[i]; + const y = b.prerelease[i]; + if (x === y) continue; + if (x === undefined || y === undefined) return x === undefined ? -1 : 1; + const xNumeric = /^\d+$/.test(x); + const yNumeric = /^\d+$/.test(y); + if (xNumeric && yNumeric) return BigInt(x) > BigInt(y) ? 1 : -1; + if (xNumeric !== yNumeric) return xNumeric ? -1 : 1; + return x > y ? 1 : -1; + } + return 0; +} + +export function normalizeCompatibilitySite(value: unknown): string { + if ( + typeof value !== "string" || + !/^https?:\/\//i.test(value) || + Array.from(value).some((character) => character.charCodeAt(0) <= 32 || character === "\\") + ) { + throw new Error("Invalid site URL"); + } + const url = parseUrl(value); + if (!url.hostname || url.username || url.password || /[?#]/.test(url.href)) throw new Error("Invalid site URL"); + return url.toString().replace(/\/+$/, ""); +} + +export function clientDownloadUrl(site: unknown): string { + return `${normalizeCompatibilitySite(site)}/core/download/`; +} + +export async function checkSiteCompatibility( + site: unknown, + clientVersion: string, + fetchResponse: typeof fetch = globalThis.fetch, + timeout = 10_000 +): Promise { + const result: SiteCompatibility = { + status: "unknown", + site: typeof site === "string" ? site : "", + clientVersion, + minCoreVersion: MIN_CORE_VERSION, + failures: [] + }; + try { + result.site = normalizeCompatibilitySite(site); + } catch { + return { ...result, reason: "invalid-site" }; + } + if (!parseVersion(clientVersion)) return { ...result, reason: "invalid-client-version" }; + + let response: Response; + let body: string; + try { + // Node's fetch has no Electron cookies or certificate trust overrides. + // Never use authService.fetchSite here: it permanently trusts entered hosts. + response = await fetchResponse(`${result.site}${COMPATIBILITY_PATH}`, { + headers: { Accept: "application/json", "Cache-Control": "no-cache" }, + credentials: "omit", + redirect: "error", + cache: "no-store", + signal: AbortSignal.timeout(timeout) + }); + if (response.status === 404) return { ...result, reason: "endpoint-missing" }; + if (!response.ok) return { ...result, reason: "http" }; + body = ""; + const reader = response.body?.getReader(); + if (reader) { + const decoder = new TextDecoder(); + let bytes = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > 64 * 1024) { + await reader.cancel(); + return { ...result, reason: "invalid-response" }; + } + body += decoder.decode(chunk.value, { stream: true }); + } + body += decoder.decode(); + } finally { + reader.releaseLock(); + } + } + } catch (error) { + const failure = error as { name?: string; cause?: { code?: string } }; + const reason = ["TimeoutError", "AbortError"].includes(failure?.name || "") + ? "timeout" + : /CERT|TLS|SELF_SIGNED/.test(failure?.cause?.code || "") + ? "tls" + : "network"; + return { ...result, reason }; + } + + try { + const data = JSON.parse(body); + if ( + !data || + data.schema_version !== 1 || + !parseVersion(data.core_version) || + !parseVersion(data.min_client_version) + ) { + throw new Error("Invalid compatibility response"); + } + result.coreVersion = data.core_version; + result.minClientVersion = data.min_client_version; + if (compareVersions(clientVersion, data.min_client_version)! < 0) result.failures.push("client-too-old"); + if (compareVersions(data.core_version, MIN_CORE_VERSION)! < 0) result.failures.push("core-too-old"); + return { ...result, status: result.failures.length ? "incompatible" : "compatible" }; + } catch { + return { ...result, reason: "invalid-response" }; + } +} diff --git a/electron/src/auth/service.ts b/electron/src/auth/service.ts index 105c94f57..cb18f1bbe 100644 --- a/electron/src/auth/service.ts +++ b/electron/src/auth/service.ts @@ -9,6 +9,7 @@ import { CLIENT_AUTH_CALLBACK } from "../shared/client-protocol"; import { electronLog } from "../shared/debug-log"; import { isTrustedCertificateHost, parseUrl, siteHostname } from "../shared/url"; import { isOAuthCallbackUrl, parseOAuthCallback } from "./oauth-callback"; +import { checkSiteCompatibility, normalizeCompatibilitySite } from "./compatibility"; const OAUTH_WELL_KNOWN = "/core/auth/oauth2-provider/.well-known/oauth-authorization-server"; const OAUTH_AUTHORIZE = "/core/auth/oauth2-provider/authorize/"; @@ -18,7 +19,6 @@ const USER_PROFILE = "/api/v1/users/profile/"; const USER_PERMISSIONS = "/api/v1/users/profile/permissions/"; const CURRENT_ORG = "/api/v1/orgs/orgs/current/"; const PUBLIC_SETTINGS = "/api/v1/settings/public/"; -const CLIENT_VERSIONS = "/api/v1/settings/client/versions/"; const DEV_CALLBACK = "http://127.0.0.1:14876/auth/callback"; const DEEP_LINK_CALLBACK = CLIENT_AUTH_CALLBACK; @@ -109,6 +109,8 @@ export class DesktopAuthService { this.redirectUri = DEEP_LINK_CALLBACK; this.tokenFile = path.join(app.getPath("userData"), "oauth-tokens.json"); this.trustedHosts = new Set(); + this.connectionGeneration = 0; + this.pendingConnection = null; } trustSite(site) { @@ -154,13 +156,26 @@ export class DesktopAuthService { await rename(tempFile, this.tokenFile); } - setSession({ sessionKey, origin, bearerToken = "", orgId = "" }) { + setSession({ sessionKey, origin, connectionId, orgId = "" }) { if (!sessionKey || !origin) throw new Error("session key and origin are required"); - const parsed = parseUrl(origin); - if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("site must use HTTP or HTTPS"); + origin = normalizeCompatibilitySite(origin); + const current = this.sessions.get(this.currentSessionKey); + const pending = this.pendingConnection; + const approved = + pending && + pending.generation === this.connectionGeneration && + pending.generation === connectionId && + pending.site === origin && + pending.sessionId === sessionKey; + if (connectionId !== undefined && !approved) return { status: "stale" }; + // A renderer can update the active organization, but cannot approve a new connection. + if (!approved && (current?.sessionKey !== sessionKey || current?.origin !== origin)) return { status: "stale" }; + const bearerToken = approved ? pending.bearer : current.bearerToken; this.currentSessionKey = sessionKey; - this.sessions.set(sessionKey, { sessionKey, origin: origin.replace(/\/+$/, ""), bearerToken, orgId }); - electronLog.info(`auth session ${parsed.origin}`); + this.sessions.set(sessionKey, { sessionKey, origin, bearerToken, orgId }); + if (approved) this.pendingConnection = null; + electronLog.info(`auth session ${parseUrl(origin).origin}`); + return { status: "success" }; } setCurrentOrg(orgId) { @@ -235,6 +250,8 @@ export class DesktopAuthService { } cancelAuth() { + this.connectionGeneration += 1; + this.pendingConnection = null; if (!this.pendingAuth) return; const pending = this.pendingAuth; this.pendingAuth = null; @@ -255,8 +272,31 @@ export class DesktopAuthService { } } - async getVersionMessage() { - return this.requestApiResponse(this.currentSession().origin, CLIENT_VERSIONS, { timeout: 10_000 }); + checkCompatibility({ site }) { + return checkSiteCompatibility(site, app.getVersion()); + } + + async prepareConnection(site) { + this.cancelAuth(); + const generation = this.connectionGeneration; + const compatibility = await this.checkCompatibility({ site }); + if (generation !== this.connectionGeneration) return { status: "stale" }; + if (compatibility.status !== "compatible") return { status: "blocked", compatibility }; + return { status: "ready", generation, site: compatibility.site }; + } + + approveConnection(attempt, sessionId, payload) { + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + if (!payload.profile?.success) return payload; + let profile; + try { + profile = JSON.parse(payload.profile.data); + } catch { + return { ...payload, status: "failure" }; + } + if (!profile || typeof profile.id !== "string" || !profile.id.trim()) return { ...payload, status: "failure" }; + this.pendingConnection = { generation: attempt.generation, site: attempt.site, sessionId, bearer: payload.bearer }; + return { ...payload, connection_id: attempt.generation }; } async syncBackendLanguage({ language }) { @@ -278,8 +318,12 @@ export class DesktopAuthService { } async authLogin({ site, sessionId }) { + const attempt = await this.prepareConnection(site); + if (attempt.status !== "ready") return attempt; + site = attempt.site; electronLog.info(`auth login start ${site}`); await this.startCallbackServer(); + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; let oauthConfig: { client_id?: string }; try { const response = await this.fetchSite(endpoint(site, OAUTH_WELL_KNOWN), { @@ -292,6 +336,7 @@ export class DesktopAuthService { ); oauthConfig = JSON.parse(text); } catch (error) { + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; const message = `Failed to fetch OAuth config: ${error}`; this.emitEvent("login-failed-detected", { status: "failure", @@ -302,6 +347,8 @@ export class DesktopAuthService { throw new Error(message); } + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + const clientId = String(oauthConfig.client_id || ""); if (!clientId) throw new Error("OAuth config did not provide client_id"); const verifier = base64Url(randomBytes(32)); @@ -317,27 +364,39 @@ export class DesktopAuthService { authorizeUrl.searchParams.set("redirect_uri", redirectUri); authorizeUrl.searchParams.set("scope", "write read"); - this.cancelAuth(); const callback = new Promise>((resolve) => { this.pendingAuth = { state, resolve }; }); this.emitEvent("auth_url", authorizeUrl.toString()); const result = await callback; + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; if (!result) return null; if (result.state !== state) throw new Error("OAuth state mismatch"); if (result.error) throw new Error(`OAuth authorization failed: ${result.error}`); - const token = await this.exchangeToken(site, { - grant_type: "authorization_code", - code: result.code, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: verifier - }); - this.tokens[sessionId] = { ...token, client_id: clientId }; - await this.persistTokens(); - electronLog.info(`auth login success ${site}`); - return this.buildLoginPayload(site, token.access_token); + try { + const token = await this.exchangeToken(site, { + grant_type: "authorization_code", + code: result.code, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: verifier + }); + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + const payload = await this.buildLoginPayload(site, token.access_token); + const approved = this.approveConnection(attempt, sessionId, payload); + if (!approved.connection_id) { + return approved.status === "success" ? { ...approved, status: "failure" } : approved; + } + this.tokens[sessionId] = { ...token, client_id: clientId }; + await this.persistTokens(); + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + electronLog.info(`auth login success ${site}`); + return approved; + } catch (error) { + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + throw error; + } } async exchangeToken(site, parameters) { @@ -418,8 +477,18 @@ export class DesktopAuthService { } async bootstrapAuthSession({ site, sessionId }) { - const bearer = await this.freshToken(site, sessionId); - return this.buildLoginPayload(site, bearer); + const attempt = await this.prepareConnection(site); + if (attempt.status !== "ready") return attempt; + site = attempt.site; + try { + const bearer = await this.freshToken(site, sessionId); + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + const payload = await this.buildLoginPayload(site, bearer); + return this.approveConnection(attempt, sessionId, payload); + } catch (error) { + if (attempt.generation !== this.connectionGeneration) return { status: "stale" }; + throw error; + } } async buildLoginPayload(site, bearer) { diff --git a/electron/src/desktop/main.ts b/electron/src/desktop/main.ts index 3fd2dbc29..d52b93dc9 100644 --- a/electron/src/desktop/main.ts +++ b/electron/src/desktop/main.ts @@ -27,6 +27,7 @@ import { ApplicationConfigService } from "../apps/application-config"; import { LocalApplicationLauncher } from "../apps/local-app-launcher"; import { listSystemFonts } from "../apps/system-fonts"; import { DesktopAuthService } from "../auth/service"; +import { clientDownloadUrl } from "../auth/compatibility"; import { isOAuthCallbackUrl } from "../auth/oauth-callback"; import { FaceEngineManager } from "../face/manager"; import { isFaceLiveWebSocket } from "../face/socket-policy"; @@ -1203,7 +1204,7 @@ async function handleInvoke(event, request) { if (command === "set_api_session") return authService.setSession(args); if (command === "set_api_org") return authService.setCurrentOrg(args.orgId); - if (command === "get_version_message") return authService.getVersionMessage(); + if (command === "open_client_download") return shell.openExternal(clientDownloadUrl(args.site)); if (command === "sync_backend_language") return authService.syncBackendLanguage(args); if (command === "init_http_callback_server") return authService.startCallbackServer(); if (command === "auth_login") return withIpcErrorLog("auth_login", () => authService.authLogin(args)); diff --git a/electron/tests/auth-compatibility.test.ts b/electron/tests/auth-compatibility.test.ts new file mode 100644 index 000000000..b6122cdd8 --- /dev/null +++ b/electron/tests/auth-compatibility.test.ts @@ -0,0 +1,268 @@ +import assert from "node:assert/strict"; +import { createHash, randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import path from "node:path"; +import test from "node:test"; +import { runInNewContext } from "node:vm"; +import type { SiteCompatibility } from "../../ui/types/clientCompatibility.ts"; +import { checkSiteCompatibility, normalizeCompatibilitySite } from "../src/auth/compatibility.ts"; +import { isOAuthCallbackUrl, parseOAuthCallback } from "../src/auth/oauth-callback.ts"; +import { isTrustedCertificateHost, parseUrl, siteHostname } from "../src/shared/url.ts"; + +// Run the real auth service with Electron's app/net/storage adapters replaced. +// This follows the existing auth request tests and never reads real accounts. +const source = readFileSync(new URL("../src/auth/service.ts", import.meta.url), "utf8"); +const body = source.slice(source.indexOf("const OAUTH_WELL_KNOWN")); +const script = stripTypeScriptTypes( + `${body.replace("export class DesktopAuthService", "class DesktopAuthService")}\nnew DesktopAuthService(emitEvent)` +); +const siteA = { sessionKey: "a", origin: "https://a.test", bearerToken: "token-a", orgId: "org-a" }; +const payload = (site = "https://b.test") => ({ + status: "success", + bearer: "token-b", + resolved_site: site, + profile: { status: 200, success: true, data: '{"id":"user-b","name":"B"}' }, + current_org: { status: 403, success: false, data: "{}" }, + permission_orgs: { status: 403, success: false, data: "{}" } +}); +const compatible = (site: string): SiteCompatibility => ({ + status: "compatible", + site, + clientVersion: "5.1.0", + minCoreVersion: "5.1.0", + coreVersion: "5.1.0", + minClientVersion: "5.1.0", + failures: [] +}); + +function setup(preflight: (site: string) => Promise = async (site) => compatible(site)) { + const calls: string[] = []; + const events: [string, unknown][] = []; + const auth = runInNewContext(script, { + app: { getPath: () => "/tmp/compatibility-test", getVersion: () => "5.1.0" }, + path, + parseUrl, + siteHostname, + isTrustedCertificateHost, + isOAuthCallbackUrl, + parseOAuthCallback, + normalizeCompatibilitySite, + checkSiteCompatibility, + createHash, + randomBytes, + CLIENT_AUTH_CALLBACK: "jms2://auth/callback", + AbortSignal, + URLSearchParams, + Buffer, + electronLog: { info() {}, warn() {} }, + compactApiErrorBody: (body: string) => body, + emitEvent: (event: string, data: unknown) => { + events.push([event, data]); + if (event === "auth_url") { + const state = new URL(data as string).searchParams.get("state"); + auth.handleCallback(`jms2://auth/callback?code=test&state=${state}`); + } + } + }); + auth.sessions.set("a", { ...siteA }); + auth.currentSessionKey = "a"; + auth.tokens = { a: { access_token: "token-a", refresh_token: "refresh-a" } }; + auth.trustedHosts.add("a.test"); + auth.checkCompatibility = ({ site }: { site: string }) => { + calls.push(`check:${site}`); + return preflight(site); + }; + auth.startCallbackServer = async () => { + calls.push("callback-server"); + }; + auth.fetchSite = async () => { + calls.push("oauth-config"); + return new Response('{"client_id":"test-client"}'); + }; + auth.exchangeToken = async () => { + calls.push("token-exchange"); + return { access_token: "token-b" }; + }; + auth.freshToken = async () => { + calls.push("fresh-token"); + return "token-b"; + }; + auth.buildLoginPayload = async (site: string) => { + calls.push("profile"); + return payload(site); + }; + auth.persistTokens = async () => { + calls.push("persist"); + }; + return { auth, calls, events }; +} + +function snapshot(auth: any) { + return JSON.stringify({ + key: auth.currentSessionKey, + sessions: [...auth.sessions], + tokens: auth.tokens, + trustedHosts: [...auth.trustedHosts] + }); +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + +test("both login and restoration stop before any authentication side effect on a failed preflight", async () => { + for (const status of ["incompatible", "unknown"] as const) { + for (const method of ["authLogin", "bootstrapAuthSession"]) { + const { auth, calls, events } = setup(async (site) => ({ + ...compatible(site), + status, + failures: status === "incompatible" ? ["client-too-old"] : [], + reason: status === "unknown" ? "endpoint-missing" : undefined + })); + const before = snapshot(auth); + const result = await auth[method]({ + site: "https://b.test", + sessionId: "b", + clientVersion: "999.0.0", + compatible: true + }); + assert.equal(result.status, "blocked"); + assert.equal(result.compatibility.status, status); + assert.equal(snapshot(auth), before); + assert.deepEqual(calls, ["check:https://b.test"]); + assert.deepEqual(events, []); + } + } +}); + +test("the runtime version comes from Electron and the probe never calls fetchSite or trusts a new certificate", async () => { + const { auth } = setup(); + const before = snapshot(auth); + const result = await auth.checkCompatibility({ site: "https://b.test", clientVersion: "999.0.0" }); + assert.equal(result.clientVersion, "5.1.0"); + assert.equal(snapshot(auth), before); + // Exercise the actual method with a local fetch adapter instead of the setup probe. + const actualScript = stripTypeScriptTypes( + `${body.replace("export class DesktopAuthService", "class DesktopAuthService")}\nDesktopAuthService.prototype.checkCompatibility` + ); + let observedVersion = ""; + const method = runInNewContext(actualScript, { + app: { getVersion: () => "5.1.2-rc1" }, + CLIENT_AUTH_CALLBACK: "callback", + checkSiteCompatibility: async (_site: string, version: string) => { + observedVersion = version; + return compatible("https://b.test"); + } + }); + await method.call(auth, { site: "https://b.test", clientVersion: "999.0.0" }); + assert.equal(observedVersion, "5.1.2-rc1"); + assert.equal(snapshot(auth), before); +}); + +test("login authenticates after checking and activates only with the main process approval", async () => { + const { auth, calls } = setup(); + const result = await auth.authLogin({ site: "https://b.test", sessionId: "b" }); + assert.deepEqual(calls, [ + "check:https://b.test", + "callback-server", + "oauth-config", + "token-exchange", + "profile", + "persist" + ]); + assert.equal(result.status, "success"); + assert.equal(auth.currentSessionKey, "a"); + assert.deepEqual(auth.currentSession(), siteA); + const args = { sessionKey: "b", origin: "https://b.test", orgId: "org-b", bearerToken: "renderer-forged-token" }; + assert.equal(auth.setSession({ ...args, compatible: true }).status, "stale"); + assert.equal(auth.setSession({ ...args, connectionId: result.connection_id + 1 }).status, "stale"); + assert.equal( + auth.setSession({ ...args, origin: "https://other.test", connectionId: result.connection_id }).status, + "stale" + ); + assert.equal(auth.setSession({ ...args, connectionId: result.connection_id }).status, "success"); + assert.equal(auth.currentSession().bearerToken, "token-b"); + assert.equal(auth.currentSession().orgId, "org-b"); +}); + +test("restoration checks before refreshing credentials and organization 403 does not invalidate authentication", async () => { + const { auth, calls } = setup(); + const result = await auth.bootstrapAuthSession({ site: "https://b.test", sessionId: "b" }); + assert.deepEqual(calls, ["check:https://b.test", "fresh-token", "profile"]); + assert.equal(result.status, "success"); + assert.ok(result.connection_id); + assert.equal(auth.currentSessionKey, "a"); +}); + +test("a failed or malformed profile cannot authorize a session", async () => { + for (const profile of [ + { status: 401, success: false, data: "{}" }, + { status: 503, success: false, data: "{}" }, + { status: 0, success: false, data: "request failed" }, + { status: 200, success: true, data: "bad" }, + { status: 200, success: true, data: "{}" } + ]) { + for (const method of ["authLogin", "bootstrapAuthSession"]) { + const { auth } = setup(); + const before = snapshot(auth); + auth.buildLoginPayload = async () => ({ ...payload(), profile }); + const result = await auth[method]({ site: "https://b.test", sessionId: "b" }); + // Login success allows the renderer to close A's workspace; bootstrap keeps + // transport failures available for its existing network/server retry logic. + if (method === "authLogin") assert.equal(result.status, "failure"); + else if (!profile.success) assert.equal(result.status, "success"); + assert.equal(result.profile.status, profile.status); + assert.equal(result.connection_id, undefined); + assert.equal(auth.pendingConnection, null); + assert.equal(snapshot(auth), before); + assert.equal( + auth.setSession({ sessionKey: "b", origin: "https://b.test", connectionId: auth.connectionGeneration }).status, + "stale" + ); + } + } +}); + +test("rapid site changes ignore old preflight results and cannot reuse old approvals", async () => { + const slow = deferred(); + const { auth, calls } = setup((site) => + site === "https://b.test" ? slow.promise : Promise.resolve(compatible(site)) + ); + const first = auth.bootstrapAuthSession({ site: "https://b.test", sessionId: "b" }); + const second = await auth.bootstrapAuthSession({ site: "https://c.test", sessionId: "c" }); + slow.resolve(compatible("https://b.test")); + assert.equal((await first).status, "stale"); + assert.deepEqual(calls, ["check:https://b.test", "check:https://c.test", "fresh-token", "profile"]); + assert.equal(auth.pendingConnection.site, "https://c.test"); + assert.equal( + auth.setSession({ sessionKey: "c", origin: "https://c.test", connectionId: second.connection_id }).status, + "success" + ); + const third = await auth.bootstrapAuthSession({ site: "https://b.test", sessionId: "b" }); + auth.cancelAuth(); + assert.equal( + auth.setSession({ sessionKey: "b", origin: "https://b.test", connectionId: third.connection_id }).status, + "stale" + ); + assert.equal(auth.currentSessionKey, "c"); +}); + +test("an obsolete authentication error cannot change a newer connection", async () => { + const token = deferred(); + const { auth } = setup(); + auth.freshToken = async (_site: string, id: string) => (id === "b" ? token.promise : "token-c"); + const first = auth.bootstrapAuthSession({ site: "https://b.test", sessionId: "b" }); + await new Promise((resolve) => setImmediate(resolve)); + const second = await auth.bootstrapAuthSession({ site: "https://c.test", sessionId: "c" }); + token.reject(new Error("old token error")); + assert.equal((await first).status, "stale"); + assert.equal(auth.pendingConnection.generation, second.connection_id); + assert.equal(auth.currentSessionKey, "a"); +}); diff --git a/electron/tests/client-compatibility.test.ts b/electron/tests/client-compatibility.test.ts new file mode 100644 index 000000000..2b9c72e1f --- /dev/null +++ b/electron/tests/client-compatibility.test.ts @@ -0,0 +1,209 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { once } from "node:events"; +import test from "node:test"; +import { + checkSiteCompatibility, + clientDownloadUrl, + compareVersions, + MIN_CORE_VERSION +} from "../src/auth/compatibility.ts"; + +test("compares patch versions, release candidates, v prefixes, metadata and the stable LTS channel", () => { + for (const [a, b, expected] of [ + ["5.1.0", "5.1.0", 0], + ["5.1.1", "5.1.0", 1], + ["5.1.0", "5.1.1", -1], + ["5.10.0", "5.2.0", 1], + ["v5.1.0", "5.1.0", 0], + ["5.1.0-rc1", "5.1.0", -1], + ["5.1.0-lts", "5.1.0", 0], + ["v5.1.0-lts+build.1", "5.1.0", 0], + ["5.1.0+build.9", "5.1.0+build.1", 0], + ["5.1.0-beta-lts", "5.1.0", -1], + ["5.1.0-1", "5.1.0-alpha", -1], + ["5.1.0-beta.11", "5.1.0-beta.2", 1], + ["9007199254740993.0.0", "9007199254740992.0.0", 1] + ] as const) { + assert.equal(compareVersions(a, b), expected, `${a} vs ${b}`); + assert.equal(compareVersions(b, a), expected === 0 ? 0 : -expected); + } + const ordered = ["alpha", "alpha.1", "alpha.beta", "beta", "beta.2", "beta.11", "rc.1"]; + for (let i = 1; i < ordered.length; i += 1) + assert.equal(compareVersions(`5.1.0-${ordered[i - 1]}`, `5.1.0-${ordered[i]}`), -1); + for (const invalid of [ + "", + "dev", + "5.1", + "5.1.x", + "05.1.0", + "5.1.0-01", + "5.1.0-beta.01", + "5.1.0_rc1", + "5.1.0.1", + 510, + null + ]) { + assert.equal(compareVersions(invalid, "5.1.0"), null, String(invalid)); + } +}); + +const metadata = (core = "5.1.0", minimum = "5.1.0") => ({ + schema_version: 1, + core_version: core, + min_client_version: minimum +}); +const fetchJson = + (data: unknown, status = 200): typeof fetch => + async () => + new Response(JSON.stringify(data), { status }); + +test("requires both minimums and reports simultaneous failures", async () => { + for (const [client, core, minimum, failures] of [ + ["5.1.0", "5.1.0", "5.1.0", []], + ["5.1.1", "5.2.0", "5.1.0", []], + ["5.1.0-rc1", "5.1.0", "5.1.0", ["client-too-old"]], + ["5.1.0", "5.1.0-rc1", "5.1.0", ["core-too-old"]], + ["5.1.0", "5.1.0", "5.1.1", ["client-too-old"]], + ["5.0.9", "5.0.9", "5.1.0", ["client-too-old", "core-too-old"]], + ["v5.1.0-lts", "v5.1.0-lts", "5.1.0", []] + ] as const) { + const result = await checkSiteCompatibility( + "https://site.test/prefix/", + client, + fetchJson(metadata(core, minimum)) + ); + assert.equal(result.status, failures.length ? "incompatible" : "compatible"); + assert.deepEqual(result.failures, failures); + assert.equal(result.minCoreVersion, MIN_CORE_VERSION); + } +}); + +test("missing, unsupported, malformed and unsuccessful responses remain unknown", async () => { + for (const data of [ + null, + [], + ["5.1.0"], + {}, + { ...metadata(), schema_version: "1" }, + { ...metadata(), schema_version: 2 }, + { ...metadata(), core_version: "dev" }, + { ...metadata(), min_client_version: 510 }, + { ...metadata(), min_client_version: "5.1" } + ]) { + const result = await checkSiteCompatibility("https://site.test", "5.1.0", fetchJson(data)); + assert.equal(result.status, "unknown"); + assert.equal(result.reason, "invalid-response"); + assert.deepEqual(result.failures, []); + } + for (const [status, reason] of [ + [404, "endpoint-missing"], + [401, "http"], + [403, "http"], + [500, "http"] + ] as const) { + const result = await checkSiteCompatibility("https://site.test", "5.1.0", fetchJson({}, status)); + assert.equal(result.status, "unknown"); + assert.equal(result.reason, reason); + } + for (const body of ["login", "{", JSON.stringify({ ...metadata(), padding: "x".repeat(65 * 1024) })]) { + assert.equal( + (await checkSiteCompatibility("https://site.test", "5.1.0", async () => new Response(body))).reason, + "invalid-response" + ); + } +}); + +test("timeouts, TLS and network errors are unknown and do not fall back to the old array", async () => { + for (const [error, reason] of [ + [new DOMException("timeout", "TimeoutError"), "timeout"], + [new TypeError("fetch failed", { cause: { code: "DEPTH_ZERO_SELF_SIGNED_CERT" } }), "tls"], + [new Error("unreachable"), "network"] + ] as const) { + let requests = 0; + const result = await checkSiteCompatibility("https://site.test", "5.1.0", async () => { + requests += 1; + throw error; + }); + assert.equal(result.status, "unknown"); + assert.equal(result.reason, reason); + assert.equal(requests, 1); + } +}); + +test("validates site and download URLs and preserves a legal deployment prefix", async () => { + for (const [site, expected] of [ + ["https://private.test", "https://private.test/core/download/"], + ["https://private.test/team/jumpserver/", "https://private.test/team/jumpserver/core/download/"], + ["http://127.0.0.1:8080/prefix", "http://127.0.0.1:8080/prefix/core/download/"], + ["https://[::1]:8080/prefix/", "https://[::1]:8080/prefix/core/download/"] + ]) + assert.equal(clientDownloadUrl(site), expected); + for (const invalid of [ + "https://user:secret@site.test", + "https://site.test/?x=1", + "https://site.test/?", + "https://site.test/#", + "javascript:alert(1)", + "file:///tmp", + "//site.test", + "https://site.test\\@evil.test", + "https://site.test\n", + undefined + ]) { + assert.throws(() => clientDownloadUrl(invalid)); + const result = await checkSiteCompatibility(invalid, "5.1.0", async () => { + throw new Error("must not fetch"); + }); + assert.equal(result.reason, "invalid-site"); + } +}); + +test("uses an anonymous, uncached request to the exact private-site endpoint", async () => { + const result = await checkSiteCompatibility("https://private.test/team/", "5.1.0", async (url, init) => { + assert.equal(url, "https://private.test/team/api/v1/settings/client/compatibility/"); + assert.equal(init?.credentials, "omit"); + assert.equal(init?.cache, "no-store"); + assert.equal(init?.redirect, "error"); + assert.ok(init?.signal instanceof AbortSignal); + assert.deepEqual(Object.keys(init.headers).sort(), ["Accept", "Cache-Control"]); + return new Response(JSON.stringify(metadata())); + }); + assert.equal(result.status, "compatible"); +}); + +test("loopback integration: timeout, redirect, legacy Core and updated minimums", async () => { + const requests: string[] = []; + const server = createServer((request, response) => { + requests.push(request.url!); + assert.equal(request.headers.authorization, undefined); + assert.equal(request.headers.cookie, undefined); + assert.equal(request.headers["x-jms-org"], undefined); + if (request.url?.startsWith("/timeout/")) return; + if (request.url?.startsWith("/redirect/")) { + response.writeHead(302, { Location: "/old/" }).end(); + return; + } + if (request.url?.startsWith("/old/")) { + response.writeHead(404).end(); + return; + } + const minimum = request.url?.startsWith("/new/") ? "5.1.1" : "5.1.0"; + response.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store" }); + response.end(JSON.stringify(metadata("5.1.0", minimum))); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const site = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + assert.equal((await checkSiteCompatibility(site, "5.1.0")).status, "compatible"); + assert.deepEqual((await checkSiteCompatibility(`${site}/new`, "5.1.0")).failures, ["client-too-old"]); + assert.equal((await checkSiteCompatibility(`${site}/old`, "5.1.0")).reason, "endpoint-missing"); + assert.equal((await checkSiteCompatibility(`${site}/timeout`, "5.1.0", fetch, 30)).reason, "timeout"); + assert.equal((await checkSiteCompatibility(`${site}/redirect`, "5.1.0")).status, "unknown"); + assert.ok(requests.every((url) => url.endsWith("/api/v1/settings/client/compatibility/"))); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); diff --git a/electron/tests/client-protocol.test.ts b/electron/tests/client-protocol.test.ts index 466ce83f9..938235080 100644 --- a/electron/tests/client-protocol.test.ts +++ b/electron/tests/client-protocol.test.ts @@ -30,6 +30,8 @@ function loadTestDependency(require: NodeJS.Require, name: string) { return require("../shared/url"); case "./oauth-callback": return require("./oauth-callback"); + case "./compatibility": + return require("./compatibility"); case "./shared/product-name": return require("./shared/product-name"); case "node:child_process": @@ -279,7 +281,7 @@ test("protocol events arriving while the desktop loads are forwarded after initi }); function authServiceFixture(isPackaged = true, createServer?: () => EventEmitter) { - const app = { getPath: () => "/unused", isPackaged }; + const app = { getPath: () => "/unused", getVersion: () => "5.1.0", isPackaged }; const { DesktopAuthService } = loadWithElectronMocks("../src/auth/service.ts", { electron: { app, net: {}, safeStorage: {} }, ...(createServer ? { "node:http": { createServer } } : {}) @@ -292,15 +294,31 @@ function authServiceFixture(isPackaged = true, createServer?: () => EventEmitter resolve: (url: string) => resolveAuthorization(url) }; const service = new DesktopAuthService((_event: string, url: string) => authorized.resolve(url)); + service.checkCompatibility = async ({ site }: { site: string }) => ({ + status: "compatible", + site, + clientVersion: "5.1.0", + minCoreVersion: "5.1.0", + failures: [] + }); service.fetchSite = async () => ({ ok: true, text: async () => JSON.stringify({ client_id: "desktop" }) }); service.persistTokens = async () => {}; - service.buildLoginPayload = async () => ({ status: "success" }); + service.buildLoginPayload = async () => ({ + status: "success", + bearer: "test-token", + profile: { success: true, data: '{"id":"test-user"}' } + }); return { service, authorized }; } test("desktop API returns explicit text responses without JSON parsing", async () => { const { service } = authServiceFixture(); - service.setSession({ sessionKey: "session", origin: "https://jumpserver.example", bearerToken: "token" }); + service.sessions.set("session", { + sessionKey: "session", + origin: "https://jumpserver.example", + bearerToken: "token" + }); + service.currentSessionKey = "session"; service.fetchSite = async () => ({ status: 200, text: async () => "full address:s:rdp.example\r\n" }); assert.equal( @@ -376,7 +394,7 @@ test("cancelling a login rejects its later callback", async () => { const login = service.authLogin({ site: "https://site.example", sessionId: "account" }); const state = new URL(await authorized.promise).searchParams.get("state"); service.cancelAuth(); - assert.equal(await login, null); + assert.equal((await login).status, "stale"); assert.equal(service.handleCallback(`${CLIENT_AUTH_CALLBACK}?code=cancelled&state=${state}`), false); }); diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 4c52f7c1b..94f7967e9 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "Login failed: certificate invalid or blocked by system security (macOS WebView). Install a valid certificate or open in system browser.", "InvalidCertificateGeneric": "Login failed: certificate or secure connection issue.", "NetworkError": "Network unavailable. Please check connectivity.", - "ServerError": "The server is temporarily unavailable. Please try again later.", - "VersionIncompatible": "The client version is incompatible with JumpServer versions earlier than v4.10.12-lts. Please download and use the v3.0.7 client.", - "VersionNoMatch": "The client version does not match the JumpServer version. Please download and use the v{version} client, or upgrade JumpServer to the latest version." + "ServerError": "The server is temporarily unavailable. Please try again later." }, "Asset": { "GetAssetFailed": "Get asset failed, please refresh and try again" @@ -2609,5 +2607,23 @@ "CopyOperationFailedDescription": "The selected data could not be copied.", "TableWithoutPrimaryKeyNotEditable": "This table has no primary key, so adding, updating, or deleting data is not supported yet.", "ClickHouseDataViewReadOnly": "ClickHouse data views are currently read-only. Adding, updating, and deleting data is not supported; queries, filters, and exports are still available." + }, + "ClientCompatibility": { + "UnknownTitle": "Compatibility check unavailable", + "IncompatibleTitle": "Versions are incompatible", + "ConnectionPaused": "This connection is paused. Retry after resolving the issue.", + "client-too-old": "Client {client} is too old. This site requires {minClient} or later.", + "core-too-old": "Core {core} is too old. Contact your administrator to upgrade to {minCore} or later.", + "endpoint-missing": "This Core does not provide the compatibility endpoint. Contact your administrator; compatibility cannot be determined.", + "timeout": "The compatibility request timed out. Check your connection and retry.", + "tls": "The site certificate could not be verified. Contact your administrator to fix HTTPS, then retry.", + "network": "The site could not be reached. Check your connection and retry.", + "http": "The site could not return compatibility information. Retry or contact your administrator.", + "invalid-response": "The site returned invalid compatibility information. Retry or contact your administrator.", + "invalid-site": "The site URL is invalid. Enter an HTTP or HTTPS URL without credentials, a query, or a fragment.", + "invalid-client-version": "The running client version is invalid. Use a client built with its release version.", + "Download": "Download compatible client", + "DownloadFailed": "Could not open the download page.", + "Retry": "Retry" } } diff --git a/i18n/locales/es.json b/i18n/locales/es.json index b05b41a0f..00740b839 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "Error de inicio de sesión: certificado no válido o bloqueado por la seguridad del sistema (WebView de macOS). Instale un certificado válido o abra el sitio en el navegador del sistema.", "InvalidCertificateGeneric": "Error de inicio de sesión: problema con el certificado o la conexión segura.", "NetworkError": "La red no está disponible. Compruebe la conectividad.", - "ServerError": "El servidor no está disponible temporalmente. Inténtelo de nuevo más tarde.", - "VersionIncompatible": "La versión del cliente no es compatible con versiones de JumpServer anteriores a v4.10.12-lts. Descargue y use el cliente v3.0.7.", - "VersionNoMatch": "La versión del cliente no coincide con la de JumpServer. Descargue y use el cliente v{version}, o actualice JumpServer a la última versión." + "ServerError": "El servidor no está disponible temporalmente. Inténtelo de nuevo más tarde." }, "Asset": { "GetAssetFailed": "Error al obtener el activo. Actualice e inténtelo de nuevo" @@ -2609,5 +2607,23 @@ "Description": "Lo sentimos, no hemos encontrado la página que buscas.", "RequestedPath": "Ruta solicitada", "GoHome": "Ir al inicio" + }, + "ClientCompatibility": { + "UnknownTitle": "Comprobación de compatibilidad no disponible", + "IncompatibleTitle": "Versiones incompatibles", + "ConnectionPaused": "Esta conexión está pausada. Reintente después de resolver el problema.", + "client-too-old": "El cliente {client} es demasiado antiguo. Este sitio requiere {minClient} o posterior.", + "core-too-old": "Core {core} es demasiado antiguo. Contacte con su administrador para actualizar a {minCore} o posterior.", + "endpoint-missing": "Este Core no ofrece el endpoint de compatibilidad. Contacte con su administrador; no se puede determinar la compatibilidad.", + "timeout": "La comprobación agotó el tiempo de espera. Revise la conexión y reintente.", + "tls": "No se pudo verificar el certificado del sitio. Contacte con su administrador para corregir HTTPS y reintente.", + "network": "No se pudo acceder al sitio. Revise la conexión y reintente.", + "http": "El sitio no pudo devolver información de compatibilidad. Reintente o contacte con su administrador.", + "invalid-response": "El sitio devolvió información de compatibilidad inválida. Reintente o contacte con su administrador.", + "invalid-site": "URL del sitio inválida. Introduzca una URL HTTP o HTTPS sin credenciales, consulta ni fragmento.", + "invalid-client-version": "La versión del cliente es inválida. Use un cliente compilado con su versión de publicación.", + "Download": "Descargar cliente compatible", + "DownloadFailed": "No se pudo abrir la página de descarga.", + "Retry": "Reintentar" } } diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index f8840a477..4496c3447 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "Échec de la connexion : certificat invalide ou bloqué par la sécurité du système (WebView macOS). Installez un certificat valide ou ouvrez le site dans le navigateur système.", "InvalidCertificateGeneric": "Échec de la connexion : problème de certificat ou de connexion sécurisée.", "NetworkError": "Réseau indisponible. Vérifiez votre connexion.", - "ServerError": "Le serveur est temporairement indisponible. Réessayez plus tard.", - "VersionIncompatible": "Cette version du client est incompatible avec les versions de JumpServer antérieures à v4.10.12-lts. Téléchargez et utilisez le client v3.0.7.", - "VersionNoMatch": "La version du client ne correspond pas à celle de JumpServer. Téléchargez et utilisez le client v{version} ou mettez JumpServer à jour vers la dernière version." + "ServerError": "Le serveur est temporairement indisponible. Réessayez plus tard." }, "Asset": { "GetAssetFailed": "Impossible de récupérer l’actif. Actualisez et réessayez" @@ -2609,5 +2607,23 @@ "CopyOperationFailedDescription": "Impossible de copier les données sélectionnées.", "TableWithoutPrimaryKeyNotEditable": "Cette table n’a pas de clé primaire ; l’ajout, la modification et la suppression de données ne sont donc pas encore pris en charge.", "ClickHouseDataViewReadOnly": "Les vues de données ClickHouse sont actuellement en lecture seule. L’ajout, la modification et la suppression de données ne sont pas pris en charge ; les requêtes, les filtres et les exports restent disponibles." + }, + "ClientCompatibility": { + "UnknownTitle": "Vérification de compatibilité indisponible", + "IncompatibleTitle": "Versions incompatibles", + "ConnectionPaused": "Cette connexion est suspendue. Réessayez après avoir résolu le problème.", + "client-too-old": "Le client {client} est trop ancien. Ce site exige {minClient} ou une version ultérieure.", + "core-too-old": "Core {core} est trop ancien. Demandez à votre administrateur de passer à {minCore} ou une version ultérieure.", + "endpoint-missing": "Ce Core ne fournit pas le point de terminaison de compatibilité. Contactez votre administrateur ; la compatibilité ne peut pas être déterminée.", + "timeout": "La vérification a expiré. Vérifiez votre connexion et réessayez.", + "tls": "Le certificat du site ne peut pas être vérifié. Demandez à votre administrateur de corriger HTTPS, puis réessayez.", + "network": "Le site est inaccessible. Vérifiez votre connexion et réessayez.", + "http": "Le site ne peut pas fournir les informations de compatibilité. Réessayez ou contactez votre administrateur.", + "invalid-response": "Les informations de compatibilité sont invalides. Réessayez ou contactez votre administrateur.", + "invalid-site": "URL du site invalide. Saisissez une URL HTTP ou HTTPS sans identifiants, paramètres ni fragment.", + "invalid-client-version": "La version du client est invalide. Utilisez un client compilé avec sa version de publication.", + "Download": "Télécharger un client compatible", + "DownloadFailed": "Impossible d’ouvrir la page de téléchargement.", + "Retry": "Réessayer" } } diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index 1786d62e9..e30ac30f8 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "ログインに失敗しました。証明書が無効か、システムセキュリティによってブロックされています(macOS WebView)。有効な証明書をインストールするか、システムブラウザーで開いてください。", "InvalidCertificateGeneric": "ログインに失敗しました。証明書または安全な接続に問題があります。", "NetworkError": "ネットワークを利用できません。接続を確認してください。", - "ServerError": "サーバーは一時的に利用できません。後でもう一度お試しください。", - "VersionIncompatible": "クライアントのバージョンは v4.10.12-lts より前の JumpServer と互換性がありません。v3.0.7 クライアントをダウンロードして使用してください。", - "VersionNoMatch": "クライアントのバージョンが JumpServer のバージョンと一致しません。v{version} クライアントをダウンロードして使用するか、JumpServer を最新バージョンに更新してください。" + "ServerError": "サーバーは一時的に利用できません。後でもう一度お試しください。" }, "Asset": { "GetAssetFailed": "アセットの取得に失敗しました。更新して再試行してください" @@ -2609,5 +2607,23 @@ "Description": "お探しのページが見つかりませんでした。", "RequestedPath": "リクエストされたパス", "GoHome": "ホームへ" + }, + "ClientCompatibility": { + "UnknownTitle": "互換性を確認できません", + "IncompatibleTitle": "バージョンに互換性がありません", + "ConnectionPaused": "この接続は一時停止されています。問題を解決してから再試行してください。", + "client-too-old": "クライアント {client} は古すぎます。このサイトには {minClient} 以降が必要です。", + "core-too-old": "Core {core} は古すぎます。管理者に {minCore} 以降への更新を依頼してください。", + "endpoint-missing": "この Core は互換性確認用のエンドポイントを提供していません。互換性を判断できないため、管理者に連絡してください。", + "timeout": "互換性の確認がタイムアウトしました。接続を確認して再試行してください。", + "tls": "サイトの証明書を検証できません。管理者に HTTPS の修正を依頼してから再試行してください。", + "network": "サイトに接続できません。接続を確認して再試行してください。", + "http": "サイトから互換性情報を取得できません。再試行するか管理者に連絡してください。", + "invalid-response": "サイトの互換性情報が無効です。再試行するか管理者に連絡してください。", + "invalid-site": "サイト URL が無効です。認証情報、クエリ、フラグメントを含まない HTTP または HTTPS URL を入力してください。", + "invalid-client-version": "実行中のクライアントのバージョンが無効です。リリースバージョンを注入してビルドしたクライアントを使用してください。", + "Download": "互換クライアントをダウンロード", + "DownloadFailed": "ダウンロードページを開けませんでした。", + "Retry": "再試行" } } diff --git a/i18n/locales/ko.json b/i18n/locales/ko.json index 7c9d99c4d..8c446993c 100644 --- a/i18n/locales/ko.json +++ b/i18n/locales/ko.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "로그인 실패: 인증서가 유효하지 않거나 시스템 보안에 의해 차단되었습니다(macOS WebView). 유효한 인증서를 설치하거나 시스템 브라우저에서 여세요.", "InvalidCertificateGeneric": "로그인 실패: 인증서 또는 보안 연결에 문제가 있습니다.", "NetworkError": "네트워크를 사용할 수 없습니다. 연결 상태를 확인하세요.", - "ServerError": "서버를 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요.", - "VersionIncompatible": "클라이언트 버전이 v4.10.12-lts 이전 JumpServer 버전과 호환되지 않습니다. v3.0.7 클라이언트를 다운로드하여 사용하세요.", - "VersionNoMatch": "클라이언트 버전이 JumpServer 버전과 일치하지 않습니다. v{version} 클라이언트를 다운로드하여 사용하거나 JumpServer를 최신 버전으로 업그레이드하세요." + "ServerError": "서버를 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요." }, "Asset": { "GetAssetFailed": "자산을 가져오지 못했습니다. 새로 고침 후 다시 시도하세요" @@ -2609,5 +2607,23 @@ "Description": "죄송합니다. 요청하신 페이지를 찾을 수 없습니다.", "RequestedPath": "요청 경로", "GoHome": "홈으로" + }, + "ClientCompatibility": { + "UnknownTitle": "호환성을 확인할 수 없음", + "IncompatibleTitle": "호환되지 않는 버전", + "ConnectionPaused": "이 연결은 일시 중지되었습니다. 문제를 해결한 후 다시 시도하세요.", + "client-too-old": "클라이언트 {client} 버전이 너무 오래되었습니다. 이 사이트에는 {minClient} 이상이 필요합니다.", + "core-too-old": "Core {core} 버전이 너무 오래되었습니다. 관리자에게 {minCore} 이상으로 업그레이드하도록 요청하세요.", + "endpoint-missing": "이 Core는 호환성 확인 엔드포인트를 제공하지 않습니다. 호환성을 판단할 수 없으므로 관리자에게 문의하세요.", + "timeout": "호환성 확인 시간이 초과되었습니다. 연결을 확인하고 다시 시도하세요.", + "tls": "사이트 인증서를 검증할 수 없습니다. 관리자에게 HTTPS 수정을 요청한 후 다시 시도하세요.", + "network": "사이트에 연결할 수 없습니다. 연결을 확인하고 다시 시도하세요.", + "http": "사이트에서 호환성 정보를 반환하지 못했습니다. 다시 시도하거나 관리자에게 문의하세요.", + "invalid-response": "사이트에서 잘못된 호환성 정보를 반환했습니다. 다시 시도하거나 관리자에게 문의하세요.", + "invalid-site": "사이트 URL이 잘못되었습니다. 자격 증명, 쿼리 또는 프래그먼트가 없는 HTTP 또는 HTTPS URL을 입력하세요.", + "invalid-client-version": "실행 중인 클라이언트 버전이 잘못되었습니다. 릴리스 버전이 주입된 클라이언트를 사용하세요.", + "Download": "호환 클라이언트 다운로드", + "DownloadFailed": "다운로드 페이지를 열 수 없습니다.", + "Retry": "다시 시도" } } diff --git a/i18n/locales/pt_br.json b/i18n/locales/pt_br.json index c6a589a56..bef6ac7b2 100644 --- a/i18n/locales/pt_br.json +++ b/i18n/locales/pt_br.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "Falha no login: certificado inválido ou bloqueado pela segurança do sistema (WebView do macOS). Instale um certificado válido ou abra no navegador do sistema.", "InvalidCertificateGeneric": "Falha no login: problema no certificado ou na conexão segura.", "NetworkError": "Rede indisponível. Verifique a conectividade.", - "ServerError": "O servidor está temporariamente indisponível. Tente novamente mais tarde.", - "VersionIncompatible": "A versão do cliente é incompatível com versões do JumpServer anteriores à v4.10.12-lts. Baixe e use o cliente v3.0.7.", - "VersionNoMatch": "A versão do cliente não corresponde à versão do JumpServer. Baixe e use o cliente v{version} ou atualize o JumpServer para a versão mais recente." + "ServerError": "O servidor está temporariamente indisponível. Tente novamente mais tarde." }, "Asset": { "GetAssetFailed": "Falha ao obter o ativo. Atualize e tente novamente" @@ -2609,5 +2607,23 @@ "Description": "Desculpe, não encontramos a página que você procura.", "RequestedPath": "Caminho solicitado", "GoHome": "Ir para o início" + }, + "ClientCompatibility": { + "UnknownTitle": "Verificação de compatibilidade indisponível", + "IncompatibleTitle": "Versões incompatíveis", + "ConnectionPaused": "Esta conexão está pausada. Tente novamente após resolver o problema.", + "client-too-old": "O cliente {client} é antigo demais. Este site exige {minClient} ou posterior.", + "core-too-old": "Core {core} é antigo demais. Peça ao administrador para atualizar para {minCore} ou posterior.", + "endpoint-missing": "Este Core não fornece o endpoint de compatibilidade. Contate o administrador; não é possível determinar a compatibilidade.", + "timeout": "A verificação excedeu o tempo limite. Verifique a conexão e tente novamente.", + "tls": "Não foi possível verificar o certificado do site. Peça ao administrador para corrigir HTTPS e tente novamente.", + "network": "Não foi possível acessar o site. Verifique a conexão e tente novamente.", + "http": "O site não retornou informações de compatibilidade. Tente novamente ou contate o administrador.", + "invalid-response": "O site retornou informações de compatibilidade inválidas. Tente novamente ou contate o administrador.", + "invalid-site": "URL do site inválida. Insira uma URL HTTP ou HTTPS sem credenciais, consulta ou fragmento.", + "invalid-client-version": "A versão do cliente é inválida. Use um cliente compilado com sua versão de lançamento.", + "Download": "Baixar cliente compatível", + "DownloadFailed": "Não foi possível abrir a página de download.", + "Retry": "Tentar novamente" } } diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index f429c8a69..d90c3c13c 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "Не удалось войти: сертификат недействителен или заблокирован системной защитой (macOS WebView). Установите действительный сертификат или откройте страницу в системном браузере.", "InvalidCertificateGeneric": "Не удалось войти: проблема с сертификатом или защищенным соединением.", "NetworkError": "Сеть недоступна. Проверьте подключение.", - "ServerError": "Сервер временно недоступен. Повторите попытку позже.", - "VersionIncompatible": "Версия клиента несовместима с версиями JumpServer ниже v4.10.12-lts. Скачайте и используйте клиент v3.0.7.", - "VersionNoMatch": "Версия клиента не соответствует версии JumpServer. Скачайте клиент v{version} или обновите JumpServer до последней версии." + "ServerError": "Сервер временно недоступен. Повторите попытку позже." }, "Asset": { "GetAssetFailed": "Не удалось получить ресурс. Обновите страницу и повторите попытку" @@ -2609,5 +2607,23 @@ "Description": "К сожалению, не удалось найти нужную страницу.", "RequestedPath": "Запрошенный путь", "GoHome": "На главную" + }, + "ClientCompatibility": { + "UnknownTitle": "Проверка совместимости недоступна", + "IncompatibleTitle": "Версии несовместимы", + "ConnectionPaused": "Это подключение приостановлено. Повторите попытку после устранения проблемы.", + "client-too-old": "Клиент {client} устарел. Сайт требует {minClient} или новее.", + "core-too-old": "Core {core} устарел. Попросите администратора обновить его до {minCore} или новее.", + "endpoint-missing": "Этот Core не предоставляет API проверки совместимости. Обратитесь к администратору; совместимость определить нельзя.", + "timeout": "Время проверки истекло. Проверьте соединение и повторите попытку.", + "tls": "Не удалось проверить сертификат сайта. Попросите администратора исправить HTTPS и повторите попытку.", + "network": "Сайт недоступен. Проверьте соединение и повторите попытку.", + "http": "Сайт не вернул сведения о совместимости. Повторите попытку или обратитесь к администратору.", + "invalid-response": "Сайт вернул неверные сведения о совместимости. Повторите попытку или обратитесь к администратору.", + "invalid-site": "Неверный URL сайта. Введите HTTP или HTTPS URL без учётных данных, запроса и фрагмента.", + "invalid-client-version": "Неверная версия запущенного клиента. Используйте клиент, собранный с версией выпуска.", + "Download": "Скачать совместимый клиент", + "DownloadFailed": "Не удалось открыть страницу загрузки.", + "Retry": "Повторить" } } diff --git a/i18n/locales/vi.json b/i18n/locales/vi.json index 9a82a71f9..551e32dc4 100644 --- a/i18n/locales/vi.json +++ b/i18n/locales/vi.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "Đăng nhập thất bại: chứng chỉ không hợp lệ hoặc bị bảo mật hệ thống chặn (WebView macOS). Hãy cài chứng chỉ hợp lệ hoặc mở trong trình duyệt hệ thống.", "InvalidCertificateGeneric": "Đăng nhập thất bại: có vấn đề với chứng chỉ hoặc kết nối bảo mật.", "NetworkError": "Mạng không khả dụng. Hãy kiểm tra kết nối.", - "ServerError": "Máy chủ tạm thời không khả dụng. Vui lòng thử lại sau.", - "VersionIncompatible": "Phiên bản máy khách không tương thích với JumpServer trước v4.10.12-lts. Hãy tải và dùng máy khách v3.0.7.", - "VersionNoMatch": "Phiên bản máy khách không khớp với JumpServer. Hãy tải máy khách v{version} hoặc nâng cấp JumpServer lên phiên bản mới nhất." + "ServerError": "Máy chủ tạm thời không khả dụng. Vui lòng thử lại sau." }, "Asset": { "GetAssetFailed": "Không thể lấy tài sản, hãy làm mới và thử lại" @@ -2609,5 +2607,23 @@ "Description": "Rất tiếc, chúng tôi không tìm thấy trang bạn đang tìm.", "RequestedPath": "Đường dẫn được yêu cầu", "GoHome": "Về trang chủ" + }, + "ClientCompatibility": { + "UnknownTitle": "Không thể kiểm tra tính tương thích", + "IncompatibleTitle": "Các phiên bản không tương thích", + "ConnectionPaused": "Kết nối này đã tạm dừng. Thử lại sau khi giải quyết vấn đề.", + "client-too-old": "Máy khách {client} quá cũ. Trang này yêu cầu {minClient} trở lên.", + "core-too-old": "Core {core} quá cũ. Liên hệ quản trị viên để nâng cấp lên {minCore} trở lên.", + "endpoint-missing": "Core này không cung cấp điểm cuối kiểm tra tương thích. Liên hệ quản trị viên; không thể xác định tính tương thích.", + "timeout": "Yêu cầu kiểm tra tương thích đã hết thời gian. Kiểm tra kết nối và thử lại.", + "tls": "Không thể xác minh chứng chỉ của trang. Liên hệ quản trị viên để sửa HTTPS rồi thử lại.", + "network": "Không thể truy cập trang. Kiểm tra kết nối và thử lại.", + "http": "Trang không trả về thông tin tương thích. Thử lại hoặc liên hệ quản trị viên.", + "invalid-response": "Trang trả về thông tin tương thích không hợp lệ. Thử lại hoặc liên hệ quản trị viên.", + "invalid-site": "URL của trang không hợp lệ. Nhập URL HTTP hoặc HTTPS không có thông tin đăng nhập, truy vấn hoặc phân đoạn.", + "invalid-client-version": "Phiên bản máy khách đang chạy không hợp lệ. Dùng máy khách được xây dựng với phiên bản phát hành.", + "Download": "Tải máy khách tương thích", + "DownloadFailed": "Không thể mở trang tải xuống.", + "Retry": "Thử lại" } } diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 3a6c24c7e..975913a7f 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -1142,9 +1142,7 @@ "InvalidCertificateMac": "登录失败:证书无效或被系统安全策略拦截(macOS WebView)。请安装有效证书", "InvalidCertificateGeneric": "登录失败:证书或安全连接异常。", "NetworkError": "网络不可用,请检查网络连接", - "ServerError": "服务器暂时不可用,请稍后重试", - "VersionIncompatible": "客户端与JumpServer 版本(< v4.10.12-lts)不匹配,请下载使用 v3.0.7 的客户端", - "VersionNoMatch": "客户端与 JumpServer 版本不匹配,请下载使用 v{version} 版本的客户端或将 JumpServer 更新至最新" + "ServerError": "服务器暂时不可用,请稍后重试" }, "Loading": { "Loading": "加载中", @@ -2609,5 +2607,23 @@ "Description": "抱歉,找不到您要访问的页面。", "RequestedPath": "请求路径", "GoHome": "返回首页" + }, + "ClientCompatibility": { + "UnknownTitle": "兼容检查未能完成", + "IncompatibleTitle": "版本不兼容", + "ConnectionPaused": "本次连接已暂停,请解决问题后重试。", + "client-too-old": "客户端 {client} 版本过旧,本站要求 {minClient} 或更高版本。", + "core-too-old": "Core {core} 版本过旧,请联系管理员升级至 {minCore} 或更高版本。", + "endpoint-missing": "此 Core 未提供兼容检查接口,无法确定兼容性,请联系管理员。", + "timeout": "兼容检查请求超时,请检查网络后重试。", + "tls": "无法验证站点证书,请联系管理员修复 HTTPS 后重试。", + "network": "无法连接站点,请检查网络后重试。", + "http": "站点未能返回兼容信息,请重试或联系管理员。", + "invalid-response": "站点返回的兼容信息无效,请重试或联系管理员。", + "invalid-site": "站点地址无效,请输入不含凭据、查询参数或片段的 HTTP 或 HTTPS 地址。", + "invalid-client-version": "运行中的客户端版本无效,请使用已注入发布版本的客户端。", + "Download": "下载兼容客户端", + "DownloadFailed": "无法打开下载页面。", + "Retry": "重试" } } diff --git a/i18n/locales/zh_hant.json b/i18n/locales/zh_hant.json index a94608685..ef331dcd9 100644 --- a/i18n/locales/zh_hant.json +++ b/i18n/locales/zh_hant.json @@ -1147,9 +1147,7 @@ "InvalidCertificateMac": "登入失敗:憑證無效或遭系統安全性封鎖(macOS WebView)。請安裝有效憑證,或在系統瀏覽器中開啟。", "InvalidCertificateGeneric": "登入失敗:證書或安全連線異常。", "NetworkError": "網路不可用,請檢查網路連線", - "ServerError": "伺服器暫時不可用,請稍後重試", - "VersionIncompatible": "客戶端與JumpServer 版本(< v4.10.12-lts)不匹配,請下載使用 v3.0.7 的客戶端", - "VersionNoMatch": "客戶端與 JumpServer 版本不匹配,請下載使用 v{version} 版本的客戶端或將 JumpServer 更新至最新" + "ServerError": "伺服器暫時不可用,請稍後重試" }, "Asset": { "GetAssetFailed": "獲取資產失敗,請重新整理重試" @@ -2609,5 +2607,23 @@ "Description": "抱歉,找不到您要訪問的頁面。", "RequestedPath": "請求路徑", "GoHome": "返回首頁" + }, + "ClientCompatibility": { + "UnknownTitle": "相容檢查未能完成", + "IncompatibleTitle": "版本不相容", + "ConnectionPaused": "本次連線已暫停,請解決問題後重試。", + "client-too-old": "客戶端 {client} 版本過舊,本站要求 {minClient} 或更新版本。", + "core-too-old": "Core {core} 版本過舊,請聯絡管理員升級至 {minCore} 或更新版本。", + "endpoint-missing": "此 Core 未提供相容檢查介面,無法確定相容性,請聯絡管理員。", + "timeout": "相容檢查請求逾時,請檢查網路後重試。", + "tls": "無法驗證站點憑證,請聯絡管理員修復 HTTPS 後重試。", + "network": "無法連線至站點,請檢查網路後重試。", + "http": "站點未能傳回相容資訊,請重試或聯絡管理員。", + "invalid-response": "站點傳回的相容資訊無效,請重試或聯絡管理員。", + "invalid-site": "站點位址無效,請輸入不含憑據、查詢參數或片段的 HTTP 或 HTTPS 位址。", + "invalid-client-version": "執行中的客戶端版本無效,請使用已注入發行版本的客戶端。", + "Download": "下載相容客戶端", + "DownloadFailed": "無法開啟下載頁面。", + "Retry": "重試" } } diff --git a/package.json b/package.json index 48ecba4c2..13b74e2f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "jumpserver-web", "type": "module", - "version": "5.0.0", + "version": "5.1.0", "private": true, "packageManager": "pnpm@11.4.0", "description": "JumpServer web application", diff --git a/ui/app.config.ts b/ui/app.config.ts index 59c4b3f01..bf9ab5506 100644 --- a/ui/app.config.ts +++ b/ui/app.config.ts @@ -2,7 +2,7 @@ export default defineAppConfig({ app: { name: "JumpServer", author: "JumpServer", - version: "5.0.0", + version: "5.1.0", repo: "https://github.com/jumpserver/clients" }, componentsConfig: { diff --git a/ui/app.vue b/ui/app.vue index 18190dae1..381525438 100644 --- a/ui/app.vue +++ b/ui/app.vue @@ -453,6 +453,7 @@ onBeforeUnmount(() => { + +const { t } = useI18n(); +const toast = useToast(); +const { blocked, dismiss, retry, download } = useClientCompatibility(); +const open = computed({ + get: () => blocked.value !== null, + set: (value) => { + if (!value) dismiss(); + } +}); +const description = computed(() => { + const result = blocked.value; + if (!result) return ""; + if (result.status === "unknown") return t(`ClientCompatibility.${result.reason || "network"}`); + return result.failures + .map((failure) => + t(`ClientCompatibility.${failure}`, { + client: result.clientVersion, + minClient: result.minClientVersion, + core: result.coreVersion, + minCore: result.minCoreVersion + }) + ) + .join("\n"); +}); +const canDownload = computed( + () => blocked.value?.status === "incompatible" && blocked.value.failures.includes("client-too-old") +); +const openDownload = async () => { + try { + await download(); + } catch { + toast.add({ title: t("ClientCompatibility.DownloadFailed"), color: "error" }); + } +}; + + + diff --git a/ui/components/Main/main.vue b/ui/components/Main/main.vue index 922367d8f..1ec9c9820 100644 --- a/ui/components/Main/main.vue +++ b/ui/components/Main/main.vue @@ -1,19 +1,6 @@ @@ -56,17 +34,6 @@ provide("providerClearSelection", providerClearSelection); :ui="cardUi" @click="clearSelection" > - - diff --git a/ui/components/SideBar/profile.vue b/ui/components/SideBar/profile.vue index 4c7c78182..d451a2e4f 100644 --- a/ui/components/SideBar/profile.vue +++ b/ui/components/SideBar/profile.vue @@ -2,24 +2,14 @@ import type { DesktopUnlistenFn } from "~/shared/desktop/bridge"; import type { LangType, ThemePresetId, UserData } from "~/types/index"; +import { useClientCompatibility } from "~/composables/useClientCompatibility"; import { useSettingManager } from "~/composables/useSettingManager"; import { closeCurrentSiteWorkspace, confirmLeaveCurrentSiteSessions } from "~/composables/useSiteAccountSwitch"; import { DARK_THEME_PRESETS, getThemePreset, LIGHT_THEME_PRESETS } from "~/composables/useThemePresets"; -import { desktopApp, desktopEmit, desktopInvoke, desktopListen } from "~/shared/desktop/bridge"; +import { desktopEmit, desktopInvoke, desktopListen } from "~/shared/desktop/bridge"; import { useUserInfoStore } from "~/store/modules/userInfo"; import RecentSites from "./recentSites.vue"; -interface VersionAlertPayload { - type: string; - version?: string; -} - -interface VersionMessageResponse { - status: number; - data: string; - success: boolean; -} - const recentSiteLimit = 5; const loginFailureToastId = "login-failed"; @@ -82,6 +72,8 @@ const headerIconButtonActiveClass = "bg-[var(--app-hover-soft)] text-[var(--app- let loginBtnUnlockTimer: ReturnType | null = null; let desktopListenersActive = true; +let loginRequestGeneration = 0; +let loginInFlight = false; const retainDesktopListener = (target: typeof unlistenAuthUrlRef, unlisten: DesktopUnlistenFn) => { if (!desktopListenersActive) { @@ -132,6 +124,15 @@ const clearLoginBtnUnlockTimer = () => { } }; +watch(normalizedInputSite, () => { + loginRequestGeneration += 1; + if (!loginInFlight) return; + loginInFlight = false; + loginBtn.value = false; + clearLoginBtnUnlockTimer(); + void desktopInvoke("auth_cancel").catch((error) => console.debug("auth_cancel failed", error)); +}); + const enableLoginBtnAfter = (ms: number) => { clearLoginBtnUnlockTimer(); @@ -478,106 +479,6 @@ const handleClearInput = () => { recentSitesDismissed.value = false; }; -function normalizeVersionMessage(response: VersionMessageResponse) { - if (response.status === 404) { - return { status: "incompatible" as const, versions: [] as string[] }; - } - - if (!response.data) { - return { status: "list" as const, versions: [] as string[] }; - } - - try { - const parsed = JSON.parse(response.data); - const versions = Array.isArray(parsed) - ? parsed.map((item) => (item == null ? "" : String(item))).filter((v) => v.length > 0) - : []; - return { status: "list" as const, versions }; - } catch { - return { status: "list" as const, versions: [] as string[] }; - } -} - -function normalizeMajorMinor(version: string) { - const cleaned = (version || "").trim(); - if (!cleaned) return ""; - - const parts = cleaned.split("."); - const major = (parts[0] || "").replace(/\D/g, ""); - if (!major) return ""; - - const minor = (parts[1] || "").replace(/\D/g, ""); - return minor ? `${major}.${minor}` : major; -} - -function normalizeMajorMinorList(versions: string[]) { - const normalized: string[] = []; - const seen = new Set(); - - for (const version of versions) { - const value = normalizeMajorMinor(version); - if (!value || seen.has(value)) continue; - seen.add(value); - normalized.push(value); - } - - return normalized; -} - -const emitVersionAlertAndCloseModal = (payload: VersionAlertPayload) => { - openModal.value = false; - loginBtn.value = false; - useEventBus().emit("versionAlert", payload); -}; - -const checkVersionBeforeOAuth = async (accountId: string, site: string) => { - if (!isDesktopRuntime()) return true; - - await desktopInvoke("set_api_session", { - sessionKey: accountId, - origin: site, - bearerToken: "", - orgId: "" - }); - - const [versionResponse, appVersion] = await Promise.all([ - desktopInvoke("get_version_message").catch(() => { - return null; - }), - desktopApp.getVersion().catch(() => "") - ]); - - if (!versionResponse || versionResponse.status === 0) { - console.warn("Skip version precheck before OAuth because version endpoint is unavailable", { - site, - versionResponse - }); - return true; - } - - const { status: versionStatus, versions } = normalizeVersionMessage(versionResponse); - - if (versionStatus === "incompatible") { - emitVersionAlertAndCloseModal({ type: "incompatible" }); - return false; - } - - const normalizedAppVersion = normalizeMajorMinor(appVersion); - const normalizedVersions = normalizeMajorMinorList(versions); - - if (normalizedAppVersion && normalizedVersions.length > 0) { - if (!normalizedVersions.includes(normalizedAppVersion)) { - emitVersionAlertAndCloseModal({ type: "noMatch", version: versions[versions.length - 1] }); - return false; - } - } else if (appVersion && versions.length > 0 && !versions.includes(appVersion)) { - emitVersionAlertAndCloseModal({ type: "noMatch", version: versions[versions.length - 1] }); - return false; - } - - return true; -}; - /** * @description 打开登录页面 */ @@ -634,8 +535,8 @@ async function handleSwitchAccount(accountId: string) { if (accountId === currentAccountId.value) return; profileOpen.value = false; - if (!(await confirmLeaveCurrentSiteSessions("switch"))) return; - await userInfoStore.setCurrentAccount(accountId); + if (!(await confirmLeaveCurrentSiteSessions("switch", { close: false }))) return; + await userInfoStore.setCurrentAccount(accountId, { beforeSwitch: closeCurrentSiteWorkspace }); } function openAddSite() { @@ -789,27 +690,43 @@ const handleConfirm = async () => { const isReauth = Boolean(editingAccountId.value && editingAccountId.value === currentAccountId.value); const leavingCurrentSite = loggedIn.value && !isReauth; + const generation = ++loginRequestGeneration; if (leavingCurrentSite && !(await confirmLeaveCurrentSiteSessions("login", { close: false }))) return; + if (generation !== loginRequestGeneration) return; try { clearLoginBtnUnlockTimer(); loginBtn.value = true; + loginInFlight = true; const accountId = editingAccountId.value || globalThis.crypto.randomUUID(); - const ok = await checkVersionBeforeOAuth(accountId, normalizedSite); - if (!ok) return; - + useClientCompatibility().dismiss(); const payload = await desktopInvoke("auth_login", { site: normalizedSite, sessionId: accountId }); + if (generation !== loginRequestGeneration) return; + if (payload?.status === "blocked" && payload.compatibility) { + openModal.value = false; + loginBtn.value = false; + useClientCompatibility().show(payload.compatibility, async () => { + openModal.value = true; + await handleConfirm(); + }); + return; + } if (!payload || payload.status !== "success") { loginBtn.value = false; return; } if (leavingCurrentSite) await closeCurrentSiteWorkspace(); - await applyLoginPayload(payload, { showToast: true, navigateHome: true, accountId, siteName }); + if (generation !== loginRequestGeneration) return; + if (!(await applyLoginPayload(payload, { showToast: true, navigateHome: true, accountId, siteName }))) { + loginBtn.value = false; + return; + } void saveRecentSite(normalizedSite); } catch (e: any) { + if (generation !== loginRequestGeneration) return; const raw = (e?.message || e || "").toString(); const looksLikeSiteIssue = [ "Failed to fetch OAuth config", @@ -837,6 +754,8 @@ const handleConfirm = async () => { nextTick(() => { inputRef.value?.$el?.querySelector("input")?.focus(); }); + } finally { + if (generation === loginRequestGeneration) loginInFlight = false; } }; diff --git a/ui/composables/useAuthSession.test.ts b/ui/composables/useAuthSession.test.ts index 23a6f62f0..dca123120 100644 --- a/ui/composables/useAuthSession.test.ts +++ b/ui/composables/useAuthSession.test.ts @@ -12,11 +12,12 @@ const organization = (id: string, name: string, isDefault = false) => ({ const mocks = vi.hoisted(() => ({ store: {} as any, + desktopInvoke: vi.fn(), fetchResponse: null as ((url: string) => Promise) | null, pathname: "/" })); -vi.mock("~/shared/desktop/bridge", () => ({ desktopInvoke: vi.fn() })); +vi.mock("~/shared/desktop/bridge", () => ({ desktopInvoke: mocks.desktopInvoke })); vi.mock("~/store/modules/userInfo", () => ({ useUserInfoStore: () => mocks.store })); vi.mock("~/utils/runtime", async (importOriginal) => ({ ...(await importOriginal()), @@ -25,17 +26,20 @@ vi.mock("~/utils/runtime", async (importOriginal) => ({ let useAuthSession: typeof import("./useAuthSession").useAuthSession; let currentAccountIdRef: Ref; +let userMapRef: Ref>; beforeEach(async () => { vi.resetModules(); globalThis.localStorage?.clear(); mocks.pathname = "/"; + mocks.desktopInvoke.mockReset(); currentAccountIdRef = ref(""); - const userMap = ref({}); + userMapRef = ref({}); mocks.store = { currentAccountId: "", currentUser: null, loggedIn: false, + withSessionActivation: vi.fn((activate: () => Promise) => activate()), setUserData: vi.fn((_accountId: string, user: any) => { mocks.store.currentAccountId = user.accountId; mocks.store.currentUser = user; @@ -50,7 +54,7 @@ beforeEach(async () => { }) }; - vi.stubGlobal("storeToRefs", () => ({ currentAccountId: currentAccountIdRef, userMap })); + vi.stubGlobal("storeToRefs", () => ({ currentAccountId: currentAccountIdRef, userMap: userMapRef })); vi.stubGlobal("useNuxtApp", () => ({ $i18n: { t: (key: string) => key } })); vi.stubGlobal("useToast", () => ({ add: vi.fn() })); vi.stubGlobal("useLocalePath", () => (path: unknown) => path); @@ -114,10 +118,101 @@ describe("desktop session expiry", () => { }); }); +describe("desktop compatibility during startup", () => { + const loginPayload = { + status: "success", + connection_id: 1, + bearer: "token-1", + resolved_site: "https://site.test/prefix", + profile: { status: 200, data: '{"id":"user-1","name":"Alice"}' }, + current_org: { status: 403, data: "{}" }, + permission_orgs: { status: 403, data: "{}" } + }; + + beforeEach(() => { + vi.stubGlobal("isDesktopRuntime", () => true); + currentAccountIdRef.value = "account-1"; + userMapRef.value = { "account-1": { site: "https://site.test/prefix", siteName: "Private site" } }; + }); + + it.each(["unknown", "incompatible"] as const)( + "pauses a remembered account when compatibility is %s and resumes only on retry", + async (status) => { + const { useClientCompatibility } = await import("./useClientCompatibility"); + const compatibility = { + status, + site: "https://site.test/prefix", + clientVersion: "5.1.0", + minCoreVersion: "5.1.0", + failures: status === "incompatible" ? ["core-too-old"] : [], + reason: status === "unknown" ? "endpoint-missing" : undefined + }; + mocks.desktopInvoke.mockResolvedValue({ status: "blocked", compatibility }); + const auth = useAuthSession(); + + await expect(auth.bootstrapPersistedSession()).resolves.toBe(false); + expect(mocks.desktopInvoke.mock.calls).toEqual([ + ["bootstrap_auth_session", { site: "https://site.test/prefix", sessionId: "account-1" }] + ]); + expect(mocks.store.setUserData).not.toHaveBeenCalled(); + expect(mocks.store.loggedIn).toBe(false); + expect(useClientCompatibility().blocked.value).toEqual(compatibility); + + mocks.desktopInvoke.mockImplementation(async (command) => + command === "bootstrap_auth_session" ? loginPayload : { status: "success" } + ); + await useClientCompatibility().retry(); + expect(mocks.desktopInvoke.mock.calls.map(([command]) => command)).toEqual([ + "bootstrap_auth_session", + "bootstrap_auth_session", + "set_api_session" + ]); + expect(mocks.desktopInvoke).toHaveBeenLastCalledWith( + "set_api_session", + expect.objectContaining({ connectionId: 1, sessionKey: "account-1", origin: "https://site.test/prefix" }) + ); + expect(mocks.store.loggedIn).toBe(true); + expect(mocks.store.setUserData).toHaveBeenCalledOnce(); + expect(mocks.store.withSessionActivation).toHaveBeenCalledOnce(); + } + ); + + it("does not publish a login when the main process rejects an obsolete approval", async () => { + mocks.desktopInvoke.mockResolvedValue({ status: "stale" }); + await expect(useAuthSession().applyLoginPayload(loginPayload as any, { accountId: "account-1" })).resolves.toBe( + false + ); + expect(mocks.store.setUserData).not.toHaveBeenCalled(); + expect(mocks.store.setOrganizations).not.toHaveBeenCalled(); + expect(mocks.store.loggedIn).toBe(false); + }); + + it("keeps desktop login publication inside the shared activation step", async () => { + const activation = Promise.withResolvers(); + mocks.store.withSessionActivation.mockImplementation(async (activate: () => Promise) => { + await activation.promise; + return activate(); + }); + mocks.desktopInvoke.mockResolvedValue({ status: "success" }); + const pending = useAuthSession().applyLoginPayload(loginPayload as any, { + accountId: "account-1", + showToast: false, + navigateHome: false + }); + expect(mocks.desktopInvoke).not.toHaveBeenCalled(); + expect(mocks.store.setUserData).not.toHaveBeenCalled(); + + activation.resolve(); + await expect(pending).resolves.toBe(true); + expect(mocks.store.setUserData).toHaveBeenCalledOnce(); + }); +}); + describe("web session bootstrap", () => { it("initializes the organization before exposing the authenticated session", async () => { await expect(useAuthSession().bootstrapPersistedSession()).resolves.toBe(true); + expect(mocks.store.withSessionActivation).not.toHaveBeenCalled(); expect(mocks.store.setUserData).toHaveBeenCalledOnce(); expect(mocks.store.setUserData).toHaveBeenCalledWith( "https://luna.test", diff --git a/ui/composables/useAuthSession.ts b/ui/composables/useAuthSession.ts index d1efa73e7..9a46458ec 100644 --- a/ui/composables/useAuthSession.ts +++ b/ui/composables/useAuthSession.ts @@ -1,5 +1,7 @@ import type { PublicSettings } from "~/composables/useApiRequest"; import type { SiteUserData } from "~/store/modules/userInfo"; +import type { DesktopConnectionResult } from "~/types/clientCompatibility"; +import { useClientCompatibility } from "~/composables/useClientCompatibility"; import type { ConnectionInfo, ConnectionPreferenceInfo, @@ -8,6 +10,7 @@ import type { PermOrgItem, RdpGraphics, RoleType, + UserData, UserIntiInfo } from "~/types"; import { getUserPermissions } from "~/composables/useApiRequest"; @@ -31,12 +34,13 @@ interface BootstrapResponse { data: string; status?: number; } -type LoginPayload = UserIntiInfo & { - bearer: string; - profile: BootstrapResponse; - current_org: BootstrapResponse; - permission_orgs: BootstrapResponse; -}; +type LoginPayload = UserIntiInfo & + DesktopConnectionResult & { + bearer: string; + profile: BootstrapResponse; + current_org: BootstrapResponse; + permission_orgs: BootstrapResponse; + }; interface PersistedUserSnapshot { loggedIn?: boolean; currentAccountId?: string; @@ -141,7 +145,7 @@ export const useAuthSession = () => { ); const currentOrg: CurrentOrg | null = selectedOrg ? { ...selectedOrg, comment: selectedOrg.comment || "" } : null; - userInfoStore.setUserData(accountId, { + const userData: UserData = { accountId, userId, siteName, @@ -164,13 +168,35 @@ export const useAuthSession = () => { protocol: "", username: "" } - }); + }; - userInfoStore.setOrganizations(availableOrgs); - if (currentOrg?.id) { - userInfoStore.setCurrentOrg(currentOrg); + const publishSession = () => { + userInfoStore.setUserData(accountId, userData); + + userInfoStore.setOrganizations(availableOrgs); + if (currentOrg?.id) { + userInfoStore.setCurrentOrg(currentOrg); + } + userInfoStore.setUserLoggedIn(true); + }; + + if (isDesktopRuntime()) { + if (Number(profile.status) < 200 || Number(profile.status) >= 300) return false; + const applied = await userInfoStore.withSessionActivation(async () => { + const activation = await desktopInvoke("set_api_session", { + sessionKey: accountId, + origin: resolvedSite, + connectionId: payload.connection_id, + orgId: existingUser?.org?.id || "" + }); + if (activation?.status !== "success") return false; + publishSession(); + return true; + }); + if (!applied) return false; + } else { + publishSession(); } - userInfoStore.setUserLoggedIn(true); if (options.showToast !== false) { toast.add({ @@ -231,7 +257,7 @@ export const useAuthSession = () => { currentConnectionPreferenceMap: parsed.currentConnectionPreferenceMap || {} }); - userInfoStore.setCurrentAccount(snapshotAccountId); + if (!isDesktopRuntime()) void userInfoStore.setCurrentAccount(snapshotAccountId); userInfoStore.setUserLoggedIn(false); console.info("restore persisted userInfo success", { snapshotAccountId, @@ -399,6 +425,11 @@ export const useAuthSession = () => { site, sessionId: accountId }); + if (payload?.status === "stale") return false; + if (payload?.status === "blocked" && payload.compatibility) { + useClientCompatibility().show(payload.compatibility, bootstrapPersistedSession); + return false; + } const failure = classifyBootstrapFailure(payload); if (failure === "auth") { @@ -449,7 +480,7 @@ export const useAuthSession = () => { const authReady = useState("auth-bootstrap-ready", () => false); - const bootstrapPersistedSession = () => { + function bootstrapPersistedSession() { if (!bootstrapPromise) { bootstrapPromise = bootstrapSession().finally(() => { authReady.value = true; @@ -458,7 +489,7 @@ export const useAuthSession = () => { } return bootstrapPromise; - }; + } const handleDesktopAuthExpired = async (sessionId: string) => { if (!sessionId || sessionId !== currentAccountId.value) return false; diff --git a/ui/composables/useClientCompatibility.ts b/ui/composables/useClientCompatibility.ts new file mode 100644 index 000000000..4566f7e8b --- /dev/null +++ b/ui/composables/useClientCompatibility.ts @@ -0,0 +1,28 @@ +import type { SiteCompatibility } from "~/types/clientCompatibility"; +import { readonly, shallowRef } from "vue"; +import { desktopInvoke } from "~/shared/desktop/bridge"; + +// Desktop connection prompts are shared across the workspace and settings layouts. +const blocked = shallowRef(null); +let retryConnection: (() => Promise) | null = null; + +export const useClientCompatibility = () => { + const dismiss = () => { + blocked.value = null; + retryConnection = null; + }; + return { + blocked: readonly(blocked), + dismiss, + show: (result: SiteCompatibility, retry: () => Promise) => { + blocked.value = result; + retryConnection = retry; + }, + retry: async () => { + const action = retryConnection; + dismiss(); + await action?.(); + }, + download: () => desktopInvoke("open_client_download", { site: blocked.value?.site }) + }; +}; diff --git a/ui/composables/useEventBus.ts b/ui/composables/useEventBus.ts index 4e4f6fc31..e400548f6 100644 --- a/ui/composables/useEventBus.ts +++ b/ui/composables/useEventBus.ts @@ -10,10 +10,6 @@ type BusEvents = { loaded: undefined; loading: undefined; refresh: undefined; - versionAlert: { - type: string; - version?: string; - }; assetRenamed: { assetId: string; name: string; diff --git a/ui/pages/setting/user.vue b/ui/pages/setting/user.vue index bb73ffda8..6702e29c0 100644 --- a/ui/pages/setting/user.vue +++ b/ui/pages/setting/user.vue @@ -1,7 +1,7 @@