From 5dd62fe548d15289d5c84e349676219a6aa03892 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 20 Jul 2026 15:41:41 -0700 Subject: [PATCH] FN-8446: preserve dismissed OAuth re-login banners Keep OAuth re-login dismissals sticky until the matching provider successfully authenticates. - Preserve dismissed provider IDs across transient healthy auth-status responses. - Re-arm only the provider identified by an OAuth re-login success event. - Add Copilot and multi-provider regression coverage plus operator documentation. - Add a patch changeset for the banner behavior fix. Files changed: .changeset/fn-8446-oauth-relogin-dismiss-sticky.md | 7 ++ docs/dashboard-guide.md | 2 + .../app/components/OAuthReloginBanner.tsx | 30 +++--- .../__tests__/OAuthReloginBanner.test.tsx | 113 +++++++++++++++++++-- 4 files changed, 128 insertions(+), 24 deletions(-) Fusion-Task-Id: FN-8446 Fusion-Task-Lineage: 30574bbc-3efb-4f72-956b-b249d24251c9 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8446-oauth-relogin-dismiss-sticky.md | 7 ++ docs/dashboard-guide.md | 2 + .../app/components/OAuthReloginBanner.tsx | 30 +++-- .../__tests__/OAuthReloginBanner.test.tsx | 117 ++++++++++++++++-- 4 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 .changeset/fn-8446-oauth-relogin-dismiss-sticky.md diff --git a/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md b/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md new file mode 100644 index 0000000000..18328e15e0 --- /dev/null +++ b/.changeset/fn-8446-oauth-relogin-dismiss-sticky.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep dismissed OAuth re-login banners hidden until successful re-login (fixes Copilot flicker). +category: fix +dev: OAuthReloginBanner no longer prunes fusion:oauth-relogin-dismissed when a provider leaves the expired set after silent refresh; OAUTH_RELOGIN_SUCCESS_EVENT clears that provider's dismissal to re-arm. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index dc14110582..5259de8a23 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -897,6 +897,8 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval. +Dismissing the banner suppresses each currently shown provider until that provider successfully re-authenticates. A silent refresh or temporary non-expired `/auth/status` response does not clear the dismissal, preventing short-lived GitHub Copilot tokens from repeatedly re-showing a manually dismissed banner; a successful re-login re-arms that provider for a future true expiry. + For OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the provider without manual re-login and without waiting for a separate model request. If the OAuth credential has no refresh token or the refresh request fails/leaves the credential expired, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops. diff --git a/packages/dashboard/app/components/OAuthReloginBanner.tsx b/packages/dashboard/app/components/OAuthReloginBanner.tsx index 7354ea63a3..fae9d7327c 100644 --- a/packages/dashboard/app/components/OAuthReloginBanner.tsx +++ b/packages/dashboard/app/components/OAuthReloginBanner.tsx @@ -69,17 +69,6 @@ export function OAuthReloginBanner({ const nextExpiredProviders = getVisibleExpiredOAuthProvidersForGlobalBanner(providers); setExpiredProviders(nextExpiredProviders); - setDismissedProviderIds((currentDismissed) => { - const expiredProviderIds = new Set(nextExpiredProviders.map((provider) => provider.id)); - const filteredDismissed = new Set( - Array.from(currentDismissed).filter((providerId) => expiredProviderIds.has(providerId)), - ); - if (filteredDismissed.size === currentDismissed.size) { - return currentDismissed; - } - persistDismissedProviderIds(filteredDismissed); - return filteredDismissed; - }); } catch { // Non-blocking banner; ignore transient status fetch failures. } @@ -97,8 +86,23 @@ export function OAuthReloginBanner({ useEffect(() => { const handleOAuthReloginSuccess = (event: Event) => { const { detail } = event as CustomEvent<{ providerId?: string }>; - if (detail?.providerId) { - setExpiredProviders((current) => current.filter((provider) => provider.id !== detail.providerId)); + const providerId = detail?.providerId; + if (providerId) { + setExpiredProviders((current) => current.filter((provider) => provider.id !== providerId)); + setDismissedProviderIds((currentDismissed) => { + if (!currentDismissed.has(providerId)) { + return currentDismissed; + } + + /* + FNXC:ProviderAuth 2026-07-20-12:00: + FN-8446 — A manual dismissal must survive an expired→refreshed→expired flicker from short-lived OAuth tokens such as GitHub Copilot. Re-arm only this provider after its successful re-login event; polling a healthy status must never prune its browser preference. + */ + const nextDismissed = new Set(currentDismissed); + nextDismissed.delete(providerId); + persistDismissedProviderIds(nextDismissed); + return nextDismissed; + }); } void refreshAuthStatus(); }; diff --git a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx index da9a8cf24c..23044166c4 100644 --- a/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx +++ b/packages/dashboard/app/components/__tests__/OAuthReloginBanner.test.tsx @@ -129,39 +129,130 @@ describe("OAuthReloginBanner", () => { await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); }); - it("keeps dismissed state scoped to currently expired provider ids", async () => { + it("keeps a dismissed GitHub Copilot banner hidden across refresh and re-expiry", async () => { + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: true, expired: false }], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }); + + const firstRender = render(); + expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot"); + fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner")); + await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot"); + + firstRender.unmount(); + const refreshedRender = render(); + await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2)); + expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot"); + + refreshedRender.unmount(); + render(); + await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3)); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("keeps multiple dismissed providers suppressed while showing newly expired providers", async () => { mockFetchAuthStatus .mockResolvedValueOnce({ providers: [ - { id: "anthropic-subscription", name: "Anthropic Subscription", type: "oauth", authenticated: false, expired: true }, + { id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }, + { id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true }, ], ghCli: { available: false, authenticated: false }, }) - .mockResolvedValueOnce({ - providers: [], - ghCli: { available: false, authenticated: false }, - }) + .mockResolvedValueOnce({ providers: [], ghCli: { available: false, authenticated: false } }) .mockResolvedValueOnce({ providers: [ { id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true }, + { id: "google-gemini", name: "Google Gemini", type: "oauth", authenticated: false, expired: true }, ], ghCli: { available: false, authenticated: false }, }); - const { unmount } = render(); - expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription"); - + const firstRender = render(); + expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot, OpenAI Codex"); fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner")); - await waitFor(() => expect(screen.queryByRole("status")).toBeNull()); + firstRender.unmount(); + + const clearedRender = render(); + await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2)); + clearedRender.unmount(); - unmount(); render(); + expect(await screen.findByRole("status")).toHaveTextContent("Google Gemini"); + expect(screen.getByRole("status")).not.toHaveTextContent("OpenAI Codex"); + expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot"); + expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("openai-codex"); + }); + + it("re-arms a dismissed provider after its successful OAuth re-login", async () => { + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: true, expired: false }], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }); + + const firstRender = render(); + expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot"); + fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner")); + await act(async () => { + window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "github-copilot" } })); await flushPromises(); }); - unmount(); + expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).not.toContain("github-copilot"); + firstRender.unmount(); render(); - expect(await screen.findByRole("status")).toHaveTextContent("OpenAI Codex"); + expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot"); + }); + + it("does not clear dismissed providers for an OAuth success event without a provider id", async () => { + mockFetchAuthStatus + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }) + .mockResolvedValueOnce({ + providers: [{ id: "github-copilot", name: "GitHub Copilot", type: "oauth", authenticated: false, expired: true }], + ghCli: { available: false, authenticated: false }, + }); + + const firstRender = render(); + expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot"); + fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner")); + + await act(async () => { + window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT)); + await flushPromises(); + }); + expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot"); + + firstRender.unmount(); + render(); + await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3)); + expect(screen.queryByRole("status")).toBeNull(); }); });