diff --git a/CHANGELOG.md b/CHANGELOG.md index f987feeec35..4fcfd49096e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes. +- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence. ## 2026.3.8 diff --git a/docs/help/faq.md b/docs/help/faq.md index 0ea9c4d92d5..c4ade05c63d 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -2504,7 +2504,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not Facts (from code): -- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage. +- The Control UI keeps the token in `sessionStorage` for the current browser tab session, so same-tab refreshes keep working without restoring long-lived localStorage token persistence. Fix: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index bbee9443b83..afcd36e1f04 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -27,7 +27,7 @@ Auth is supplied during the WebSocket handshake via: - `connect.params.auth.token` - `connect.params.auth.password` - The dashboard settings panel lets you store a token; passwords are not persisted. + The dashboard settings panel keeps a token for the current browser tab session; passwords are not persisted. The onboarding wizard generates a gateway token by default, so paste it here on first connect. ## Device pairing (first connection) @@ -237,7 +237,7 @@ http://localhost:5173/?gatewayUrl=wss://:18789#token= { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123"); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( undefined, ); @@ -179,6 +180,7 @@ describe("control UI routing", () => { expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({ gatewayUrl: "wss://gateway.example/openclaw", }); + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123"); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( undefined, ); @@ -191,10 +193,26 @@ describe("control UI routing", () => { await app.updateComplete; expect(app.settings.token).toBe("abc123"); + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123"); expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( undefined, ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); }); + + it("restores the token after a same-tab refresh", async () => { + const first = mountApp("/ui/overview?token=abc123"); + await first.updateComplete; + first.remove(); + + const refreshed = mountApp("/ui/overview"); + await refreshed.updateComplete; + + expect(refreshed.settings.token).toBe("abc123"); + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); + }); }); diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index 34563291fe3..3a1c174d5d5 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -66,8 +66,10 @@ describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); vi.stubGlobal("navigator", { language: "en-US" } as Navigator); localStorage.clear(); + sessionStorage.clear(); setControlUiBasePath(undefined); }); @@ -132,6 +134,22 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navGroupsCollapsed: {}, }); + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBeNull(); + }); + + it("loads the current-tab token from sessionStorage", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + sessionStorage.setItem("openclaw.control.token.v1", "session-token"); + + const { loadSettings } = await import("./storage.ts"); + expect(loadSettings()).toMatchObject({ + gatewayUrl: "wss://gateway.example:8443", + token: "session-token", + }); }); it("does not persist gateway tokens when saving settings", async () => { @@ -166,5 +184,31 @@ describe("loadSettings default gateway URL derivation", () => { navCollapsed: false, navGroupsCollapsed: {}, }); + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBe("memory-only-token"); + }); + + it("clears the current-tab token when saving an empty token", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + sessionStorage.setItem("openclaw.control.token.v1", "stale-token"); + + const { saveSettings } = await import("./storage.ts"); + saveSettings({ + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navGroupsCollapsed: {}, + }); + + expect(sessionStorage.getItem("openclaw.control.token.v1")).toBeNull(); }); }); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index b413cf38eb5..7ac29d4e1ac 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -1,4 +1,5 @@ const KEY = "openclaw.control.settings.v1"; +const TOKEN_SESSION_KEY = "openclaw.control.token.v1"; type PersistedUiSettings = Omit & { token?: never }; @@ -20,6 +21,43 @@ export type UiSettings = { locale?: string; }; +function getSessionStorage(): Storage | null { + if (typeof window !== "undefined" && window.sessionStorage) { + return window.sessionStorage; + } + if (typeof sessionStorage !== "undefined") { + return sessionStorage; + } + return null; +} + +function loadSessionToken(): string { + try { + const storage = getSessionStorage(); + const token = storage?.getItem(TOKEN_SESSION_KEY) ?? ""; + return token.trim(); + } catch { + return ""; + } +} + +function persistSessionToken(token: string) { + try { + const storage = getSessionStorage(); + if (!storage) { + return; + } + const normalized = token.trim(); + if (normalized) { + storage.setItem(TOKEN_SESSION_KEY, normalized); + return; + } + storage.removeItem(TOKEN_SESSION_KEY); + } catch { + // best-effort + } +} + export function loadSettings(): UiSettings { const defaultUrl = (() => { const proto = location.protocol === "https:" ? "wss" : "ws"; @@ -35,7 +73,7 @@ export function loadSettings(): UiSettings { const defaults: UiSettings = { gatewayUrl: defaultUrl, - token: "", + token: loadSessionToken(), sessionKey: "main", lastActiveSessionKey: "main", theme: "system", @@ -106,6 +144,7 @@ export function saveSettings(next: UiSettings) { } function persistSettings(next: UiSettings) { + persistSessionToken(next.token); const persisted: PersistedUiSettings = { gatewayUrl: next.gatewayUrl, sessionKey: next.sessionKey, diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index d6fda9475c4..e078b186203 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -16,12 +16,14 @@ export function registerAppMountHooks() { beforeEach(() => { window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; localStorage.clear(); + sessionStorage.clear(); document.body.innerHTML = ""; }); afterEach(() => { window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined; localStorage.clear(); + sessionStorage.clear(); document.body.innerHTML = ""; }); }