import { useEffect, useRef, useState } from "react"; import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react"; import { H2 } from "@nous-research/ui"; import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useI18n } from "@/i18n"; 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); const { t } = useI18n(); // 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") { window.open(resp.auth_url, "_blank", "noopener,noreferrer"); } else { 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); }; // 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) { setPhase("error"); setErrorMsg(t.oauth.sessionExpired); return 0; } return s !== null && s > 0 ? s - 1 : 0; }); }, 1000); return () => window.clearInterval(tick); }, [secondsLeft, phase, t]); // 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) { 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 () => { if (start && phase !== "approved" && phase !== "error") { try { await api.cancelOAuthSession(start.session_id); } catch { // ignore } } 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"); } }; 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 (

{t.oauth.connect} {provider.name}

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

{t.oauth.sessionExpires.replace( "{time}", fmtTime(secondsLeft), )}

)}
{/* ── starting ───────────────────────────────────── */} {phase === "starting" && (
{t.oauth.initiatingLogin}
)} {/* ── PKCE: paste code ───────────────────────────── */} {start?.flow === "pkce" && phase === "awaiting_user" && ( <>
  1. {t.oauth.pkceStep1}
  2. {t.oauth.pkceStep2}
  3. {t.oauth.pkceStep3}
setPkceCode(e.target.value)} placeholder={t.oauth.pasteCode} onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} autoFocus />
)} {/* ── PKCE: submitting exchange ──────────────────── */} {phase === "submitting" && (
{t.oauth.exchangingCode}
)} {/* ── Device code: show code + URL, polling ──────── */} {start?.flow === "device_code" && phase === "polling" && ( <>

{t.oauth.enterCodePrompt}

{ ( start as Extract< OAuthStartResponse, { flow: "device_code" } > ).user_code }
).verification_url } target="_blank" rel="noopener noreferrer" className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" > {t.oauth.reOpenVerification}
{t.oauth.waitingAuth}
)} {/* ── approved ───────────────────────────────────── */} {phase === "approved" && (
{t.oauth.connectedClosing}
)} {/* ── error ──────────────────────────────────────── */} {phase === "error" && ( <>
{errorMsg || t.oauth.loginFailed}
)}
); }