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.
This commit is contained in:
Peter Steinberger
2026-08-07 17:18:02 -07:00
committed by GitHub
parent fa9626c4e1
commit 13e8e1df4a
5 changed files with 60 additions and 57 deletions
+5 -5
View File
@@ -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
-28
View File
@@ -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 {
-2
View File
@@ -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;
+8 -17
View File
@@ -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`
<openclaw-tooltip-provider>
+47 -5
View File
@@ -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<void> {
await page.screenshot({ fullPage: true, path: path.join(artifactDir, `${name}.png`) });
}
async function traceLoginGateMounts(page: Page): Promise<() => Promise<boolean>> {
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();