From 13e8e1df4a8230acbbd732850b2561eee63003cd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 7 Aug 2026 17:18:02 -0700 Subject: [PATCH] fix(ui): hide connection form during initial auth (#120372) * fix(ui): hide connection form during initial auth Use the authoritative gateway connecting/error state so trusted-proxy, Tailscale, bootstrap, device, and token auth all keep the centered loading screen until the first handshake resolves. Real failures still reveal the login gate, manual retries stay pinned, and established reconnects retain the shell. * fix(ui): preserve approval document login gate Scope the neutral unresolved first-connect splash to the normal Control UI document. Standalone approval links continue to expose their authentication gate without losing the deep link, while embedded terminal behavior remains unchanged. --- docs/web/control-ui.md | 10 ++-- ui/src/api/gateway-browser-auth.ts | 28 ---------- ui/src/api/gateway.ts | 2 - ui/src/app/app-root.ts | 25 +++------ ui/src/e2e/initial-connect-splash.e2e.test.ts | 52 +++++++++++++++++-- 5 files changed, 60 insertions(+), 57 deletions(-) diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 77ba70d8e8e2..dab56cd0d2fc 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -524,11 +524,11 @@ automatically when the Gateway returns. Live controls and slash commands remain offline, except that **Stop** can queue an exact local run ID for replay. A session-only stop is not replayed because newer work may start in that session before the connection returns. -When this browser already holds credentials (a configured token/password or an approved device -token), first opens and reloads show a small animated OpenClaw mark while the connection is -established instead of flashing the login gate. The login gate only appears when no credentials -are stored yet or when the Gateway actively rejects them (bad token/password, revoked pairing) — -states that need your input rather than waiting. +First opens and reloads show a small animated OpenClaw mark while the Gateway resolves the initial +connection, including when authentication comes from a trusted proxy or Tailscale instead of a +browser-stored credential. The login gate appears only after the initial connection fails or the +Gateway actively rejects authentication (bad token/password, missing trusted identity, revoked +pairing) — states that need your input rather than waiting. ## PWA install and web push diff --git a/ui/src/api/gateway-browser-auth.ts b/ui/src/api/gateway-browser-auth.ts index 13115341688f..ebc416f2dfc1 100644 --- a/ui/src/api/gateway-browser-auth.ts +++ b/ui/src/api/gateway-browser-auth.ts @@ -1,5 +1,4 @@ import type { GatewayConnectAuthSelection } from "@openclaw/gateway-client/browser"; -import { loadDeviceAuthToken, peekStoredDeviceIdentityId } from "../lib/nodes/index.ts"; const CONTROL_UI_OPERATOR_ROLE = "operator"; @@ -12,33 +11,6 @@ export function storedDeviceTokenScopesAllowRead(role: string, scopes: readonly ); } -/** True when the next browser connect would present a usable stored credential. */ -export function hasStoredGatewayAuth(params: { - gatewayUrl: string; - token?: string; - password?: string; -}): boolean { - if (params.token?.trim() || params.password?.trim()) { - return true; - } - // Insecure contexts skip device identity, so their stored token is unusable. - if (typeof crypto === "undefined" || !crypto.subtle) { - return false; - } - const deviceId = peekStoredDeviceIdentityId(); - if (!deviceId) { - return false; - } - const storedEntry = loadDeviceAuthToken({ - deviceId, - gatewayUrl: params.gatewayUrl, - role: CONTROL_UI_OPERATOR_ROLE, - }); - return Boolean( - storedEntry && storedDeviceTokenScopesAllowRead(CONTROL_UI_OPERATOR_ROLE, storedEntry.scopes), - ); -} - export function gatewayRecoveryScopeMaterial( selected: GatewayConnectAuthSelection, ): string | undefined { diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 1d9811e3d29e..2339a5c29cf8 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -55,8 +55,6 @@ import { } from "./gateway-browser-auth.ts"; import { createBrowserGatewaySocket } from "./gateway-browser-socket.ts"; -export { hasStoredGatewayAuth } from "./gateway-browser-auth.ts"; - export type GatewayEventFrame = EventFrame; type GatewayErrorInfo = ErrorShape; diff --git a/ui/src/app/app-root.ts b/ui/src/app/app-root.ts index 417995e062e4..b4d5298db892 100644 --- a/ui/src/app/app-root.ts +++ b/ui/src/app/app-root.ts @@ -1,7 +1,7 @@ import { ContextProvider } from "@lit/context"; import { html, nothing } from "lit"; import { state } from "lit/decorators.js"; -import { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts"; +import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { RouteId } from "../app-routes.ts"; import "../components/gateway-url-confirmation.ts"; import "../components/github-link-hovercard-registration.ts"; @@ -76,10 +76,6 @@ export class OpenClawApp extends OpenClawLightDomElement { @state() private onboarding = resolveOnboardingMode(globalThis.location?.search ?? ""); private readonly terminalOnly = isTerminalOnlyView(); - // Fixed at page load: whether this browser held credentials (token, - // password, or stored device token) before the first connect attempt. - // Later manual gate submissions are covered by loginGatePinned instead. - private initialAuthPresent = false; private runtime: ApplicationRuntime | undefined; private readonly contextProvider = new ContextProvider(this, { context: applicationContext, @@ -118,7 +114,6 @@ export class OpenClawApp extends OpenClawLightDomElement { preloadOptionalElement(this, APPROVAL_PAGE_ELEMENT); } const context = this.runtime.context; - this.initialAuthPresent = hasStoredGatewayAuth(context.gateway.connection); this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null; // Context identity changes only across a full app-tree connection epoch; // descendants reconnect and rebuild their controller-owned state afterward. @@ -228,19 +223,15 @@ export class OpenClawApp extends OpenClawLightDomElement { : nothing} `; } - // Transport drops after an established session keep the shell mounted - // (offline presentation + client auto-retry); the login gate is reserved for - // credential-less first connects, credential rejections, and manual gate - // submissions. A first connect backed by stored credentials paints the - // connecting splash instead of flashing the login gate; the gate returns - // the moment the attempt fails (lastError set on every close). + // In the normal Control UI document, the Gateway lifecycle owns unresolved + // first-connect state across every auth mode. Failures publish lastError + // before the gate returns; reconnects keep the shell mounted, and + // loginGatePinned protects manual submissions. const initialConnectPending = - this.initialAuthPresent && - !gatewayConnected && - gatewaySnapshot.phase !== "reconnecting" && + runtime.documentMode === null && + gatewaySnapshot.phase === "connecting" && !this.loginGatePinned && - gatewaySnapshot.lastError === null && - gatewaySnapshot.client !== null; + gatewaySnapshot.lastError === null; if (initialConnectPending) { return html` diff --git a/ui/src/e2e/initial-connect-splash.e2e.test.ts b/ui/src/e2e/initial-connect-splash.e2e.test.ts index e5ab013b1b6f..8ae3839ac934 100644 --- a/ui/src/e2e/initial-connect-splash.e2e.test.ts +++ b/ui/src/e2e/initial-connect-splash.e2e.test.ts @@ -1,5 +1,5 @@ // Control UI tests cover the initial-connect splash shown instead of the -// login gate while a first connect backed by stored credentials is in flight. +// login gate while the Gateway resolves its first connection attempt. import { mkdir } from "node:fs/promises"; import path from "node:path"; import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; @@ -45,6 +45,38 @@ async function captureProof(page: Page, name: string): Promise { await page.screenshot({ fullPage: true, path: path.join(artifactDir, `${name}.png`) }); } +async function traceLoginGateMounts(page: Page): Promise<() => Promise> { + await page.addInitScript(() => { + const trace = { mounted: false }; + ( + window as Window & { + openclawLoginGateMountTrace?: typeof trace; + } + ).openclawLoginGateMountTrace = trace; + new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes) { + if ( + node instanceof Element && + (node.localName === "openclaw-login-gate" || node.querySelector("openclaw-login-gate")) + ) { + trace.mounted = true; + } + } + } + }).observe(document, { childList: true, subtree: true }); + }); + return () => + page.evaluate( + () => + ( + window as Window & { + openclawLoginGateMountTrace?: { mounted: boolean }; + } + ).openclawLoginGateMountTrace?.mounted ?? false, + ); +} + describeControlUiE2e("Control UI initial connect splash E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -69,6 +101,7 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { it("shows the splash instead of the login gate while a configured token connects", async () => { const page = await createPage(); + const loginGateMounted = await traceLoginGateMounts(page); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); await page.goto(`${server.baseUrl}#token=e2e-shared-token`); @@ -93,6 +126,7 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { await gateway.resolveDeferred("connect"); await page.locator("openclaw-app-shell").waitFor(); expect(await page.locator(".connect-splash").count()).toBe(0); + expect(await loginGateMounted()).toBe(false); await captureProof(page, "02-connected-content"); }); @@ -159,14 +193,22 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { } }); - it("keeps the login gate for first connects without stored credentials", async () => { + it("shows the splash while a credential-less first connection resolves", async () => { const page = await createPage(); + const loginGateMounted = await traceLoginGateMounts(page); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); await page.goto(server.baseUrl); await gateway.waitForRequest("connect"); - await page.locator("openclaw-login-gate").waitFor(); + await page.locator(".connect-splash").waitFor(); + expect(await page.locator("openclaw-login-gate").count()).toBe(0); + expect(await loginGateMounted()).toBe(false); + await captureProof(page, "05-credentialless-connecting-mascot"); + + await gateway.resolveDeferred("connect"); + await page.locator("openclaw-app-shell").waitFor(); expect(await page.locator(".connect-splash").count()).toBe(0); + expect(await loginGateMounted()).toBe(false); }); it("falls back to the login gate when stored credentials are rejected", async () => { @@ -190,10 +232,10 @@ describeControlUiE2e("Control UI initial connect splash E2E", () => { const page = await createPage(); const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); - // First visit has no credentials: the login gate owns the pending connect. + // First visit has no credentials, but the Gateway still owns the pending attempt. await page.goto(server.baseUrl); await gateway.waitForRequest("connect"); - await page.locator("openclaw-login-gate").waitFor(); + await page.locator(".connect-splash").waitFor(); await gateway.resolveDeferred("connect"); await page.locator("openclaw-app-shell").waitFor();