| "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, |
| idcConfig, |
| }: any) { |
| 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 [authMethod, setAuthMethod] = useState(null); |
| const [cookieValue, setCookieValue] = useState(""); |
| const [cookieAuthenticating, setCookieAuthenticating] = 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); |
|
|
| |
| 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, |
| }), |
| }); |
|
|
| 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 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 === "iflow" && !authMethod) { |
| setStep("method-select"); |
| return; |
| } |
|
|
| |
| 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) throw new Error(data.error); |
|
|
| 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; |
| } |
|
|
| |
| |
| 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"); |
| window.open(serverData.authUrl, "oauth_auth"); |
|
|
| 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) { |
| setPolling(false); |
| setStep("input"); |
| setError(codexErr.message + " — You can paste the callback URL manually below."); |
| } |
| return; |
| } |
| |
| } |
|
|
| |
| |
| |
| |
| |
| let redirectUri: string; |
| if (provider === "codex" || provider === "openai") { |
| redirectUri = "http://localhost:1455/auth/callback"; |
| } else if (provider === "iflow") { |
| redirectUri = "http://localhost:8080/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) throw new Error(data.error); |
|
|
| setAuthData({ ...data, redirectUri }); |
|
|
| |
| |
| if (provider === "iflow" || !isLocalhost) { |
| 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, startPolling, onSuccess, authMethod]); |
|
|
| |
| const authenticateWithCookie = useCallback(async () => { |
| if (!cookieValue.trim()) { |
| setError("Please enter your cookie"); |
| return; |
| } |
|
|
| setCookieAuthenticating(true); |
| setError(null); |
|
|
| try { |
| const res = await fetch("/api/oauth/iflow/cookie", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ cookie: cookieValue }), |
| }); |
|
|
| const data = await res.json(); |
|
|
| if (!res.ok) { |
| throw new Error(data.error || "Cookie authentication failed"); |
| } |
|
|
| setStep("success"); |
| onSuccess?.(); |
| } catch (err) { |
| setError(err.message); |
| setStep("error"); |
| } finally { |
| setCookieAuthenticating(false); |
| } |
| }, [cookieValue, 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); |
| setAuthMethod(null); |
| setCookieValue(""); |
| setCookieAuthenticating(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]); |
|
|
| |
| const handleManualSubmit = async () => { |
| try { |
| setError(null); |
|
|
| let code, state; |
| const input = callbackUrl.trim(); |
|
|
| |
| if (input.includes("://") || input.startsWith("http")) { |
| |
| const url = new URL(input); |
| code = url.searchParams.get("code"); |
| 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("No authorization code found in URL"); |
| } |
| } else { |
| |
| code = input; |
| state = authData?.state || null; |
| } |
|
|
| 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"> |
| {/* Method Selection for iFlow */} |
| {step === "method-select" && provider === "iflow" && ( |
| <> |
| <div className="space-y-4"> |
| <p className="text-sm text-text-muted mb-4"> |
| Choose how you want to connect to iFlow: |
| </p> |
| |
| <button |
| onClick={() => { |
| setAuthMethod("oauth"); |
| startOAuthFlow(); |
| }} |
| className="w-full p-4 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-all text-left" |
| > |
| <div className="flex items-start gap-3"> |
| <span className="material-symbols-outlined text-2xl text-primary">key</span> |
| <div> |
| <h4 className="font-semibold mb-1">OAuth Authentication</h4> |
| <p className="text-xs text-text-muted"> |
| Standard OAuth flow with browser authorization. Requires callback server. |
| </p> |
| </div> |
| </div> |
| </button> |
| |
| <button |
| onClick={() => { |
| setAuthMethod("cookie"); |
| setStep("cookie-input"); |
| }} |
| className="w-full p-4 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-all text-left" |
| > |
| <div className="flex items-start gap-3"> |
| <span className="material-symbols-outlined text-2xl text-green-500">cookie</span> |
| <div> |
| <h4 className="font-semibold mb-1">Cookie Authentication</h4> |
| <p className="text-xs text-text-muted"> |
| Simpler method using browser cookies. Works on HF Spaces and restricted |
| platforms. |
| </p> |
| <p className="text-xs text-green-500 mt-1">✓ Recommended for HF Spaces</p> |
| </div> |
| </div> |
| </button> |
| </div> |
| |
| <Button onClick={onClose} variant="ghost" fullWidth> |
| Cancel |
| </Button> |
| </> |
| )} |
| |
| {/* Cookie Input for iFlow */} |
| {step === "cookie-input" && provider === "iflow" && ( |
| <> |
| <div className="space-y-4"> |
| <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>How to get your cookie:</strong> |
| <ol className="mt-2 ml-4 space-y-1 list-decimal"> |
| <li>Go to https://platform.iflow.cn/ and log in</li> |
| <li>Press F12 to open DevTools → Application tab</li> |
| <li>Find Cookies → https://platform.iflow.cn</li> |
| <li> |
| Copy the <code className="bg-black/30 px-1 rounded">BXAuth</code> value |
| </li> |
| </ol> |
| </div> |
| |
| <div> |
| <label className="text-sm font-medium mb-2 block"> |
| Paste your cookie (BXAuth value) |
| </label> |
| <textarea |
| value={cookieValue} |
| onChange={(e) => setCookieValue(e.target.value)} |
| placeholder="BXAuth=your-cookie-value-here;" |
| className="w-full px-3 py-2 rounded-lg border border-border bg-surface text-text-main font-mono text-xs resize-none" |
| rows={4} |
| /> |
| <p className="text-xs text-text-muted mt-1"> |
| You can paste the entire cookie string or just the BXAuth value |
| </p> |
| </div> |
| |
| {error && ( |
| <div className="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200"> |
| <span className="material-symbols-outlined text-sm align-middle mr-1">error</span> |
| {error} |
| </div> |
| )} |
| </div> |
| |
| <div className="flex gap-2"> |
| <Button |
| onClick={authenticateWithCookie} |
| fullWidth |
| disabled={!cookieValue.trim() || cookieAuthenticating} |
| loading={cookieAuthenticating} |
| > |
| Connect with Cookie |
| </Button> |
| <Button |
| onClick={() => { |
| setStep("method-select"); |
| setError(null); |
| }} |
| variant="ghost" |
| fullWidth |
| > |
| Back |
| </Button> |
| </div> |
| </> |
| )} |
|
|
| {} |
| {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-4"> |
| Complete the authorization in the popup window. |
| </p> |
| <Button variant="ghost" onClick={() => setStep("input")}> |
| Popup blocked? Enter URL manually |
| </Button> |
| </div> |
| )} |
|
|
| {} |
| {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> |
| )} |
| </> |
| )} |
|
|
| {} |
| {step === "input" && !isDeviceCode && ( |
| <> |
| <div className="space-y-4"> |
| {!isLocalhost && ( |
| <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 {provider === "iflow" ? "authorization code" : "callback URL"}{" "} |
| here |
| </p> |
| {provider === "iflow" ? ( |
| <p className="text-xs text-text-muted mb-2"> |
| After authorization, iFlow will display a code on their page. Copy and paste it |
| here. You can paste either the full URL or just the code itself. |
| </p> |
| ) : ( |
| <p className="text-xs text-text-muted mb-2"> |
| After authorization, copy the full URL from your browser. |
| </p> |
| )} |
| <Input |
| value={callbackUrl} |
| onChange={(e) => setCallbackUrl(e.target.value)} |
| placeholder={ |
| provider === "iflow" |
| ? "3db665bf58654ad782a7163343f3f512 or full URL" |
| : placeholderUrl |
| } |
| className="font-mono text-xs" |
| /> |
| </div> |
| </div> |
| |
| <div className="flex gap-2"> |
| <Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}> |
| 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> |
| |
| {/* Suggest cookie auth for iFlow OAuth failures */} |
| {provider === "iflow" && authMethod === "oauth" && ( |
| <div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-xs text-yellow-200 mb-4 text-left"> |
| <span className="material-symbols-outlined text-sm align-middle mr-1"> |
| lightbulb |
| </span> |
| <strong>Alternative:</strong> Try cookie authentication instead. It's simpler |
| and works on restricted platforms. |
| </div> |
| )} |
| |
| <div className="flex gap-2"> |
| {provider === "iflow" && authMethod === "oauth" && ( |
| <Button |
| onClick={() => { |
| setAuthMethod("cookie"); |
| setStep("cookie-input"); |
| setError(null); |
| }} |
| variant="secondary" |
| fullWidth |
| > |
| Try Cookie Auth |
| </Button> |
| )} |
| <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, |
| }; |
|
|