From 7a92d170997bac67fda5d2d24014a7dfef0f3518 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 02:40:18 +0900 Subject: [PATCH 01/10] fix(gui): show verified Claude account in overview --- .../src/content/docs/guides/claude-code.md | 4 +- .../docs/reference/configuration/server.md | 7 +- .../provider-workspace/ProviderDetails.tsx | 5 +- .../provider-workspace/ProviderOverview.tsx | 7 +- gui/src/hooks/useProviderAccountPools.ts | 32 +++++++-- gui/src/pages/Providers.tsx | 6 +- .../anthropic2-provider-surfaces.test.tsx | 20 ++++++ .../provider-account-pause-refresh.test.tsx | 62 ++++++++++++++++- .../provider-account-quota-loading.test.tsx | 66 +++++++++++++++++++ .../provider-first-response-guidance.test.tsx | 33 +++++++++- 10 files changed, 227 insertions(+), 15 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index d3e46fbbd37..d1f12866b13 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -79,7 +79,9 @@ The dashboard shows Pro, Max 5x, or Ultra beside Claude account quotas, using su metadata from the authenticated profile. Both `ultra` and `claude_max_20x` display as Ultra (the existing `ultra` and `max_20x` tier aliases are retained). Unfamiliar validated tiers retain their original name without failing the usage read. Claude account rows prefer your alias, then a masked email, and -fall back to a numbered label when no safe identity is available. +fall back to a numbered label when no safe identity is available. The provider Overview shows the +same label as a separate current-account field when its account list is verified. Email labels stay +masked even when `privacy.maskEmails` is false. You can log in multiple Claude accounts via the Providers dashboard (`ocx login anthropic` / add-account). By default every request uses the **active** account only. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 7c435e2b08e..f1c809e3352 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -463,13 +463,16 @@ use `ssh -g -L`, broad container publishing, or forwarding modes that expose the ## Account email masking (`privacy`) -Stored account emails are masked everywhere they leave the proxy — the dashboard account lists, +Stored account emails are masked by default wherever they leave the proxy — the dashboard account lists, `GET /api/codex-auth/accounts`, `GET /api/oauth/status`, and the `ocx status` logins section all show `p***n@example.com` rather than the address on file. | Field | Type | Default | Meaning | | --- | --- | --- | --- | -| `privacy.maskEmails?` | `boolean` | `true` | Set to `false` to show stored account emails in full. Absent, `true`, and any malformed value all keep masking, so only a deliberate `false` reveals an address. | +| `privacy.maskEmails?` | `boolean` | `true` | Set to `false` to reveal stored account emails in projected email fields. Absent, `true`, and any malformed value keep masking, so only a deliberate `false` reveals an address. | + +Claude dashboard account labels remain masked even when this flag is `false`; an account alias +takes precedence over the masked label. The active account uses the same label in Overview. Turn it off when you run many accounts on a machine you control and cannot tell them apart from masked forms. Read the flag as a disclosure decision rather than a display preference: management diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index ab45351a1f4..cbaeade2fd2 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -8,7 +8,7 @@ import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { formatProviderDisplayName } from "../../provider-icons"; import { isAccountProvider, isFreeProvider } from "../../provider-workspace/catalog"; import { isLocalProvider } from "../../provider-workspace/kind"; -import { providerAuthSurface } from "../../provider-workspace/auth"; +import { oauthAccountDisplayLabel, providerAuthSurface } from "../../provider-workspace/auth"; import { ProviderIcon } from "./ProviderRail"; import { Switch } from "../../ui"; import { IconChevron, IconTrash } from "../../icons"; @@ -136,6 +136,7 @@ export default function ProviderDetails({ const free = useMemo(() => isFreeProvider(item), [item]); const local = useMemo(() => isLocalProvider(item), [item]); const authSurface = useMemo(() => providerAuthSurface(item), [item]); + const activeOAuthAccount = accounts?.find(account => account.active); const currentQuotaReading = authSurface === "oauth-accounts" ? accounts?.find(account => account.active) : authSurface === "api-keys" ? keys?.find(entry => entry.active) : undefined; @@ -372,6 +373,8 @@ export default function ProviderDetails({ currentQuotaReading={currentQuotaReading} onRefreshQuota={onRefreshQuota} oauthEmail={oauthEmail} + currentAccountLabel={authSurface === "oauth-accounts" && accountLoadState === "ready" && activeOAuthAccount + ? oauthAccountDisplayLabel(accounts ?? [], activeOAuthAccount, t) : undefined} oauth={oauth} onEditSettings={() => switchTab("settings")} onViewUsage={() => switchTab("usage")} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 4912b2ed86a..9c8dfe69e5d 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -37,7 +37,7 @@ type ConnectionTestState = { }; export default function ProviderOverview({ - item, preset, usageTotals, quotaReport, currentQuotaReading, onRefreshQuota, oauthEmail, oauth, + item, preset, usageTotals, quotaReport, currentQuotaReading, onRefreshQuota, oauthEmail, currentAccountLabel, oauth, apiBase, connectionIdentity, probeController, availableModels = [], selectedModels, modelRows, modelRowsReady = false, modelsLoading = false, modelsLoadFailed = false, onRetryModels, onOpenAccounts, onOpenModelsTab, onOpenModels, onOpenConnect, onEditSettings, onViewUsage, onUpdateProvider, @@ -51,6 +51,7 @@ export default function ProviderOverview({ currentQuotaReading?: AccountQuotaReading; onRefreshQuota?: () => Promise; oauthEmail?: string; + currentAccountLabel?: string; /** Login state for OAuth summaries that carry no email (e.g. Cursor/Kimi). */ oauth?: { loggedIn?: boolean; needsReauth?: boolean }; apiBase?: string; @@ -246,6 +247,7 @@ export default function ProviderOverview({ ? t("pws.firstResponse.probeReverify", { model: reverifyModel }) : t("pws.firstResponse.probeUntested"); const displayEmail = isAnthropicInstanceId(item.name) && anthropicInstanceRowShapeMatches(item.name, item) ? undefined : oauthEmail; + const currentClaudeAccount = isAnthropicInstanceId(item.name) && anthropicInstanceRowShapeMatches(item.name, item) ? currentAccountLabel : undefined; const openModelsAction = onOpenModelsTab ?? onOpenModels; const fixCredentialsAction = onOpenAccounts ?? onEditSettings; @@ -274,6 +276,9 @@ export default function ProviderOverview({
{t("pws.cell.auth")}
{displayEmail ? `${authModeLabel(item, t)} · ${displayEmail}` : authModeLabel(item, t)}
+ {currentClaudeAccount &&
+
{t("pws.accountCurrent")}
{currentClaudeAccount}
+
}
{t("modal.defaultModel")}
{item.defaultModel ?? —}
diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index afdb6d8a4cb..d9a61721d35 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -232,7 +232,8 @@ export function useProviderAccountPools(deps: { activeAccountId: data.activeAccountId ?? null, accounts: mergeQuotaRows(rows, current[provider]?.accounts ?? [], false), } } : current); - setAccountLoadStates(current => currentRoster() ? { ...current, [provider]: "ready" } : current); + setAccountLoadStates(current => currentRoster() ? { ...current, + [provider]: selectionMutationsRef.current.has(key) ? "loading" : "ready" } : current); if (!rows.some(supportsQuotaRead)) return true; const enrich = async (): Promise => { @@ -352,13 +353,16 @@ export function useProviderAccountPools(deps: { if (selectionMutationsRef.current.has(key)) return false; const generation = (rosterGenerationRef.current[key] ?? 0) + 1; rosterGenerationRef.current[key] = generation; - const currentRequest = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase - && !signal?.aborted && rosterGenerationRef.current[key] === generation && !selectionMutationsRef.current.has(key); + const ownsRoster = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase + && rosterGenerationRef.current[key] === generation && !selectionMutationsRef.current.has(key); + const currentRequest = () => ownsRoster() && !signal?.aborted; + if (kind === "oauth") setAccountLoadStates(current => currentRequest() ? { ...current, [provider]: "loading" } : current); try { if (kind === "oauth") { const data = await readRoster<{ activeAccountId?: string | null; accounts?: OAuthAccount[] }>( `${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}`, signal); - if (!Array.isArray(data.accounts) || !currentRequest()) return false; + if (!Array.isArray(data.accounts)) throw new Error("Invalid account roster"); + if (!currentRequest()) return false; const rows = selectionRows(data.accounts, data.activeAccountId); setAccountSets(current => currentRequest() ? { ...current, [provider]: { activeAccountId: data.activeAccountId === undefined ? rows.find(row => row.active)?.id ?? null : data.activeAccountId, @@ -375,6 +379,7 @@ export function useProviderAccountPools(deps: { return true; } catch { // A missed invalidation read does not change quota health; recovery retries it. + if (kind === "oauth") setAccountLoadStates(current => ownsRoster() ? { ...current, [provider]: "error" } : current); return false; } })); @@ -385,6 +390,9 @@ export function useProviderAccountPools(deps: { const key = `${kind === "oauth" ? "oauth" : "key"}:${provider}`; accountRequestGenerationRef.current[key] = (accountRequestGenerationRef.current[key] ?? 0) + 1; rosterGenerationRef.current[key] = (rosterGenerationRef.current[key] ?? 0) + 1; + if (kind === "oauth" && aliveRef.current && mountedRef.current && serverRef.current === apiBase) { + setAccountLoadStates(current => ({ ...current, [provider]: "loading" })); + } // The independent quota generation still owns pending/error flags for surviving IDs. return key; }; @@ -602,6 +610,7 @@ export function useProviderAccountPools(deps: { const rememberedRetry = affinityResetRetries(apiBase)[provider]; const target = { provider, accountId }; const mutation = Symbol(); + invalidateSelectionReads(provider, "oauth"); selectionMutationsRef.current.set(key, mutation); switchingAccountRef.current.set(provider, target); setSwitchingAccounts(current => ({ ...current, [provider]: target })); @@ -632,7 +641,13 @@ export function useProviderAccountPools(deps: { if (currentRetry()) notify(t("prov.affinityResetPending"), false); } finally { receipt(); - if (selectionMutationsRef.current.get(key) === mutation) selectionMutationsRef.current.delete(key); + if (selectionMutationsRef.current.get(key) === mutation) { + selectionMutationsRef.current.delete(key); + if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) { + invalidateSelectionReads(provider, "oauth"); + void refreshAccountRosters({ provider, kind: "oauth" }); + } + } if (switchingAccountRef.current.get(provider) === target) { switchingAccountRef.current.delete(provider); if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) { @@ -810,14 +825,17 @@ export function useProviderAccountPools(deps: { notify(t("prov.accountRemoved", { email: label }), true); } await fetchAccountSets([provider]); - await Promise.all([fetchOauth(), fetchProviderQuotas(true)]); + void fetchOauth().catch(() => { + if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) notify(t("pws.accountsLoadFailed"), false); + }); + await fetchProviderQuotas(true); } catch { if (current()) notify(t(committed ? "pws.accountsLoadFailed" : "prov.accountRemoveFail", { email: label }), false); } finally { receipt(); if (selectionMutationsRef.current.get(key) === mutation) { selectionMutationsRef.current.delete(key); - if (committed && aliveRef.current && mountedRef.current && serverRef.current === apiBase) { + if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) { invalidateSelectionReads(provider, "oauth"); void refreshAccountRosters({ provider, kind: "oauth" }); } diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 4e9cae60efd..c2e3ae5f085 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -213,7 +213,11 @@ function useAccountSelectionEvents( return useCallback(() => recoverRef.current(), []); } -export default function Providers({ apiBase }: { apiBase: string }) { +export default function Providers(props: { apiBase: string }) { + return ; +} + +function ProvidersPage({ apiBase }: { apiBase: string }) { const t = useT(); const configCacheKey = `ocx.providers.config.v1:${apiBase}`; const [config, setConfig] = useState( diff --git a/gui/tests/anthropic2-provider-surfaces.test.tsx b/gui/tests/anthropic2-provider-surfaces.test.tsx index bc973850cba..dd85ef18908 100644 --- a/gui/tests/anthropic2-provider-surfaces.test.tsx +++ b/gui/tests/anthropic2-provider-surfaces.test.tsx @@ -13,6 +13,8 @@ import { en } from "../src/i18n/en"; import { LanguageProvider } from "../src/i18n/provider"; import { useT, type TFn } from "../src/i18n/shared"; import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel"; +import ProviderDetails from "../src/components/provider-workspace/ProviderDetails"; +import { renderToStaticMarkup } from "react-dom/server"; import CatalogAccountRow from "../src/components/provider-catalog/CatalogAccountRow"; import type { OAuthAccountRow, ProviderAuthHandlers } from "../src/components/provider-workspace/types"; import type { WorkspaceItem } from "../src/provider-workspace/catalog"; @@ -364,6 +366,24 @@ test("UX-05: equal account IDs keep pool-keyed rosters and thresholds in the acc }); for (const item of [POOL_A, POOL_B]) { + test(`${item.name} Overview shows only a verified current account label`, () => { + const renderOverview = (rows: OAuthAccountRow[], accountLoadState: "idle" | "loading" | "ready" | "error", loggedIn: boolean) => renderToStaticMarkup( + {}} onDeselect={() => {}} apiBase="" />, + ); + const rows = accounts(item.name).map((account, index) => ({ ...account, + alias: index === 0 ? "Work" : undefined, maskedLabel: index === 0 ? "s***d@example.test" : "o***r@example.test" })); + expect(renderOverview(rows, "ready", true)).toContain("Current account
Work"); + expect(renderOverview(rows.map(row => ({ ...row, alias: undefined })), "ready", true)).toContain("Current account
s***d@example.test"); + expect(renderOverview(rows.map((row, index) => ({ ...row, alias: undefined, active: index === 1 })), "ready", true)).toContain("Current account
o***r@example.test"); + expect(renderOverview(rows.map(row => ({ ...row, alias: undefined, maskedLabel: undefined })), "ready", true)).toContain("Current account
Account 1"); + for (const state of ["idle", "loading", "error"] as const) expect(renderOverview(rows, state, true)).not.toContain("Current account
Work"); + const loggedOut = renderOverview(rows, "ready", false); + expect(loggedOut).toContain("Current account
Work"); + expect(loggedOut).not.toContain("Logged in as Work"); + expect(loggedOut).not.toContain(`raw@${item.name}.test`); + }); test(`${item.name} account rows prefer aliases and masked labels without rendering raw email`, async () => { respond = async request => request.path === "/api/pool/settings" ? Response.json(poolSettings(item.name, false, 55)) : Response.json(grantSnapshot(item.name, 1)); diff --git a/gui/tests/provider-account-pause-refresh.test.tsx b/gui/tests/provider-account-pause-refresh.test.tsx index 28908ec35e3..7cc32cdfddb 100644 --- a/gui/tests/provider-account-pause-refresh.test.tsx +++ b/gui/tests/provider-account-pause-refresh.test.tsx @@ -227,6 +227,28 @@ test("reset confirmation after unmount clears its receipt without notifying the expect(notices).toEqual([]); }); +test("an affinity-reset retry reconciles an overtaken roster read when it settles", async () => { + let finishRead!: (response: Response) => void; + let finishRetry!: (response: Response) => void; + let reads = 0; + await act(async () => { rememberAffinityResetRetry("/pause-hook", "fixture", { kind: "reset", accountId: "b" }); }); + respond = async (url, init) => { + if (init?.method === "PUT" && url.includes("affinity-reset")) return new Promise(resolve => { finishRetry = resolve; }); + if (++reads === 1) return new Promise(resolve => { finishRead = resolve; }); + return Response.json({ activeAccountId: "b", accounts: [row("a", false), row("b", true)] }); + }; + let read!: Promise; + let retry!: Promise; + await act(async () => { read = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + await act(async () => { retry = pools.retryAffinityReset("fixture"); }); + await act(async () => { finishRead(Response.json({ activeAccountId: "a", accounts: [row("a", true)] })); expect(await read).toBe(false); }); + expect(pools.accountLoadStates.fixture).toBe("loading"); + await act(async () => { finishRetry(Response.json({ affinityResetConfirmed: true })); await retry; }); + await act(async () => { await Promise.resolve(); }); + expect(pools.accountLoadStates.fixture).toBe("ready"); + expect(pools.accountSets.fixture.accounts.find(account => account.active)?.id).toBe("b"); +}); + test("a successful inactive pause cannot replay a retained active selection into the paused account", async () => { let writes = 0; await act(async () => { rememberAffinityResetRetry("/pause-hook", "fixture", { kind: "active", accountId: "b" }); }); @@ -501,6 +523,24 @@ test("in-flight deletion owns its provider until the committed receipt is retain expect(pools.pendingAffinityResets.fixture).toBeUndefined(); }); +test("a stalled unrelated OAuth status does not hold a completed deletion", async () => { + const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true); + let releaseStatus!: () => void; + let statusStarted!: () => void; + const started = new Promise(resolve => { statusStarted = resolve; }); + fetchOauth = () => { statusStarted(); return new Promise(resolve => { releaseStatus = resolve; }); }; + let switches = 0; + respond = async (_url, init) => init?.method === "DELETE" ? Response.json({ ok: true }) + : init?.method === "PUT" ? (switches++, Response.json({ activeAccountId: "b" })) + : Response.json({ activeAccountId: "a", accounts: [row("a", true), row("b", false)] }); + let deletion!: Promise; + try { + await act(async () => { deletion = pools.removeAccount("fixture", row("b", false)); await started; }); + await act(async () => { void pools.switchAccount("fixture", row("b", false)); await Promise.resolve(); }); + expect(switches).toBe(1); + } finally { await act(async () => { releaseStatus(); await deletion; }); consent.mockRestore(); } +}); + test("deletion rechecks ownership after the confirmation dialog settles", async () => { let confirm!: (value: boolean) => void; const consent = spyOn(dialogs, "confirmAction").mockImplementation(() => new Promise(resolve => { confirm = resolve; })); @@ -524,6 +564,24 @@ test("deletion rechecks ownership after the confirmation dialog settles", async } }); +test.each([true, false])("failed deletion reconciles identity (roster succeeds: %s)", async succeeds => { + const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true); + let reads = 0; + respond = async (_url, init) => { + if (init?.method === "DELETE") return new Response(null, { status: 503 }); + reads++; + return succeeds ? Response.json({ activeAccountId: "b", accounts: [row("a", false), row("b", true)] }) + : new Response(null, { status: 503 }); + }; + try { + await act(async () => { await pools.removeAccount("fixture", row("b", false)); }); + await act(async () => { await Promise.resolve(); }); + expect(reads).toBe(1); + expect(pools.accountLoadStates.fixture).toBe(succeeds ? "ready" : "error"); + if (succeeds) expect(pools.accountSets.fixture.accounts.find(account => account.active)?.id).toBe("b"); + } finally { consent.mockRestore(); } +}); + test.each(["http", "transport"] as const)("failed deletion releases its mutation owner after %s failure", async failure => { const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true); let pauses = 0; @@ -552,8 +610,10 @@ test("a rejected deletion without a committed receipt preserves the roster and r }; try { await act(async () => { await pools.removeAccount("fixture", row("a", true)); }); - expect(requests).toHaveLength(1); + expect(requests).toHaveLength(2); expect(requests[0]).toStartWith("DELETE "); + expect(requests[1]).toStartWith("GET "); + expect(pools.accountLoadStates.fixture).toBe("error"); expect(pools.accountSets.fixture.accounts.map(account => account.id)).toEqual(["a", "b"]); expect(pools.pendingAffinityResets.fixture).toEqual({ kind: "active", accountId: "b" }); expect(notices).toEqual([{ key: "prov.accountRemoveFail", ok: false }]); diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx index e173d1f1514..528d27316ec 100644 --- a/gui/tests/provider-account-quota-loading.test.tsx +++ b/gui/tests/provider-account-quota-loading.test.tsx @@ -213,6 +213,72 @@ afterEach(async () => { await win.happyDOM.close(); }); +test("an OAuth selection refresh hides stale roster until successful reconciliation", async () => { + const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const })); + respond = async () => Response.json({ activeAccountId: "a", accounts: rows }); + await act(async () => { expect(await pools.fetchAccountSets(["fixture"])).toBe(true); }); + expect(pools.accountLoadStates.fixture).toBe("ready"); + const pending = deferred(); + respond = async () => pending.promise; + let refresh!: Promise; + await act(async () => { refresh = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + expect(pools.accountLoadStates.fixture).toBe("loading"); + await act(async () => { pending.resolve(new Response(null, { status: 503 })); expect(await refresh).toBe(false); }); + expect(pools.accountLoadStates.fixture).toBe("error"); + respond = async () => Response.json({ activeAccountId: "b", accounts: rows }); + await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(true); }); + expect(pools.accountLoadStates.fixture).toBe("ready"); + expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b"); +}); + +test("a malformed or stale OAuth roster cannot publish a verified selection", async () => { + const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const })); + respond = async () => Response.json({ activeAccountId: "a", accounts: rows }); + await act(async () => { await pools.fetchAccountSets(["fixture"]); }); + respond = async () => Response.json({ accounts: null }); + await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(false); }); + expect(pools.accountLoadStates.fixture).toBe("error"); + const old = deferred(); + respond = async () => old.promise; + let pending!: Promise; + await act(async () => { pending = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); }); + respond = async () => Response.json({ activeAccountId: "b", accounts: rows }); + await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(true); }); + await act(async () => { old.resolve(new Response(null, { status: 503 })); expect(await pending).toBe(false); }); + expect(pools.accountLoadStates.fixture).toBe("ready"); + expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b"); +}); + +test("an aborted OAuth roster refresh leaves the retained selection unverified", async () => { + const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const })); + respond = async () => Response.json({ activeAccountId: "a", accounts: rows }); + await act(async () => { await pools.fetchAccountSets(["fixture"]); }); + const pending = deferred(); + respond = async () => pending.promise; + const controller = new AbortController(); + let refresh!: Promise; + await act(async () => { refresh = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }, controller.signal); }); + await act(async () => { controller.abort(); pending.resolve(Response.json({ activeAccountId: "a", accounts: rows })); expect(await refresh).toBe(false); }); + expect(pools.accountLoadStates.fixture).toBe("error"); +}); + +test("an OAuth quota roster cannot verify identity while account selection is pending", async () => { + const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const })); + respond = async () => Response.json({ activeAccountId: "a", accounts: rows }); + await act(async () => { await pools.fetchAccountSets(["fixture"]); }); + const put = deferred(); + respond = async (_url, _signal, init) => init?.method === "PUT" ? put.promise + : Response.json({ activeAccountId: "a", accounts: rows }); + let switching!: Promise; + await act(async () => { switching = pools.switchAccount("fixture", rows[1]); }); + await act(async () => { await pools.fetchAccountSets(["fixture"]); }); + expect(pools.accountLoadStates.fixture).toBe("loading"); + respond = async () => Response.json({ activeAccountId: "b", accounts: rows }); + await act(async () => { put.resolve(Response.json({ activeAccountId: "b" })); await switching; }); + expect(pools.accountLoadStates.fixture).toBe("ready"); + expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b"); +}); + for (const kind of ["oauth", "api-key"] as const) { test(`${kind} push refresh overtakes a slow quota probe while late quota cannot change selection or resurrect rows`, async () => { const quota = deferred(); diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx index 4fcf1867090..f2515194daa 100644 --- a/gui/tests/provider-first-response-guidance.test.tsx +++ b/gui/tests/provider-first-response-guidance.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act, type ReactNode } from "react"; +import { act, useLayoutEffect, type ReactNode } from "react"; import type { Root } from "react-dom/client"; import { clearClientResourceStoresForTests } from "../src/client-resource"; import ProviderModelsNotice from "../src/components/ProviderModelsNotice"; @@ -330,6 +330,37 @@ describe("first-response progression states on ProviderOverview", () => { }); describe("ProviderDetails raw ID disambiguation and Providers page integration", () => { + test("switching API targets never paints the previous server's account", async () => { + let firstNewPaint = ""; + function Inspect({ apiBase }: { apiBase: string }) { + useLayoutEffect(() => { if (apiBase === "/new") firstNewPaint = host.textContent ?? ""; }, [apiBase]); + return ; + } + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path.startsWith("/new/")) return new Promise(() => {}); + if (path === "/old/api/config") return Response.json({ port: 0, defaultProvider: "anthropic", providers: { + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, + } }); + if (path === "/old/api/providers") return Response.json([{ name: "anthropic", adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }]); + if (path === "/old/api/oauth/providers") return Response.json({ providers: ["anthropic"] }); + if (path === "/old/api/oauth/status") return Response.json({ loggedIn: true }); + if (path === "/old/api/oauth/accounts") return Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, alias: "Old Server Account" }] }); + if (path === "/old/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} }); + if (path === "/old/api/models") return Response.json([]); + if (path === "/old/api/provider-quotas") return Response.json({ reports: [] }); + if (path === "/old/api/usage") return Response.json({ providers: [], models: [] }); + if (path === "/old/api/codex-auth/accounts") return Response.json({ accounts: [] }); + return Response.json({}); + }) as typeof fetch; + await render(); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); + await act(async () => { host.querySelector('[role="option"]')!.click(); }); + expect(host.textContent).toContain("Old Server Account"); + await render(); + expect(firstNewPaint).not.toContain("Old Server Account"); + expect(host.textContent).not.toContain("Old Server Account"); + }); test("ProviderDetails header shows both display name and raw config ID when they differ", async () => { await render( Date: Sun, 8 Nov 2026 02:52:49 +0900 Subject: [PATCH 02/10] fix(gui): order account status refreshes by provider --- gui/src/hooks/useProviderAccountPools.ts | 7 +- gui/src/pages/Providers.tsx | 10 +-- gui/src/pages/use-providers-fetch.ts | 39 +++++++-- gui/src/pages/use-providers-oauth.ts | 4 + .../provider-first-response-guidance.test.tsx | 30 +++++++ gui/tests/provider-models-notice.test.tsx | 87 ++++++++++++++++++- 6 files changed, 161 insertions(+), 16 deletions(-) diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index d9a61721d35..d2e6e6139ec 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -127,6 +127,8 @@ export function useProviderAccountPools(deps: { apiBase, t, config, aliveRef, notify, fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, } = deps; + const accountTarget = useMemo(() => ({ apiBase }), [apiBase]); + const [accountStateTarget, setAccountStateTarget] = useState(accountTarget); const [accountSets, setAccountSets] = useState>({}); const [accountLoadStates, setAccountLoadStates] = useState>({}); const [switchingAccounts, setSwitchingAccounts] = useState>({}); @@ -167,6 +169,7 @@ export function useProviderAccountPools(deps: { setSwitchingAccounts({}); setKeyPools({}); setAccountLoadStates({}); + setAccountStateTarget(accountTarget); }); return () => { mountedRef.current = false; @@ -179,7 +182,7 @@ export function useProviderAccountPools(deps: { for (const controller of requests) controller.abort(); requests.clear(); }; - }, [apiBase]); + }, [apiBase, accountTarget]); // Provider lists this instance has already fetched for. The deferred loads below are deliberately // uncancellable, and StrictMode double-invokes their effects, so dedupe by list identity here. const accountSetsKeyRef = useRef(null); @@ -879,7 +882,7 @@ export function useProviderAccountPools(deps: { ); return { - accountSets, accountLoadStates, switchingAccount, pausingAccount, switchingAccounts, pausingAccounts, pendingAffinityResets, retryAffinityReset, openAccounts, keyPools, addingKeyFor, newKeyValue, + accountSets, accountLoadStates: accountStateTarget === accountTarget ? accountLoadStates : {}, switchingAccount, pausingAccount, switchingAccounts, pausingAccounts, pendingAffinityResets, retryAffinityReset, openAccounts, keyPools, addingKeyFor, newKeyValue, setAccountSets, setAccountLoadStates, setSwitchingAccount, setOpenAccounts, setKeyPools, setAddingKeyFor, setNewKeyValue, fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, oauthCardProviders, keyCardProviders, activeAccountNeedsReauth, diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index c2e3ae5f085..915bb983ba9 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -213,11 +213,7 @@ function useAccountSelectionEvents( return useCallback(() => recoverRef.current(), []); } -export default function Providers(props: { apiBase: string }) { - return ; -} - -function ProvidersPage({ apiBase }: { apiBase: string }) { +export default function Providers({ apiBase }: { apiBase: string }) { const t = useT(); const configCacheKey = `ocx.providers.config.v1:${apiBase}`; const [config, setConfig] = useState( @@ -342,7 +338,7 @@ function ProvidersPage({ apiBase }: { apiBase: string }) { * is silent while every real mutation path still forces a re-read. */ const { quotaRefresh, invalidateProviderQuotas, settleQuotaRefresh, beginQuotaRefresh } = useQuotaRefreshCoordinator(apiBase); - const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas } = useProvidersFetch({ + const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas, invalidateOauthStatusReads } = useProvidersFetch({ apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify, invalidateProviderQuotas, configCacheKey, @@ -483,7 +479,7 @@ function ProvidersPage({ apiBase }: { apiBase: string }) { const { cancelLoginOAuth, loginOAuth, logoutOAuth, onNativeLoginSettled } = useProvidersOAuth({ apiBase, t, aliveRef, accountSets, setAccountSets, - setBusy, setStatus, setLoginInfo, setOauthStatus, notify, + setBusy, setStatus, setLoginInfo, setOauthStatus, invalidateOauthStatusReads, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, onLoginSettled: onProviderLoginSettled, }); diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts index 5b7d8632eba..fcc77cddfb5 100644 --- a/gui/src/pages/use-providers-fetch.ts +++ b/gui/src/pages/use-providers-fetch.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { writeSessionListCache } from "../session-list-cache"; @@ -27,7 +27,23 @@ export function useProvidersFetch({ configCacheKey?: string; }) { const configRequest = useRef(0); + const oauthDiscovery = useRef(0); + const target = useMemo(() => ({ apiBase }), [apiBase]); + const activeTarget = useRef(null); + const oauthWrites = useRef(new Map()); + const oauthReads = useRef(new Map()); + const oauthPublished = useRef(new Map()); + const oauthAdmitted = useRef(new Set()); useEffect(() => () => { configRequest.current += 1; }, [apiBase]); + useLayoutEffect(() => { + activeTarget.current = target; + return () => { activeTarget.current = null; oauthDiscovery.current += 1; oauthAdmitted.current.clear(); }; + }, [target]); + const invalidateOauthStatusReads = useCallback((provider: string) => { + if (activeTarget.current !== target) return false; + oauthWrites.current.set(provider, (oauthWrites.current.get(provider) ?? 0) + 1); + return true; + }, [target]); const fetchConfig = useCallback(async (): Promise => { const request = ++configRequest.current; try { @@ -46,20 +62,31 @@ export function useProvidersFetch({ }, [apiBase, configCacheKey, notify, setConfig, t]); const fetchOauth = useCallback(async () => { + if (activeTarget.current !== target) return; + const discovery = ++oauthDiscovery.current; + const writes = new Map(oauthWrites.current); + const unchanged = (provider: string) => (writes.get(provider) ?? 0) === (oauthWrites.current.get(provider) ?? 0); try { // Codex openai status is owned by useCodexAccountPool — do not duplicate /accounts. const provRes = await fetch(`${apiBase}/api/oauth/providers`); const provData = await readJsonOrThrow<{ providers?: string[] }>(provRes); const provs: string[] = provData?.providers ?? []; + if (activeTarget.current !== target || discovery !== oauthDiscovery.current) return; + oauthAdmitted.current = new Set(provs); setOauthProviders(provs); - const oauthEntries = await Promise.all(provs.map(async p => { + setOauthStatus(current => Object.fromEntries(Object.entries(current).filter(([provider]) => oauthAdmitted.current.has(provider) || !unchanged(provider)))); + await Promise.all(provs.map(async p => { + const read = (oauthReads.current.get(p) ?? 0) + 1; + oauthReads.current.set(p, read); const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${encodeURIComponent(p)}`).catch(() => null); const s = sRes ? (await readJsonIfOk(sRes) ?? { loggedIn: false }) : { loggedIn: false }; - return [p, s] as const; + if (activeTarget.current !== target || !oauthAdmitted.current.has(p) || !unchanged(p) || read < (oauthPublished.current.get(p) ?? 0)) return; + oauthPublished.current.set(p, read); + setOauthStatus(current => activeTarget.current === target && oauthAdmitted.current.has(p) && unchanged(p) + && read === oauthPublished.current.get(p) ? { ...current, [p]: s } : current); })); - setOauthStatus(Object.fromEntries(oauthEntries)); } catch { /* ignore */ } - }, [apiBase, setOauthProviders, setOauthStatus]); + }, [apiBase, target, setOauthProviders, setOauthStatus]); /* * The workspace shell owns the single quota read; this only invalidates it. Keeping the @@ -70,5 +97,5 @@ export function useProvidersFetch({ invalidateProviderQuotas(refresh); }, [invalidateProviderQuotas]); - return { fetchConfig, fetchOauth, fetchProviderQuotas }; + return { fetchConfig, fetchOauth, fetchProviderQuotas, invalidateOauthStatusReads }; } diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index ac66bd33b85..6ba11592e96 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -20,6 +20,7 @@ export function useProvidersOAuth({ setStatus, setLoginInfo, setOauthStatus, + invalidateOauthStatusReads, notify, fetchConfig, fetchOauth, @@ -37,6 +38,7 @@ export function useProvidersOAuth({ setStatus: React.Dispatch>; setLoginInfo: React.Dispatch>; setOauthStatus: React.Dispatch>>; + invalidateOauthStatusReads?: (provider: string) => boolean; notify: (msg: string, ok: boolean) => void; fetchConfig: () => Promise; fetchOauth: () => Promise; @@ -168,6 +170,7 @@ export function useProvidersOAuth({ if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return; if (!s) continue; if (s.error) { + if (invalidateOauthStatusReads?.(provider) === false) return; setOauthStatus(prev => ({ ...prev, [provider]: s })); const cancelled = /cancel/i.test(s.error); notify( @@ -185,6 +188,7 @@ export function useProvidersOAuth({ ? (statusCount > baselineCount || s.done === true) : (s.loggedIn || s.done === true); if (completed) { + if (invalidateOauthStatusReads?.(provider) === false) return; setOauthStatus(prev => ({ ...prev, [provider]: s })); const target = reauthTargetId ? s.accounts?.find(a => a.id === reauthTargetId) diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx index f2515194daa..c783f1c171a 100644 --- a/gui/tests/provider-first-response-guidance.test.tsx +++ b/gui/tests/provider-first-response-guidance.test.tsx @@ -330,6 +330,36 @@ describe("first-response progression states on ProviderOverview", () => { }); describe("ProviderDetails raw ID disambiguation and Providers page integration", () => { + test("changing API targets does not unmount an unsaved Settings draft", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path.startsWith("/new/")) return new Promise(() => {}); + if (path === "/old/api/config") return Response.json({ port: 0, defaultProvider: "my-provider", providers: { + "my-provider": { adapter: "openai-chat", baseUrl: "https://example.test/v1", hasApiKey: true }, + } }); + if (path === "/old/api/providers") return Response.json([{ name: "my-provider", adapter: "openai-chat", baseUrl: "https://example.test/v1", hasApiKey: true }]); + if (path === "/old/api/oauth/providers" || path === "/old/api/provider-presets") return Response.json({ providers: [] }); + if (path === "/old/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} }); + if (path === "/old/api/models") return Response.json([]); + if (path === "/old/api/provider-quotas") return Response.json({ reports: [] }); + if (path === "/old/api/usage") return Response.json({ providers: [], models: [] }); + if (path === "/old/api/codex-auth/accounts") return Response.json({ accounts: [] }); + return Response.json({}); + }) as typeof fetch; + await render(); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); + await act(async () => { host.querySelector('[role="option"]')!.click(); }); + await act(async () => { host.querySelector("#pws-tab-settings")!.click(); }); + const input = [...host.querySelectorAll("input")].find(element => element.value === "https://example.test/v1")!; + await act(async () => { + Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "https://draft.example.test/v1"); + input.dispatchEvent(new win.Event("input", { bubbles: true })); + }); + expect(host.textContent).toContain("unsaved changes"); + await render(); + expect([...host.querySelectorAll("input")].some(element => element.value === "https://draft.example.test/v1")).toBe(true); + expect(host.textContent).toContain("unsaved changes"); + }); test("switching API targets never paints the previous server's account", async () => { let firstNewPaint = ""; function Inspect({ apiBase }: { apiBase: string }) { diff --git a/gui/tests/provider-models-notice.test.tsx b/gui/tests/provider-models-notice.test.tsx index b69007ab096..9d5e56d40fb 100644 --- a/gui/tests/provider-models-notice.test.tsx +++ b/gui/tests/provider-models-notice.test.tsx @@ -6,7 +6,7 @@ import ProviderModelsNotice, { type ProviderModelsNoticeProps } from "../src/com import { LanguageProvider } from "../src/i18n/provider"; import { useProviderModelsNotice } from "../src/pages/use-provider-models-notice"; import { useProvidersFetch } from "../src/pages/use-providers-fetch"; -import type { ProvidersConfig } from "../src/pages/providers-shared"; +import type { OAuthStatus, ProvidersConfig } from "../src/pages/providers-shared"; const keys = ["window", "document", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; let saved: Record; @@ -41,6 +41,91 @@ function button(label: string): HTMLButtonElement { return found; } +test("an older OAuth refresh cannot overwrite a newer status", async () => { + let loader!: ReturnType; + let statuses: Record = {}; + let finish!: (response: Response) => void; + let started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + let reads = 0; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + if (String(input).includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] }); + if (++reads === 1) { started(); return new Promise(resolve => { finish = resolve; }); } + return Response.json({ loggedIn: true }); + } }); + function Harness() { + const [state, setOauthStatus] = useState>({}); + statuses = state; + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, + setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + let old!: Promise; + await act(async () => { old = loader.fetchOauth(); await requested; }); + await act(async () => { await loader.fetchOauth(); }); + await act(async () => { finish(Response.json({ loggedIn: false })); await old; }); + expect(statuses.anthropic).toEqual({ loggedIn: true }); +}); + +test("a login update cannot be overwritten by an older OAuth status", async () => { + let loader!: ReturnType; + let statuses: Record = {}; + let publish!: (next: Record) => void; + let finish!: (response: Response) => void; + let started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + if (String(input).includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] }); + started(); return new Promise(resolve => { finish = resolve; }); + } }); + function Harness() { + const [state, setOauthStatus] = useState>({}); + statuses = state; publish = setOauthStatus; + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, + setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + let old!: Promise; + await act(async () => { old = loader.fetchOauth(); await requested; }); + await act(async () => { loader.invalidateOauthStatusReads("anthropic"); publish({ anthropic: { loggedIn: true } }); }); + await act(async () => { finish(Response.json({ loggedIn: false })); await old; }); + expect(statuses.anthropic).toEqual({ loggedIn: true }); +}); + +test("a newer pending OAuth refresh does not starve another provider's earlier result", async () => { + let loader!: ReturnType; + let statuses: Record = {}; + const releases = new Map void>>(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic", "cursor"] }); + const name = new URL(url, "http://localhost").searchParams.get("provider")!; + return new Promise(resolve => { const list = releases.get(name) ?? []; list.push(resolve); releases.set(name, list); }); + } }); + function Harness() { + const [state, setOauthStatus] = useState>({}); + statuses = state; + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, + setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + let first!: Promise, second!: Promise; + await act(async () => { first = loader.fetchOauth(); await Promise.resolve(); await Promise.resolve(); }); + await act(async () => { second = loader.fetchOauth(); await Promise.resolve(); await Promise.resolve(); }); + expect(releases.get("cursor")?.length).toBe(2); + await act(async () => { releases.get("cursor")![0]!(Response.json({ loggedIn: true })); }); + expect(statuses.cursor).toEqual({ loggedIn: true }); + await act(async () => { + releases.get("anthropic")![0]!(Response.json({ loggedIn: false })); + releases.get("anthropic")![1]!(Response.json({ loggedIn: true })); + releases.get("cursor")![1]!(Response.json({ loggedIn: true })); + await Promise.all([first, second]); + }); +}); + test("all-OFF notice has keyboard navigation, explicit actions and focus restoration", async () => { const trigger = win.document.createElement("button"); win.document.body.appendChild(trigger); From c33238122e1fb5ccc4a550c72427ce03b7ad591d Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 03:02:07 +0900 Subject: [PATCH 03/10] fix(gui): keep account identity uncertain during logout --- gui/src/hooks/useProviderAccountPools.ts | 15 +++++- gui/src/pages/Providers.tsx | 4 +- gui/src/pages/use-providers-fetch.ts | 10 ++-- gui/src/pages/use-providers-oauth.ts | 20 +++++-- .../provider-account-quota-loading.test.tsx | 13 +++++ .../provider-first-response-guidance.test.tsx | 33 ++++++++++++ gui/tests/provider-models-notice.test.tsx | 54 +++++++++++++++++++ 7 files changed, 139 insertions(+), 10 deletions(-) diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index d2e6e6139ec..ee6e2a22fe5 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -400,6 +400,19 @@ export function useProviderAccountPools(deps: { return key; }; + const beginAccountLogout = (provider: string): (() => void) | null => { + const key = `oauth:${provider}`; + if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase || selectionMutationsRef.current.has(key)) return null; + invalidateSelectionReads(provider, "oauth"); + const owner = Symbol(); + selectionMutationsRef.current.set(key, owner); + return () => { + if (selectionMutationsRef.current.get(key) !== owner) return; + invalidateSelectionReads(provider, "oauth"); + selectionMutationsRef.current.delete(key); + }; + }; + const setAccountPoolThreshold = async (provider: string, threshold: number): Promise => { if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return false; // Pool settings and roster reads describe one server value. Invalidate older reads before @@ -884,7 +897,7 @@ export function useProviderAccountPools(deps: { return { accountSets, accountLoadStates: accountStateTarget === accountTarget ? accountLoadStates : {}, switchingAccount, pausingAccount, switchingAccounts, pausingAccounts, pendingAffinityResets, retryAffinityReset, openAccounts, keyPools, addingKeyFor, newKeyValue, setAccountSets, setAccountLoadStates, setSwitchingAccount, setOpenAccounts, setKeyPools, setAddingKeyFor, setNewKeyValue, - fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, + fetchAccountSets, fetchKeyPools, refreshAccountRosters, beginAccountLogout, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount, oauthCardProviders, keyCardProviders, activeAccountNeedsReauth, }; } diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 915bb983ba9..36087eb43f6 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -387,7 +387,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, }); const { - accountSets, setAccountSets, accountLoadStates, switchingAccounts, pausingAccounts, keyPools, fetchAccountSets, fetchKeyPools, + accountSets, setAccountSets, accountLoadStates, switchingAccounts, pausingAccounts, keyPools, fetchAccountSets, fetchKeyPools, beginAccountLogout, refreshAccountRosters, oauthCardProviders, keyCardProviders, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, removeAccount, activeAccountNeedsReauth, @@ -479,7 +479,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const { cancelLoginOAuth, loginOAuth, logoutOAuth, onNativeLoginSettled } = useProvidersOAuth({ apiBase, t, aliveRef, accountSets, setAccountSets, - setBusy, setStatus, setLoginInfo, setOauthStatus, invalidateOauthStatusReads, notify, + setBusy, setStatus, setLoginInfo, setOauthStatus, invalidateOauthStatusReads, beginAccountLogout, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, onLoginSettled: onProviderLoginSettled, }); diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts index fcc77cddfb5..50615996ed1 100644 --- a/gui/src/pages/use-providers-fetch.ts +++ b/gui/src/pages/use-providers-fetch.ts @@ -28,6 +28,7 @@ export function useProvidersFetch({ }) { const configRequest = useRef(0); const oauthDiscovery = useRef(0); + const oauthDiscoveryPublished = useRef(0); const target = useMemo(() => ({ apiBase }), [apiBase]); const activeTarget = useRef(null); const oauthWrites = useRef(new Map()); @@ -70,8 +71,10 @@ export function useProvidersFetch({ // Codex openai status is owned by useCodexAccountPool — do not duplicate /accounts. const provRes = await fetch(`${apiBase}/api/oauth/providers`); const provData = await readJsonOrThrow<{ providers?: string[] }>(provRes); - const provs: string[] = provData?.providers ?? []; - if (activeTarget.current !== target || discovery !== oauthDiscovery.current) return; + if (!Array.isArray(provData?.providers) || !provData.providers.every(p => typeof p === "string")) return; + const provs = provData.providers; + if (activeTarget.current !== target || discovery < oauthDiscoveryPublished.current) return; + oauthDiscoveryPublished.current = discovery; oauthAdmitted.current = new Set(provs); setOauthProviders(provs); setOauthStatus(current => Object.fromEntries(Object.entries(current).filter(([provider]) => oauthAdmitted.current.has(provider) || !unchanged(provider)))); @@ -79,7 +82,8 @@ export function useProvidersFetch({ const read = (oauthReads.current.get(p) ?? 0) + 1; oauthReads.current.set(p, read); const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${encodeURIComponent(p)}`).catch(() => null); - const s = sRes ? (await readJsonIfOk(sRes) ?? { loggedIn: false }) : { loggedIn: false }; + const s = sRes ? await readJsonIfOk(sRes) : null; + if (!s || typeof s.loggedIn !== "boolean") return; if (activeTarget.current !== target || !oauthAdmitted.current.has(p) || !unchanged(p) || read < (oauthPublished.current.get(p) ?? 0)) return; oauthPublished.current.set(p, read); setOauthStatus(current => activeTarget.current === target && oauthAdmitted.current.has(p) && unchanged(p) diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 6ba11592e96..f3703edff42 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -21,6 +21,7 @@ export function useProvidersOAuth({ setLoginInfo, setOauthStatus, invalidateOauthStatusReads, + beginAccountLogout, notify, fetchConfig, fetchOauth, @@ -39,6 +40,7 @@ export function useProvidersOAuth({ setLoginInfo: React.Dispatch>; setOauthStatus: React.Dispatch>>; invalidateOauthStatusReads?: (provider: string) => boolean; + beginAccountLogout?: (provider: string) => (() => void) | null; notify: (msg: string, ok: boolean) => void; fetchConfig: () => Promise; fetchOauth: () => Promise; @@ -260,21 +262,31 @@ export function useProvidersOAuth({ }; const logoutOAuth = async (provider: string) => { + const finishLogout = beginAccountLogout?.(provider); + if (beginAccountLogout && !finishLogout) return; + invalidateOauthStatusReads?.(provider); // Invalidate any in-flight login poll so a late completion cannot reseed accounts. bumpLoginGeneration(provider); setBusy(current => current === provider ? null : current); setLoginInfo(current => current?.provider === provider ? null : current); + let success = false; try { const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" }); if (!res.ok) { notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); return; } + success = true; + } catch { + notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); + } finally { + finishLogout?.(); + if (!success) void fetchAccountSets([provider]).catch(() => {}); + } + if (!success) return; + try { await Promise.all([ - fetchAccountSets([provider]), - fetchOauth(), - fetchConfig(), - fetchProviderQuotas(true), + fetchAccountSets([provider]), fetchOauth(), fetchConfig(), fetchProviderQuotas(true), ]); bumpModelsRefresh(); notify(t("prov.logoutOk", { provider: oauthLabel(provider) }), true); diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx index 528d27316ec..d52089b27a7 100644 --- a/gui/tests/provider-account-quota-loading.test.tsx +++ b/gui/tests/provider-account-quota-loading.test.tsx @@ -115,6 +115,19 @@ function Harness({ apiBase = "/quota-hook" }: { apiBase?: string }) { useLayoutEffect(() => { pools = currentPools; }, [currentPools]); return null; } + +test("a roster read during logout cannot verify the current account", async () => { + respond = async () => Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, quotaMode: "unsupported" }] }); + await act(async () => { await pools.fetchAccountSets(["fixture"]); }); + expect(pools.accountLoadStates.fixture).toBe("ready"); + let finish!: () => void; + await act(async () => { finish = pools.beginAccountLogout("fixture")!; }); + expect(pools.accountLoadStates.fixture).toBe("loading"); + await act(async () => { await pools.fetchAccountSets(["fixture"]); }); + expect(pools.accountLoadStates.fixture).toBe("loading"); + await act(async () => { finish(); await pools.fetchAccountSets(["fixture"]); }); + expect(pools.accountLoadStates.fixture).toBe("ready"); +}); beforeEach(async () => { previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previous; win = new Window({ url: "http://localhost" }); diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx index c783f1c171a..177bace5d17 100644 --- a/gui/tests/provider-first-response-guidance.test.tsx +++ b/gui/tests/provider-first-response-guidance.test.tsx @@ -330,6 +330,39 @@ describe("first-response progression states on ProviderOverview", () => { }); describe("ProviderDetails raw ID disambiguation and Providers page integration", () => { + test("logout hides the current account until the roster is reconciled", async () => { + let finishLogout!: (response: Response) => void; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const path = new URL(String(input), "http://localhost").pathname; + if (path === "/api/oauth/logout") return new Promise(resolve => { finishLogout = resolve; }); + if (path === "/api/config") return Response.json({ port: 0, defaultProvider: "anthropic", providers: { + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, + } }); + if (path === "/api/providers") return Response.json([{ name: "anthropic", adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }]); + if (path === "/api/oauth/providers") return Response.json({ providers: ["anthropic"] }); + if (path === "/api/oauth/status") return Response.json({ loggedIn: true }); + if (path === "/api/oauth/accounts") return Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, alias: "Work" }] }); + if (path === "/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} }); + if (path === "/api/provider-presets") return Response.json({ providers: [] }); + if (path === "/api/models") return Response.json([]); + if (path === "/api/provider-quotas") return Response.json({ reports: [] }); + if (path === "/api/usage") return Response.json({ providers: [], models: [] }); + if (path === "/api/codex-auth/accounts") return Response.json({ accounts: [] }); + return Response.json({}); + }) as typeof fetch; + await render(); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); + await act(async () => { host.querySelector('[role="option"]')!.click(); }); + expect(host.textContent).toContain("Current accountWork"); + await act(async () => { host.querySelector("#pws-tab-accounts")!.click(); }); + const logout = [...host.querySelectorAll("button")].find(button => button.textContent === "Logout")!; + await act(async () => { logout.click(); }); + await act(async () => { host.querySelector("#pws-tab-overview")!.click(); }); + expect(host.textContent).not.toContain("Current accountWork"); + await act(async () => { finishLogout(new Response(null, { status: 503 })); await Promise.resolve(); }); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); + expect(host.textContent).toContain("Current accountWork"); + }); test("changing API targets does not unmount an unsaved Settings draft", async () => { globalThis.fetch = (async (input: RequestInfo | URL) => { const path = new URL(String(input), "http://localhost").pathname; diff --git a/gui/tests/provider-models-notice.test.tsx b/gui/tests/provider-models-notice.test.tsx index 9d5e56d40fb..90094ddbb2b 100644 --- a/gui/tests/provider-models-notice.test.tsx +++ b/gui/tests/provider-models-notice.test.tsx @@ -41,6 +41,60 @@ function button(label: string): HTMLButtonElement { return found; } +test("a failed newer OAuth discovery does not discard an earlier success", async () => { + let loader!: ReturnType; + let providers: string[] = []; + let finish!: (response: Response) => void; + let reads = 0; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + if (String(input).includes("/api/oauth/providers")) { + if (++reads === 1) return new Promise(resolve => { finish = resolve; }); + return new Response("unavailable", { status: 503 }); + } + return Response.json({ loggedIn: true }); + } }); + function Harness() { + const [state, setProviders] = useState([]); + providers = state; + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: setProviders, + setOauthStatus: () => {}, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + let first!: Promise; + await act(async () => { first = loader.fetchOauth(); await Promise.resolve(); }); + await act(async () => { await loader.fetchOauth(); }); + await act(async () => { finish(Response.json({ providers: ["anthropic"] })); await first; }); + expect(providers).toEqual(["anthropic"]); +}); + +test("a failed newer OAuth status does not replace an earlier success", async () => { + let loader!: ReturnType; + let statuses: Record = {}; + let finish!: (response: Response) => void; + let started!: () => void; + const requested = new Promise(resolve => { started = resolve; }); + let reads = 0; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + if (String(input).includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] }); + if (++reads === 1) { started(); return new Promise(resolve => { finish = resolve; }); } + return new Response("unavailable", { status: 503 }); + } }); + function Harness() { + const [state, setOauthStatus] = useState>({}); + statuses = state; + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, + setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} }); + return null; + } + await render(); + let first!: Promise; + await act(async () => { first = loader.fetchOauth(); await requested; }); + await act(async () => { await loader.fetchOauth(); }); + await act(async () => { finish(Response.json({ loggedIn: true })); await first; }); + expect(statuses.anthropic).toEqual({ loggedIn: true }); +}); + test("an older OAuth refresh cannot overwrite a newer status", async () => { let loader!: ReturnType; let statuses: Record = {}; From f5bcba6f556548c77e34a0ecf3adfadca06cf5c9 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 15:29:32 +0900 Subject: [PATCH 04/10] Fix OAuth status races across logout and targets --- .../provider-workspace/ProviderOverview.tsx | 4 +- gui/src/pages/Providers.tsx | 4 +- gui/src/pages/use-providers-fetch.ts | 14 +-- gui/src/pages/use-providers-oauth.ts | 26 ++++-- gui/src/pages/use-target-oauth-state.ts | 24 +++++ .../provider-first-response-guidance.test.tsx | 54 +++++++++++ .../provider-oauth-status-races.test.tsx | 89 +++++++++++++++++++ 7 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 gui/src/pages/use-target-oauth-state.ts create mode 100644 gui/tests/provider-oauth-status-races.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index 9c8dfe69e5d..c723c8e208a 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -453,9 +453,9 @@ export default function ProviderOverview({ : item.authMode === "oauth" ? (displayEmail ? t("pws.loggedInAs", { email: displayEmail }) - : oauth?.loggedIn + : oauth?.loggedIn === true ? t("pws.loggedInTitle") - : t("pws.notLoggedIn")) + : oauth?.loggedIn === false ? t("pws.notLoggedIn") : t("startup.unverified")) : item.hasApiKey ? t("pws.apiKeyConfigured") : authModeLabel(item, t)} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 36087eb43f6..c6594c71cfb 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -1,3 +1,4 @@ +import { useTargetOAuthState } from "./use-target-oauth-state"; import { readUsageResponseJson, usageSummary30dResourceKey } from "../usage-summary-resource"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; @@ -225,8 +226,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [statusTone, setStatusTone] = useState("err"); /** Bumped on every notify so repeated identical success toasts restart the dismiss timer. */ const [statusRevision, setStatusRevision] = useState(0); - const [oauthProviders, setOauthProviders] = useState([]); - const [oauthStatus, setOauthStatus] = useState>({}); + const { oauthProviders, setOauthProviders, oauthStatus, setOauthStatus } = useTargetOAuthState(apiBase); const [busy, setBusy] = useState(null); const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string; browserLaunch?: BrowserLaunch } | null>(null); const [workspaceSelected, setWorkspaceSelected] = useState(null); diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts index 50615996ed1..9379d0b7f6f 100644 --- a/gui/src/pages/use-providers-fetch.ts +++ b/gui/src/pages/use-providers-fetch.ts @@ -35,14 +35,18 @@ export function useProvidersFetch({ const oauthReads = useRef(new Map()); const oauthPublished = useRef(new Map()); const oauthAdmitted = useRef(new Set()); + const oauthBlocked = useRef(new Set()); useEffect(() => () => { configRequest.current += 1; }, [apiBase]); useLayoutEffect(() => { activeTarget.current = target; - return () => { activeTarget.current = null; oauthDiscovery.current += 1; oauthAdmitted.current.clear(); }; + const blocked = oauthBlocked.current; + return () => { activeTarget.current = null; oauthDiscovery.current += 1; oauthAdmitted.current.clear(); blocked.clear(); }; }, [target]); - const invalidateOauthStatusReads = useCallback((provider: string) => { - if (activeTarget.current !== target) return false; + const invalidateOauthStatusReads = useCallback((provider: string, blocked?: boolean) => { + if (activeTarget.current !== target || (blocked === undefined && oauthBlocked.current.has(provider))) return false; oauthWrites.current.set(provider, (oauthWrites.current.get(provider) ?? 0) + 1); + if (blocked === true) oauthBlocked.current.add(provider); + if (blocked === false) oauthBlocked.current.delete(provider); return true; }, [target]); const fetchConfig = useCallback(async (): Promise => { @@ -84,9 +88,9 @@ export function useProvidersFetch({ const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${encodeURIComponent(p)}`).catch(() => null); const s = sRes ? await readJsonIfOk(sRes) : null; if (!s || typeof s.loggedIn !== "boolean") return; - if (activeTarget.current !== target || !oauthAdmitted.current.has(p) || !unchanged(p) || read < (oauthPublished.current.get(p) ?? 0)) return; + if (activeTarget.current !== target || !oauthAdmitted.current.has(p) || oauthBlocked.current.has(p) || !unchanged(p) || read < (oauthPublished.current.get(p) ?? 0)) return; oauthPublished.current.set(p, read); - setOauthStatus(current => activeTarget.current === target && oauthAdmitted.current.has(p) && unchanged(p) + setOauthStatus(current => activeTarget.current === target && oauthAdmitted.current.has(p) && !oauthBlocked.current.has(p) && unchanged(p) && read === oauthPublished.current.get(p) ? { ...current, [p]: s } : current); })); } catch { /* ignore */ } diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index f3703edff42..196227b2ddd 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -39,7 +39,7 @@ export function useProvidersOAuth({ setStatus: React.Dispatch>; setLoginInfo: React.Dispatch>; setOauthStatus: React.Dispatch>>; - invalidateOauthStatusReads?: (provider: string) => boolean; + invalidateOauthStatusReads?: (provider: string, blocked?: boolean) => boolean; beginAccountLogout?: (provider: string) => (() => void) | null; notify: (msg: string, ok: boolean) => void; fetchConfig: () => Promise; @@ -264,7 +264,14 @@ export function useProvidersOAuth({ const logoutOAuth = async (provider: string) => { const finishLogout = beginAccountLogout?.(provider); if (beginAccountLogout && !finishLogout) return; - invalidateOauthStatusReads?.(provider); + if (invalidateOauthStatusReads?.(provider, true) === false) { finishLogout?.(); return; } + const clearStatus = () => setOauthStatus(current => { + if (!(provider in current)) return current; + const next = { ...current }; + delete next[provider]; + return next; + }); + clearStatus(); // Invalidate any in-flight login poll so a late completion cannot reseed accounts. bumpLoginGeneration(provider); setBusy(current => current === provider ? null : current); @@ -274,16 +281,23 @@ export function useProvidersOAuth({ const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" }); if (!res.ok) { notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); - return; + } else { + success = true; } - success = true; } catch { notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); } finally { finishLogout?.(); - if (!success) void fetchAccountSets([provider]).catch(() => {}); } - if (!success) return; + // A read started while the server was processing logout can still describe + // the old account. Only a read started after settlement can verify status. + if (invalidateOauthStatusReads?.(provider, false) === false) return; + clearStatus(); + if (!success) { + void fetchAccountSets([provider]).catch(() => {}); + void fetchOauth(); + return; + } try { await Promise.all([ fetchAccountSets([provider]), fetchOauth(), fetchConfig(), fetchProviderQuotas(true), diff --git a/gui/src/pages/use-target-oauth-state.ts b/gui/src/pages/use-target-oauth-state.ts new file mode 100644 index 00000000000..a0b693cc0ba --- /dev/null +++ b/gui/src/pages/use-target-oauth-state.ts @@ -0,0 +1,24 @@ +import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; +import type { OAuthStatus } from "./providers-shared"; + +type OAuthSnapshot = { target: object; providers: string[]; status: Record }; + +// Each visit gets a new identity, including A -> B -> A. Old async writers cannot +// update the current visit, and the render that changes target sees empty state. +export function useTargetOAuthState(apiBase: string) { + const target = useMemo(() => ({ apiBase }), [apiBase]); + const [snapshot, setSnapshot] = useState(() => ({ target, providers: [], status: {} })); + const current: OAuthSnapshot = snapshot.target === target ? snapshot : { target, providers: [], status: {} }; + if (snapshot !== current) setSnapshot(current); + const setOauthProviders = useCallback>>(update => { + setSnapshot(previous => previous.target !== target ? previous : { + ...previous, providers: typeof update === "function" ? update(previous.providers) : update, + }); + }, [target]); + const setOauthStatus = useCallback>>>(update => { + setSnapshot(previous => previous.target !== target ? previous : { + ...previous, status: typeof update === "function" ? update(previous.status) : update, + }); + }, [target]); + return { oauthProviders: current.providers, oauthStatus: current.status, setOauthProviders, setOauthStatus }; +} diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx index 177bace5d17..a3ad14df3e0 100644 --- a/gui/tests/provider-first-response-guidance.test.tsx +++ b/gui/tests/provider-first-response-guidance.test.tsx @@ -359,6 +359,7 @@ describe("ProviderDetails raw ID disambiguation and Providers page integration", await act(async () => { logout.click(); }); await act(async () => { host.querySelector("#pws-tab-overview")!.click(); }); expect(host.textContent).not.toContain("Current accountWork"); + expect(host.querySelector(".pws-auth-summary")?.textContent).toContain("Not verified"); await act(async () => { finishLogout(new Response(null, { status: 503 })); await Promise.resolve(); }); await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); expect(host.textContent).toContain("Current accountWork"); @@ -762,3 +763,56 @@ describe("ProviderDetails raw ID disambiguation and Providers page integration", }); }); + +test.each(["discovery", "status"] as const)("target switch hides old OAuth identity on the first commit when %s fails", async failure => { + let releaseOld!: (response: Response) => void; + let late = false; + let oldStarted!: () => void; + const oldRequested = new Promise(resolve => { oldStarted = resolve; }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + const target = url.pathname.startsWith("/a/") ? "a" : "b"; + const path = url.pathname.replace(/^\/[ab]/, ""); + if (path === "/api/config") return Response.json({ port: 0, defaultProvider: "kimi", providers: { + kimi: { adapter: "kimi", baseUrl: "https://api.kimi.com", authMode: "oauth" }, + } }); + if (path === "/api/providers") return Response.json([{ name: "kimi", adapter: "kimi", authMode: "oauth" }]); + if (path === "/api/oauth/providers") return target === "b" && failure === "discovery" ? new Response(null, { status: 503 }) : Response.json({ providers: ["kimi"] }); + if (path === "/api/oauth/status") { + if (target === "b") return new Response(null, { status: 503 }); + if (late) { oldStarted(); return new Promise(resolve => { releaseOld = resolve; }); } + return Response.json({ loggedIn: true, email: "identity-a@example.test" }); + } + if (path === "/api/oauth/accounts") return new Response(null, { status: 503 }); + if (path === "/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} }); + if (path === "/api/provider-presets") return Response.json({ providers: [] }); + if (path === "/api/models") return Response.json([]); + if (path === "/api/provider-quotas") return Response.json({ reports: [] }); + if (path === "/api/usage") return Response.json({ providers: [], models: [] }); + if (path === "/api/codex-auth/accounts") return Response.json({ accounts: [] }); + return Response.json({}); + }) as typeof fetch; + const commits: string[] = []; + function Observe({ target }: { target: string }) { + useLayoutEffect(() => { commits.push(`${target}:${host.textContent}`); }); + return null; + } + const page = (target: string) => <>; + await render(page("a")); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); + await act(async () => { host.querySelector('[role="option"]')!.click(); }); + expect(host.textContent).toContain("identity-a@example.test"); + late = true; + // Trigger a fresh OAuth read through the existing refresh event by returning to A after B. + await render(page("b")); + expect(commits.find(value => value.startsWith("b:"))).not.toContain("identity-a@example.test"); + await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); }); + expect(host.textContent).not.toContain("identity-a@example.test"); + await render(page("a")); + await act(async () => { await oldRequested; }); + const releaseFirstOld = releaseOld; + await render(page("b")); + await render(page("a")); + await act(async () => { releaseFirstOld(Response.json({ loggedIn: true, email: "stale-a@example.test" })); await Promise.resolve(); }); + expect(host.textContent).not.toContain("stale-a@example.test"); +}); diff --git a/gui/tests/provider-oauth-status-races.test.tsx b/gui/tests/provider-oauth-status-races.test.tsx new file mode 100644 index 00000000000..8d668cded76 --- /dev/null +++ b/gui/tests/provider-oauth-status-races.test.tsx @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useRef, useState, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import { useProvidersFetch } from "../src/pages/use-providers-fetch"; +import { useProvidersOAuth } from "../src/pages/use-providers-oauth"; +import type { OAuthStatus } from "../src/pages/providers-shared"; + +const keys = ["window", "document", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let saved: Record; +let win: Window; +let root: Root | undefined; +let host: HTMLElement; +beforeEach(() => { + saved = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + for (const key of ["window", "document", "navigator", "localStorage", "sessionStorage"] as const) + Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] }); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); +afterEach(async () => { + if (root) await act(async () => { root?.unmount(); }); + root = undefined; + await win.happyDOM.close(); + for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: saved[key] }); +}); +async function render(node: ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root ??= createRoot(host); root.render(node); }); +} + +test.each(["failed refresh", "late read", "read during logout"] as const)("logout %s fences old status, preserves other providers and recovers a remaining account", async mode => { + let loader!: ReturnType; + let actions!: ReturnType; + let statuses: Record = {}; + let releaseLogout!: (r: Response) => void; + let releaseOld!: (r: Response) => void; + let started!: () => void; + const oldStarted = new Promise(resolve => { started = resolve; }); + let phase: "initial" | "during" | "failed" | "recovered" = "initial"; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/oauth/logout") return new Promise(resolve => { releaseLogout = resolve; }); + if (url.pathname === "/api/oauth/providers") return Response.json({ providers: ["anthropic", "kimi"] }); + if (url.searchParams.get("provider") === "kimi") return Response.json({ loggedIn: true, email: "kimi@example.test" }); + if (phase === "during") { started(); return new Promise(resolve => { releaseOld = resolve; }); } + if (phase === "failed") return new Response(null, { status: 503 }); + return Response.json({ loggedIn: true, email: phase === "initial" ? "first@example.test" : "remaining@example.test" }); + } }); + function Harness() { + const [state, setOauthStatus] = useState>({}); + statuses = state; + const aliveRef = useRef(true); + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus, + notify: () => {}, invalidateProviderQuotas: () => {} }); + actions = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: {}, setAccountSets: () => {}, + setBusy: () => {}, setStatus: () => {}, setLoginInfo: () => {}, setOauthStatus, + invalidateOauthStatusReads: loader.invalidateOauthStatusReads, beginAccountLogout: () => () => {}, notify: () => {}, + fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchAccountSets: async () => {}, + fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} }); + return null; + } + await render(); + await act(async () => { await loader.fetchOauth(); }); + expect(statuses.anthropic?.email).toBe("first@example.test"); + let logout!: Promise; + await act(async () => { logout = actions.logoutOAuth("anthropic"); }); + if (mode !== "failed refresh") expect(statuses.anthropic).toBeUndefined(); + expect(statuses.kimi?.email).toBe("kimi@example.test"); + phase = "during"; + let old!: Promise; + await act(async () => { old = loader.fetchOauth(); await oldStarted; }); + if (mode === "read during logout") { + expect(loader.invalidateOauthStatusReads("anthropic")).toBe(false); + await act(async () => { releaseOld(Response.json({ loggedIn: true, email: "first@example.test" })); await old; }); + expect(statuses.anthropic).toBeUndefined(); + } + phase = "failed"; + await act(async () => { releaseLogout(new Response(null, { status: 200 })); await logout; }); + if (mode === "failed refresh") expect(statuses.anthropic).toBeUndefined(); + if (mode !== "read during logout") await act(async () => { releaseOld(Response.json({ loggedIn: true, email: "first@example.test" })); await old; }); + expect(statuses.anthropic).toBeUndefined(); + expect(statuses.kimi?.email).toBe("kimi@example.test"); + phase = "recovered"; + await act(async () => { await loader.fetchOauth(); }); + expect(statuses.anthropic).toEqual({ loggedIn: true, email: "remaining@example.test" }); +}); From 93eb50707932a9b8eb29891c227c8a34b709e49e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 15:40:00 +0900 Subject: [PATCH 05/10] Fix OAuth account mutation status races --- .../src/content/docs/guides/web-dashboard.md | 2 + .../provider-workspace/ProviderAuthPanel.tsx | 4 +- gui/src/hooks/useProviderAccountPools.ts | 28 +++- gui/src/i18n/de.ts | 2 + gui/src/i18n/en.ts | 2 + gui/src/i18n/fr.ts | 2 + gui/src/i18n/ja.ts | 2 + gui/src/i18n/ko.ts | 2 + gui/src/i18n/pt.ts | 2 + gui/src/i18n/ru.ts | 2 + gui/src/i18n/tr.ts | 2 + gui/src/i18n/vi.ts | 2 + gui/src/i18n/zh-TW.ts | 2 + gui/src/i18n/zh.ts | 2 + gui/src/pages/Providers.tsx | 2 +- gui/src/pages/use-providers-oauth.ts | 5 +- .../anthropic2-provider-surfaces.test.tsx | 55 ++++++++ .../provider-oauth-status-races.test.tsx | 120 ++++++++++++++++++ 18 files changed, 230 insertions(+), 8 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 5dd7a99df8f..c471a9b7d6f 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -539,6 +539,8 @@ them. Both switches apply immediately; **Save** never changes them. When interce Claude/Anthropic accounts are managed only on **Providers > Anthropic > Accounts**: login and the browser option, the account roster with switch, pause, remove, and reauthentication, the paste-code field, account pool settings, and quota. Claude no longer has an Account sub-tab. Provider-level controls such as the default provider, removal, and the enabled switch stay on Providers. +If an account change is still in progress, wait for it to finish before retrying logout. After an account removal, the dashboard rechecks the provider's login status; if it cannot verify the result, refresh the account list before retrying the removal. + The bookmarks `#claude` and `#claude/code` open this page, and the former `#claude/settings` redirects to `#claude/code`; `#claude/desktop` selects the separate Claude Desktop tab. The old `#claude/account` bookmark redirects to `#providers?provider=anthropic&tab=accounts`. Legacy `#integrations/claude` and `#integrations/claude/desktop` bookmarks still redirect to `#claude/code` and `#claude/desktop`, respectively. Arrow keys move between tabs; Home and End select the first and last tab. diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 4ee68d79c16..b841006aa73 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -331,7 +331,7 @@ export default function ProviderAuthPanel({ setManualCodeBusy(false); } }; - const loggedIn = accounts.length > 0 || oauth?.loggedIn === true; + const loggedIn = (accountLoadState === "ready" && accounts.length > 0) || oauth?.loggedIn === true; const activeReauthAccount = accounts.find(a => a.active && a.needsReauth); const activeNeedsReauth = Boolean(activeReauthAccount); const quotaRows = isOauth ? accounts : keys; @@ -488,7 +488,7 @@ export default function ProviderAuthPanel({ {loggedIn ? t("pws.loggedInTitle") - : (oauth?.error || t("pws.notLoggedInTitle"))} + : (oauth?.error || (oauth?.loggedIn === false ? t("pws.notLoggedInTitle") : t("startup.unverified")))} {activeReauthAccount && ( diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index ee6e2a22fe5..deb0510a01e 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -122,10 +122,12 @@ export function useProviderAccountPools(deps: { fetchOauth: () => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; codexActiveNeedsReauth: boolean; + invalidateOauthStatusReads?: (provider: string, blocked?: boolean) => boolean; + setOauthStatus?: React.Dispatch>>; }) { const { apiBase, t, config, aliveRef, notify, - fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, + fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, invalidateOauthStatusReads, setOauthStatus, } = deps; const accountTarget = useMemo(() => ({ apiBase }), [apiBase]); const [accountStateTarget, setAccountStateTarget] = useState(accountTarget); @@ -815,6 +817,22 @@ export function useProviderAccountPools(deps: { invalidateSelectionReads(provider, "oauth"); const mutation = Symbol(); selectionMutationsRef.current.set(key, mutation); + const statusBlocked = invalidateOauthStatusReads?.(provider, true) === true; + const clearStatus = () => setOauthStatus?.(current => { + if (!(provider in current)) return current; + const next = { ...current }; + delete next[provider]; + return next; + }); + if (statusBlocked) clearStatus(); + let statusSettled = false; + const settleStatus = () => { + if (statusSettled || !statusBlocked) return; + statusSettled = true; + if (invalidateOauthStatusReads?.(provider, false) !== true) return; + clearStatus(); + void fetchOauth().catch(() => {}); + }; const pending = affinityResetRetries(apiBase)[provider]; const current = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase && selectionMutationsRef.current.get(key) === mutation; @@ -828,8 +846,9 @@ export function useProviderAccountPools(deps: { else { result = (await res.json().catch(() => ({})) ?? {}) as typeof result; partial = result.code === "affinity_reset_not_persisted" && result.retryable === true && result.deletionCommitted === true; - if (!partial) { if (current()) notify(t("prov.accountRemoveFail", { email: label }), false); return; } + if (!partial) { if (current()) notify(t(result.deletionCommitted === false ? "prov.accountRemoveFail" : "prov.accountRemoveUnverified", { email: label }), false); return; } } + settleStatus(); committed = true; if (partial) receipt(() => ({ kind: "reset", accountId: account.id }), true, { kind: "unpersisted", accountId: account.id }); else if (result.affinityResetConfirmed === true) receipt(() => undefined, true, { kind: "confirmed" }); @@ -841,13 +860,14 @@ export function useProviderAccountPools(deps: { notify(t("prov.accountRemoved", { email: label }), true); } await fetchAccountSets([provider]); - void fetchOauth().catch(() => { + if (!statusBlocked) void fetchOauth().catch(() => { if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) notify(t("pws.accountsLoadFailed"), false); }); await fetchProviderQuotas(true); } catch { - if (current()) notify(t(committed ? "pws.accountsLoadFailed" : "prov.accountRemoveFail", { email: label }), false); + if (current()) notify(t(committed ? "pws.accountsLoadFailed" : "prov.accountRemoveUnverified", { email: label }), false); } finally { + settleStatus(); receipt(); if (selectionMutationsRef.current.get(key) === mutation) { selectionMutationsRef.current.delete(key); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 855aad80b86..f8967c2ffde 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -839,6 +839,8 @@ export const de: Record = { "subagentSurface.learnMore": "Warum das fehlschlägt", "prov.logoutOk": "Von {provider} abgemeldet.", "prov.logoutFail": "Abmeldung von {provider} fehlgeschlagen. Der Kontostatus bleibt unverändert.", + "prov.logoutBusy": "Eine Kontoänderung läuft. Warten Sie bis zum Abschluss und versuchen Sie die Abmeldung erneut.", + "prov.accountRemoveUnverified": "Es konnte nicht geprüft werden, ob {email} entfernt wurde. Aktualisieren Sie vor einem erneuten Versuch die Kontoliste.", "prov.removed": "\"{name}\" entfernt.", "prov.removedDefault": "\"{name}\" entfernt. Standardanbieter ist jetzt \"{defaultProvider}\".", "prov.removedShadowTarget": "\"{name}\" entfernt. Shadow-Call-Abfangen zielt weiterhin auf \"{model}\"; Hilfsaufrufe schlagen fehl, bis Sie ein anderes Ziel wählen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 93c33a27e9d..402585f465d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -862,6 +862,8 @@ export const en = { "subagentSurface.learnMore": "Why this fails", "prov.logoutOk": "Logged out of {provider}.", "prov.logoutFail": "Could not log out of {provider}. Your account state is unchanged.", + "prov.logoutBusy": "An account change is in progress. Wait for it to finish, then try logging out again.", + "prov.accountRemoveUnverified": "Could not verify whether {email} was removed. Refresh the account list before retrying.", "prov.removed": "Removed \"{name}\".", "prov.removedDefault": "Removed \"{name}\". Default provider is now \"{defaultProvider}\".", "prov.removedShadowTarget": "Removed \"{name}\". Shadow Call Intercept still targets \"{model}\", so helper calls will fail until you choose another target.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4bac4fb5bdb..4f4522d46fb 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -849,6 +849,8 @@ export const fr: Record = { "subagentSurface.learnMore": "Pourquoi cet échec", "prov.logoutOk": "Déconnexion de {provider} réussie.", "prov.logoutFail": "Impossible de se déconnecter de {provider}. L’état de votre compte n’a pas changé.", + "prov.logoutBusy": "Une modification du compte est en cours. Attendez sa fin avant de réessayer de vous déconnecter.", + "prov.accountRemoveUnverified": "Impossible de vérifier si {email} a été supprimé. Actualisez la liste des comptes avant de réessayer.", "prov.removed": "« {name} » supprimé.", "prov.removedDefault": "« {name} » supprimé. Le fournisseur par défaut est maintenant « {defaultProvider} ».", "prov.removedShadowTarget": "« {name} » supprimé. L’interception des appels fantômes cible toujours « {model} » : les appels auxiliaires échoueront tant que vous n’aurez pas choisi une autre cible.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index e17c94b7f89..67a4b17efd7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -844,6 +844,8 @@ export const ja: Record = { "subagentSurface.learnMore": "失敗する理由", "prov.logoutOk": "{provider} からログアウトしました。", "prov.logoutFail": "{provider} からログアウトできませんでした。アカウント状態は変更されていません。", + "prov.logoutBusy": "アカウントを変更中です。完了してからログアウトをもう一度お試しください。", + "prov.accountRemoveUnverified": "{email} が削除されたか確認できません。再試行する前にアカウント一覧を更新してください。", "prov.removed": "\"{name}\" を削除しました。", "prov.removedDefault": "\"{name}\" を削除しました。既定のプロバイダーは \"{defaultProvider}\" になりました。", "prov.removedShadowTarget": "\"{name}\" を削除しました。シャドウコール傍受の対象はまだ \"{model}\" のため、別の対象を選ぶまで補助呼び出しは失敗します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bab550b094b..d479248e607 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -848,6 +848,8 @@ export const ko: Record = { "subagentSurface.learnMore": "실패하는 이유", "prov.logoutOk": "{provider} 에서 로그아웃했습니다.", "prov.logoutFail": "{provider}에서 로그아웃하지 못했습니다. 계정 상태는 그대로입니다.", + "prov.logoutBusy": "계정 변경이 진행 중이에요. 완료된 후 로그아웃을 다시 시도해 주세요.", + "prov.accountRemoveUnverified": "{email} 계정이 삭제되었는지 확인할 수 없어요. 다시 시도하기 전에 계정 목록을 새로고침해 주세요.", "prov.removed": "\"{name}\" 을(를) 삭제했습니다.", "prov.removedDefault": "\"{name}\"을(를) 삭제했습니다. 이제 기본 프로바이더는 \"{defaultProvider}\"입니다.", "prov.removedShadowTarget": "\"{name}\"을(를) 삭제했습니다. 쉐도우 호출 가로채기가 아직 \"{model}\"을(를) 가리키므로, 다른 대상을 고를 때까지 보조 호출이 실패합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 2f8c7b96c62..e83cd0ce044 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -863,6 +863,8 @@ export const pt: Record = { "subagentSurface.learnMore": "Por que isso falha", "prov.logoutOk": "Sessão de {provider} encerrada.", "prov.logoutFail": "Não foi possível sair de {provider}. O estado da sua conta não mudou.", + "prov.logoutBusy": "Uma alteração de conta está em andamento. Aguarde sua conclusão e tente sair novamente.", + "prov.accountRemoveUnverified": "Não foi possível confirmar se {email} foi removido. Atualize a lista de contas antes de tentar novamente.", "prov.removed": "\"{name}\" removido.", "prov.removedDefault": "\"{name}\" removido. O provedor padrão agora é \"{defaultProvider}\".", "prov.removedShadowTarget": "\"{name}\" removido. O Shadow Call Intercept ainda aponta para \"{model}\", então as chamadas auxiliares vão falhar até você escolher outro destino.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 132bf0e533e..9756389bf17 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -850,6 +850,8 @@ export const ru: Record = { "subagentSurface.learnMore": "Почему возникает сбой", "prov.logoutOk": "Выполнен выход из {provider}.", "prov.logoutFail": "Не удалось выйти из {provider}. Состояние аккаунта не изменилось.", + "prov.logoutBusy": "Изменение учётной записи выполняется. Дождитесь завершения и повторите выход.", + "prov.accountRemoveUnverified": "Не удалось проверить, удалена ли учётная запись {email}. Обновите список перед повторной попыткой.", "prov.removed": "Провайдер \"{name}\" удалён.", "prov.removedDefault": "Провайдер \"{name}\" удалён. Провайдером по умолчанию теперь является \"{defaultProvider}\".", "prov.removedShadowTarget": "Провайдер \"{name}\" удалён. Перехват теневых вызовов всё ещё направлен на \"{model}\", поэтому вспомогательные вызовы будут завершаться ошибкой, пока вы не выберете другую цель.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index db0256de5cf..8c19fbda2da 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -863,6 +863,8 @@ export const tr: Record = { "subagentSurface.learnMore": "Neden başarısız oluyor", "prov.logoutOk": "{provider} çıkışı yapıldı.", "prov.logoutFail": "{provider} çıkışı yapılamadı.", + "prov.logoutBusy": "Hesap değişikliği sürüyor. Tamamlanmasını bekleyip tekrar çıkış yapmayı deneyin.", + "prov.accountRemoveUnverified": "{email} hesabının kaldırılıp kaldırılmadığı doğrulanamadı. Yeniden denemeden önce hesap listesini yenileyin.", "prov.removed": "\"{name}\" kaldırıldı.", "prov.removedDefault": "\"{name}\" kaldırıldı. Varsayılan sağlayıcı artık \"{defaultProvider}\".", "prov.removedShadowTarget": "\"{name}\" kaldırıldı. Gölge Çağrı Yakalama hâlâ \"{model}\" hedefini kullanıyor; başka bir hedef seçene kadar yardımcı çağrılar başarısız olur.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index e2efd284495..7aa47552b73 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -849,6 +849,8 @@ export const vi: Record = { "subagentSurface.learnMore": "Vì sao việc này thất bại", "prov.logoutOk": "Đã đăng xuất khỏi {provider}.", "prov.logoutFail": "Không thể đăng xuất khỏi {provider}. Trạng thái tài khoản của bạn vẫn không thay đổi.", + "prov.logoutBusy": "Đang thay đổi tài khoản. Hãy đợi hoàn tất rồi thử đăng xuất lại.", + "prov.accountRemoveUnverified": "Không thể xác nhận {email} đã được xóa hay chưa. Hãy tải lại danh sách tài khoản trước khi thử lại.", "prov.removed": "Đã xóa \"{name}\".", "prov.removedDefault": "Đã xoá \"{name}\". Provider mặc định hiện tại là \"{defaultProvider}\".", "prov.removedShadowTarget": "Đã xóa \"{name}\". Shadow Call Intercept vẫn trỏ tới \"{model}\", nên các lệnh gọi phụ sẽ thất bại cho đến khi bạn chọn mục tiêu khác.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f6bfa5ab864..7e539620ac6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -727,6 +727,8 @@ export const zhTW: Record = { "subagentSurface.learnMore": "為什麼會失敗", "prov.logoutOk": "已登出 {provider}。", "prov.logoutFail": "無法登出 {provider}。帳號狀態保持不變。", + "prov.logoutBusy": "帳戶變更正在進行。請等待完成後再試著登出。", + "prov.accountRemoveUnverified": "無法確認是否已移除 {email}。請重新整理帳戶清單後再試。", "prov.removed": "已移除 \"{name}\"。", "prov.removeFail": "移除 \"{name}\" 失敗。", "prov.added": "已新增 \"{name}\"。現已生效 — 執行 {cmd}(或重新啟動)以在 Codex 選擇器中列出其模型。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aa3a52e8191..b43ca2ca51c 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -845,6 +845,8 @@ export const zh: Record = { "subagentSurface.learnMore": "为什么失败", "prov.logoutOk": "已退出 {provider}。", "prov.logoutFail": "无法退出 {provider}。账户状态保持不变。", + "prov.logoutBusy": "账户更改正在进行。请等待完成后重试退出登录。", + "prov.accountRemoveUnverified": "无法确认是否已移除 {email}。请刷新账户列表后再试。", "prov.removed": "已移除 \"{name}\"。", "prov.removedDefault": "已移除 \"{name}\"。默认提供方现为 \"{defaultProvider}\"。", "prov.removedShadowTarget": "已移除 \"{name}\"。影子调用拦截仍指向 \"{model}\",在选择其他目标之前,辅助调用会失败。", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index c6594c71cfb..61143fc2cde 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -384,7 +384,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { apiBase, t: t as unknown as Parameters[0]["t"], config, oauthStatus: oauthStatusWithCodex, aliveRef, notify, - fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, + fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, invalidateOauthStatusReads, setOauthStatus, }); const { accountSets, setAccountSets, accountLoadStates, switchingAccounts, pausingAccounts, keyPools, fetchAccountSets, fetchKeyPools, beginAccountLogout, diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 196227b2ddd..82b41c26870 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -263,7 +263,10 @@ export function useProvidersOAuth({ const logoutOAuth = async (provider: string) => { const finishLogout = beginAccountLogout?.(provider); - if (beginAccountLogout && !finishLogout) return; + if (beginAccountLogout && !finishLogout) { + if (aliveRef.current) notify(t("prov.logoutBusy"), false); + return; + } if (invalidateOauthStatusReads?.(provider, true) === false) { finishLogout?.(); return; } const clearStatus = () => setOauthStatus(current => { if (!(provider in current)) return current; diff --git a/gui/tests/anthropic2-provider-surfaces.test.tsx b/gui/tests/anthropic2-provider-surfaces.test.tsx index dd85ef18908..957dd89ec7b 100644 --- a/gui/tests/anthropic2-provider-surfaces.test.tsx +++ b/gui/tests/anthropic2-provider-surfaces.test.tsx @@ -438,3 +438,58 @@ for (const item of [POOL_A, POOL_B, CUSTOM_B, { ...POOL_A, name: "cursor", adapt }); } } + +test.each(["switch", "pause"] as const)("logout explains a pending %s and succeeds after it settles", async action => { + let release!: (response: Response) => void; + let pendingStarted!: () => void; + const started = new Promise(resolve => { pendingStarted = resolve; }); + const notices: string[] = []; + respond = async request => { + if (request.method === "PUT" && request.path.startsWith("/api/oauth/accounts/")) { + pendingStarted(); return new Promise(resolve => { release = resolve; }); + } + if (request.path === "/api/oauth/logout") return Response.json({ ok: true }); + if (request.path === "/api/oauth/accounts") return Response.json({ activeAccountId: "shared", accounts: accounts("anthropic2") }); + if (request.path === "/api/pool/settings") return Response.json(poolSettings("anthropic2", false, 55)); + if (request.path === "/api/anthropic/reset-grants") return Response.json(grantSnapshot("anthropic2", 1)); + return Response.json({}); + }; + function Harness() { + const aliveRef = useRef(true); + const [status, setOauthStatus] = useState>({ anthropic2: { loggedIn: true } }); + const pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key, + oauthStatus: status, notify: message => notices.push(message), fetchConfig: async () => {}, + fetchOauth: async () => {}, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false }); + const oauth = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: pools.accountSets, + setAccountSets: pools.setAccountSets, setBusy: () => {}, setStatus: () => {}, setLoginInfo: () => {}, setOauthStatus, + beginAccountLogout: pools.beginAccountLogout, notify: message => notices.push(message), fetchConfig: async () => {}, + fetchOauth: async () => {}, fetchAccountSets: pools.fetchAccountSets, fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} }); + return ; + } + await render(); + if (action === "switch") await click(host.querySelectorAll(".pwi-auth-row-main")[1]); + else await click(host.querySelectorAll('button[aria-label^="' + en["codexAuth.pause"] + ' — "]')[1]); + await started; + await click(buttonsByText(en["prov.logout"])[0]); + expect(requests.filter(request => request.path === "/api/oauth/logout")).toHaveLength(0); + expect(notices).toContain("prov.logoutBusy"); + await act(async () => { release(Response.json({ activeAccountId: "other", activeAccountChanged: false })); }); + await tick(); await tick(); + await click(buttonsByText(en["prov.logout"])[0]); + expect(requests.filter(request => request.path === "/api/oauth/logout")).toHaveLength(1); +}); + +test("unverified OAuth status never reports a stale roster as logged in or logged out", async () => { + respond = async request => request.path === "/api/pool/settings" + ? Response.json(poolSettings("anthropic2", false, 55)) : Response.json(grantSnapshot("anthropic2", 1)); + await render(); + expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["startup.unverified"]); + await render(); + expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["startup.unverified"]); + await render(); + expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["pws.notLoggedInTitle"]); +}); diff --git a/gui/tests/provider-oauth-status-races.test.tsx b/gui/tests/provider-oauth-status-races.test.tsx index 8d668cded76..2b3c0e154d9 100644 --- a/gui/tests/provider-oauth-status-races.test.tsx +++ b/gui/tests/provider-oauth-status-races.test.tsx @@ -87,3 +87,123 @@ test.each(["failed refresh", "late read", "read during logout"] as const)("logou await act(async () => { await loader.fetchOauth(); }); expect(statuses.anthropic).toEqual({ loggedIn: true, email: "remaining@example.test" }); }); + +test.each(["success", "partial", "failure", "network"] as const)("account deletion %s fences status and recovers after settlement", async outcome => { + const dialogs = await import("../src/action-dialogs"); + const { spyOn } = await import("bun:test"); + const { useProviderAccountPools } = await import("../src/hooks/useProviderAccountPools"); + const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true); + let loader!: ReturnType; + let pools!: ReturnType; + let statuses: Record = {}; + let releaseDelete!: (r: Response) => void; + let rejectDelete!: (e: Error) => void; + let releaseOld!: (r: Response) => void; + let releaseDuring!: (r: Response) => void; + let started!: () => void; + let duringStarted!: () => void; + const oldStarted = new Promise(resolve => { started = resolve; }); + const duringReady = new Promise(resolve => { duringStarted = resolve; }); + let held = 0; + let phase: "initial" | "during" | "failed" | "recovered" = "initial"; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (init?.method === "DELETE") return new Promise((resolve, reject) => { releaseDelete = resolve; rejectDelete = reject; }); + if (url.pathname === "/api/oauth/providers") return Response.json({ providers: ["anthropic", "kimi"] }); + if (url.pathname === "/api/oauth/accounts") return Response.json({ activeAccountId: null, accounts: [] }); + if (url.searchParams.get("provider") === "kimi") return Response.json({ loggedIn: true, email: "kimi@example.test" }); + if (phase === "during") return new Promise(resolve => { + if (held++ === 0) { releaseOld = resolve; started(); } + else { releaseDuring = resolve; duringStarted(); } + }); + if (phase === "failed") return new Response(null, { status: 503 }); + return Response.json({ loggedIn: true, email: phase === "initial" ? "first@example.test" : "remaining@example.test" }); + } }); + function Harness() { + const [state, setOauthStatus] = useState>({}); + statuses = state; + const aliveRef = useRef(true); + loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus, + notify: () => {}, invalidateProviderQuotas: () => {} }); + pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key, oauthStatus: state, notify: () => {}, + fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false, + invalidateOauthStatusReads: loader.invalidateOauthStatusReads, setOauthStatus }); + return null; + } + try { + await render(); + await act(async () => { await loader.fetchOauth(); }); + let old!: Promise; + phase = "during"; + await act(async () => { old = loader.fetchOauth(); await oldStarted; }); + let deletion!: Promise; + await act(async () => { deletion = pools.removeAccount("anthropic", { id: "a", active: true }); await Promise.resolve(); }); + expect(statuses.anthropic).toBeUndefined(); + expect(statuses.kimi?.email).toBe("kimi@example.test"); + let during!: Promise; + await act(async () => { during = loader.fetchOauth(); await duringReady; }); + if (outcome === "success") { + await act(async () => { releaseDuring(Response.json({ loggedIn: true, email: "first@example.test" })); await during; }); + expect(statuses.anthropic).toBeUndefined(); + } + phase = "failed"; + await act(async () => { + if (outcome === "network") rejectDelete(new Error("offline")); + else releaseDelete(outcome === "success" ? Response.json({}) : outcome === "partial" + ? Response.json({ code: "affinity_reset_not_persisted", retryable: true, deletionCommitted: true }, { status: 503 }) + : new Response(null, { status: 500 })); + await deletion; + releaseOld(Response.json({ loggedIn: true, email: "first@example.test" })); await old; + if (outcome !== "success") { releaseDuring(Response.json({ loggedIn: true, email: "first@example.test" })); await during; } + }); + expect(statuses.anthropic).toBeUndefined(); + phase = "recovered"; + await act(async () => { await loader.fetchOauth(); }); + expect(statuses.anthropic?.email).toBe("remaining@example.test"); + expect(statuses.kimi?.email).toBe("kimi@example.test"); + } finally { consent.mockRestore(); } +}); + +test("a deletion settling after a target change cannot clear or refresh the new target", async () => { + const dialogs = await import("../src/action-dialogs"); + const { spyOn } = await import("bun:test"); + const { useProviderAccountPools } = await import("../src/hooks/useProviderAccountPools"); + const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true); + let loader!: ReturnType; + let pools!: ReturnType; + let statuses: Record = {}; + let release!: (response: Response) => void; + const requests: string[] = []; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + requests.push(url.pathname); + if (init?.method === "DELETE") return new Promise(resolve => { release = resolve; }); + if (url.pathname.endsWith("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] }); + if (url.pathname.endsWith("/api/oauth/status")) return Response.json({ loggedIn: true, email: url.pathname.startsWith("/new") ? "new@example.test" : "old@example.test" }); + return Response.json({ activeAccountId: null, accounts: [] }); + } }); + function Harness({ base }: { base: string }) { + const [state, setOauthStatus] = useState>({}); + statuses = state; + const aliveRef = useRef(true); + loader = useProvidersFetch({ apiBase: base, t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus, + notify: () => {}, invalidateProviderQuotas: () => {} }); + pools = useProviderAccountPools({ apiBase: base, config: null, aliveRef, t: key => key, oauthStatus: state, notify: () => {}, + fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false, + invalidateOauthStatusReads: loader.invalidateOauthStatusReads, setOauthStatus }); + return null; + } + try { + await render(); + await act(async () => { await loader.fetchOauth(); }); + let deletion!: Promise; + await act(async () => { deletion = pools.removeAccount("anthropic", { id: "a", active: true }); await Promise.resolve(); }); + await render(); + await act(async () => { await loader.fetchOauth(); }); + expect(statuses.anthropic?.email).toBe("new@example.test"); + const oldReads = requests.filter(path => path === "/old/api/oauth/providers").length; + await act(async () => { release(Response.json({})); await deletion; }); + expect(statuses.anthropic?.email).toBe("new@example.test"); + expect(requests.filter(path => path === "/old/api/oauth/providers")).toHaveLength(oldReads); + } finally { consent.mockRestore(); } +}); From f4fe216635e0dd4917b7448c9d2043a0abbb46f5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 8 Nov 2026 15:58:05 +0900 Subject: [PATCH 06/10] Fix OAuth account operation races and recovery --- .../src/content/docs/guides/web-dashboard.md | 4 + .../provider-workspace/ProviderAuthPanel.tsx | 3 +- gui/src/hooks/useProviderAccountPools.ts | 18 ++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/pt.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Providers.tsx | 2 +- gui/src/pages/use-providers-oauth.ts | 10 +- .../anthropic2-provider-surfaces.test.tsx | 134 ++++++++++++++++++ structure/account-auto-pause.md | 6 + 18 files changed, 182 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index c471a9b7d6f..818d52692a6 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -164,6 +164,10 @@ to save an incomplete snapshot; choose another order or repair the history befor ### Account selection +For a provider's OAuth accounts, finish the current sign-in or account removal before starting +the other operation. If the account list and sign-in status cannot be refreshed, the status remains +unverified and **Log out** is available to explicitly retry recovery. + Account selection is shared with request routing. Selecting an OAuth account takes effect on the next request even when a pool is enabled. A healthy selection is not replaced merely because another generic OAuth account has more unused quota. If the account returns 429, automatic diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index b841006aa73..298002dc44e 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -332,6 +332,7 @@ export default function ProviderAuthPanel({ } }; const loggedIn = (accountLoadState === "ready" && accounts.length > 0) || oauth?.loggedIn === true; + const canLogout = loggedIn || (accountLoadState === "error" && oauth?.loggedIn !== false); const activeReauthAccount = accounts.find(a => a.active && a.needsReauth); const activeNeedsReauth = Boolean(activeReauthAccount); const quotaRows = isOauth ? accounts : keys; @@ -496,7 +497,7 @@ export default function ProviderAuthPanel({ {t("pws.reauthenticate")} )} - {loggedIn ? ( + {canLogout ? ( ) : (
{!busy && } - {busy && hintForThis && ( + {busy && (