mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
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:
committed by
GitHub
parent
fa9626c4e1
commit
13e8e1df4a
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user