diff --git a/ui/src/components/login-gate.test.ts b/ui/src/components/login-gate.test.ts new file mode 100644 index 000000000000..e4323376b6ec --- /dev/null +++ b/ui/src/components/login-gate.test.ts @@ -0,0 +1,84 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import "./login-gate.ts"; + +type LoginGateElement = HTMLElement & { + props: Record; + updateComplete: Promise; +}; + +async function mountFailure(lastError: string, lastErrorCode: string | null) { + const element = document.createElement("openclaw-login-gate") as LoginGateElement; + element.props = { + basePath: "", + connected: false, + lastError, + lastErrorCode, + hasToken: false, + hasPassword: false, + gatewayUrl: "ws://127.0.0.1:18789", + token: "", + password: "", + showGatewayToken: false, + showGatewayPassword: false, + onGatewayUrlChange: vi.fn(), + onTokenChange: vi.fn(), + onPasswordChange: vi.fn(), + onToggleGatewayToken: vi.fn(), + onToggleGatewayPassword: vi.fn(), + onConnect: vi.fn(), + }; + document.body.append(element); + await element.updateComplete; + return element; +} + +afterEach(() => { + document.body.replaceChildren(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("login gate failure recovery", () => { + it("offers page refresh for a protocol mismatch and reloads when selected", async () => { + const element = await mountFailure( + "protocol mismatch", + ConnectErrorDetailCodes.PROTOCOL_MISMATCH, + ); + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + + const failure = element.querySelector( + '.login-gate__failure[data-kind="protocol-mismatch"]', + ); + const refresh = failure?.querySelector(".login-gate__failure-refresh"); + + expect(refresh?.textContent?.trim()).toBe("Refresh page"); + expect(failure?.querySelector(".login-gate__failure-steps")).not.toBeNull(); + expect(failure?.querySelector(".login-gate__failure-docs")).not.toBeNull(); + + refresh?.click(); + expect(reload).toHaveBeenCalledOnce(); + }); + + it.each([ + [ + "auth-required", + "unauthorized: gateway token required", + ConnectErrorDetailCodes.AUTH_REQUIRED, + ], + ["network", "WebSocket connection failed", null], + [ + "insecure-context", + "device identity required", + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ], + ])("does not offer page refresh for %s failures", async (kind, error, code) => { + const element = await mountFailure(error, code); + + expect(element.querySelector(".login-gate__failure")?.getAttribute("data-kind")).toBe(kind); + expect(element.querySelector(".login-gate__failure-refresh")).toBeNull(); + }); +}); diff --git a/ui/src/components/login-gate.ts b/ui/src/components/login-gate.ts index 32dbb8d4e518..1cc06699e5f8 100644 --- a/ui/src/components/login-gate.ts +++ b/ui/src/components/login-gate.ts @@ -30,6 +30,7 @@ type LoginFailureFeedback = { kind: LoginFailureKind; title: string; summary: string; + refreshAction?: { label: string }; steps: string[]; docsHref: string; docsLabel: string; @@ -96,12 +97,14 @@ function buildFeedback(params: { summaryKey: string; stepKeys: string[]; stepParams?: Record; + refreshAction?: { label: string }; }): LoginFailureFeedback { const docsHref = params.docsHref ?? "https://docs.openclaw.ai/web/dashboard"; return { kind: params.kind, title: t(params.titleKey, params.stepParams), summary: t(params.summaryKey, params.stepParams), + refreshAction: params.refreshAction, steps: params.stepKeys.map((key) => t(key, params.stepParams)), docsHref, docsLabel: resolveDocsLabel(docsHref), @@ -209,6 +212,7 @@ function resolveLoginFailureFeedback( "https://docs.openclaw.ai/web/control-ui#debuggingtesting-dev-server--remote-gateway", titleKey: "login.failure.protocol.title", summaryKey: "login.failure.protocol.summary", + refreshAction: { label: t("login.failure.protocol.refresh") }, stepKeys: [ "login.failure.protocol.stepDashboard", "login.failure.protocol.stepDevUi", @@ -264,6 +268,11 @@ function resolveLoginFailureFeedback( }); } +function refreshLoginGatePage() { + // The login gate blocks before the composer mounts, so there is no draft to preserve. + window.location.reload(); +} + function renderLoginFailure(feedback: LoginFailureFeedback) { return html`
+ ${feedback.refreshAction + ? html` + + ` + : nothing} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 33003bf44b65..4ebdc3226be9 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3592,6 +3592,7 @@ export const en: TranslationMap = { title: "Protocol mismatch", summary: "The served Control UI and the running Gateway do not agree on the supported connection protocol.", + refresh: "Refresh page", stepDashboard: "Reopen the served dashboard with openclaw dashboard so the UI and Gateway come from the same install.", stepDevUi: