| "use client"; |
|
|
| import { useState, useEffect, useRef, useCallback } from "react"; |
| import PropTypes from "prop-types"; |
| import Modal from "./Modal"; |
| import Button from "./Button"; |
| import Input from "./Input"; |
| import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; |
|
|
| const GOOGLE_OAUTH_PROVIDERS = new Set(["antigravity", "gemini-cli"]); |
|
|
| type OAuthModalProps = { |
| isOpen: boolean; |
| provider?: string; |
| providerInfo?: { name: string } | null; |
| onSuccess?: () => void; |
| onClose: () => void; |
| idcConfig?: unknown; |
| }; |
|
|
| |
| |
| |
| |
| |
| export default function OAuthModal({ |
| isOpen, |
| provider, |
| providerInfo, |
| onSuccess, |
| onClose, |
| idcConfig, |
| }: OAuthModalProps) { |
| const [step, setStep] = useState("waiting"); |
| 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); |
| const popupRef = useRef(null); |
| const { copied, copy } = useCopyToClipboard(); |
|
|
| |
| const [isLocalhost, setIsLocalhost] = useState(false); |
| const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=..."); |
| const callbackProcessedRef = useRef(false); |
| const flowStartedRef = useRef(false); |
|
|
| |
| |
| |
| |
| const [isTrueLocalhost, setIsTrueLocalhost] = useState(false); |
| useEffect(() => { |
| if (typeof window !== "undefined") { |
| 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"; |
| setIsLocalhost(isLocal); |
| setIsTrueLocalhost(isTrulyLocal); |
| setPlaceholderUrl(`${window.location.origin}/callback?code=...`); |
| } |
| }, []); |
|
|
| |
|
|
| |
| 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, |
| codeVerifier: authData.codeVerifier, |
| ...(normalizedState ? { state: normalizedState } : {}), |
| }), |
| }); |
|
|
| const data = await res.json(); |
| 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) { |
| |
| 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_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET") + |
| ". See the README section 'OAuth on a Remote Server'." |
| ); |
| } else { |
| setError(err.message); |
| } |
| setStep("error"); |
| } |
| }, |
| [authData, provider, onSuccess] |
| ); |
|
|
| |
| 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, codeVerifier, extraData }), |
| }); |
|
|
| const data = await res.json(); |
|
|
| 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] |
| ); |
|
|
| |
| const startOAuthFlow = useCallback(async () => { |
| if (!provider) return; |
| try { |
| setError(null); |
|
|
| |
| if ( |
| provider === "github" || |
| provider === "qwen" || |
| provider === "kiro" || |
| provider === "kimi-coding" || |
| provider === "kilocode" |
| ) { |
| setIsDeviceCode(true); |
| setStep("waiting"); |
|
|
| const res = await fetch(`/api/oauth/${provider}/device-code`); |
| const data = await res.json(); |
| if (!res.ok) { |
| const errMsg = |
| typeof data.error === "object" && data.error !== null |
| ? ((data.error as Record<string, unknown>).message as string) || |
| JSON.stringify(data.error) |
| : data.error || "Request failed"; |
| throw new Error(errMsg); |
| } |
|
|
| setDeviceData(data); |
|
|
| |
| const verifyUrl = data.verification_uri_complete || data.verification_uri; |
| if (verifyUrl) window.open(verifyUrl, "oauth_verify"); |
|
|
| |
| const extraData = |
| provider === "kiro" |
| ? { _clientId: data._clientId, _clientSecret: data._clientSecret } |
| : null; |
| startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData); |
| return; |
| } |
|
|
| let forceManual = false; |
|
|
| |
| |
| |
| if (provider === "claude" || provider === "cline") { |
| forceManual = true; |
| } |
|
|
| |
| |
| if (provider === "codex") { |
| if (isLocalhost) { |
| |
| try { |
| const serverRes = await fetch(`/api/oauth/codex/start-callback-server`); |
| const serverData = await serverRes.json(); |
| if (!serverRes.ok) throw new Error(serverData.error); |
|
|
| setAuthData({ ...serverData, redirectUri: serverData.redirectUri }); |
| setStep("waiting"); |
| popupRef.current = window.open(serverData.authUrl, "oauth_auth"); |
|
|
| |
| 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/codex/poll-callback`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({}), |
| }); |
| const pollData = await pollRes.json(); |
|
|
| 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 (codexErr) { |
| console.warn( |
| "Codex callback server failed, falling back to standard manual flow", |
| codexErr |
| ); |
| setPolling(false); |
| forceManual = true; |
| } |
| } |
| |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| let redirectUri: string; |
| if (provider === "codex" || provider === "openai") { |
| redirectUri = "http://localhost:1455/auth/callback"; |
| } else if (GOOGLE_OAUTH_PROVIDERS.has(provider)) { |
| |
| |
| const port = window.location.port || "20128"; |
| redirectUri = `http://localhost:${port}/callback`; |
| } else if (!isLocalhost) { |
| |
| |
| 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 res.json(); |
| if (!res.ok) { |
| const errMsg = |
| typeof data.error === "object" && data.error !== null |
| ? ((data.error as Record<string, unknown>).message as string) || |
| JSON.stringify(data.error) |
| : data.error || "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 }); |
|
|
| |
| if (!isTrueLocalhost || forceManual) { |
| setStep("input"); |
| window.open(data.authUrl, "oauth_auth"); |
| } else { |
| |
| setStep("waiting"); |
| popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); |
|
|
| |
| if (!popupRef.current) { |
| setStep("input"); |
| } |
| } |
| } catch (err) { |
| setError(err.message); |
| setStep("error"); |
| } |
| }, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess]); |
|
|
| |
| useEffect(() => { |
| if (!isOpen) { |
| flowStartedRef.current = false; |
| } |
| }, [isOpen]); |
|
|
| |
| useEffect(() => { |
| if (isOpen && provider) { |
| if (flowStartedRef.current) return; |
| flowStartedRef.current = true; |
| setAuthData(null); |
| setCallbackUrl(""); |
| setError(null); |
| setIsDeviceCode(false); |
| setDeviceData(null); |
| setPolling(false); |
| |
| startOAuthFlow(); |
| } |
| }, [isOpen, provider, startOAuthFlow]); |
|
|
| |
| useEffect(() => { |
| if (!authData) return; |
| callbackProcessedRef.current = false; |
|
|
| |
| const handleCallback = async (data) => { |
| if (callbackProcessedRef.current) return; |
|
|
| const { code, state, error: callbackError, errorDescription } = data; |
|
|
| if (callbackError) { |
| callbackProcessedRef.current = true; |
| setError(errorDescription || callbackError); |
| setStep("error"); |
| return; |
| } |
|
|
| if (code) { |
| callbackProcessedRef.current = true; |
| await exchangeTokens(code, state); |
| } |
| }; |
|
|
| |
| const handleMessage = (event) => { |
| |
| |
| const currentPort = window.location.port; |
| const isLocalhostSamePort = |
| event.origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) && |
| new URL(event.origin).port === currentPort; |
| if (event.origin !== window.location.origin && !isLocalhostSamePort) return; |
| if (event.data?.type === "oauth_callback") { |
| handleCallback(event.data.data); |
| } |
| }; |
| window.addEventListener("message", handleMessage); |
|
|
| |
| let channel; |
| try { |
| channel = new BroadcastChannel("oauth_callback"); |
| channel.onmessage = (event) => handleCallback(event.data); |
| } catch (e) { |
| console.log("BroadcastChannel not supported"); |
| } |
|
|
| |
| 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); |
|
|
| |
| try { |
| const stored = localStorage.getItem("oauth_callback"); |
| if (stored) { |
| const data = JSON.parse(stored); |
| |
| if (data.timestamp && Date.now() - data.timestamp < 30000) { |
| handleCallback(data); |
| localStorage.removeItem("oauth_callback"); |
| } |
| } |
| } catch { |
| |
| } |
|
|
| return () => { |
| window.removeEventListener("message", handleMessage); |
| window.removeEventListener("storage", handleStorage); |
| if (channel) channel.close(); |
| }; |
| }, [authData, exchangeTokens]); |
|
|
| |
| |
| |
| 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); |
| |
| |
| if (step === "waiting") { |
| setStep("input"); |
| } |
| } |
| } catch { |
| |
| } |
| }, 1000); |
|
|
| |
| const safetyTimeout = setTimeout( |
| () => { |
| if (!callbackProcessedRef.current && step === "waiting") { |
| clearInterval(popupClosedInterval); |
| setStep("input"); |
| } |
| }, |
| 5 * 60 * 1000 |
| ); |
|
|
| return () => { |
| clearInterval(popupClosedInterval); |
| clearTimeout(safetyTimeout); |
| }; |
| }, [step, isDeviceCode]); |
|
|
| |
| const handleManualSubmit = async () => { |
| try { |
| setError(null); |
|
|
| 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 { |
| |
| 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={`Connect ${providerInfo.name}`} onClose={onClose} size="lg"> |
| <div className="flex flex-col gap-4"> |
| {/* 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">Waiting for Authorization</h3> |
| <p className="text-sm text-text-muted mb-2"> |
| Complete the authorization in the popup window. |
| </p> |
| <p className="text-xs text-text-muted mb-4 opacity-70"> |
| If the popup closes without redirecting back (e.g. Qoder), this dialog will |
| automatically switch to manual URL input mode. |
| </p> |
| <Button variant="ghost" onClick={() => setStep("input")}> |
| Popup blocked? Enter URL manually |
| </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"> |
| Visit the URL below and enter the code: |
| </p> |
| <div className="bg-sidebar p-4 rounded-lg mb-4"> |
| <p className="text-xs text-text-muted mb-1">Verification URL</p> |
| <div className="flex items-center gap-2"> |
| <code className="flex-1 text-sm break-all">{deviceData.verification_uri}</code> |
| <Button |
| size="sm" |
| variant="ghost" |
| icon={copied === "verify_url" ? "check" : "content_copy"} |
| onClick={() => copy(deviceData.verification_uri, "verify_url")} |
| /> |
| </div> |
| </div> |
| <div className="bg-primary/10 p-4 rounded-lg"> |
| <p className="text-xs text-text-muted mb-1">Your Code</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> |
| Waiting for authorization... |
| </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>Remote access + Google OAuth:</strong> The default credentials only accept |
| redirects to <code>localhost</code>. After authorizing, your browser will try to |
| open <code>localhost</code> — copy that full URL and paste it below. For fully |
| remote use without this manual step,{" "} |
| <a |
| href="https://github.com/diegosouzapw/OmniRoute#oauth-on-a-remote-server" |
| target="_blank" |
| rel="noreferrer" |
| className="underline" |
| > |
| configure your own OAuth credentials |
| </a> |
| . |
| </div> |
| )} |
| {/* Generic remote info for other providers */} |
| {!isTrueLocalhost && !GOOGLE_OAUTH_PROVIDERS.has(provider) && ( |
| <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> |
| <strong>Remote access:</strong> Since you're accessing OmniRoute remotely, |
| after authorizing you'll see an error page (localhost not found). That's |
| expected — just copy the full URL from your browser's address bar and paste |
| it below. |
| </div> |
| )} |
| <div> |
| <p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</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")} |
| > |
| Copy |
| </Button> |
| </div> |
| </div> |
| |
| <div> |
| <p className="text-sm font-medium mb-2"> |
| Step 2: Paste the callback URL or auth code here |
| </p> |
| <p className="text-xs text-text-muted mb-2"> |
| After authorization, paste the full callback URL. For Claude Code and Cline, you |
| can also paste the Authentication Code directly, for example{" "} |
| <code>code#state</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}> |
| Connect |
| </Button> |
| <Button onClick={onClose} variant="ghost" fullWidth> |
| Cancel |
| </Button> |
| </div> |
| </> |
| )} |
|
|
| {} |
| {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">Connected Successfully!</h3> |
| <p className="text-sm text-text-muted mb-4"> |
| Your {providerInfo.name} account has been connected. |
| </p> |
| <Button onClick={onClose} fullWidth> |
| Done |
| </Button> |
| </div> |
| )} |
|
|
| {} |
| {step === "error" && ( |
| <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">Connection Failed</h3> |
| <p className="text-sm text-red-600 mb-4">{error}</p> |
| <div className="flex gap-2"> |
| <Button onClick={startOAuthFlow} variant="secondary" fullWidth> |
| Try Again |
| </Button> |
| <Button onClick={onClose} variant="ghost" fullWidth> |
| Cancel |
| </Button> |
| </div> |
| </div> |
| )} |
| </div> |
| </Modal> |
| ); |
| } |
|
|
| OAuthModal.propTypes = { |
| isOpen: PropTypes.bool.isRequired, |
| provider: PropTypes.string, |
| providerInfo: PropTypes.shape({ |
| name: PropTypes.string, |
| }), |
| onSuccess: PropTypes.func, |
| onClose: PropTypes.func.isRequired, |
| }; |
|
|