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:
@@ -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
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" }],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user