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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
Normal file
7
.changeset/fn-8446-oauth-relogin-dismiss-sticky.md
Normal file
@@ -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.
|
||||||
@@ -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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -69,17 +69,6 @@ export function OAuthReloginBanner({
|
|||||||
const nextExpiredProviders = getVisibleExpiredOAuthProvidersForGlobalBanner(providers);
|
const nextExpiredProviders = getVisibleExpiredOAuthProvidersForGlobalBanner(providers);
|
||||||
|
|
||||||
setExpiredProviders(nextExpiredProviders);
|
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 {
|
} catch {
|
||||||
// Non-blocking banner; ignore transient status fetch failures.
|
// Non-blocking banner; ignore transient status fetch failures.
|
||||||
}
|
}
|
||||||
@@ -97,8 +86,23 @@ export function OAuthReloginBanner({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOAuthReloginSuccess = (event: Event) => {
|
const handleOAuthReloginSuccess = (event: Event) => {
|
||||||
const { detail } = event as CustomEvent<{ providerId?: string }>;
|
const { detail } = event as CustomEvent<{ providerId?: string }>;
|
||||||
if (detail?.providerId) {
|
const providerId = detail?.providerId;
|
||||||
setExpiredProviders((current) => current.filter((provider) => provider.id !== 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();
|
void refreshAuthStatus();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -129,39 +129,130 @@ describe("OAuthReloginBanner", () => {
|
|||||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
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(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
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(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2));
|
||||||
|
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toContain("github-copilot");
|
||||||
|
|
||||||
|
refreshedRender.unmount();
|
||||||
|
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3));
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps multiple dismissed providers suppressed while showing newly expired providers", async () => {
|
||||||
mockFetchAuthStatus
|
mockFetchAuthStatus
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
providers: [
|
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 },
|
ghCli: { available: false, authenticated: false },
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({ providers: [], ghCli: { available: false, authenticated: false } })
|
||||||
providers: [],
|
|
||||||
ghCli: { available: false, authenticated: false },
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
providers: [
|
providers: [
|
||||||
{ id: "openai-codex", name: "OpenAI Codex", type: "oauth", authenticated: false, expired: true },
|
{ 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 },
|
ghCli: { available: false, authenticated: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { unmount } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
const firstRender = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
expect(await screen.findByRole("status")).toHaveTextContent("Anthropic Subscription");
|
expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot, OpenAI Codex");
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
|
fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
|
||||||
await waitFor(() => expect(screen.queryByRole("status")).toBeNull());
|
firstRender.unmount();
|
||||||
|
|
||||||
|
const clearedRender = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2));
|
||||||
|
clearedRender.unmount();
|
||||||
|
|
||||||
unmount();
|
|
||||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
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(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
expect(await screen.findByRole("status")).toHaveTextContent("GitHub Copilot");
|
||||||
|
fireEvent.click(screen.getByLabelText("Dismiss OAuth re-login banner"));
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "github-copilot" } }));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
});
|
});
|
||||||
unmount();
|
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).not.toContain("github-copilot");
|
||||||
|
|
||||||
|
firstRender.unmount();
|
||||||
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
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(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
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(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={60_000} />);
|
||||||
|
await waitFor(() => expect(mockFetchAuthStatus).toHaveBeenCalledTimes(3));
|
||||||
|
expect(screen.queryByRole("status")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user