FN-5698: clear OAuth relogin banner after successful login

Clear OAuth re-login banner entries immediately after provider re-authentication.

- add a shared oauth relogin success browser event constant in auth utilities
- dispatch relogin success events from Settings and Model Onboarding OAuth success flows
- update OAuthReloginBanner to listen for relogin success, optimistically clear matching provider rows, and trigger immediate status refresh
- add banner tests covering immediate clearing, refetch behavior, unrelated-provider events, and poll-only behavior
- document the immediate-clear behavior in dashboard docs and add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-5698-relogin-banner-auto-clear.md    |  5 ++
 docs/dashboard-guide.md                            |  4 +
 packages/dashboard/app/auth.ts                     |  1 +
 .../app/components/ModelOnboardingModal.tsx        |  3 +-
 .../app/components/OAuthReloginBanner.tsx          | 70 +++++++++--------
 .../dashboard/app/components/SettingsModal.tsx     |  3 +-
 .../__tests__/OAuthReloginBanner.test.tsx          | 90 ++++++++++++++++++++++
 7 files changed, 144 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-5698

Fusion-Task-Lineage: 1e2f8055-4f77-4e16-ac15-44891de13f55
This commit is contained in:
gsxdsm
2026-05-29 11:25:11 -07:00
parent 0044c23c64
commit d5b33367c1
7 changed files with 144 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dashboard: OAuth re-login banner now clears a provider immediately after successful OAuth re-authentication, instead of waiting for the next auth-status polling interval.

View File

@@ -265,6 +265,10 @@ Push follow-up (when shown):
Branch names are dynamic from merge/audit payloads; the banner is not hardcoded to `main`.
## OAuth Re-login Banner
The global OAuth re-login banner now 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.
## Smart Pull
Smart Pull is a one-shot pull workflow that keeps local work safe while advancing your checked-out integration branch.

View File

@@ -38,6 +38,7 @@ let daemonAuthFailureSignaled = false;
* indicating the current browser token is missing/invalid and user recovery is required.
*/
export const AUTH_TOKEN_RECOVERY_REQUIRED_EVENT = "fn:auth-token-recovery-required";
export const OAUTH_RELOGIN_SUCCESS_EVENT = "fusion:oauth-relogin-success";
interface DaemonUnauthorizedPayload {
error?: unknown;

View File

@@ -30,7 +30,7 @@ import { OAuthManualCodeForm } from "./OAuthManualCodeForm";
import { OnboardingDisclosure } from "./OnboardingDisclosure";
import { CustomProviderForm } from "./CustomProviderForm";
import { PluginSlot } from "./PluginSlot";
import { appendTokenQuery } from "../auth";
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
import { useShellConnection } from "../hooks/useShellConnection";
@@ -1199,6 +1199,7 @@ export function ModelOnboardingModal({
setGitHubSkippedState(false);
}
addToast("Login successful", "success");
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } }));
return;
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type JSX } from "react";
import { useCallback, useEffect, useMemo, useState, type JSX } from "react";
import { AlertTriangle, X } from "lucide-react";
import { fetchAuthStatus } from "../api";
import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
import "./OAuthReloginBanner.css";
const DISMISS_STORAGE_KEY = "fusion:oauth-relogin-dismissed";
@@ -37,44 +38,53 @@ export function OAuthReloginBanner({
const [expiredProviders, setExpiredProviders] = useState<Array<{ id: string; name: string }>>([]);
const [dismissedProviderIds, setDismissedProviderIds] = useState<Set<string>>(() => loadDismissedProviderIds());
useEffect(() => {
let active = true;
const refreshAuthStatus = useCallback(async () => {
try {
const { providers } = await fetchAuthStatus();
const nextExpiredProviders = providers
.filter((provider) => provider.type === "oauth" && provider.expired === true)
.map((provider) => ({ id: provider.id, name: provider.name }));
const refreshAuthStatus = async () => {
try {
const { providers } = await fetchAuthStatus();
if (!active) {
return;
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;
}
const nextExpiredProviders = providers
.filter((provider) => provider.type === "oauth" && provider.expired === true)
.map((provider) => ({ id: provider.id, name: provider.name }));
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.
}
};
persistDismissedProviderIds(filteredDismissed);
return filteredDismissed;
});
} catch {
// Non-blocking banner; ignore transient status fetch failures.
}
}, []);
useEffect(() => {
void refreshAuthStatus();
const interval = window.setInterval(refreshAuthStatus, pollIntervalMs ?? 60 * 60 * 1000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [pollIntervalMs]);
}, [pollIntervalMs, refreshAuthStatus]);
useEffect(() => {
const handleOAuthReloginSuccess = (event: Event) => {
const { detail } = event as CustomEvent<{ providerId?: string }>;
if (detail?.providerId) {
setExpiredProviders((current) => current.filter((provider) => provider.id !== detail.providerId));
}
void refreshAuthStatus();
};
window.addEventListener(OAUTH_RELOGIN_SUCCESS_EVENT, handleOAuthReloginSuccess);
return () => {
window.removeEventListener(OAUTH_RELOGIN_SUCCESS_EVENT, handleOAuthReloginSuccess);
};
}, [refreshAuthStatus]);
const visibleExpiredProviders = useMemo(
() => expiredProviders.filter((provider) => !dismissedProviderIds.has(provider.id)),

View File

@@ -47,7 +47,7 @@ import { AgentProvisioningPolicyEditor } from "./AgentProvisioningPolicyEditor";
import { SecretsView } from "./SecretsView";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
import { copyTextToClipboard } from "../utils/copyToClipboard";
import { appendTokenQuery } from "../auth";
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
import { useConfirm } from "../hooks/useConfirm";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
@@ -1295,6 +1295,7 @@ export function SettingsModal({
setAuthActionInProgress(null);
clearAuthLoginUiState(providerId);
addToast("Login successful", "success");
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId } }));
scrollSettingsToTop();
return;
}

View File

@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OAuthReloginBanner } from "../OAuthReloginBanner";
import * as api from "../../api";
import { OAUTH_RELOGIN_SUCCESS_EVENT } from "../../auth";
vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(),
@@ -144,4 +145,93 @@ describe("OAuthReloginBanner", () => {
});
expect(container.firstChild).toBeNull();
});
it("clears a provider row immediately when oauth relogin success event is dispatched", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
});
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
act(() => {
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "claude" } }));
});
expect(container.firstChild).toBeNull();
});
it("triggers an immediate auth status refetch when oauth relogin success event is dispatched", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
});
render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={10_000} />);
await screen.findByText(/Re-login required: Claude/i);
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "claude" } }));
});
await waitFor(() => {
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(2);
});
});
it("does not clear unrelated providers when event is for a different provider", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
],
});
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
expect(await screen.findByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
act(() => {
window.dispatchEvent(new CustomEvent(OAUTH_RELOGIN_SUCCESS_EVENT, { detail: { providerId: "openai" } }));
});
expect(screen.getByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
});
it("keeps provider row until poll result changes when no success event is dispatched", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
});
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(1_000);
});
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
await act(async () => {
vi.advanceTimersByTime(1_000);
});
expect(container.firstChild).toBeNull();
});
});