feat(FN-3047): add cancellable auth flow with recoverable pending login UX

This merge implements a recoverable pending-login auth flow (FN-3047) across three phases: cancellable in-progress auth routes in the API, an extended auth client contract, and updated ModelOnboardingModal/SettingsModal UI with proper cancel-button visibility during logout. It also adds `dashboard-r

Fusion-Task-Id: FN-3047
This commit is contained in:
Fusion
2026-05-01 01:12:36 -07:00
committed by gsxdsm
parent bb154a33e9
commit dca83061ed
9 changed files with 380 additions and 35 deletions

View File

@@ -9,6 +9,7 @@ import type { Task } from "@fusion/core";
const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn();
const mockClearApiKey = vi.fn();
const mockFetchModels = vi.fn();
@@ -22,6 +23,7 @@ vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
@@ -176,6 +178,7 @@ beforeEach(() => {
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true });
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true });
// Default to no persisted state (start at ai-setup)
@@ -3042,6 +3045,7 @@ describe("ModelOnboardingModal", () => {
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("anthropic");
// Login button should be shown again
expect(screen.getByText("Login")).toBeTruthy();
// Waiting for login should no longer be shown
@@ -3051,6 +3055,23 @@ describe("ModelOnboardingModal", () => {
});
});
it("shows cancel action for server-reported pending login", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", loginInProgress: true }],
});
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Waiting for login…")).toBeTruthy();
});
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("anthropic");
});
});
it("shows OAuth login instructions during pending auth and clears them on cancel", async () => {
mockLoginProvider.mockResolvedValueOnce({
url: "https://auth.example.com/login",
@@ -3072,10 +3093,36 @@ describe("ModelOnboardingModal", () => {
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("anthropic");
expect(screen.queryByTestId("onboarding-login-instructions-anthropic")).toBeNull();
});
});
it("does not show cancel action while logout is in progress", async () => {
let resolveLogout: (() => void) | null = null;
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }],
});
mockLogoutProvider.mockImplementationOnce(() => new Promise<void>((resolve) => {
resolveLogout = resolve;
}));
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Logout")).toBeTruthy();
});
fireEvent.click(screen.getByText("Logout"));
await waitFor(() => {
expect(screen.getByText("Logging out…")).toBeTruthy();
});
expect(screen.queryByText("Cancel")).toBeNull();
resolveLogout?.();
});
it("shows GitHub login instructions during connect attempts", async () => {
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
providers: [
@@ -3105,6 +3152,7 @@ describe("ModelOnboardingModal", () => {
fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("github");
expect(screen.queryByTestId("onboarding-login-instructions-github")).toBeNull();
});
});
@@ -3148,7 +3196,7 @@ describe("ModelOnboardingModal", () => {
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Login already in progress. Please wait or cancel the current attempt.",
"Login already in progress. Cancel it to retry.",
"warning"
);
});

View File

@@ -13,6 +13,7 @@ const mockUpdateGlobalSettings = vi.fn();
const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn();
const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchCustomProviders = vi.fn();
@@ -62,6 +63,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
@@ -212,6 +214,7 @@ describe("SettingsModal", () => {
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockUpdateCustomProvider.mockResolvedValue({ provider: {} });
mockDeleteCustomProvider.mockResolvedValue(undefined);
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSaveApiKey.mockResolvedValue(undefined);
mockTestNotification.mockResolvedValue({ success: true });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
@@ -860,6 +863,23 @@ describe("SettingsModal", () => {
expect(openSpy).toHaveBeenCalled();
});
it("shows cancel action for server-reported pending oauth login", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],
});
renderModal();
await waitForSettingsModalReady();
const copilotCard = screen.getByTestId("auth-provider-icon-github-copilot").closest(".auth-provider-card") as HTMLElement;
expect(within(copilotCard).getByRole("button", { name: "Cancel" })).toBeInTheDocument();
await userEvent.click(within(copilotCard).getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("github-copilot");
});
});
it("scrolls settings content to top after API key save succeeds", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],