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:
5
.changeset/fn-5698-relogin-banner-auto-clear.md
Normal file
5
.changeset/fn-5698-relogin-banner-auto-clear.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user