"use client"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useTranslations } from "next-intl"; import Modal from "./Modal"; import Button from "./Button"; import Input from "./Input"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { parseResponseBody, getErrorMessage } from "@/shared/utils/api"; import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit"; const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); /** Providers that use a local callback server on a random port (PKCE browser flow). */ const PKCE_CALLBACK_SERVER_PROVIDERS = new Set(["codex"]); /** * Phase 1 hotfix (2026-05-29): windsurf & devin-cli only support import-token. * Their PKCE flow targeting app.devin.ai/editor/signin returned 404 post-rebrand. * Phase 2 will reintroduce browser login via Firebase OAuth + RegisterUser. * Spec: docs/superpowers/specs/2026-05-29-windsurf-login-fix-design.md. */ const IMPORT_TOKEN_ONLY_PROVIDERS = new Set(["windsurf", "devin-cli", "grok-cli"]); type OAuthModalProps = { isOpen: boolean; provider?: string; providerInfo?: { name?: string } | null; onSuccess?: () => void; onClose: () => void; idcConfig?: unknown; reauthConnection?: null | { id?: string }; }; /** * OAuth Modal Component * - Localhost: Auto callback via popup message * - Remote: Manual paste callback URL */ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, idcConfig, reauthConnection, }: OAuthModalProps) { const t = useTranslations("oauthModal"); const [step, setStep] = useState("waiting"); // waiting | input | success | error const [authData, setAuthData] = useState(null); const [callbackUrl, setCallbackUrl] = useState(""); const [error, setError] = useState(null); const [isDeviceCode, setIsDeviceCode] = useState(false); const [deviceData, setDeviceData] = useState(null); const [polling, setPolling] = useState(false); // API-key paste mode: for providers that accept a token directly (windsurf, devin-cli) const [showPasteToken, setShowPasteToken] = useState( provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli" ); const [pasteToken, setPasteToken] = useState(""); const [savingToken, setSavingToken] = useState(false); const supportsTokenPaste = provider === "windsurf" || provider === "devin-cli" || provider === "grok-cli"; // Phase 1 hotfix (2026-05-29): windsurf/devin-cli are import-token-only. // Hide the "Browser Login" tab — Phase 2 will restore it via Firebase OAuth. const importTokenOnly = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider); const popupRef = useRef(null); const { copied, copy } = useCopyToClipboard(); const deviceVerificationUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || ""; // Client-only runtime values const runtimeLocation = useMemo(() => { if (typeof window === "undefined") { return { isLocalhost: false, isTrueLocalhost: false, placeholderUrl: "/callback?code=...", }; } const hostname = window.location.hostname; const isLocal = hostname === "localhost" || hostname === "127.0.0.1" || hostname.startsWith("192.168.") || hostname.startsWith("10.") || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname); const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1"; return { isLocalhost: isLocal, isTrueLocalhost: isTrulyLocal, placeholderUrl: `${window.location.origin}/callback?code=...`, }; }, []); const { isLocalhost, isTrueLocalhost, placeholderUrl } = runtimeLocation; const callbackProcessedRef = useRef(false); const flowStartedRef = useRef(false); // Define all useCallback hooks BEFORE the useEffects that reference them // Exchange tokens const exchangeTokens = useCallback( async (code, state) => { if (!authData) return; try { if (!authData.redirectUri || !authData.codeVerifier) { throw new Error( "OAuth session is incomplete (missing redirect URI or code verifier). Restart the connection and try again." ); } const normalizedState = typeof state === "string" && state.length > 0 ? state : undefined; const res = await fetch(`/api/oauth/${provider}/exchange`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, redirectUri: authData.redirectUri, connectionId: reauthConnection?.id, codeVerifier: authData.codeVerifier, ...(normalizedState ? { state: normalizedState } : {}), }), }); const data = (await parseResponseBody(res)) as Record; if (!res.ok) { const errorObject = typeof data.error === "object" && data.error !== null ? (data.error as Record) : null; const errMsg = errorObject ? (errorObject.message as string) || JSON.stringify(errorObject) : data.error || "Exchange failed"; const details = Array.isArray(errorObject?.details) ? (errorObject.details as Array<{ field?: string; message?: string }>) .map((detail) => { if (!detail?.message) return null; return detail.field ? `${detail.field}: ${detail.message}` : detail.message; }) .filter(Boolean) .join("; ") : ""; throw new Error(details ? `${errMsg} (${details})` : errMsg); } setStep("success"); onSuccess?.(); } catch (err) { // Provide actionable guidance for redirect_uri_mismatch on Google OAuth providers if ( err.message?.toLowerCase().includes("redirect_uri_mismatch") && GOOGLE_OAUTH_PROVIDERS.has(provider) ) { setError( "redirect_uri_mismatch: The default Google OAuth credentials only work on localhost. " + "For remote use, configure your own OAuth credentials via environment variables: " + (provider === "antigravity" ? "ANTIGRAVITY_OAUTH_CLIENT_ID and ANTIGRAVITY_OAUTH_CLIENT_SECRET" : "GEMINI_CLI_OAUTH_CLIENT_ID and GEMINI_CLI_OAUTH_CLIENT_SECRET") + ". See the README section 'OAuth on a Remote Server'." ); } else { setError(err.message); } setStep("error"); } }, [authData, provider, onSuccess, reauthConnection] ); // Save a raw API token directly (windsurf / devin-cli import-token path) const handleSaveToken = useCallback(async () => { const token = pasteToken.trim(); if (!token || !provider) return; setSavingToken(true); setError(null); try { // POST to /exchange with a synthetic "import_token" payload. // The windsurf provider's mapTokens() handles a bare accessToken/apiKey field. const res = await fetch(`/api/oauth/${provider}/import-token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, connectionId: reauthConnection?.id, }), }); const data = (await parseResponseBody(res)) as Record; if (!res.ok) { const errMsg = getErrorMessage(data, res.status, "Save failed"); throw new Error(errMsg); } setStep("success"); onSuccess?.(); } catch (err) { // Show error inline inside the paste-token form (don't flip to error step) setError(err.message); } finally { setSavingToken(false); } }, [pasteToken, provider, onSuccess, reauthConnection]); // Poll for device code token const startPolling = useCallback( async (deviceCode, codeVerifier, interval, extraData) => { setPolling(true); const maxAttempts = 60; for (let i = 0; i < maxAttempts; i++) { await new Promise((r) => setTimeout(r, interval * 1000)); try { const res = await fetch(`/api/oauth/${provider}/poll`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ deviceCode, connectionId: reauthConnection?.id, codeVerifier, extraData, }), }); const data = (await parseResponseBody(res)) as Record; if (data.success) { setStep("success"); setPolling(false); onSuccess?.(); return; } if (data.error === "expired_token" || data.error === "access_denied") { throw new Error(data.errorDescription || data.error); } if (data.error === "slow_down") { interval = Math.min(interval + 5, 30); } } catch (err) { setError(err.message); setStep("error"); setPolling(false); return; } } setError("Authorization timeout"); setStep("error"); setPolling(false); }, [provider, onSuccess, reauthConnection] ); // Start OAuth flow const startOAuthFlow = useCallback(async () => { if (!provider) return; try { setError(null); // Device code flow (GitHub, Qwen, Kiro, Kimi Coding, KiloCode) if ( provider === "github" || provider === "qwen" || provider === "kiro" || provider === "amazon-q" || provider === "kimi-coding" || provider === "kilocode" || provider === "codebuddy-cn" ) { setIsDeviceCode(true); setStep("waiting"); const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin); if ( (provider === "kiro" || provider === "amazon-q") && idcConfig && typeof idcConfig === "object" ) { const idc = idcConfig as { startUrl?: string; region?: string }; if (typeof idc.startUrl === "string" && idc.startUrl.trim()) { deviceCodeUrl.searchParams.set("startUrl", idc.startUrl.trim()); } if (typeof idc.region === "string" && idc.region.trim()) { deviceCodeUrl.searchParams.set("region", idc.region.trim()); } } const res = await fetch(deviceCodeUrl.toString()); const data = (await parseResponseBody(res)) as Record; if (!res.ok) { const errMsg = getErrorMessage(data, res.status, "Request failed"); throw new Error(errMsg); } setDeviceData(data); // Open verification URL const verifyUrl = data.verification_uri_complete || data.verification_uri; if (verifyUrl) window.open(verifyUrl, "oauth_verify"); // Start polling - pass extraData for Kiro (contains _clientId, _clientSecret) const extraData = provider === "kiro" || provider === "amazon-q" ? { _clientId: data._clientId, _clientSecret: data._clientSecret, _region: data._region, } : null; startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData); return; } let forceManual = false; // Claude Code and Cline OAuth flows can finish on provider-hosted pages that // show an auth code instead of redirecting back to OmniRoute. // Start directly in manual mode so users always have an input to paste code/url. if (provider === "claude" || provider === "cline") { forceManual = true; } // PKCE callback server providers (Codex, Windsurf, Devin CLI): // On localhost, spin up a local callback server and poll for the result. // Codex uses a fixed port 1455; Windsurf/Devin CLI use a random OS-assigned port. // On remote the server is unreachable — fall through to standard manual flow. if (PKCE_CALLBACK_SERVER_PROVIDERS.has(provider)) { if (isTrueLocalhost) { try { const serverRes = await fetch(`/api/oauth/${provider}/start-callback-server`); const serverData = (await parseResponseBody(serverRes)) as Record; if (!serverRes.ok) throw new Error( getErrorMessage(serverData, serverRes.status, "Failed to start callback server") ); setAuthData({ ...serverData, redirectUri: serverData.redirectUri }); setStep("waiting"); popupRef.current = window.open(serverData.authUrl, "oauth_auth"); // If browser blocked the popup, switch to manual input step immediately if (!popupRef.current) { setStep("input"); } setPolling(true); const maxAttempts = 150; for (let i = 0; i < maxAttempts; i++) { await new Promise((r) => setTimeout(r, 2000)); const pollRes = await fetch(`/api/oauth/${provider}/poll-callback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ connectionId: reauthConnection?.id }), }); const pollData = (await parseResponseBody(pollRes)) as Record; if (pollData.success) { setStep("success"); setPolling(false); onSuccess?.(); return; } if (pollData.error && !pollData.pending) { throw new Error(pollData.errorDescription || pollData.error); } } setPolling(false); throw new Error("Authorization timeout"); } catch (pkceErr) { console.warn( `${provider} callback server failed, falling back to manual flow`, pkceErr ); setPolling(false); forceManual = true; } } // Remote: fall through to standard auth code flow below } // Authorization code flow // Redirect URI strategy: // - Codex/OpenAI: always port 1455 (registered in OAuth app) // - Windsurf/Devin CLI (remote fallback): use localhost with OmniRoute port + /auth/callback // (on true localhost the callback server handles it; this is only reached on remote) // - Google OAuth providers (antigravity, gemini-cli): default to loopback so the // bundled native/desktop credentials keep working. Prefer 127.0.0.1 over // localhost for the Google native-app handoff; Google documents that localhost // can run into local firewall/name-resolution edge cases. The authorize route // upgrades this to the public callback when custom Google web credentials plus // NEXT_PUBLIC_BASE_URL or OMNIROUTE_PUBLIC_BASE_URL are configured. // - Other providers on remote: use actual origin (supports PUBLIC_URL env var) // - Localhost: use localhost:port let redirectUri: string; if (provider === "codex" || provider === "openai") { redirectUri = "http://localhost:1455/auth/callback"; } else if (provider === "windsurf" || provider === "devin-cli") { // Remote fallback: use OmniRoute's port with the /auth/callback path Windsurf expects. // On true localhost this code is never reached (callback server handles the flow above). const port = window.location.port || "20128"; redirectUri = `http://localhost:${port}/auth/callback`; } else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) { // Google OAuth built-in credentials only accept loopback redirect URIs. // Even in remote deployments we use loopback — user copies the callback URL manually. const port = window.location.port || "20128"; redirectUri = `http://127.0.0.1:${port}/callback`; } else if (!isLocalhost) { // Behind reverse proxy: use actual origin (e.g., https://omniroute.example.com/callback) // Supports PUBLIC_URL env var override, or falls back to window.location.origin. const publicUrl = process.env.NEXT_PUBLIC_BASE_URL; const origin = publicUrl && publicUrl !== "http://localhost:20128" ? publicUrl.replace(/\/$/, "") : window.location.origin; redirectUri = `${origin}/callback`; } else { const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); redirectUri = `http://localhost:${port}/callback`; } const res = await fetch( `/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}` ); const data = (await parseResponseBody(res)) as Record; if (!res.ok) { const errMsg = getErrorMessage(data, res.status, "Authorization failed"); throw new Error(errMsg); } if (!data.authUrl) { throw new Error( data.error || "Browser OAuth is unavailable for this provider in the current environment. Use the supported auth method instead." ); } setAuthData({ ...data, redirectUri: data.redirectUri || redirectUri }); // For non-true-localhost (LAN IPs, remote) or manual fallback: use manual input mode (user pastes callback URL) if (!isTrueLocalhost || forceManual) { setStep("input"); window.open(data.authUrl, "oauth_auth"); } else { // Localhost: Open popup and wait for message setStep("waiting"); popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); // Check if popup was blocked if (!popupRef.current) { setStep("input"); } } } catch (err) { setError(err.message); setStep("error"); } }, [ provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess, reauthConnection, idcConfig, ]); // Reset guard when modal closes useEffect(() => { if (!isOpen) { flowStartedRef.current = false; } }, [isOpen]); // Reset state and start OAuth when modal opens useEffect(() => { if (isOpen && provider) { if (flowStartedRef.current) return; // Already started, prevent duplicate flowStartedRef.current = true; setAuthData(null); setCallbackUrl(""); setError(null); setIsDeviceCode(false); setDeviceData(null); setPolling(false); // Auto start OAuth startOAuthFlow(); } }, [isOpen, provider, startOAuthFlow]); // Listen for OAuth callback via multiple methods useEffect(() => { if (!authData) return; callbackProcessedRef.current = false; // Reset when authData changes // Handler for callback data - only process once const handleCallback = async (data) => { if (callbackProcessedRef.current) return; // Already processed const { code, state, error: callbackError, errorDescription } = data; if (authData?.state && state && state !== authData.state) { callbackProcessedRef.current = true; setError("OAuth state mismatch. Restart the connection and try again."); setStep("error"); return; } if (callbackError) { callbackProcessedRef.current = true; setError(errorDescription || callbackError); setStep("error"); return; } if (code) { callbackProcessedRef.current = true; await exchangeTokens(code, state); } }; // Method 1: postMessage from popup const handleMessage = (event) => { // Accept same-origin OR localhost with same port (remote access scenario: // dashboard at 192.168.x:port, callback redirects to localhost:port) const currentPort = window.location.port; let isLoopbackOrigin = false; let isLocalhostSamePort = false; try { const eventUrl = new URL(event.origin); isLoopbackOrigin = /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(eventUrl.hostname); isLocalhostSamePort = isLoopbackOrigin && eventUrl.port === currentPort; } catch { // Ignore malformed origins. } const payload = event.data?.data; const hasMatchingState = !!authData?.state && payload?.state === authData.state; const isGoogleLoopbackRelay = GOOGLE_OAUTH_PROVIDERS.has(provider) && isLoopbackOrigin && hasMatchingState; if ( event.origin !== window.location.origin && !isLocalhostSamePort && !isGoogleLoopbackRelay ) { return; } if (event.data?.type === "oauth_callback") { handleCallback(payload); } }; window.addEventListener("message", handleMessage); // Method 2: BroadcastChannel let channel; try { channel = new BroadcastChannel("oauth_callback"); channel.onmessage = (event) => handleCallback(event.data); } catch (e) { console.log("BroadcastChannel not supported"); } // Method 3: localStorage event const handleStorage = (event) => { if (event.key === "oauth_callback" && event.newValue) { try { const data = JSON.parse(event.newValue); handleCallback(data); localStorage.removeItem("oauth_callback"); } catch (e) { console.log("Failed to parse localStorage data"); } } }; window.addEventListener("storage", handleStorage); // Also check localStorage on mount (in case callback already happened) try { const stored = localStorage.getItem("oauth_callback"); if (stored) { const data = JSON.parse(stored); // Only use if recent (within 30 seconds) if (data.timestamp && Date.now() - data.timestamp < 30000) { handleCallback(data); localStorage.removeItem("oauth_callback"); } } } catch { // localStorage may be unavailable or data may be malformed - ignore silently } return () => { window.removeEventListener("message", handleMessage); window.removeEventListener("storage", handleStorage); if (channel) channel.close(); }; }, [authData, exchangeTokens, provider]); // Fix #344: Detect when OAuth popup is closed without completing authorization // Some providers (like Qoder) redirect to their own chat UI instead of sending a callback, // leaving the modal stuck at "Waiting for Authorization" forever. useEffect(() => { if (step !== "waiting" || isDeviceCode || !popupRef.current) return; let closed = false; const popupClosedInterval = setInterval(() => { if (callbackProcessedRef.current) { clearInterval(popupClosedInterval); return; } try { if (popupRef.current?.closed) { closed = true; clearInterval(popupClosedInterval); // Popup was closed without completing OAuth — switch to manual input mode // so user can paste the callback URL from their browser address bar if (step === "waiting") { setStep("input"); } } } catch { // Cross-origin access may throw — ignore } }, 1000); // Safety timeout: 5 minutes const safetyTimeout = setTimeout( () => { if (!callbackProcessedRef.current && step === "waiting") { clearInterval(popupClosedInterval); setStep("input"); } }, 5 * 60 * 1000 ); return () => { clearInterval(popupClosedInterval); clearTimeout(safetyTimeout); }; }, [step, isDeviceCode]); // Handle manual URL input const handleManualSubmit = async () => { try { setError(null); if (isCredentialBlob(callbackUrl)) { await submitCredentialBlob(provider, callbackUrl, reauthConnection, setStep, onSuccess); return; } if (!authData) { throw new Error( "OAuth session not initialized. Restart the connection flow and try again." ); } const input = callbackUrl.trim(); let code = null; let state = authData?.state || null; let errorParam = null; let errorDescription = null; try { const url = new URL(input); code = url.searchParams.get("code"); state = url.searchParams.get("state") || url.hash.replace(/^#/, "") || state; errorParam = url.searchParams.get("error"); errorDescription = url.searchParams.get("error_description"); } catch { // Claude Code remote auth may provide a raw "Authentication Code" like code#state. const [rawCode, rawState] = input.split("#", 2); code = rawCode || null; state = rawState || state; } if (errorParam) { throw new Error(errorDescription || errorParam); } if (!code) { throw new Error( "No authorization code found. Paste the callback URL or the Authentication Code." ); } await exchangeTokens(code, state); } catch (err) { setError(err.message); setStep("error"); } }; if (!provider || !providerInfo) return null; return (
{/* Paste-token tab toggle (Windsurf / Devin CLI only). Phase 1 hotfix: when importTokenOnly is true, hide the entire toggle — there is no "Browser Login" tab to switch to until Phase 2 ships. */} {supportsTokenPaste && !importTokenOnly && step !== "success" && (
)} {/* Paste-token form (Windsurf / Devin CLI) */} {supportsTokenPaste && showPasteToken && step !== "success" && (

{provider === "windsurf" ? 'In the Windsurf / VS Code IDE, run the "Windsurf: Provide Auth Token" command from the command palette (or click the Jupyter "Get Windsurf Authentication Token" button), then copy the shown token and paste it below. Opening windsurf.com/show-auth-token directly only shows a "Redirecting" page — the IDE must initiate the flow.' : provider === "grok-cli" ? 'Paste your Grok Build JWT token from ~/.grok/auth.json (the "key" field value). You can get it by running `grok login` in your terminal.' : 'Provide your WINDSURF_API_KEY (obtained via `devin auth login`, or via the Windsurf IDE "Windsurf: Provide Auth Token" command).'}

setPasteToken(e.target.value)} placeholder={provider === "grok-cli" ? "eyJ..." : "ws-..."} type="password" label={provider === "grok-cli" ? "JWT Token" : "API Key / Token"} /> {error &&

{error}

}
)} {/* OAuth flow steps — hidden when paste-token mode is active */} {(!supportsTokenPaste || !showPasteToken) && ( <> {/* Waiting Step (Localhost - popup mode) */} {step === "waiting" && !isDeviceCode && (
progress_activity

{t("waiting")}

{t("completeAuthInPopup")}

{t("popupClosedHint")}

)} {/* Device Code Flow - Waiting */} {step === "waiting" && isDeviceCode && deviceData && ( <>

{t("deviceCodeVisitUrl")}

{t("deviceCodeVerificationUrl")}

{deviceVerificationUrl}

{t("deviceCodeYourCode")}

{deviceData.user_code}

{polling && (
progress_activity {t("deviceCodeWaiting")}
)} )} {/* Manual Input Step */} {step === "input" && !isDeviceCode && ( <>
{/* Remote/LAN server info for Google OAuth providers */} {!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && (
warning {t.rich("googleOAuthWarning", { code: (c) => {c}, a: (c) => ( {c} ), })}
)} {/* Actionable remote paste instruction — shown for ALL remote providers, including Google OAuth (antigravity/agy/gemini-cli). The Google loopback creds redirect to 127.0.0.1:/callback, which on a remotely-accessed dashboard lands on the operator's own machine and shows a "can't reach this page" error. That is expected: the URL bar still carries ?code=…, and pasting it below completes the login. Before this, Google providers only saw the discouraging loopback warning and never the "copy the URL and paste it" step, so remote login appeared to hang. */} {!isTrueLocalhost && (
info {t("remoteAccessInfo")}
)}

{t("step1OpenUrl")}

{t("step2PasteCallback")}

{t.rich("step2Hint", { code: (c) => {c}, })}

setCallbackUrl(e.target.value)} placeholder={ provider === "claude" || provider === "cline" ? "code#state or /callback?code=..." : placeholderUrl } className="font-mono text-xs" />
)} )} {/* Success Step — shown for both OAuth and paste-token flows */} {step === "success" && (
check_circle

{t("success")}

{t("successMessage", { providerName: providerInfo.name })}

)} {/* Error Step — OAuth errors only; paste-token errors shown inline */} {step === "error" && !showPasteToken && (
error

{t("error")}

{error}

)}
); }