Spaces:
Runtime error
Runtime error
| "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<string, unknown>; | |
| if (!res.ok) { | |
| const errorObject = | |
| typeof data.error === "object" && data.error !== null | |
| ? (data.error as Record<string, unknown>) | |
| : 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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<string, unknown>; | |
| 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 ( | |
| <Modal | |
| isOpen={isOpen} | |
| title={t("title", { providerName: providerInfo.name })} | |
| onClose={onClose} | |
| size="lg" | |
| > | |
| <div className="flex flex-col gap-4"> | |
| {/* 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" && ( | |
| <div className="flex gap-2 border-b border-border pb-3"> | |
| <button | |
| className={`text-sm px-3 py-1 rounded-t ${!showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`} | |
| onClick={() => setShowPasteToken(false)} | |
| > | |
| Browser Login | |
| </button> | |
| <button | |
| className={`text-sm px-3 py-1 rounded-t ${showPasteToken ? "font-semibold border-b-2 border-primary text-primary" : "text-text-muted"}`} | |
| onClick={() => setShowPasteToken(true)} | |
| > | |
| Paste API Key | |
| </button> | |
| </div> | |
| )} | |
| {/* Paste-token form (Windsurf / Devin CLI) */} | |
| {supportsTokenPaste && showPasteToken && step !== "success" && ( | |
| <div className="flex flex-col gap-3"> | |
| <p className="text-sm text-text-muted"> | |
| {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).'} | |
| </p> | |
| <Input | |
| value={pasteToken} | |
| onChange={(e) => setPasteToken(e.target.value)} | |
| placeholder={provider === "grok-cli" ? "eyJ..." : "ws-..."} | |
| type="password" | |
| label={provider === "grok-cli" ? "JWT Token" : "API Key / Token"} | |
| /> | |
| {error && <p className="text-sm text-red-500">{error}</p>} | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={handleSaveToken} | |
| fullWidth | |
| disabled={!pasteToken.trim() || savingToken} | |
| > | |
| {savingToken ? "Saving…" : "Save Connection"} | |
| </Button> | |
| <Button onClick={onClose} variant="ghost" fullWidth> | |
| Cancel | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* OAuth flow steps — hidden when paste-token mode is active */} | |
| {(!supportsTokenPaste || !showPasteToken) && ( | |
| <> | |
| {/* Waiting Step (Localhost - popup mode) */} | |
| {step === "waiting" && !isDeviceCode && ( | |
| <div className="text-center py-6"> | |
| <div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center"> | |
| <span className="material-symbols-outlined text-3xl text-primary animate-spin"> | |
| progress_activity | |
| </span> | |
| </div> | |
| <h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3> | |
| <p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p> | |
| <p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p> | |
| <Button variant="ghost" onClick={() => setStep("input")}> | |
| {t("popupBlocked")} | |
| </Button> | |
| </div> | |
| )} | |
| {/* Device Code Flow - Waiting */} | |
| {step === "waiting" && isDeviceCode && deviceData && ( | |
| <> | |
| <div className="text-center py-4"> | |
| <p className="text-sm text-text-muted mb-4">{t("deviceCodeVisitUrl")}</p> | |
| <div className="bg-sidebar p-4 rounded-lg mb-4"> | |
| <p className="text-xs text-text-muted mb-1">{t("deviceCodeVerificationUrl")}</p> | |
| <div className="flex items-center gap-2"> | |
| <code className="flex-1 text-sm break-all">{deviceVerificationUrl}</code> | |
| <Button | |
| size="sm" | |
| variant="ghost" | |
| icon={copied === "verify_url" ? "check" : "content_copy"} | |
| onClick={() => copy(deviceVerificationUrl, "verify_url")} | |
| /> | |
| </div> | |
| </div> | |
| <div className="bg-primary/10 p-4 rounded-lg"> | |
| <p className="text-xs text-text-muted mb-1">{t("deviceCodeYourCode")}</p> | |
| <div className="flex items-center justify-center gap-2"> | |
| <p className="text-2xl font-mono font-bold text-primary"> | |
| {deviceData.user_code} | |
| </p> | |
| <Button | |
| size="sm" | |
| variant="ghost" | |
| icon={copied === "user_code" ? "check" : "content_copy"} | |
| onClick={() => copy(deviceData.user_code, "user_code")} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {polling && ( | |
| <div className="flex items-center justify-center gap-2 text-sm text-text-muted"> | |
| <span className="material-symbols-outlined animate-spin"> | |
| progress_activity | |
| </span> | |
| {t("deviceCodeWaiting")} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| {/* Manual Input Step */} | |
| {step === "input" && !isDeviceCode && ( | |
| <> | |
| <div className="space-y-4"> | |
| {/* Remote/LAN server info for Google OAuth providers */} | |
| {!isTrueLocalhost && GOOGLE_OAUTH_PROVIDERS.has(provider) && ( | |
| <div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200"> | |
| <span className="material-symbols-outlined text-sm align-middle mr-1"> | |
| warning | |
| </span> | |
| <strong> | |
| {t.rich("googleOAuthWarning", { | |
| code: (c) => <code className="font-mono">{c}</code>, | |
| a: (c) => ( | |
| <a | |
| href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server" | |
| target="_blank" | |
| rel="noreferrer" | |
| className="underline" | |
| > | |
| {c} | |
| </a> | |
| ), | |
| })} | |
| </strong> | |
| </div> | |
| )} | |
| {/* 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:<port>/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 && ( | |
| <div className="rounded-lg border border-blue-500/30 bg-blue-500/10 p-3 text-xs text-blue-200"> | |
| <span className="material-symbols-outlined text-sm align-middle mr-1"> | |
| info | |
| </span> | |
| {t("remoteAccessInfo")} | |
| </div> | |
| )} | |
| <div> | |
| <p className="text-sm font-medium mb-2">{t("step1OpenUrl")}</p> | |
| <div className="flex gap-2"> | |
| <Input | |
| value={authData?.authUrl || ""} | |
| readOnly | |
| className="flex-1 font-mono text-xs" | |
| /> | |
| <Button | |
| variant="secondary" | |
| icon={copied === "auth_url" ? "check" : "content_copy"} | |
| onClick={() => copy(authData?.authUrl, "auth_url")} | |
| > | |
| {t("copy")} | |
| </Button> | |
| </div> | |
| </div> | |
| <div> | |
| <p className="text-sm font-medium mb-2">{t("step2PasteCallback")}</p> | |
| <p className="text-xs text-text-muted mb-2"> | |
| {t.rich("step2Hint", { | |
| code: (c) => <code className="font-mono">{c}</code>, | |
| })} | |
| </p> | |
| <Input | |
| value={callbackUrl} | |
| onChange={(e) => setCallbackUrl(e.target.value)} | |
| placeholder={ | |
| provider === "claude" || provider === "cline" | |
| ? "code#state or /callback?code=..." | |
| : placeholderUrl | |
| } | |
| className="font-mono text-xs" | |
| /> | |
| </div> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={handleManualSubmit} | |
| fullWidth | |
| disabled={!callbackUrl || (!authData && !isCredentialBlob(callbackUrl))} | |
| > | |
| {t("connect")} | |
| </Button> | |
| <Button onClick={onClose} variant="ghost" fullWidth> | |
| {t("cancel")} | |
| </Button> | |
| </div> | |
| </> | |
| )} | |
| </> | |
| )} | |
| {/* Success Step — shown for both OAuth and paste-token flows */} | |
| {step === "success" && ( | |
| <div className="text-center py-6"> | |
| <div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center"> | |
| <span className="material-symbols-outlined text-3xl text-green-600"> | |
| check_circle | |
| </span> | |
| </div> | |
| <h3 className="text-lg font-semibold mb-2">{t("success")}</h3> | |
| <p className="text-sm text-text-muted mb-4"> | |
| {t("successMessage", { providerName: providerInfo.name })} | |
| </p> | |
| <Button onClick={onClose} fullWidth> | |
| {t("done")} | |
| </Button> | |
| </div> | |
| )} | |
| {/* Error Step — OAuth errors only; paste-token errors shown inline */} | |
| {step === "error" && !showPasteToken && ( | |
| <div className="text-center py-6"> | |
| <div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> | |
| <span className="material-symbols-outlined text-3xl text-red-600">error</span> | |
| </div> | |
| <h3 className="text-lg font-semibold mb-2">{t("error")}</h3> | |
| <p className="text-sm text-red-600 mb-4">{error}</p> | |
| <div className="flex gap-2"> | |
| <Button onClick={startOAuthFlow} variant="secondary" fullWidth> | |
| {t("tryAgain")} | |
| </Button> | |
| <Button onClick={onClose} variant="ghost" fullWidth> | |
| {t("cancel")} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </Modal> | |
| ); | |
| } | |