| "use client"; |
|
|
| import { useState, useEffect, useRef, useCallback } from "react"; |
| import PropTypes from "prop-types"; |
| import { Modal, Button, Input } from "@/shared/components"; |
| import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; |
|
|
| |
| |
| |
| |
| |
| export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, oauthMeta, idcConfig }) { |
| 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 pollingAbortRef = useRef(false); |
| const openedRef = useRef(false); |
| const { copied, copy } = useCopyToClipboard(); |
|
|
| |
| const [isLocalhost, setIsLocalhost] = useState(false); |
| const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=..."); |
| const callbackProcessedRef = useRef(false); |
|
|
| |
| useEffect(() => { |
| if (typeof window !== "undefined") { |
| setIsLocalhost( |
| window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1" |
| ); |
| setPlaceholderUrl(`${window.location.origin}/callback?code=...`); |
| } |
| }, []); |
|
|
| |
|
|
| |
| const exchangeTokens = useCallback(async (code, state) => { |
| if (!authData) return; |
| try { |
| 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, |
| state, |
| ...(oauthMeta ? { meta: oauthMeta } : {}), |
| }), |
| }); |
|
|
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error); |
|
|
| setStep("success"); |
| onSuccess?.(); |
| } catch (err) { |
| setError(err.message); |
| setStep("error"); |
| } |
| }, [authData, provider, onSuccess]); |
|
|
| const completeXaiManualCode = useCallback(async (code) => { |
| if (!authData?.state) return; |
| try { |
| const res = await fetch("/api/oauth/xai/manual-code", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ code, state: authData.state }), |
| }); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error); |
|
|
| setStep("success"); |
| onSuccess?.(); |
| } catch (err) { |
| setError(err.message); |
| setStep("error"); |
| } |
| }, [authData, onSuccess]); |
|
|
| |
| const startPolling = useCallback(async (deviceCode, codeVerifier, interval, extraData, deadlineMs) => { |
| pollingAbortRef.current = false; |
| setPolling(true); |
| |
| |
| |
| const startedAt = Date.now(); |
| const deadline = startedAt + (Number.isFinite(deadlineMs) && deadlineMs > 0 ? deadlineMs : 120_000); |
|
|
| while (Date.now() < deadline) { |
| |
| if (pollingAbortRef.current) { |
| console.log("[OAuthModal] Polling aborted"); |
| setPolling(false); |
| return; |
| } |
|
|
| await new Promise((r) => setTimeout(r, interval * 1000)); |
|
|
| |
| if (pollingAbortRef.current) { |
| console.log("[OAuthModal] Polling aborted after sleep"); |
| setPolling(false); |
| return; |
| } |
|
|
| 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) { |
| pollingAbortRef.current = true; |
| 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); |
|
|
| |
| const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy", "qoder"]; |
| if (deviceCodeProviders.includes(provider)) { |
| setIsDeviceCode(true); |
| setStep("waiting"); |
|
|
| const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin); |
| if (provider === "kiro" && idcConfig?.startUrl) { |
| deviceCodeUrl.searchParams.set("start_url", idcConfig.startUrl); |
| if (idcConfig.region) { |
| deviceCodeUrl.searchParams.set("region", idcConfig.region); |
| } |
| deviceCodeUrl.searchParams.set("auth_method", "idc"); |
| } |
| const res = await fetch(deviceCodeUrl.toString()); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error); |
|
|
| setDeviceData(data); |
|
|
| |
| const verifyUrl = data.verification_uri_complete || data.verification_uri; |
| if (verifyUrl) window.open(verifyUrl, "_blank", "noopener,noreferrer"); |
|
|
| |
| |
| |
| const extraData = provider === "kiro" |
| ? { |
| _clientId: data._clientId, |
| _clientSecret: data._clientSecret, |
| _region: data._region, |
| _authMethod: data._authMethod, |
| _startUrl: data._startUrl, |
| } |
| : provider === "qoder" |
| ? { |
| _qoderNonce: data._qoderNonce, |
| _qoderMachineId: data._qoderMachineId, |
| _qoderVerifier: data.codeVerifier, |
| } |
| : null; |
| startPolling( |
| data.device_code, |
| data.codeVerifier, |
| data.interval || 5, |
| extraData, |
| |
| |
| Number.isFinite(data.expires_in) && data.expires_in > 0 |
| ? data.expires_in * 1000 |
| : undefined, |
| ); |
| return; |
| } |
|
|
| |
| const appPort = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); |
| let redirectUri; |
| if (provider === "codex") { |
| redirectUri = "http://localhost:1455/auth/callback"; |
| } else if (provider === "xai") { |
| redirectUri = "http://127.0.0.1:56121/callback"; |
| } else { |
| redirectUri = `http://localhost:${appPort}/callback`; |
| } |
|
|
| |
| const authorizeUrl = new URL(`/api/oauth/${provider}/authorize`, window.location.origin); |
| authorizeUrl.searchParams.set("redirect_uri", redirectUri); |
| if (oauthMeta) { |
| Object.entries(oauthMeta).forEach(([k, v]) => { if (v) authorizeUrl.searchParams.set(k, v); }); |
| } |
| const res = await fetch(authorizeUrl.toString()); |
| const data = await res.json(); |
| if (!res.ok) throw new Error(data.error); |
|
|
| |
| let codexProxyActive = false; |
| let codexServerSide = false; |
| if (provider === "codex") { |
| try { |
| const proxyUrl = new URL(`/api/oauth/codex/start-proxy`, window.location.origin); |
| proxyUrl.searchParams.set("app_port", appPort); |
| proxyUrl.searchParams.set("state", data.state); |
| proxyUrl.searchParams.set("code_verifier", data.codeVerifier); |
| proxyUrl.searchParams.set("redirect_uri", redirectUri); |
| const proxyRes = await fetch(proxyUrl.toString()); |
| const proxyData = await proxyRes.json(); |
| codexProxyActive = proxyData.success; |
| codexServerSide = !!proxyData.serverSide; |
| } catch { |
| codexProxyActive = false; |
| } |
| } |
|
|
| |
| let xaiProxyActive = false; |
| let xaiServerSide = false; |
| if (provider === "xai") { |
| try { |
| const proxyUrl = new URL(`/api/oauth/xai/start-proxy`, window.location.origin); |
| proxyUrl.searchParams.set("app_port", appPort); |
| proxyUrl.searchParams.set("state", data.state); |
| proxyUrl.searchParams.set("code_verifier", data.codeVerifier); |
| proxyUrl.searchParams.set("redirect_uri", redirectUri); |
| const proxyRes = await fetch(proxyUrl.toString()); |
| const proxyData = await proxyRes.json(); |
| xaiProxyActive = proxyData.success; |
| xaiServerSide = !!proxyData.serverSide; |
| if (!xaiProxyActive && proxyData.reason === "port_busy") { |
| throw new Error("Port 56121 in use; close the conflicting process and retry"); |
| } |
| } catch (e) { |
| if (e?.message) throw e; |
| xaiProxyActive = false; |
| } |
| } |
|
|
| setAuthData({ ...data, redirectUri, codexServerSide, xaiServerSide }); |
|
|
| if (provider === "codex" && codexProxyActive) { |
| |
| setStep("waiting"); |
| popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); |
| if (!popupRef.current) { |
| setStep("input"); |
| } |
| } else if (provider === "xai" && xaiProxyActive) { |
| setStep("waiting"); |
| popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); |
| if (!popupRef.current) { |
| setStep("input"); |
| } |
| } else if (!isLocalhost || provider === "codex" || provider === "xai") { |
| |
| setStep("input"); |
| window.open(data.authUrl, "_blank"); |
| } 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, startPolling, oauthMeta, idcConfig]); |
|
|
| |
| useEffect(() => { |
| if (isOpen && provider) { |
| |
| if (openedRef.current) return; |
| openedRef.current = true; |
| setAuthData(null); |
| setCallbackUrl(""); |
| setError(null); |
| setIsDeviceCode(false); |
| setDeviceData(null); |
| setPolling(false); |
| pollingAbortRef.current = false; |
| startOAuthFlow(); |
| } else if (!isOpen) { |
| |
| pollingAbortRef.current = true; |
| openedRef.current = false; |
| if (provider === "codex") { |
| fetch("/api/oauth/codex/stop-proxy").catch(() => {}); |
| } else if (provider === "xai") { |
| fetch("/api/oauth/xai/stop-proxy").catch(() => {}); |
| } |
| } |
| }, [isOpen, provider, startOAuthFlow]); |
|
|
| |
| useEffect(() => { |
| const pollProvider = authData?.codexServerSide ? "codex" : authData?.xaiServerSide ? "xai" : null; |
| if (!pollProvider || !authData?.state) return; |
| if (callbackProcessedRef.current) return; |
| let cancelled = false; |
| const POLL_INTERVAL_MS = 1500; |
| const MAX_ATTEMPTS = 200; |
| let attempts = 0; |
|
|
| const tick = async () => { |
| if (cancelled || callbackProcessedRef.current) return; |
| attempts += 1; |
| try { |
| const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`); |
| const data = await res.json(); |
| if (cancelled || callbackProcessedRef.current) return; |
| if (data.status === "done") { |
| callbackProcessedRef.current = true; |
| setStep("success"); |
| onSuccess?.(); |
| return; |
| } |
| if (data.status === "error") { |
| callbackProcessedRef.current = true; |
| setError(data.error || "Authentication failed"); |
| setStep("error"); |
| return; |
| } |
| } catch { |
| |
| } |
| if (attempts >= MAX_ATTEMPTS) { |
| callbackProcessedRef.current = true; |
| setError("Authentication timeout"); |
| setStep("error"); |
| return; |
| } |
| setTimeout(tick, POLL_INTERVAL_MS); |
| }; |
| setTimeout(tick, POLL_INTERVAL_MS); |
| return () => { cancelled = true; }; |
| }, [authData, onSuccess]); |
|
|
| |
| 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 isLocalhost = event.origin.includes("localhost") || event.origin.includes("127.0.0.1"); |
| const isSameOrigin = event.origin === window.location.origin; |
| if (!isLocalhost && !isSameOrigin) 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]); |
|
|
| |
| const handleManualSubmit = async () => { |
| try { |
| setError(null); |
|
|
| const input = callbackUrl.trim(); |
|
|
| |
| if (input.startsWith("eyJ") && input.includes(".")) { |
| await exchangeTokens(input, null); |
| return; |
| } |
|
|
| if (provider === "xai" && input && !input.includes("://") && !input.includes("?") && !input.includes("code=")) { |
| await completeXaiManualCode(input); |
| return; |
| } |
|
|
| const url = new URL(input); |
| const code = url.searchParams.get("code"); |
| const state = url.searchParams.get("state"); |
| const errorParam = url.searchParams.get("error"); |
|
|
| if (errorParam) { |
| throw new Error(url.searchParams.get("error_description") || errorParam); |
| } |
|
|
| if (!code) { |
| throw new Error(provider === "xai" ? "Paste the callback URL or copied xAI code" : "No authorization code found in URL"); |
| } |
|
|
| await exchangeTokens(code, state); |
| } catch (err) { |
| setError(err.message); |
| setStep("error"); |
| } |
| }; |
|
|
| |
| const handleClose = useCallback(() => { |
| if (provider === "codex") { |
| fetch("/api/oauth/codex/stop-proxy").catch(() => {}); |
| } else if (provider === "xai") { |
| fetch("/api/oauth/xai/stop-proxy").catch(() => {}); |
| } |
| onClose(); |
| }, [onClose, provider]); |
|
|
| if (!provider || !providerInfo) return null; |
| const isXaiProvider = provider === "xai"; |
| const deviceLoginUrl = deviceData?.verification_uri_complete || deviceData?.verification_uri || ""; |
| const modalTitle = isXaiProvider ? "Connect Grok Build OAuth" : `Connect ${providerInfo.name}`; |
| const manualPlaceholder = isXaiProvider |
| ? "http://127.0.0.1:56121/callback?code=... or copied code" |
| : placeholderUrl; |
|
|
| return ( |
| <Modal isOpen={isOpen} title={modalTitle} onClose={handleClose} size="lg"> |
| <div className="flex flex-col gap-4"> |
| {/* Waiting + Manual Input combined (non-device-code) */} |
| {(step === "waiting" || step === "input") && !isDeviceCode && ( |
| <> |
| {/* Option A: Auto via popup */} |
| <div className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg bg-sidebar/50"> |
| <span className="material-symbols-outlined text-base text-primary animate-spin"> |
| progress_activity |
| </span> |
| <span className="text-sm"> |
| {isXaiProvider ? "Waiting for Grok Build OAuth…" : "Waiting for popup authorization…"} |
| </span> |
| </div> |
| |
| {/* Divider */} |
| <div className="flex items-center gap-3 my-1"> |
| <div className="flex-1 h-px bg-border" /> |
| <span className="text-xs text-text-muted uppercase tracking-wider">Or paste callback URL manually</span> |
| <div className="flex-1 h-px bg-border" /> |
| </div> |
| |
| {/* Option B: Manual paste */} |
| <div className="space-y-4"> |
| <div> |
| <p className="text-sm font-medium mb-2"> |
| Step 1: Open this {isXaiProvider ? "Grok Build OAuth URL" : "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")} disabled={!authData?.authUrl}> |
| Copy |
| </Button> |
| </div> |
| </div> |
| |
| <div> |
| <p className="text-sm font-medium mb-2"> |
| Step 2: Paste the {provider === "xai" ? "callback URL or copied code" : "callback URL"} here |
| </p> |
| <p className="text-xs text-text-muted mb-2"> |
| {provider === "xai" |
| ? "If xAI shows a code instead of redirecting, paste that code here." |
| : "After authorization, copy the full URL from your browser."} |
| </p> |
| <Input |
| value={callbackUrl} |
| onChange={(e) => setCallbackUrl(e.target.value)} |
| placeholder={manualPlaceholder} |
| className="font-mono text-xs" |
| /> |
| </div> |
| </div> |
| |
| <div className="flex gap-2"> |
| <Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}> |
| Connect |
| </Button> |
| <Button onClick={handleClose} variant="ghost" fullWidth> |
| Cancel |
| </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 login URL below and authorize: |
| </p> |
| <div className="bg-sidebar p-4 rounded-lg mb-4"> |
| <p className="text-xs text-text-muted mb-1">Login URL</p> |
| <div className="flex items-center gap-2"> |
| <code className="flex-1 text-sm break-all">{deviceLoginUrl}</code> |
| <Button |
| size="sm" |
| variant="ghost" |
| icon={copied === "login_url" ? "check" : "content_copy"} |
| onClick={() => copy(deviceLoginUrl, "login_url")} |
| disabled={!deviceLoginUrl} |
| /> |
| <Button |
| size="sm" |
| variant="ghost" |
| icon="open_in_new" |
| onClick={() => window.open(deviceLoginUrl, "_blank", "noopener,noreferrer")} |
| disabled={!deviceLoginUrl} |
| > |
| Open |
| </Button> |
| </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> |
| )} |
| </> |
| )} |
|
|
| {} |
| {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={handleClose} 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={handleClose} 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, |
| |
| oauthMeta: PropTypes.object, |
| |
| idcConfig: PropTypes.shape({ |
| startUrl: PropTypes.string, |
| region: PropTypes.string, |
| }), |
| }; |
|
|