fix(ui): restore control-ui query token imports

This commit is contained in:
大禹 2026-03-12 19:20:02 +08:00
parent 4f620bebe5
commit 9872c551c2
4 changed files with 116 additions and 4 deletions

View File

@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; it is not stored in localStorage.
- `token` is preferably imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; legacy `?token=` query params are also imported once for compatibility and then removed.
- `password` is kept in memory only.
- 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.

View File

@ -54,6 +54,8 @@ type SettingsHost = {
themeMediaHandler: ((event: MediaQueryListEvent) => void) | null;
logsPollInterval: number | null;
debugPollInterval: number | null;
pendingGatewayUrl?: string | null;
pendingGatewayToken?: string | null;
};
function createStorageMock(): Storage {
@ -80,6 +82,49 @@ function createStorageMock(): Storage {
};
}
function setTestWindowUrl(urlString: string) {
const current = new URL(urlString);
const history = {
replaceState: vi.fn((_state: unknown, _title: string, nextUrl: string | URL) => {
const next = new URL(String(nextUrl), current.toString());
current.href = next.toString();
current.protocol = next.protocol;
current.host = next.host;
current.pathname = next.pathname;
current.search = next.search;
current.hash = next.hash;
}),
};
const locationLike = {
get href() {
return current.toString();
},
get protocol() {
return current.protocol;
},
get host() {
return current.host;
},
get pathname() {
return current.pathname;
},
get search() {
return current.search;
},
get hash() {
return current.hash;
},
};
vi.stubGlobal("window", {
location: locationLike,
history,
setInterval,
clearInterval,
} as unknown as Window & typeof globalThis);
vi.stubGlobal("location", locationLike as Location);
return { history, location: locationLike };
}
const createHost = (tab: Tab): SettingsHost => ({
settings: {
gatewayUrl: "",
@ -233,3 +278,48 @@ describe("setTabFromRoute", () => {
expect(root.style.colorScheme).toBe("light");
});
});
describe("applySettingsFromUrl", () => {
let appSettings: AppSettingsModule;
beforeEach(() => {
vi.resetModules();
vi.stubGlobal("localStorage", createStorageMock());
vi.stubGlobal("sessionStorage", createStorageMock());
vi.stubGlobal("navigator", { language: "en-US" } as Navigator);
setTestWindowUrl("https://control.example/ui/overview");
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("hydrates query token params and strips them from the URL", async () => {
appSettings ??= await import("./app-settings.ts");
setTestWindowUrl("https://control.example/ui/overview?token=abc123");
const host = createHost("overview");
host.settings.gatewayUrl = "wss://control.example/openclaw";
appSettings.applySettingsFromUrl(host);
expect(host.settings.token).toBe("abc123");
expect(window.location.search).toBe("");
});
it("keeps query token params pending when a gatewayUrl confirmation is required", async () => {
appSettings ??= await import("./app-settings.ts");
setTestWindowUrl(
"https://control.example/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123",
);
const host = createHost("overview");
host.settings.gatewayUrl = "wss://control.example/openclaw";
appSettings.applySettingsFromUrl(host);
expect(host.settings.token).toBe("");
expect(host.pendingGatewayUrl).toBe("wss://other-gateway.example/openclaw");
expect(host.pendingGatewayToken).toBe("abc123");
expect(window.location.search).toBe("");
});
});

View File

@ -107,7 +107,7 @@ export function applySettingsFromUrl(host: SettingsHost) {
const gatewayUrlRaw = params.get("gatewayUrl") ?? hashParams.get("gatewayUrl");
const nextGatewayUrl = gatewayUrlRaw?.trim() ?? "";
const gatewayUrlChanged = Boolean(nextGatewayUrl && nextGatewayUrl !== host.settings.gatewayUrl);
const tokenRaw = hashParams.get("token");
const tokenRaw = hashParams.get("token") ?? params.get("token");
const passwordRaw = params.get("password") ?? hashParams.get("password");
const sessionRaw = params.get("session") ?? hashParams.get("session");
let shouldCleanUrl = false;

View File

@ -146,11 +146,11 @@ describe("control UI routing", () => {
expect(container.scrollTop).toBe(maxScroll);
});
it("strips query token params without importing them", async () => {
it("hydrates token from query params and strips them", async () => {
const app = mountApp("/ui/overview?token=abc123");
await app.updateComplete;
expect(app.settings.token).toBe("");
expect(app.settings.token).toBe("abc123");
expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe(
undefined,
);
@ -236,6 +236,28 @@ describe("control UI routing", () => {
expect(window.location.hash).toBe("");
});
it("keeps a query token pending until the gateway URL change is confirmed", async () => {
const app = mountApp(
"/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123",
);
await app.updateComplete;
expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw");
expect(app.settings.token).toBe("");
const confirmButton = Array.from(app.querySelectorAll<HTMLButtonElement>("button")).find(
(button) => button.textContent?.trim() === "Confirm",
);
expect(confirmButton).not.toBeUndefined();
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await app.updateComplete;
expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw");
expect(app.settings.token).toBe("abc123");
expect(window.location.search).toBe("");
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;