mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(dashboard): keep gateway tokens out of URL storage
This commit is contained in:
@@ -602,6 +602,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Dashboard/macOS auth handling: switch the macOS “Open Dashboard” flow from query-string token injection to URL fragments, stop persisting Control UI gateway tokens in browser localStorage, and scrub legacy stored tokens on load. Thanks @JNX03 for reporting.
|
||||||
- Models/provider config precedence: prefer exact `models.providers.<name>` matches before normalized provider aliases in embedded model resolution, preventing alias/canonical key collisions from applying the wrong provider `api`, `baseUrl`, or headers. (#35934) thanks @RealKai42.
|
- Models/provider config precedence: prefer exact `models.providers.<name>` matches before normalized provider aliases in embedded model resolution, preventing alias/canonical key collisions from applying the wrong provider `api`, `baseUrl`, or headers. (#35934) thanks @RealKai42.
|
||||||
- Hooks/auth throttling: reject non-`POST` `/hooks/*` requests before auth-failure accounting so unsupported methods can no longer burn the hook auth lockout budget and block legitimate webhook delivery. Thanks @JNX03 for reporting.
|
- Hooks/auth throttling: reject non-`POST` `/hooks/*` requests before auth-failure accounting so unsupported methods can no longer burn the hook auth lockout budget and block legitimate webhook delivery. Thanks @JNX03 for reporting.
|
||||||
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
|
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
|
||||||
|
|||||||
@@ -661,18 +661,20 @@ extension GatewayEndpointStore {
|
|||||||
components.path = "/"
|
components.path = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryItems: [URLQueryItem] = []
|
var fragmentItems: [URLQueryItem] = []
|
||||||
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
!token.isEmpty
|
!token.isEmpty
|
||||||
{
|
{
|
||||||
queryItems.append(URLQueryItem(name: "token", value: token))
|
fragmentItems.append(URLQueryItem(name: "token", value: token))
|
||||||
}
|
}
|
||||||
if let password = config.password?.trimmingCharacters(in: .whitespacesAndNewlines),
|
components.queryItems = nil
|
||||||
!password.isEmpty
|
if fragmentItems.isEmpty {
|
||||||
{
|
components.fragment = nil
|
||||||
queryItems.append(URLQueryItem(name: "password", value: password))
|
} else {
|
||||||
|
var fragment = URLComponents()
|
||||||
|
fragment.queryItems = fragmentItems
|
||||||
|
components.fragment = fragment.percentEncodedQuery
|
||||||
}
|
}
|
||||||
components.queryItems = queryItems.isEmpty ? nil : queryItems
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
throw NSError(domain: "Dashboard", code: 2, userInfo: [
|
||||||
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
NSLocalizedDescriptionKey: "Failed to build dashboard URL",
|
||||||
|
|||||||
@@ -216,6 +216,20 @@ import Testing
|
|||||||
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
|
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func dashboardURLUsesFragmentTokenAndOmitsPassword() throws {
|
||||||
|
let config: GatewayConnection.Config = try (
|
||||||
|
url: #require(URL(string: "ws://127.0.0.1:18789")),
|
||||||
|
token: "abc123",
|
||||||
|
password: "sekret")
|
||||||
|
|
||||||
|
let url = try GatewayEndpointStore.dashboardURL(
|
||||||
|
for: config,
|
||||||
|
mode: .local,
|
||||||
|
localBasePath: "/control")
|
||||||
|
#expect(url.absoluteString == "http://127.0.0.1:18789/control/#token=abc123")
|
||||||
|
#expect(url.query == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() {
|
@Test func normalizeGatewayUrlAddsDefaultPortForLoopbackWs() {
|
||||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1")
|
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.0.0.1")
|
||||||
#expect(url?.port == 18789)
|
#expect(url?.port == 18789)
|
||||||
|
|||||||
@@ -2503,7 +2503,7 @@ Your gateway is running with auth enabled (`gateway.auth.*`), but the UI is not
|
|||||||
|
|
||||||
Facts (from code):
|
Facts (from code):
|
||||||
|
|
||||||
- The Control UI stores the token in browser localStorage key `openclaw.control.settings.v1`.
|
- The Control UI keeps the token in memory for the current tab; it no longer persists gateway tokens in browser localStorage.
|
||||||
|
|
||||||
Fix:
|
Fix:
|
||||||
|
|
||||||
|
|||||||
@@ -231,13 +231,14 @@ http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
|
|||||||
Optional one-time auth (if needed):
|
Optional one-time auth (if needed):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token>
|
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
||||||
- `token` is stored in localStorage; `password` is kept in memory only.
|
- `token` is imported into memory for the current tab and stripped from the URL; it is not stored in localStorage.
|
||||||
|
- `password` is kept in memory only.
|
||||||
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
|
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
|
||||||
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
|
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
|
||||||
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
|
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ Authentication is enforced at the WebSocket handshake via `connect.params.auth`
|
|||||||
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
|
(token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration).
|
||||||
|
|
||||||
Security note: the Control UI is an **admin surface** (chat, config, exec approvals).
|
Security note: the Control UI is an **admin surface** (chat, config, exec approvals).
|
||||||
Do not expose it publicly. The UI stores the token in `localStorage` after first load.
|
Do not expose it publicly. The UI keeps dashboard URL tokens in memory for the current tab
|
||||||
|
and strips them from the URL after load.
|
||||||
Prefer localhost, Tailscale Serve, or an SSH tunnel.
|
Prefer localhost, Tailscale Serve, or an SSH tunnel.
|
||||||
|
|
||||||
## Fast path (recommended)
|
## Fast path (recommended)
|
||||||
@@ -36,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
|
|||||||
## Token basics (local vs remote)
|
## Token basics (local vs remote)
|
||||||
|
|
||||||
- **Localhost**: open `http://127.0.0.1:18789/`.
|
- **Localhost**: open `http://127.0.0.1:18789/`.
|
||||||
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect.
|
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); `openclaw dashboard` can pass it via URL fragment for one-time bootstrap, but the Control UI does not persist gateway tokens in localStorage.
|
||||||
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments.
|
- If `gateway.auth.token` is SecretRef-managed, `openclaw dashboard` prints/copies/opens a non-tokenized URL by design. This avoids exposing externally managed tokens in shell logs, clipboard history, or browser-launch arguments.
|
||||||
- If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance.
|
- If `gateway.auth.token` is configured as a SecretRef and is unresolved in your current shell, `openclaw dashboard` still prints a non-tokenized URL plus actionable auth setup guidance.
|
||||||
- **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
|
- **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export async function finalizeOnboardingWizard(
|
|||||||
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
|
"Stored in: ~/.openclaw/openclaw.json (gateway.auth.token) or OPENCLAW_GATEWAY_TOKEN.",
|
||||||
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
|
`View token: ${formatCliCommand("openclaw config get gateway.auth.token")}`,
|
||||||
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
|
`Generate token: ${formatCliCommand("openclaw doctor --generate-gateway-token")}`,
|
||||||
"Web UI stores a copy in this browser's localStorage (openclaw.control.settings.v1).",
|
"Web UI keeps dashboard URL tokens in memory for the current tab and strips them from the URL after load.",
|
||||||
`Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
`Open the dashboard anytime: ${formatCliCommand("openclaw dashboard --no-open")}`,
|
||||||
"If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).",
|
"If prompted: paste the token into Control UI settings (or use the tokenized dashboard URL).",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|||||||
@@ -151,6 +151,9 @@ describe("control UI routing", () => {
|
|||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
expect(app.settings.token).toBe("abc123");
|
expect(app.settings.token).toBe("abc123");
|
||||||
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(window.location.pathname).toBe("/ui/overview");
|
expect(window.location.pathname).toBe("/ui/overview");
|
||||||
expect(window.location.search).toBe("");
|
expect(window.location.search).toBe("");
|
||||||
});
|
});
|
||||||
@@ -167,12 +170,18 @@ describe("control UI routing", () => {
|
|||||||
it("hydrates token from URL params even when settings already set", async () => {
|
it("hydrates token from URL params even when settings already set", async () => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"openclaw.control.settings.v1",
|
"openclaw.control.settings.v1",
|
||||||
JSON.stringify({ token: "existing-token" }),
|
JSON.stringify({ token: "existing-token", gatewayUrl: "wss://gateway.example/openclaw" }),
|
||||||
);
|
);
|
||||||
const app = mountApp("/ui/overview?token=abc123");
|
const app = mountApp("/ui/overview?token=abc123");
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
expect(app.settings.token).toBe("abc123");
|
expect(app.settings.token).toBe("abc123");
|
||||||
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toMatchObject({
|
||||||
|
gatewayUrl: "wss://gateway.example/openclaw",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(window.location.pathname).toBe("/ui/overview");
|
expect(window.location.pathname).toBe("/ui/overview");
|
||||||
expect(window.location.search).toBe("");
|
expect(window.location.search).toBe("");
|
||||||
});
|
});
|
||||||
@@ -182,6 +191,9 @@ describe("control UI routing", () => {
|
|||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
expect(app.settings.token).toBe("abc123");
|
expect(app.settings.token).toBe("abc123");
|
||||||
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
expect(window.location.pathname).toBe("/ui/overview");
|
expect(window.location.pathname).toBe("/ui/overview");
|
||||||
expect(window.location.hash).toBe("");
|
expect(window.location.hash).toBe("");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,40 +24,147 @@ function createStorageMock(): Storage {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTestLocation(params: { protocol: string; host: string; pathname: string }) {
|
||||||
|
if (typeof window !== "undefined" && window.history?.replaceState) {
|
||||||
|
window.history.replaceState({}, "", params.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vi.stubGlobal("location", {
|
||||||
|
protocol: params.protocol,
|
||||||
|
host: params.host,
|
||||||
|
pathname: params.pathname,
|
||||||
|
} as Location);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setControlUiBasePath(value: string | undefined) {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"window",
|
||||||
|
value == null
|
||||||
|
? ({} as Window & typeof globalThis)
|
||||||
|
: ({ __OPENCLAW_CONTROL_UI_BASE_PATH__: value } as Window & typeof globalThis),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
delete window.__OPENCLAW_CONTROL_UI_BASE_PATH__;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", {
|
||||||
|
value,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedGatewayUrl(basePath: string): string {
|
||||||
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||||
|
return `${proto}://${location.host}${basePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
describe("loadSettings default gateway URL derivation", () => {
|
describe("loadSettings default gateway URL derivation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.stubGlobal("localStorage", createStorageMock());
|
vi.stubGlobal("localStorage", createStorageMock());
|
||||||
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
|
||||||
|
localStorage.clear();
|
||||||
|
setControlUiBasePath(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
setControlUiBasePath(undefined);
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses configured base path and normalizes trailing slash", async () => {
|
it("uses configured base path and normalizes trailing slash", async () => {
|
||||||
vi.stubGlobal("location", {
|
setTestLocation({
|
||||||
protocol: "https:",
|
protocol: "https:",
|
||||||
host: "gateway.example:8443",
|
host: "gateway.example:8443",
|
||||||
pathname: "/ignored/path",
|
pathname: "/ignored/path",
|
||||||
} as Location);
|
});
|
||||||
vi.stubGlobal("window", { __OPENCLAW_CONTROL_UI_BASE_PATH__: " /openclaw/ " } as Window &
|
setControlUiBasePath(" /openclaw/ ");
|
||||||
typeof globalThis);
|
|
||||||
|
|
||||||
const { loadSettings } = await import("./storage.ts");
|
const { loadSettings } = await import("./storage.ts");
|
||||||
expect(loadSettings().gatewayUrl).toBe("wss://gateway.example:8443/openclaw");
|
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/openclaw"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("infers base path from nested pathname when configured base path is not set", async () => {
|
it("infers base path from nested pathname when configured base path is not set", async () => {
|
||||||
vi.stubGlobal("location", {
|
setTestLocation({
|
||||||
protocol: "http:",
|
protocol: "http:",
|
||||||
host: "gateway.example:18789",
|
host: "gateway.example:18789",
|
||||||
pathname: "/apps/openclaw/chat",
|
pathname: "/apps/openclaw/chat",
|
||||||
} as Location);
|
});
|
||||||
vi.stubGlobal("window", {} as Window & typeof globalThis);
|
|
||||||
|
|
||||||
const { loadSettings } = await import("./storage.ts");
|
const { loadSettings } = await import("./storage.ts");
|
||||||
expect(loadSettings().gatewayUrl).toBe("ws://gateway.example:18789/apps/openclaw");
|
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/apps/openclaw"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores and scrubs legacy persisted tokens", async () => {
|
||||||
|
setTestLocation({
|
||||||
|
protocol: "https:",
|
||||||
|
host: "gateway.example:8443",
|
||||||
|
pathname: "/",
|
||||||
|
});
|
||||||
|
localStorage.setItem(
|
||||||
|
"openclaw.control.settings.v1",
|
||||||
|
JSON.stringify({
|
||||||
|
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||||
|
token: "persisted-token",
|
||||||
|
sessionKey: "agent",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadSettings } = await import("./storage.ts");
|
||||||
|
expect(loadSettings()).toMatchObject({
|
||||||
|
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||||
|
token: "",
|
||||||
|
sessionKey: "agent",
|
||||||
|
});
|
||||||
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
|
||||||
|
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||||
|
sessionKey: "agent",
|
||||||
|
lastActiveSessionKey: "agent",
|
||||||
|
theme: "system",
|
||||||
|
chatFocusMode: false,
|
||||||
|
chatShowThinking: true,
|
||||||
|
splitRatio: 0.6,
|
||||||
|
navCollapsed: false,
|
||||||
|
navGroupsCollapsed: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not persist gateway tokens when saving settings", async () => {
|
||||||
|
setTestLocation({
|
||||||
|
protocol: "https:",
|
||||||
|
host: "gateway.example:8443",
|
||||||
|
pathname: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { saveSettings } = await import("./storage.ts");
|
||||||
|
saveSettings({
|
||||||
|
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||||
|
token: "memory-only-token",
|
||||||
|
sessionKey: "main",
|
||||||
|
lastActiveSessionKey: "main",
|
||||||
|
theme: "system",
|
||||||
|
chatFocusMode: false,
|
||||||
|
chatShowThinking: true,
|
||||||
|
splitRatio: 0.6,
|
||||||
|
navCollapsed: false,
|
||||||
|
navGroupsCollapsed: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}")).toEqual({
|
||||||
|
gatewayUrl: "wss://gateway.example:8443/openclaw",
|
||||||
|
sessionKey: "main",
|
||||||
|
lastActiveSessionKey: "main",
|
||||||
|
theme: "system",
|
||||||
|
chatFocusMode: false,
|
||||||
|
chatShowThinking: true,
|
||||||
|
splitRatio: 0.6,
|
||||||
|
navCollapsed: false,
|
||||||
|
navGroupsCollapsed: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const KEY = "openclaw.control.settings.v1";
|
const KEY = "openclaw.control.settings.v1";
|
||||||
|
|
||||||
|
type PersistedUiSettings = Omit<UiSettings, "token"> & { token?: never };
|
||||||
|
|
||||||
import { isSupportedLocale } from "../i18n/index.ts";
|
import { isSupportedLocale } from "../i18n/index.ts";
|
||||||
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
|
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
|
||||||
import type { ThemeMode } from "./theme.ts";
|
import type { ThemeMode } from "./theme.ts";
|
||||||
@@ -50,12 +52,13 @@ export function loadSettings(): UiSettings {
|
|||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
const parsed = JSON.parse(raw) as Partial<UiSettings>;
|
||||||
return {
|
const settings = {
|
||||||
gatewayUrl:
|
gatewayUrl:
|
||||||
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
|
||||||
? parsed.gatewayUrl.trim()
|
? parsed.gatewayUrl.trim()
|
||||||
: defaults.gatewayUrl,
|
: defaults.gatewayUrl,
|
||||||
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
|
// Gateway auth is intentionally in-memory only; scrub any legacy persisted token on load.
|
||||||
|
token: defaults.token,
|
||||||
sessionKey:
|
sessionKey:
|
||||||
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
|
||||||
? parsed.sessionKey.trim()
|
? parsed.sessionKey.trim()
|
||||||
@@ -89,11 +92,31 @@ export function loadSettings(): UiSettings {
|
|||||||
: defaults.navGroupsCollapsed,
|
: defaults.navGroupsCollapsed,
|
||||||
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
|
locale: isSupportedLocale(parsed.locale) ? parsed.locale : undefined,
|
||||||
};
|
};
|
||||||
|
if ("token" in parsed) {
|
||||||
|
persistSettings(settings);
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
} catch {
|
} catch {
|
||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveSettings(next: UiSettings) {
|
export function saveSettings(next: UiSettings) {
|
||||||
localStorage.setItem(KEY, JSON.stringify(next));
|
persistSettings(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSettings(next: UiSettings) {
|
||||||
|
const persisted: PersistedUiSettings = {
|
||||||
|
gatewayUrl: next.gatewayUrl,
|
||||||
|
sessionKey: next.sessionKey,
|
||||||
|
lastActiveSessionKey: next.lastActiveSessionKey,
|
||||||
|
theme: next.theme,
|
||||||
|
chatFocusMode: next.chatFocusMode,
|
||||||
|
chatShowThinking: next.chatShowThinking,
|
||||||
|
splitRatio: next.splitRatio,
|
||||||
|
navCollapsed: next.navCollapsed,
|
||||||
|
navGroupsCollapsed: next.navGroupsCollapsed,
|
||||||
|
...(next.locale ? { locale: next.locale } : {}),
|
||||||
|
};
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(persisted));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user