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:
5
.changeset/FN-4160-oauth-manual-code-warning.md
Normal file
5
.changeset/FN-4160-oauth-manual-code-warning.md
Normal 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.
|
||||
@@ -74,7 +74,7 @@ fn dashboard
|
||||
|
||||
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
|
||||
3. **First Task** — create your first task or import one from GitHub
|
||||
|
||||
|
||||
@@ -331,7 +331,7 @@ Recovery entrypoints in the dashboard:
|
||||
### 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:
|
||||
- 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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -1330,6 +1330,8 @@ export interface AuthProvider {
|
||||
authenticated: boolean;
|
||||
/** True when the server currently has an active OAuth login flow for this provider. */
|
||||
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.
|
||||
* - "oauth": OAuth flow (user clicks Login → redirect)
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PluginSlot } from "./PluginSlot";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||
import { useShellConnection } from "../hooks/useShellConnection";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
|
||||
const mapLegacyCustomProviderToConfig = (
|
||||
provider: CustomProvider | CustomProviderConfig,
|
||||
@@ -178,6 +179,9 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
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 {
|
||||
if (PROVIDER_DISPLAY_NAMES[providerId]) {
|
||||
return PROVIDER_DISPLAY_NAMES[providerId];
|
||||
@@ -555,6 +559,7 @@ export function ModelOnboardingModal({
|
||||
firstCreatedTask,
|
||||
onViewTask,
|
||||
}: ModelOnboardingModalProps) {
|
||||
const { confirm } = useConfirm();
|
||||
// Initialize from persisted state if available (allows resume from last step)
|
||||
const persistedState = getOnboardingState();
|
||||
const persistedStep = persistedState?.currentStep;
|
||||
@@ -1054,6 +1059,19 @@ export function ModelOnboardingModal({
|
||||
// OAuth login handler
|
||||
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: getManualCodeLoginWarningMessage(provider.name),
|
||||
confirmLabel: "Continue to login",
|
||||
cancelLabel: "Cancel",
|
||||
});
|
||||
if (!shouldContinue) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any previous terminal outcome before starting a new login attempt
|
||||
setLoginOutcomes((prev) => {
|
||||
const outcome = prev[providerId];
|
||||
@@ -1181,7 +1199,7 @@ export function ModelOnboardingModal({
|
||||
clearAuthLoginUiState();
|
||||
}
|
||||
},
|
||||
[addToast, loadAuthStatus, setGitHubSkippedState],
|
||||
[addToast, authProviders, confirm, loadAuthStatus, setGitHubSkippedState],
|
||||
);
|
||||
|
||||
const handleSubmitManualCode = useCallback(async (providerId: string) => {
|
||||
|
||||
@@ -978,6 +978,20 @@ export function SettingsModal({
|
||||
}, []);
|
||||
|
||||
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);
|
||||
clearAuthLoginUiState(providerId);
|
||||
|
||||
@@ -1035,7 +1049,7 @@ export function SettingsModal({
|
||||
setAuthActionInProgress(null);
|
||||
clearAuthLoginUiState(providerId);
|
||||
}
|
||||
}, [addToast, clearAuthLoginUiState, loadAuthStatus, scrollSettingsToTop]);
|
||||
}, [addToast, authProviders, clearAuthLoginUiState, confirm, loadAuthStatus, scrollSettingsToTop]);
|
||||
|
||||
const handleSubmitManualCode = useCallback(async (providerId: string) => {
|
||||
const code = manualCodeInputs[providerId]?.trim();
|
||||
|
||||
@@ -22,6 +22,7 @@ const mockCreateCustomProvider = vi.fn();
|
||||
const mockFetchCursorCliStatus = vi.fn();
|
||||
const mockSetCursorCliEnabled = vi.fn();
|
||||
const mockUseShellConnection = vi.fn();
|
||||
const mockConfirm = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: (...args: unknown[]) => mockFetchAuthStatus(...args),
|
||||
@@ -112,6 +113,10 @@ vi.mock("../../hooks/useShellConnection", () => ({
|
||||
useShellConnection: (...args: unknown[]) => mockUseShellConnection(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
|
||||
}));
|
||||
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
ProviderIcon: ({ provider, size }: { provider: string; size?: string }) => (
|
||||
<span data-testid="provider-icon" data-provider={provider} data-size={size}>
|
||||
@@ -204,6 +209,7 @@ beforeEach(() => {
|
||||
ready: false,
|
||||
});
|
||||
mockSetCursorCliEnabled.mockResolvedValue({ enabled: true, restartRequired: false });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
// Default to no persisted state (start at ai-setup)
|
||||
mockGetOnboardingState.mockReturnValue(null);
|
||||
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 () => {
|
||||
localStorage.setItem("fn.authToken", "daemon-token");
|
||||
mockLoginProvider.mockResolvedValueOnce({ url: "/api/auth/providers/anthropic/login?state=xyz" });
|
||||
|
||||
@@ -57,6 +57,7 @@ const mockSetDroidCliEnabled = vi.fn();
|
||||
const mockFetchCursorCliStatus = vi.fn();
|
||||
const mockSetCursorCliEnabled = vi.fn();
|
||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||
const mockConfirm = vi.fn();
|
||||
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const { createDashboardApiMock } = await import("../../test/mockApi");
|
||||
@@ -125,6 +126,10 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: (...args: unknown[]) => mockConfirm(...args) }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
useViewportMode: () => "mobile",
|
||||
}));
|
||||
@@ -268,6 +273,7 @@ describe("SettingsModal", () => {
|
||||
mockFetchSettings.mockResolvedValue(defaultSettings);
|
||||
mockFetchSettingsByScope.mockResolvedValue({ global: defaultSettings, project: {} });
|
||||
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
|
||||
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
|
||||
@@ -1156,6 +1162,71 @@ describe("SettingsModal", () => {
|
||||
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 () => {
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
|
||||
@@ -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");
|
||||
expect(providers).toEqual([
|
||||
{ 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: "kimi-coding", name: "Kimi", authenticated: false, 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 () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
*/
|
||||
router.get("/auth/status", async (req, res) => {
|
||||
try {
|
||||
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
|
||||
const storage = getAuthStorage();
|
||||
storage.reload();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
@@ -234,12 +235,14 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
type: "oauth" | "api_key" | "cli";
|
||||
keyHint?: string;
|
||||
loginInProgress?: boolean;
|
||||
requiresManualCode?: boolean;
|
||||
}[] = oauthProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasAuth(p.id) && !isExpiredOauthCredential(p.id, storage),
|
||||
type: "oauth" as const,
|
||||
loginInProgress: loginInProgress.has(p.id),
|
||||
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
|
||||
}));
|
||||
|
||||
// Include API-key-backed providers if supported
|
||||
|
||||
Reference in New Issue
Block a user