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

@@ -82,6 +82,8 @@ On first launch, Fusion opens an onboarding wizard with three steps:
The wizard is dismissible and non-blocking. You can skip it and continue using Fusion, then reopen it later from **Settings → Authentication**. The wizard is dismissible and non-blocking. You can skip it and continue using Fusion, then reopen it later from **Settings → Authentication**.
If a provider login gets stuck in progress (for example GitHub Copilot/device-code sign-in), use **Cancel** on the provider card in onboarding or in **Settings → Authentication**, then retry immediately — no dashboard restart is required.
On startup, Fusion prints an `Open:` URL that includes a bearer token (for example, `http://localhost:4040/?token=fn_...`). Open that URL to sign in quickly. On startup, Fusion prints an `Open:` URL that includes a bearer token (for example, `http://localhost:4040/?token=fn_...`). Open that URL to sign in quickly.
## Create Your First Task ## Create Your First Task

View File

@@ -1236,6 +1236,8 @@ export interface AuthProvider {
id: string; id: string;
name: string; name: string;
authenticated: boolean; authenticated: boolean;
/** True when the server currently has an active OAuth login flow for this provider. */
loginInProgress?: boolean;
/** /**
* How this provider authenticates / is activated. * How this provider authenticates / is activated.
* - "oauth": OAuth flow (user clicks Login → redirect) * - "oauth": OAuth flow (user clicks Login → redirect)
@@ -1653,6 +1655,14 @@ export function logoutProvider(provider: string): Promise<{ success: boolean }>
}); });
} }
/** Cancel an in-progress OAuth login attempt for a provider. */
export function cancelProviderLogin(provider: string): Promise<{ success: boolean; cancelled: boolean }> {
return api<{ success: boolean; cancelled: boolean }>("/auth/cancel", {
method: "POST",
body: JSON.stringify({ provider }),
});
}
/** Save an API key for an API-key-backed provider. */ /** Save an API key for an API-key-backed provider. */
export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> { export function saveApiKey(provider: string, apiKey: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>("/auth/api-key", { return api<{ success: boolean }>("/auth/api-key", {

View File

@@ -8,6 +8,7 @@ import {
fetchGlobalSettings, fetchGlobalSettings,
loginProvider, loginProvider,
logoutProvider, logoutProvider,
cancelProviderLogin,
saveApiKey, saveApiKey,
clearApiKey, clearApiKey,
fetchModels, fetchModels,
@@ -738,12 +739,27 @@ export function ModelOnboardingModal({
const next: Record<string, string> = {}; const next: Record<string, string> = {};
for (const [providerId, instructions] of Object.entries(prev)) { for (const [providerId, instructions] of Object.entries(prev)) {
const provider = providers.find((candidate) => candidate.id === providerId); const provider = providers.find((candidate) => candidate.id === providerId);
if (provider && !provider.authenticated) { if (provider && !provider.authenticated && provider.loginInProgress) {
next[providerId] = instructions; next[providerId] = instructions;
} }
} }
return Object.keys(next).length === Object.keys(prev).length ? prev : next; return Object.keys(next).length === Object.keys(prev).length ? prev : next;
}); });
setLoginOutcomes((prev) => {
let changed = false;
const next = { ...prev };
for (const [providerId, outcome] of Object.entries(prev)) {
if (outcome !== "pending") {
continue;
}
const provider = providers.find((candidate) => candidate.id === providerId);
if (!provider?.loginInProgress) {
delete next[providerId];
changed = true;
}
}
return changed ? next : prev;
});
// Remove from skippedProviders when a provider becomes authenticated // Remove from skippedProviders when a provider becomes authenticated
setSkippedProviders((prev) => { setSkippedProviders((prev) => {
const updated = { ...prev }; const updated = { ...prev };
@@ -795,10 +811,22 @@ export function ModelOnboardingModal({
aiSetupReturnRef.current = step !== "ai-setup"; aiSetupReturnRef.current = step !== "ai-setup";
}, [step, loadAuthStatus, loadCustomProviders]); }, [step, loadAuthStatus, loadCustomProviders]);
useEffect(() => {
const hasPendingLogin = authProviders.some((provider) => provider.loginInProgress);
if (!hasPendingLogin) {
return;
}
const interval = setInterval(() => {
void loadAuthStatus();
}, 2000);
return () => clearInterval(interval);
}, [authProviders, loadAuthStatus]);
// OAuth status for the GitHub provider (used for OAuth-specific controls like Connect/Disconnect). // OAuth status for the GitHub provider (used for OAuth-specific controls like Connect/Disconnect).
const githubProvider = authProviders.find((p) => p.id === "github"); const githubProvider = authProviders.find((p) => p.id === "github");
const hasGithubProvider = !!githubProvider; const hasGithubProvider = !!githubProvider;
const isGithubAuthenticated = githubProvider?.authenticated ?? false; const isGithubAuthenticated = githubProvider?.authenticated ?? false;
const isGithubLoginInProgress = githubProvider?.loginInProgress ?? false;
const isGithubCliAuthenticated = ghCliStatus?.authenticated ?? false; const isGithubCliAuthenticated = ghCliStatus?.authenticated ?? false;
// Effective GitHub readiness (matches useSetupReadiness): OAuth OR authenticated gh CLI session. // Effective GitHub readiness (matches useSetupReadiness): OAuth OR authenticated gh CLI session.
const isGitHubReady = isGithubAuthenticated || isGithubCliAuthenticated; const isGitHubReady = isGithubAuthenticated || isGithubCliAuthenticated;
@@ -1132,8 +1160,9 @@ export function ModelOnboardingModal({
(err && typeof err === "object" && "status" in err && (err as { status: number }).status === 409); (err && typeof err === "object" && "status" in err && (err as { status: number }).status === 409);
if (isConcurrentLogin) { if (isConcurrentLogin) {
addToast("Login already in progress. Please wait or cancel the current attempt.", "warning"); addToast("Login already in progress. Cancel it to retry.", "warning");
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "pending" }));
void loadAuthStatus();
} else { } else {
addToast(err instanceof Error ? err.message : "Login failed", "error"); addToast(err instanceof Error ? err.message : "Login failed", "error");
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" })); setLoginOutcomes((prev) => ({ ...prev, [providerId]: "failed" }));
@@ -1149,27 +1178,37 @@ export function ModelOnboardingModal({
}); });
} }
}, },
[addToast, setGitHubSkippedState], [addToast, loadAuthStatus, setGitHubSkippedState],
); );
// Cancellation handler for in-progress logins // Cancellation handler for in-progress logins
const handleCancelLogin = useCallback((providerId: string) => { const handleCancelLogin = useCallback(async (providerId: string) => {
if (pollIntervalRef.current) { if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current); clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null; pollIntervalRef.current = null;
} }
setAuthActionInProgress(null); setAuthActionInProgress(providerId);
pollCountRef.current = 0; pollCountRef.current = 0;
setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
setLoginInstructions((prev) => { try {
if (!(providerId in prev)) { await cancelProviderLogin(providerId);
return prev; await loadAuthStatus();
} setLoginOutcomes((prev) => ({ ...prev, [providerId]: "cancelled" }));
const next = { ...prev }; addToast("Login cancelled", "success");
delete next[providerId]; } catch (err) {
return next; addToast(getErrorMessage(err) || "Failed to cancel login", "error");
}); } finally {
}, []); setAuthActionInProgress(null);
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
}
}, [addToast, loadAuthStatus]);
// API key input update handler // API key input update handler
const handleApiKeyInputChange = useCallback((providerId: string, value: string) => { const handleApiKeyInputChange = useCallback((providerId: string, value: string) => {
@@ -1753,15 +1792,31 @@ export function ModelOnboardingModal({
</div> </div>
<div className="onboarding-provider-card__actions"> <div className="onboarding-provider-card__actions">
{authActionInProgress === provider.id ? ( {authActionInProgress === provider.id ? (
provider.authenticated ? (
<button className="btn btn-sm" disabled>
Logging out…
</button>
) : (
<>
<button className="btn btn-sm" disabled>
Waiting for login…
</button>
<button
className="btn btn-sm"
onClick={() => void handleCancelLogin(provider.id)}
>
Cancel
</button>
</>
)
) : provider.loginInProgress ? (
<> <>
<button className="btn btn-sm" disabled> <button className="btn btn-sm" disabled>
{provider.authenticated Waiting for login…
? "Logging out…"
: "Waiting for login…"}
</button> </button>
<button <button
className="btn btn-sm" className="btn btn-sm"
onClick={() => handleCancelLogin(provider.id)} onClick={() => void handleCancelLogin(provider.id)}
> >
Cancel Cancel
</button> </button>
@@ -1782,7 +1837,7 @@ export function ModelOnboardingModal({
</button> </button>
)} )}
</div> </div>
{authActionInProgress === provider.id && loginInstructions[provider.id] && ( {(authActionInProgress === provider.id || provider.loginInProgress) && loginInstructions[provider.id] && (
<LoginInstructions <LoginInstructions
instructions={loginInstructions[provider.id]} instructions={loginInstructions[provider.id]}
data-testid={`onboarding-login-instructions-${provider.id}`} data-testid={`onboarding-login-instructions-${provider.id}`}
@@ -2192,7 +2247,7 @@ export function ModelOnboardingModal({
: "Continue without GitHub →"} : "Continue without GitHub →"}
</button> </button>
{isGitHubReadyViaCli && ( {isGitHubReadyViaCli && (
authActionInProgress === "github" ? ( (authActionInProgress === "github" || isGithubLoginInProgress) ? (
<button className="btn btn-sm" disabled> <button className="btn btn-sm" disabled>
<Loader2 size={14} className="onboarding-spinner" /> <Loader2 size={14} className="onboarding-spinner" />
Waiting for OAuth login… Waiting for OAuth login…
@@ -2208,7 +2263,7 @@ export function ModelOnboardingModal({
) )
)} )}
</div> </div>
{isGitHubReadyViaCli && authActionInProgress === "github" && loginInstructions.github && ( {isGitHubReadyViaCli && (authActionInProgress === "github" || isGithubLoginInProgress) && loginInstructions.github && (
<LoginInstructions <LoginInstructions
instructions={loginInstructions.github} instructions={loginInstructions.github}
data-testid="onboarding-login-instructions-github" data-testid="onboarding-login-instructions-github"
@@ -2245,14 +2300,14 @@ export function ModelOnboardingModal({
{(githubStatus === "not-connected" || githubStatus === "pending") && ( {(githubStatus === "not-connected" || githubStatus === "pending") && (
<div className="onboarding-github-connect-cta" data-testid="onboarding-github-connect-cta"> <div className="onboarding-github-connect-cta" data-testid="onboarding-github-connect-cta">
{authActionInProgress === "github" ? ( {(authActionInProgress === "github" || isGithubLoginInProgress) ? (
<div className="onboarding-github-connect-actions"> <div className="onboarding-github-connect-actions">
<button className="btn btn-sm" disabled> <button className="btn btn-sm" disabled>
Waiting for login… Waiting for login…
</button> </button>
<button <button
className="btn btn-sm" className="btn btn-sm"
onClick={() => handleCancelLogin("github")} onClick={() => void handleCancelLogin("github")}
> >
Cancel Cancel
</button> </button>
@@ -2266,7 +2321,7 @@ export function ModelOnboardingModal({
Connect Connect
</button> </button>
)} )}
{authActionInProgress === "github" && loginInstructions.github && ( {(authActionInProgress === "github" || isGithubLoginInProgress) && loginInstructions.github && (
<LoginInstructions <LoginInstructions
instructions={loginInstructions.github} instructions={loginInstructions.github}
data-testid="onboarding-login-instructions-github" data-testid="onboarding-login-instructions-github"

View File

@@ -1238,6 +1238,11 @@
color: var(--color-error); color: var(--color-error);
padding-right: 4px; padding-right: 4px;
} }
.auth-provider-actions-row {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
}
/* === Key Hint === */ /* === Key Hint === */
.auth-key-hint { .auth-key-hint {
@@ -1560,6 +1565,11 @@
margin-left: auto; margin-left: auto;
} }
.auth-provider-actions-row {
width: 100%;
justify-content: flex-end;
}
.auth-provider-info { .auth-provider-info {
width: 100%; width: 100%;
flex-basis: 100%; flex-basis: 100%;

View File

@@ -10,7 +10,7 @@ import {
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
} from "@fusion/core"; } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api"; import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api"; import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
@@ -863,6 +863,23 @@ export function SettingsModal({
}; };
}, [activeSection, loadAuthStatus]); }, [activeSection, loadAuthStatus]);
useEffect(() => {
if (activeSection !== "authentication") {
return;
}
const hasPendingServerLogin = authProviders.some((provider) => provider.type !== "api_key" && provider.loginInProgress);
if (!hasPendingServerLogin) {
return;
}
const interval = setInterval(() => {
void loadAuthStatus();
}, 2000);
return () => clearInterval(interval);
}, [activeSection, authProviders, loadAuthStatus]);
const scrollSettingsToTop = useCallback(() => { const scrollSettingsToTop = useCallback(() => {
settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []); }, []);
@@ -913,7 +930,14 @@ export function SettingsModal({
} }
}, 2000); }, 2000);
} catch (err) { } catch (err) {
addToast(getErrorMessage(err) || "Login failed", "error"); const message = getErrorMessage(err) || "Login failed";
const isConflict = message.includes("already in progress") || (typeof err === "object" && err !== null && "status" in err && (err as { status?: number }).status === 409);
if (isConflict) {
addToast("Login already in progress. You can cancel it and retry.", "warning");
await loadAuthStatus();
} else {
addToast(message, "error");
}
setAuthActionInProgress(null); setAuthActionInProgress(null);
setLoginInstructions((prev) => { setLoginInstructions((prev) => {
if (!(providerId in prev)) { if (!(providerId in prev)) {
@@ -924,7 +948,32 @@ export function SettingsModal({
return next; return next;
}); });
} }
}, [addToast, scrollSettingsToTop]); }, [addToast, loadAuthStatus, scrollSettingsToTop]);
const handleCancelLogin = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId);
try {
await cancelProviderLogin(providerId);
setLoginInstructions((prev) => {
if (!(providerId in prev)) {
return prev;
}
const next = { ...prev };
delete next[providerId];
return next;
});
await loadAuthStatus();
addToast("Login cancelled", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to cancel login", "error");
} finally {
setAuthActionInProgress(null);
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
}, [addToast, loadAuthStatus]);
const handleLogout = useCallback(async (providerId: string) => { const handleLogout = useCallback(async (providerId: string) => {
setAuthActionInProgress(providerId); setAuthActionInProgress(providerId);
@@ -4894,6 +4943,15 @@ export function SettingsModal({
<button className="btn btn-sm" disabled> <button className="btn btn-sm" disabled>
Logging out… Logging out…
</button> </button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
Waiting for login…
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
Cancel
</button>
</div>
) : ( ) : (
<button <button
className="btn btn-sm" className="btn btn-sm"
@@ -4965,6 +5023,15 @@ export function SettingsModal({
<button className="btn btn-sm" disabled> <button className="btn btn-sm" disabled>
Waiting for login… Waiting for login…
</button> </button>
) : provider.loginInProgress ? (
<div className="auth-provider-actions-row">
<button className="btn btn-sm" disabled>
Waiting for login…
</button>
<button className="btn btn-sm" onClick={() => handleCancelLogin(provider.id)}>
Cancel
</button>
</div>
) : ( ) : (
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
@@ -4973,7 +5040,7 @@ export function SettingsModal({
Login Login
</button> </button>
)} )}
{loginInstructions[provider.id] && ( {loginInstructions[provider.id] && (provider.loginInProgress || authActionInProgress === provider.id) && (
<LoginInstructions <LoginInstructions
instructions={loginInstructions[provider.id]} instructions={loginInstructions[provider.id]}
data-testid={`auth-login-instructions-${provider.id}`} data-testid={`auth-login-instructions-${provider.id}`}

View File

@@ -9,6 +9,7 @@ import type { Task } from "@fusion/core";
const mockFetchAuthStatus = vi.fn(); const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn(); const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn(); const mockLogoutProvider = vi.fn();
const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn(); const mockSaveApiKey = vi.fn();
const mockClearApiKey = vi.fn(); const mockClearApiKey = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
@@ -22,6 +23,7 @@ vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args), fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args), loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args), logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args), saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
clearApiKey: (...args: unknown[]) => mockClearApiKey(...args), clearApiKey: (...args: unknown[]) => mockClearApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args), fetchModels: (...args: unknown[]) => mockFetchModels(...args),
@@ -176,6 +178,7 @@ beforeEach(() => {
mockCreateCustomProvider.mockResolvedValue({ provider: {} }); mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" }); mockLoginProvider.mockResolvedValue({ url: "https://auth.example.com/login" });
mockLogoutProvider.mockResolvedValue({ success: true }); mockLogoutProvider.mockResolvedValue({ success: true });
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSaveApiKey.mockResolvedValue({ success: true }); mockSaveApiKey.mockResolvedValue({ success: true });
mockClearApiKey.mockResolvedValue({ success: true }); mockClearApiKey.mockResolvedValue({ success: true });
// Default to no persisted state (start at ai-setup) // Default to no persisted state (start at ai-setup)
@@ -3042,6 +3045,7 @@ describe("ModelOnboardingModal", () => {
fireEvent.click(screen.getByText("Cancel")); fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => { await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("anthropic");
// Login button should be shown again // Login button should be shown again
expect(screen.getByText("Login")).toBeTruthy(); expect(screen.getByText("Login")).toBeTruthy();
// Waiting for login should no longer be shown // 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 () => { it("shows OAuth login instructions during pending auth and clears them on cancel", async () => {
mockLoginProvider.mockResolvedValueOnce({ mockLoginProvider.mockResolvedValueOnce({
url: "https://auth.example.com/login", url: "https://auth.example.com/login",
@@ -3072,10 +3093,36 @@ describe("ModelOnboardingModal", () => {
fireEvent.click(screen.getByText("Cancel")); fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => { await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("anthropic");
expect(screen.queryByTestId("onboarding-login-instructions-anthropic")).toBeNull(); 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 () => { it("shows GitHub login instructions during connect attempts", async () => {
mockFetchAuthStatus.mockImplementation(() => Promise.resolve({ mockFetchAuthStatus.mockImplementation(() => Promise.resolve({
providers: [ providers: [
@@ -3105,6 +3152,7 @@ describe("ModelOnboardingModal", () => {
fireEvent.click(screen.getByText("Cancel")); fireEvent.click(screen.getByText("Cancel"));
await waitFor(() => { await waitFor(() => {
expect(mockCancelProviderLogin).toHaveBeenCalledWith("github");
expect(screen.queryByTestId("onboarding-login-instructions-github")).toBeNull(); expect(screen.queryByTestId("onboarding-login-instructions-github")).toBeNull();
}); });
}); });
@@ -3148,7 +3196,7 @@ describe("ModelOnboardingModal", () => {
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith( expect(addToast).toHaveBeenCalledWith(
"Login already in progress. Please wait or cancel the current attempt.", "Login already in progress. Cancel it to retry.",
"warning" "warning"
); );
}); });

View File

@@ -13,6 +13,7 @@ const mockUpdateGlobalSettings = vi.fn();
const mockFetchAuthStatus = vi.fn(); const mockFetchAuthStatus = vi.fn();
const mockLoginProvider = vi.fn(); const mockLoginProvider = vi.fn();
const mockLogoutProvider = vi.fn(); const mockLogoutProvider = vi.fn();
const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn(); const mockSaveApiKey = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
const mockFetchCustomProviders = vi.fn(); const mockFetchCustomProviders = vi.fn();
@@ -62,6 +63,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args), fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
loginProvider: (...args: unknown[]) => mockLoginProvider(...args), loginProvider: (...args: unknown[]) => mockLoginProvider(...args),
logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args), logoutProvider: (...args: unknown[]) => mockLogoutProvider(...args),
cancelProviderLogin: (...args: unknown[]) => mockCancelProviderLogin(...args),
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args), saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args), fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args), fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
@@ -212,6 +214,7 @@ describe("SettingsModal", () => {
mockCreateCustomProvider.mockResolvedValue({ provider: {} }); mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockUpdateCustomProvider.mockResolvedValue({ provider: {} }); mockUpdateCustomProvider.mockResolvedValue({ provider: {} });
mockDeleteCustomProvider.mockResolvedValue(undefined); mockDeleteCustomProvider.mockResolvedValue(undefined);
mockCancelProviderLogin.mockResolvedValue({ success: true, cancelled: true });
mockSaveApiKey.mockResolvedValue(undefined); mockSaveApiKey.mockResolvedValue(undefined);
mockTestNotification.mockResolvedValue({ success: true }); mockTestNotification.mockResolvedValue({ success: true });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 }); mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
@@ -860,6 +863,23 @@ describe("SettingsModal", () => {
expect(openSpy).toHaveBeenCalled(); 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 () => { it("scrolls settings content to top after API key save succeeds", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({ mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }], providers: [{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" }],

View File

@@ -4719,7 +4719,7 @@ describe("GET /auth/status", () => {
// Structural assertions here are about OAuth + API-key paths only. // Structural assertions here are about OAuth + API-key paths only.
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli"); const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli");
expect(providers).toEqual([ expect(providers).toEqual([
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }, { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }, { id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
]); ]);
@@ -4744,8 +4744,8 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli"); const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli");
expect(providers).toEqual([ expect(providers).toEqual([
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" }, { id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth" }, { id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: false },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" }, { id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" }, { id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
{ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" }, { id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" },
@@ -4761,6 +4761,31 @@ describe("GET /auth/status", () => {
expect(res.body.providers[0].authenticated).toBe(false); expect(res.body.providers[0].authenticated).toBe(false);
}); });
it("reports loginInProgress for oauth providers with active logins", async () => {
let releaseLogin: (() => void) | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
(_provider: string, callbacks: { onAuth: (info: { url: string }) => void }) => {
callbacks.onAuth({ url: "https://auth.example.com/login" });
return new Promise<void>((resolve) => {
releaseLogin = resolve;
});
},
);
const app = buildApp();
const loginRequest = REQUEST(app, "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
});
await new Promise((resolve) => setTimeout(resolve, 0));
const statusRes = await GET(app, "/api/auth/status");
const anthropic = statusRes.body.providers.find((p: any) => p.id === "anthropic");
expect(anthropic.loginInProgress).toBe(true);
releaseLogin?.();
await loginRequest;
});
it("returns authenticated true for API-key provider when hasApiKey is true", async () => { it("returns authenticated true for API-key provider when hasApiKey is true", async () => {
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false); (authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
(authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockReturnValue(true); (authStorage.hasApiKey as ReturnType<typeof vi.fn>).mockReturnValue(true);
@@ -5050,6 +5075,76 @@ describe("POST /auth/login", () => {
}); });
}); });
describe("POST /auth/cancel", () => {
let store: TaskStore;
let authStorage: AuthStorageLike;
beforeEach(() => {
store = createMockStore();
authStorage = createMockAuthStorage();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, { authStorage }));
return app;
}
it("cancels active login and allows immediate retry", async () => {
let releaseLogin: (() => void) | undefined;
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation(
(_provider: string, callbacks: { onAuth: (info: { url: string }) => void; signal: AbortSignal }) => {
callbacks.onAuth({ url: "https://auth.example.com/login" });
return new Promise<void>((resolve, reject) => {
releaseLogin = resolve;
callbacks.signal.addEventListener("abort", () => {
reject(new Error("cancelled"));
});
});
},
);
const app = buildApp();
const firstLogin = REQUEST(app, "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
});
await new Promise((resolve) => setTimeout(resolve, 0));
const cancelRes = await REQUEST(app, "POST", "/api/auth/cancel", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
});
expect(cancelRes.status).toBe(200);
expect(cancelRes.body).toEqual({ success: true, cancelled: true });
const retryRes = await REQUEST(app, "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
});
expect(retryRes.status).toBe(200);
releaseLogin?.();
await firstLogin;
});
it("returns success when there is no active login", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/cancel", JSON.stringify({ provider: "anthropic" }), {
"Content-Type": "application/json",
});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, cancelled: false });
});
it("returns 400 when provider is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/auth/cancel", JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(res.status).toBe(400);
expect(res.body.error).toBe("provider is required");
});
});
describe("GET /auth/oauth-callback", () => { describe("GET /auth/oauth-callback", () => {
let store: TaskStore; let store: TaskStore;
let authStorage: AuthStorageLike; let authStorage: AuthStorageLike;

View File

@@ -120,11 +120,19 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
const storage = getAuthStorage(); const storage = getAuthStorage();
storage.reload(); storage.reload();
const oauthProviders = storage.getOAuthProviders(); const oauthProviders = storage.getOAuthProviders();
const providers: { id: string; name: string; authenticated: boolean; type: "oauth" | "api_key" | "cli"; keyHint?: string }[] = oauthProviders.map((p) => ({ const providers: {
id: string;
name: string;
authenticated: boolean;
type: "oauth" | "api_key" | "cli";
keyHint?: string;
loginInProgress?: boolean;
}[] = oauthProviders.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
authenticated: storage.hasAuth(p.id), authenticated: storage.hasAuth(p.id),
type: "oauth" as const, type: "oauth" as const,
loginInProgress: loginInProgress.has(p.id),
})); }));
// Include API-key-backed providers if supported // Include API-key-backed providers if supported
@@ -421,6 +429,36 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
} }
}); });
/**
* POST /api/auth/cancel
* Cancel an in-progress OAuth login for a provider.
* Body: { provider: string }
* Response: { success: true, cancelled: boolean }
*/
router.post("/auth/cancel", (req, res) => {
try {
const { provider } = req.body;
if (!provider || typeof provider !== "string") {
throw badRequest("provider is required");
}
const activeLogin = loginInProgress.get(provider);
if (!activeLogin) {
res.json({ success: true, cancelled: false });
return;
}
loginInProgress.delete(provider);
activeLogin.abort();
res.json({ success: true, cancelled: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/auth/oauth-callback", async (req, res) => { router.get("/auth/oauth-callback", async (req, res) => {
try { try {
const error = typeof req.query.error === "string" ? req.query.error : undefined; const error = typeof req.query.error === "string" ? req.query.error : undefined;