mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 00:36:07 +00:00
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:
committed by
GitHub
parent
41425bf094
commit
6235344006
@@ -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
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
113
ui/src/e2e/initial-connect-splash.e2e.test.ts
Normal file
113
ui/src/e2e/initial-connect-splash.e2e.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
=========================================== */
|
||||
|
||||
Reference in New Issue
Block a user