import { useEffect, useRef, useState } from "react"; import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; /** * OAuthLoginModal — drives the in-browser OAuth flow for a single provider. * * Two variants share the same modal shell: * * - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes, * pastes the resulting code back. We POST it to /submit which exchanges * the (code + verifier) pair for tokens server-side. * * - Device code (Nous, OpenAI Codex): we display the verification URL * and short user code; the backend polls the provider's token endpoint * in a background thread; we poll /poll/{session_id} every 2s for status. * * Edge cases handled: * - Popup blocker (we use plain anchor href + open in new tab; no popup * window.open which is more likely to be blocked). * - Modal dismissal mid-flight cancels the server-side session via DELETE. * - Code expiry surfaces as a clear error state with retry button. * - Polling continues to work if the user backgrounds the tab (setInterval * keeps firing in modern browsers; we guard against polls firing after * component unmount via an isMounted ref). */ interface Props { provider: OAuthProvider; onClose: () => void; onSuccess: (msg: string) => void; onError: (msg: string) => void; } type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error"; export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) { const [phase, setPhase] = useState("starting"); const [start, setStart] = useState(null); const [pkceCode, setPkceCode] = useState(""); const [errorMsg, setErrorMsg] = useState(null); const [secondsLeft, setSecondsLeft] = useState(null); const [codeCopied, setCodeCopied] = useState(false); const isMounted = useRef(true); const pollTimer = useRef(null); // Initiate flow on mount useEffect(() => { isMounted.current = true; api .startOAuthLogin(provider.id) .then((resp) => { if (!isMounted.current) return; setStart(resp); setSecondsLeft(resp.expires_in); setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); if (resp.flow === "pkce") { // Auto-open the auth URL in a new tab window.open(resp.auth_url, "_blank", "noopener,noreferrer"); } else { // Device-code: open the verification URL automatically window.open(resp.verification_url, "_blank", "noopener,noreferrer"); } }) .catch((e) => { if (!isMounted.current) return; setPhase("error"); setErrorMsg(`Failed to start login: ${e}`); }); return () => { isMounted.current = false; if (pollTimer.current !== null) window.clearInterval(pollTimer.current); }; // We only want to start the flow once on mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Tick the countdown useEffect(() => { if (secondsLeft === null) return; if (phase === "approved" || phase === "error") return; const tick = window.setInterval(() => { if (!isMounted.current) return; setSecondsLeft((s) => { if (s !== null && s <= 1) { // Session expired — transition to error state setPhase("error"); setErrorMsg("Session expired. Click Retry to start a new login."); return 0; } return s !== null && s > 0 ? s - 1 : 0; }); }, 1000); return () => window.clearInterval(tick); }, [secondsLeft, phase]); // Device-code: poll backend every 2s useEffect(() => { if (!start || start.flow !== "device_code" || phase !== "polling") return; const sid = start.session_id; pollTimer.current = window.setInterval(async () => { try { const resp = await api.pollOAuthSession(provider.id, sid); if (!isMounted.current) return; if (resp.status === "approved") { setPhase("approved"); if (pollTimer.current !== null) window.clearInterval(pollTimer.current); onSuccess(`${provider.name} connected`); window.setTimeout(() => isMounted.current && onClose(), 1500); } else if (resp.status !== "pending") { setPhase("error"); setErrorMsg(resp.error_message || `Login ${resp.status}`); if (pollTimer.current !== null) window.clearInterval(pollTimer.current); } } catch (e) { // 404 = session expired/cleaned up; treat as error if (!isMounted.current) return; setPhase("error"); setErrorMsg(`Polling failed: ${e}`); if (pollTimer.current !== null) window.clearInterval(pollTimer.current); } }, 2000); return () => { if (pollTimer.current !== null) window.clearInterval(pollTimer.current); }; }, [start, phase, provider.id, provider.name, onSuccess, onClose]); const handleSubmitPkceCode = async () => { if (!start || start.flow !== "pkce") return; if (!pkceCode.trim()) return; setPhase("submitting"); setErrorMsg(null); try { const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim()); if (!isMounted.current) return; if (resp.ok && resp.status === "approved") { setPhase("approved"); onSuccess(`${provider.name} connected`); window.setTimeout(() => isMounted.current && onClose(), 1500); } else { setPhase("error"); setErrorMsg(resp.message || "Token exchange failed"); } } catch (e) { if (!isMounted.current) return; setPhase("error"); setErrorMsg(`Submit failed: ${e}`); } }; const handleClose = async () => { // Cancel server session if still in flight if (start && phase !== "approved" && phase !== "error") { try { await api.cancelOAuthSession(start.session_id); } catch { // ignore — server-side TTL will clean it up anyway } } onClose(); }; const handleCopyUserCode = async (code: string) => { try { await navigator.clipboard.writeText(code); setCodeCopied(true); window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500); } catch { onError("Clipboard write failed"); } }; // Backdrop click closes const handleBackdrop = (e: React.MouseEvent) => { if (e.target === e.currentTarget) handleClose(); }; const fmtTime = (s: number | null) => { if (s === null) return ""; const m = Math.floor(s / 60); const r = s % 60; return `${m}:${String(r).padStart(2, "0")}`; }; return (

Connect {provider.name}

{secondsLeft !== null && phase !== "approved" && phase !== "error" && (

Session expires in {fmtTime(secondsLeft)}

)}
{/* ── starting ───────────────────────────────────── */} {phase === "starting" && (
Initiating login flow…
)} {/* ── PKCE: paste code ───────────────────────────── */} {start?.flow === "pkce" && phase === "awaiting_user" && ( <>
  1. A new tab opened to claude.ai. Sign in and click Authorize.
  2. Copy the authorization code shown after authorizing.
  3. Paste it below and submit.
setPkceCode(e.target.value)} placeholder="Paste authorization code (with #state suffix is fine)" onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} autoFocus />
)} {/* ── PKCE: submitting exchange ──────────────────── */} {phase === "submitting" && (
Exchanging code for tokens…
)} {/* ── Device code: show code + URL, polling ──────── */} {start?.flow === "device_code" && phase === "polling" && ( <>

A new tab opened. Enter this code if prompted:

{(start as Extract).user_code}
).verification_url} target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" > Re-open verification page
Waiting for you to authorize in the browser…
)} {/* ── approved ───────────────────────────────────── */} {phase === "approved" && (
Connected! Closing…
)} {/* ── error ──────────────────────────────────────── */} {phase === "error" && ( <>
{errorMsg || "Login failed."}
)}
); }