fix(ui): stop flashing the login gate on dashboard load when credentials are stored (#101849)

First connects backed by stored auth (URL/session token, password, or an
approved device token) now paint a small wiggling OpenClaw mark (delayed
fade-in, reduced-motion aware) until the hello resolves. The login gate
still owns credential-less first opens, rejected credentials, and manual
gate submissions.

Closes #101847
This commit is contained in:
Peter Steinberger
2026-07-07 19:40:30 +01:00
committed by GitHub
parent 41425bf094
commit 6235344006
6 changed files with 272 additions and 12 deletions

View File

@@ -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

View File

@@ -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 &&

View File

@@ -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 <img src> —
// inlining the markup would freeze it (see ui/public/favicon.svg).
function renderConnectingSplash(basePath: string) {
return html`
<main class="connect-splash" role="status" aria-live="polite" aria-label=${t("common.loading")}>
<img
class="connect-splash__logo"
src=${controlUiPublicAssetPath("favicon.svg", basePath)}
alt=""
/>
</main>
`;
}
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<RouteId> | 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`
<openclaw-tooltip-provider>
${renderConnectingSplash(context.basePath)} ${gatewayUrlConfirmation}
</openclaw-tooltip-provider>
`;
}
const showLoginGate =
!this.gatewayConnected && (this.loginGatePinned || !this.gatewayReconnecting);
if (showLoginGate) {

View File

@@ -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<BrowserContext>();
async function createPage(): Promise<Page> {
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();
});
});

View File

@@ -651,6 +651,26 @@ async function generateIdentity(): Promise<DeviceIdentity> {
};
}
/**
* 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<DeviceIdentity> {
const storage = getSafeLocalStorage();
try {

View File

@@ -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
=========================================== */