diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index f949e7da97db..25300ab7751c 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -287,9 +287,11 @@ stays visible with a floating amber "Gateway connection lost — Reconnecting… bar while the client retries automatically with backoff (800 ms up to 15 s). Live updates and actions pause until the connection returns; **Retry now** in the pill forces an immediate attempt. -The login gate only appears when there is no established session yet (first open, page reload -before connect) or when the Gateway actively rejects the credentials (bad token/password, revoked -pairing) — states that need your input rather than waiting. +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. ## PWA install and web push diff --git a/ui/src/api/gateway.ts b/ui/src/api/gateway.ts index 144abb358b7e..ef02ed478375 100644 --- a/ui/src/api/gateway.ts +++ b/ui/src/api/gateway.ts @@ -26,6 +26,7 @@ import { loadDeviceAuthToken, storeDeviceAuthToken, loadOrCreateDeviceIdentity, + peekStoredDeviceIdentityId, signDevicePayload, } from "../lib/nodes/index.ts"; import { generateUUID } from "../lib/uuid.ts"; @@ -478,6 +479,52 @@ async function buildGatewayConnectDevice(params: { }; } +// Operator connects only trust stored device tokens that can at least read; +// weaker tokens go through the pairing upgrade flow instead of silent auth. +function storedDeviceTokenScopesAllowRead(role: string, scopes: readonly string[]): boolean { + return ( + role !== CONTROL_UI_OPERATOR_ROLE || + scopes.includes("operator.read") || + scopes.includes("operator.write") || + scopes.includes("operator.admin") + ); +} + +/** + * True when the next connect() from this browser would present stored + * credentials: an explicit token/password, or a readable stored device token + * in a secure context. Render gating only — lets the app paint a connecting + * state instead of flashing the login gate while a likely-authenticated first + * attempt is in flight. connect() remains the source of truth for auth. + */ +export function hasStoredGatewayAuth(params: { + gatewayUrl: string; + token?: string; + password?: string; +}): boolean { + if (params.token?.trim() || params.password?.trim()) { + return true; + } + // Mirrors buildConnectPlan: insecure contexts skip device identity, so a + // stored device token would not be presented and must not suppress the gate. + 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, + }); + if (!storedEntry) { + return false; + } + return storedDeviceTokenScopesAllowRead(CONTROL_UI_OPERATOR_ROLE, storedEntry.scopes); +} + export function shouldRetryWithDeviceToken(params: DeviceTokenRetryDecision): boolean { return ( !params.deviceTokenRetryBudgetUsed && @@ -1048,12 +1095,10 @@ export class GatewayBrowserClient { gatewayUrl: this.opts.url, role: params.role, }); - const storedScopes = storedEntry?.scopes ?? []; - const storedTokenCanRead = - params.role !== CONTROL_UI_OPERATOR_ROLE || - storedScopes.includes("operator.read") || - storedScopes.includes("operator.write") || - storedScopes.includes("operator.admin"); + const storedTokenCanRead = storedDeviceTokenScopesAllowRead( + params.role, + storedEntry?.scopes ?? [], + ); const storedToken = storedTokenCanRead ? storedEntry?.token : undefined; const shouldUseDeviceRetryToken = this.pendingDeviceTokenRetry && diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 71c8ead723c9..71c0fe540faf 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -2,7 +2,7 @@ import { consume, ContextProvider } from "@lit/context"; import type { RouteLocation, RouterState } from "@openclaw/uirouter"; import { html, LitElement, nothing } from "lit"; import { property, query, state } from "lit/decorators.js"; -import type { GatewayBrowserClient } from "../api/gateway.ts"; +import { hasStoredGatewayAuth, type GatewayBrowserClient } from "../api/gateway.ts"; import type { AgentsListResult } from "../api/types.ts"; import "../components/app-sidebar.ts"; import "../components/app-topbar.ts"; @@ -39,6 +39,7 @@ import { } from "./context.ts"; import { hasOperatorAdminAccess } from "./operator-access.ts"; import type { ApplicationOverlaySnapshot } from "./overlays.ts"; +import { controlUiPublicAssetPath } from "./public-assets.ts"; import { selectRenderedRouteMatch } from "./router-outlet.ts"; type ShellRouteState = { @@ -99,6 +100,20 @@ function resolveTerminalThemeMode(): "dark" | "light" { return document.documentElement.dataset.themeMode === "light" ? "light" : "dark"; } +// The mascot SVG animates via SMIL, so it must load through — +// inlining the markup would freeze it (see ui/public/favicon.svg). +function renderConnectingSplash(basePath: string) { + return html` +
+ +
+ `; +} + function isTerminalAvailable( snapshot: ApplicationContext["gateway"]["snapshot"], terminalEnabled: boolean, @@ -131,6 +146,10 @@ export class OpenClawApp extends LitElement { @state() private terminalClient: GatewayBrowserClient | null = null; 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 context: ApplicationContext | undefined; private readonly contextProvider = new ContextProvider(this, { @@ -147,6 +166,7 @@ export class OpenClawApp extends LitElement { super.connectedCallback(); this.runtime = bootstrapApplication(); this.context = this.runtime.context; + this.initialAuthPresent = hasStoredGatewayAuth(this.context.gateway.connection); this.pendingGatewayUrl = this.runtime.pendingGatewayConnection?.gatewayUrl ?? null; this.contextProvider.setValue(this.context); this.syncLoginConnection(); @@ -262,7 +282,24 @@ export class OpenClawApp extends LitElement { } // Transport drops after an established session keep the shell mounted // (offline banner + client auto-retry); the login gate is reserved for - // first connects, credential rejections, and manual gate submissions. + // 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). + const initialConnectPending = + this.initialAuthPresent && + !this.gatewayConnected && + !this.gatewayReconnecting && + !this.loginGatePinned && + this.gatewayLastError === null && + context.gateway.snapshot.client !== null; + if (initialConnectPending) { + return html` + + ${renderConnectingSplash(context.basePath)} ${gatewayUrlConfirmation} + + `; + } const showLoginGate = !this.gatewayConnected && (this.loginGatePinned || !this.gatewayReconnecting); if (showLoginGate) { diff --git a/ui/src/e2e/initial-connect-splash.e2e.test.ts b/ui/src/e2e/initial-connect-splash.e2e.test.ts new file mode 100644 index 000000000000..4c58d9f31ba9 --- /dev/null +++ b/ui/src/e2e/initial-connect-splash.e2e.test.ts @@ -0,0 +1,113 @@ +// 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. +import { chromium, type Browser, type BrowserContext, type Page } from "playwright"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; + +let browser: Browser; +let server: ControlUiE2eServer; +const openContexts = new Set(); + +async function createPage(): Promise { + const context = await browser.newContext({ viewport: { height: 900, width: 1280 } }); + openContexts.add(context); + return await context.newPage(); +} + +describeControlUiE2e("Control UI initial connect splash E2E", () => { + beforeAll(async () => { + if (!chromiumAvailable) { + throw new Error( + `Playwright Chromium is not installed or cannot start at ${chromiumExecutablePath}.`, + ); + } + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + await browser?.close(); + await server?.close(); + }); + + afterEach(async () => { + await Promise.all([...openContexts].map((context) => context.close().catch(() => {}))); + openContexts.clear(); + }); + + it("shows the splash instead of the login gate while a configured token connects", async () => { + const page = await createPage(); + const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); + + await page.goto(`${server.baseUrl}#token=e2e-shared-token`); + await gateway.waitForRequest("connect"); + await page.locator(".connect-splash").waitFor(); + expect(await page.locator("openclaw-login-gate").count()).toBe(0); + + await gateway.resolveDeferred("connect"); + await page.locator("openclaw-app-shell").waitFor(); + expect(await page.locator(".connect-splash").count()).toBe(0); + }); + + it("keeps the login gate for first connects without stored credentials", async () => { + const page = await createPage(); + const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); + + await page.goto(server.baseUrl); + await gateway.waitForRequest("connect"); + await page.locator("openclaw-login-gate").waitFor(); + expect(await page.locator(".connect-splash").count()).toBe(0); + }); + + it("falls back to the login gate when stored credentials are rejected", async () => { + const page = await createPage(); + const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); + + await page.goto(`${server.baseUrl}#token=stale-token`); + await gateway.waitForRequest("connect"); + await page.locator(".connect-splash").waitFor(); + + await gateway.rejectDeferred("connect", { + code: "UNAUTHORIZED", + message: "unauthorized: gateway token mismatch", + details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH }, + }); + await page.locator("openclaw-login-gate").waitFor(); + expect(await page.locator(".connect-splash").count()).toBe(0); + }); + + it("uses the splash for a stored device token on reload", async () => { + const page = await createPage(); + const gateway = await installMockGateway(page, { deferredMethods: ["connect"] }); + + // First visit has no credentials: the login gate owns the pending connect. + await page.goto(server.baseUrl); + await gateway.waitForRequest("connect"); + await page.locator("openclaw-login-gate").waitFor(); + await gateway.resolveDeferred("connect"); + await page.locator("openclaw-app-shell").waitFor(); + + // The hello stored a device token, so the reload connect is authenticated + // and must paint the splash instead of flashing the gate. + await page.reload(); + await gateway.waitForRequest("connect"); + await page.locator(".connect-splash").waitFor(); + expect(await page.locator("openclaw-login-gate").count()).toBe(0); + + await gateway.resolveDeferred("connect"); + await page.locator("openclaw-app-shell").waitFor(); + }); +}); diff --git a/ui/src/lib/nodes/index.ts b/ui/src/lib/nodes/index.ts index ba757b029b8c..24d25efbc527 100644 --- a/ui/src/lib/nodes/index.ts +++ b/ui/src/lib/nodes/index.ts @@ -651,6 +651,26 @@ async function generateIdentity(): Promise { }; } +/** + * Synchronous identity probe for render gating: reads the stored device id + * without creating, repairing, or fingerprint-verifying an identity, so a + * "do we hold credentials?" check stays side-effect free before connect(). + */ +export function peekStoredDeviceIdentityId(): string | null { + try { + const raw = getSafeLocalStorage()?.getItem(DEVICE_IDENTITY_STORAGE_KEY); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as StoredIdentity; + return parsed?.version === 1 && typeof parsed.deviceId === "string" && parsed.deviceId + ? parsed.deviceId + : null; + } catch { + return null; + } +} + export async function loadOrCreateDeviceIdentity(): Promise { const storage = getSafeLocalStorage(); try { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index b2af1ce1717c..2a5e8b1f4be3 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -1,3 +1,47 @@ +/* =========================================== + Connect Splash (first connect with stored credentials) + =========================================== */ + +.connect-splash { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + height: 100dvh; + max-height: 100%; + background: var(--bg); + /* Stay invisible past fast handshakes so a quick connect paints nothing. */ + opacity: 0; + animation: fade-in 0.3s var(--ease-out) 0.2s forwards; +} + +.connect-splash__logo { + width: 44px; + height: 44px; + animation: connect-splash-wiggle 1.8s ease-in-out infinite; +} + +@keyframes connect-splash-wiggle { + 0%, + 100% { + transform: rotate(-6deg); + } + 50% { + transform: rotate(6deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .connect-splash { + opacity: 1; + animation: none; + } + + .connect-splash__logo { + animation: none; + } +} + /* =========================================== Login Gate =========================================== */ @@ -633,7 +677,6 @@ color: var(--danger); } - /* =========================================== Status Dot - With glow for emphasis =========================================== */