feat(ui): offer page refresh on protocol-mismatch failure (#111772)

This commit is contained in:
Peter Steinberger
2026-07-20 02:31:40 -07:00
committed by GitHub
parent 3ef2d8cf05
commit a1f43b218a
3 changed files with 105 additions and 0 deletions
+84
View File
@@ -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<string, unknown>;
updateComplete: Promise<boolean>;
};
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<HTMLElement>(
'.login-gate__failure[data-kind="protocol-mismatch"]',
);
const refresh = failure?.querySelector<HTMLButtonElement>(".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();
});
});
+20
View File
@@ -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<string, string>;
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`
<div
@@ -274,6 +283,17 @@ function renderLoginFailure(feedback: LoginFailureFeedback) {
>
<div class="login-gate__failure-title">${feedback.title}</div>
<div class="login-gate__failure-summary">${feedback.summary}</div>
${feedback.refreshAction
? html`
<button
type="button"
class="btn primary login-gate__failure-refresh"
@click=${refreshLoginGatePage}
>
${feedback.refreshAction.label}
</button>
`
: nothing}
<ol class="login-gate__failure-steps">
${feedback.steps.map((step) => html`<li>${step}</li>`)}
</ol>
+1
View File
@@ -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: