feat(FN-4160): warn before OAuth login when manual code mode is active

Adds user-facing warnings before OAuth login flows in both onboarding and settings, surfacing that manual code authentication is available as an alternative, with corresponding documentation updates and test coverage for the new warning UI.

Fusion-Task-Id: FN-4160
This commit is contained in:
Fusion
2026-05-12 11:24:44 -07:00
committed by gsxdsm
parent c420e1acd0
commit f7b12b526c
10 changed files with 223 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dashboard now warns before starting OAuth login for providers whose redirect can't reach the dashboard host (Anthropic / OpenAI Codex), reminding users to copy the browser address bar URL before the redirect tab navigates away.

View File

@@ -74,7 +74,7 @@ fn dashboard
On first launch, Fusion opens an onboarding wizard with three steps: On first launch, Fusion opens an onboarding wizard with three steps:
1. **AI Setup** — choose a provider and authenticate (you only need one to start). Anthropic/Claude and OpenAI Codex use a pasted authorization-code OAuth flow in onboarding and Settings (sign in, then paste the final redirect URL or code back into Fusion), while **Anthropic — via Claude CLI** remains available as a separate optional path. Deprecated Google Gemini CLI / Antigravity entries are hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code options remain available. 1. **AI Setup** — choose a provider and authenticate (you only need one to start). Anthropic/Claude and OpenAI Codex use a pasted authorization-code OAuth flow in onboarding and Settings (sign in, then paste the final redirect URL or code back into Fusion), and Fusion warns before login so you remember to copy the browser address bar URL before the redirect tab appears to fail. **Anthropic — via Claude CLI** remains available as a separate optional path. Deprecated Google Gemini CLI / Antigravity entries are hidden; Google/Gemini API key, Google Generative AI, Vertex, and Cloud Code options remain available.
2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows 2. **GitHub (Optional)** — connect GitHub for issue import and PR workflows
3. **First Task** — create your first task or import one from GitHub 3. **First Task** — create your first task or import one from GitHub

View File

@@ -331,7 +331,7 @@ Recovery entrypoints in the dashboard:
### Authentication troubleshooting (mobile OAuth fallback) ### Authentication troubleshooting (mobile OAuth fallback)
When an OAuth provider returns a localhost callback that this dashboard host cannot open directly, use the **manual code** fallback in Settings/Onboarding: When an OAuth provider returns a localhost callback that this dashboard host cannot open directly, use the **manual code** fallback in Settings/Onboarding:
- Tap **Login** for the provider, complete sign-in in the browser, then paste either the final redirect URL or the authorization code into the fallback textbox. - Tap **Login** for the provider, complete sign-in in the browser, then paste either the final redirect URL or the authorization code into the fallback textbox. Fusion now shows a pre-login warning first so you know to copy the browser address bar URL before the redirect tab appears to fail.
- On mobile/coarse-pointer layouts, the fallback textbox now auto-scrolls into view on focus (and after keyboard viewport shifts) so the paste/submit path remains usable. - On mobile/coarse-pointer layouts, the fallback textbox now auto-scrolls into view on focus (and after keyboard viewport shifts) so the paste/submit path remains usable.
**Credential storage rule:** API keys for Research providers are not stored in settings JSON. They are managed through the existing auth storage pipeline (`/api/auth/status`, `POST /api/auth/api-key`, `DELETE /api/auth/api-key`) and persisted in auth credential storage with masked hints in API responses. **Credential storage rule:** API keys for Research providers are not stored in settings JSON. They are managed through the existing auth storage pipeline (`/api/auth/status`, `POST /api/auth/api-key`, `DELETE /api/auth/api-key`) and persisted in auth credential storage with masked hints in API responses.

View File

@@ -1330,6 +1330,8 @@ export interface AuthProvider {
authenticated: boolean; authenticated: boolean;
/** True when the server currently has an active OAuth login flow for this provider. */ /** True when the server currently has an active OAuth login flow for this provider. */
loginInProgress?: boolean; loginInProgress?: boolean;
/** True when the redirect cannot reach this dashboard host and the user must paste the URL/code back manually. */
requiresManualCode?: 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)

View File

@@ -33,6 +33,7 @@ import { PluginSlot } from "./PluginSlot";
import { appendTokenQuery } from "../auth"; import { appendTokenQuery } from "../auth";
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
import { useShellConnection } from "../hooks/useShellConnection"; import { useShellConnection } from "../hooks/useShellConnection";
import { useConfirm } from "../hooks/useConfirm";
const mapLegacyCustomProviderToConfig = ( const mapLegacyCustomProviderToConfig = (
provider: CustomProvider | CustomProviderConfig, provider: CustomProvider | CustomProviderConfig,
@@ -178,6 +179,9 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
moonshot: "Moonshot", moonshot: "Moonshot",
}; };
const getManualCodeLoginWarningMessage = (providerName: string) =>
`After you sign in with ${providerName}, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?`;
function getProviderDisplayName(providerId: string): string { function getProviderDisplayName(providerId: string): string {
if (PROVIDER_DISPLAY_NAMES[providerId]) { if (PROVIDER_DISPLAY_NAMES[providerId]) {
return PROVIDER_DISPLAY_NAMES[providerId]; return PROVIDER_DISPLAY_NAMES[providerId];
@@ -555,6 +559,7 @@ export function ModelOnboardingModal({
firstCreatedTask, firstCreatedTask,
onViewTask, onViewTask,
}: ModelOnboardingModalProps) { }: ModelOnboardingModalProps) {
const { confirm } = useConfirm();
// Initialize from persisted state if available (allows resume from last step) // Initialize from persisted state if available (allows resume from last step)
const persistedState = getOnboardingState(); const persistedState = getOnboardingState();
const persistedStep = persistedState?.currentStep; const persistedStep = persistedState?.currentStep;
@@ -1054,6 +1059,19 @@ export function ModelOnboardingModal({
// OAuth login handler // OAuth login handler
const handleLogin = useCallback( const handleLogin = useCallback(
async (providerId: string) => { async (providerId: string) => {
const provider = authProviders.find((entry) => entry.id === providerId);
if (provider?.requiresManualCode === true) {
const shouldContinue = await confirm({
title: "Heads up — manual paste-back required",
message: getManualCodeLoginWarningMessage(provider.name),
confirmLabel: "Continue to login",
cancelLabel: "Cancel",
});
if (!shouldContinue) {
return;
}
}
// Clear any previous terminal outcome before starting a new login attempt // Clear any previous terminal outcome before starting a new login attempt
setLoginOutcomes((prev) => { setLoginOutcomes((prev) => {
const outcome = prev[providerId]; const outcome = prev[providerId];
@@ -1181,7 +1199,7 @@ export function ModelOnboardingModal({
clearAuthLoginUiState(); clearAuthLoginUiState();
} }
}, },
[addToast, loadAuthStatus, setGitHubSkippedState], [addToast, authProviders, confirm, loadAuthStatus, setGitHubSkippedState],
); );
const handleSubmitManualCode = useCallback(async (providerId: string) => { const handleSubmitManualCode = useCallback(async (providerId: string) => {

View File

@@ -978,6 +978,20 @@ export function SettingsModal({
}, []); }, []);
const handleLogin = useCallback(async (providerId: string) => { const handleLogin = useCallback(async (providerId: string) => {
const provider = authProviders.find((entry) => entry.id === providerId);
if (provider?.requiresManualCode === true) {
const shouldContinue = await confirm({
title: "Heads up — manual paste-back required",
message:
`After you sign in with ${provider.name}, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?`,
confirmLabel: "Continue to login",
cancelLabel: "Cancel",
});
if (!shouldContinue) {
return;
}
}
setAuthActionInProgress(providerId); setAuthActionInProgress(providerId);
clearAuthLoginUiState(providerId); clearAuthLoginUiState(providerId);
@@ -1035,7 +1049,7 @@ export function SettingsModal({
setAuthActionInProgress(null); setAuthActionInProgress(null);
clearAuthLoginUiState(providerId); clearAuthLoginUiState(providerId);
} }
}, [addToast, clearAuthLoginUiState, loadAuthStatus, scrollSettingsToTop]); }, [addToast, authProviders, clearAuthLoginUiState, confirm, loadAuthStatus, scrollSettingsToTop]);
const handleSubmitManualCode = useCallback(async (providerId: string) => { const handleSubmitManualCode = useCallback(async (providerId: string) => {
const code = manualCodeInputs[providerId]?.trim(); const code = manualCodeInputs[providerId]?.trim();

View File

@@ -22,6 +22,7 @@ const mockCreateCustomProvider = vi.fn();
const mockFetchCursorCliStatus = vi.fn(); const mockFetchCursorCliStatus = vi.fn();
const mockSetCursorCliEnabled = vi.fn(); const mockSetCursorCliEnabled = vi.fn();
const mockUseShellConnection = vi.fn(); const mockUseShellConnection = vi.fn();
const mockConfirm = vi.fn();
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args), fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
@@ -112,6 +113,10 @@ vi.mock("../../hooks/useShellConnection", () => ({
useShellConnection: (...args: unknown[]) => mockUseShellConnection(...args), useShellConnection: (...args: unknown[]) => mockUseShellConnection(...args),
})); }));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
}));
vi.mock("../ProviderIcon", () => ({ vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => ( ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => (
<span data-testid="provider-icon" data-provider={provider} data-size={size}> <span data-testid="provider-icon" data-provider={provider} data-size={size}>
@@ -204,6 +209,7 @@ beforeEach(() => {
ready: false, ready: false,
}); });
mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false }); mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
mockConfirm.mockResolvedValue(true);
// Default to no persisted state (start at ai-setup) // Default to no persisted state (start at ai-setup)
mockGetOnboardingState.mockReturnValue(null); mockGetOnboardingState.mockReturnValue(null);
mockSaveOnboardingState.mockImplementation(() => {}); mockSaveOnboardingState.mockImplementation(() => {});
@@ -658,6 +664,74 @@ describe("ModelOnboardingModal", () => {
}); });
}); });
it("warns before starting manual-code oauth login and stops when cancelled", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
});
vi.spyOn(window, "open").mockImplementation(vi.fn());
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
mockConfirm.mockResolvedValueOnce(false);
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith({
title: "Heads up — manual paste-back required",
message:
"After you sign in with Anthropic, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?",
confirmLabel: "Continue to login",
cancelLabel: "Cancel",
});
});
expect(mockLoginProvider).not.toHaveBeenCalled();
});
it("continues manual-code oauth login after confirmation", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
});
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
await waitFor(() => {
expect(screen.getByText("Login")).toBeTruthy();
});
mockConfirm.mockResolvedValueOnce(true);
fireEvent.click(screen.getByText("Login"));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalled();
expect(mockLoginProvider).toHaveBeenCalledWith("anthropic");
expect(mockWindowOpen).toHaveBeenCalledWith("https://auth.example.com/login", "_blank");
});
});
it("skips the warning for oauth providers without manual-code fallback", async () => {
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "google", name: "Google", authenticated: false, type: "oauth" }],
});
const mockWindowOpen = vi.fn();
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
const loginButton = await screen.findByText("Login");
fireEvent.click(loginButton);
await waitFor(() => {
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockLoginProvider).toHaveBeenCalledWith("google");
expect(mockWindowOpen).toHaveBeenCalledWith("https://auth.example.com/login", "_blank");
});
});
it("appends daemon query token for same-origin OAuth login popup URLs", async () => { it("appends daemon query token for same-origin OAuth login popup URLs", async () => {
localStorage.setItem("fn.authToken", "daemon-token"); localStorage.setItem("fn.authToken", "daemon-token");
mockLoginProvider.mockResolvedValueOnce({ url: "/api/auth/providers/anthropic/login?state=xyz" }); mockLoginProvider.mockResolvedValueOnce({ url: "/api/auth/providers/anthropic/login?state=xyz" });

View File

@@ -57,6 +57,7 @@ const mockSetDroidCliEnabled = vi.fn();
const mockFetchCursorCliStatus = vi.fn(); const mockFetchCursorCliStatus = vi.fn();
const mockSetCursorCliEnabled = vi.fn(); const mockSetCursorCliEnabled = vi.fn();
const mockUseWorkspaceFileBrowser = vi.fn(); const mockUseWorkspaceFileBrowser = vi.fn();
const mockConfirm = vi.fn();
vi.mock("../../api", async (importOriginal) => { vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi"); const { createDashboardApiMock } = await import("../../test/mockApi");
@@ -125,6 +126,10 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args), useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
})); }));
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
}));
vi.mock("../../hooks/useViewportMode", () => ({ vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: () => "mobile", useViewportMode: () => "mobile",
})); }));
@@ -268,6 +273,7 @@ describe("SettingsModal", () => {
mockFetchSettings.mockResolvedValue(defaultSettings); mockFetchSettings.mockResolvedValue(defaultSettings);
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} }); mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
mockFetchAuthStatus.mockResolvedValue({ providers: [] }); mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockConfirm.mockResolvedValue(true);
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchCustomProviders.mockResolvedValue({ providers: [] }); mockFetchCustomProviders.mockResolvedValue({ providers: [] });
mockCreateCustomProvider.mockResolvedValue({ provider: {} }); mockCreateCustomProvider.mockResolvedValue({ provider: {} });
@@ -1156,6 +1162,71 @@ describe("SettingsModal", () => {
expect(openSpy).toHaveBeenCalled(); expect(openSpy).toHaveBeenCalled();
}); });
it("warns before starting manual-code oauth login and stops when cancelled", async () => {
vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
});
mockConfirm.mockResolvedValueOnce(false);
renderModal();
await waitForSettingsModalReady();
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith({
title: "Heads up — manual paste-back required",
message:
"After you sign in with Anthropic, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?",
confirmLabel: "Continue to login",
cancelLabel: "Cancel",
});
});
expect(mockLoginProvider).not.toHaveBeenCalled();
});
it("continues manual-code oauth login after confirmation", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth", requiresManualCode: true }],
});
mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" });
mockConfirm.mockResolvedValueOnce(true);
renderModal();
await waitForSettingsModalReady();
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalled();
expect(mockLoginProvider).toHaveBeenCalledWith("anthropic");
expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank");
});
});
it("skips the warning for oauth providers without manual-code fallback", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus.mockResolvedValueOnce({
providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }],
});
mockLoginProvider.mockResolvedValueOnce({ url: "https://example.com/auth" });
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Login" }));
await waitFor(() => {
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockLoginProvider).toHaveBeenCalledWith("github");
expect(openSpy).toHaveBeenCalledWith("https://example.com/auth", "_blank");
});
});
it("renders Anthropic pasted-code form when login response includes manualCode", async () => { it("renders Anthropic pasted-code form when login response includes manualCode", async () => {
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null); const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
mockFetchAuthStatus.mockResolvedValueOnce({ mockFetchAuthStatus.mockResolvedValueOnce({

View File

@@ -611,13 +611,44 @@ describe("GET /auth/status", () => {
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp"); const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([ expect(providers).toEqual([
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false }, { id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", loginInProgress: false }, { id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", loginInProgress: false, requiresManualCode: true },
{ 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" },
]); ]);
}); });
it.each(["https://my-host.example.com", undefined])(
"marks manual-code oauth providers during auth status when origin is %s",
async (origin) => {
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "github-copilot", name: "GitHub Copilot" },
{ id: "openai-codex", name: "OpenAI Codex" },
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "openrouter", name: "OpenRouter" },
]);
const res = origin
? await REQUEST(buildApp(), "GET", "/api/auth/status", undefined, { Origin: origin })
: await REQUEST(buildApp(), "GET", "/api/auth/status");
expect(res.status).toBe(200);
const openAiCodex = res.body.providers.find((p: any) => p.id === "openai-codex");
const anthropic = res.body.providers.find((p: any) => p.id === "anthropic");
const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot");
const openrouter = res.body.providers.find((p: any) => p.id === "openrouter");
const claudeCli = res.body.providers.find((p: any) => p.id === "claude-cli");
expect(openAiCodex.requiresManualCode).toBe(true);
expect(anthropic.requiresManualCode).toBe(true);
expect(githubCopilot).not.toHaveProperty("requiresManualCode");
expect(openrouter).not.toHaveProperty("requiresManualCode");
expect(claudeCli).not.toHaveProperty("requiresManualCode");
},
);
it("returns unauthenticated status", async () => { it("returns unauthenticated status", async () => {
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false); (authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);

View File

@@ -224,6 +224,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/ */
router.get("/auth/status", async (req, res) => { router.get("/auth/status", async (req, res) => {
try { try {
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
const storage = getAuthStorage(); const storage = getAuthStorage();
storage.reload(); storage.reload();
const oauthProviders = storage.getOAuthProviders(); const oauthProviders = storage.getOAuthProviders();
@@ -234,12 +235,14 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
type: "oauth" | "api_key" | "cli"; type: "oauth" | "api_key" | "cli";
keyHint?: string; keyHint?: string;
loginInProgress?: boolean; loginInProgress?: boolean;
requiresManualCode?: boolean;
}[] = oauthProviders.map((p) => ({ }[] = oauthProviders.map((p) => ({
id: p.id, id: p.id,
name: p.name, name: p.name,
authenticated: storage.hasAuth(p.id) && !isExpiredOauthCredential(p.id, storage), authenticated: storage.hasAuth(p.id) && !isExpiredOauthCredential(p.id, storage),
type: "oauth" as const, type: "oauth" as const,
loginInProgress: loginInProgress.has(p.id), loginInProgress: loginInProgress.has(p.id),
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
})); }));
// Include API-key-backed providers if supported // Include API-key-backed providers if supported