OAuthLoginModal.tsx
1 import { useEffect, useRef, useState } from "react"; 2 import { ExternalLink, X, Check } from "lucide-react"; 3 import { Button } from "@nous-research/ui/ui/components/button"; 4 import { CopyButton } from "@nous-research/ui/ui/components/command-block"; 5 import { Spinner } from "@nous-research/ui/ui/components/spinner"; 6 import { H2 } from "@/components/NouiTypography"; 7 import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api"; 8 import { Input } from "@/components/ui/input"; 9 import { useI18n } from "@/i18n"; 10 11 interface Props { 12 provider: OAuthProvider; 13 onClose: () => void; 14 onSuccess: (msg: string) => void; 15 onError: (msg: string) => void; 16 } 17 18 type Phase = 19 | "idle" 20 | "starting" 21 | "awaiting_user" 22 | "submitting" 23 | "polling" 24 | "approved" 25 | "error"; 26 27 export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) { 28 const [phase, setPhase] = useState<Phase>("starting"); 29 const [start, setStart] = useState<OAuthStartResponse | null>(null); 30 const [pkceCode, setPkceCode] = useState(""); 31 const [errorMsg, setErrorMsg] = useState<string | null>(null); 32 const [secondsLeft, setSecondsLeft] = useState<number | null>(null); 33 const isMounted = useRef(true); 34 const pollTimer = useRef<number | null>(null); 35 const { t } = useI18n(); 36 37 // Initiate flow on mount 38 useEffect(() => { 39 isMounted.current = true; 40 api 41 .startOAuthLogin(provider.id) 42 .then((resp) => { 43 if (!isMounted.current) return; 44 setStart(resp); 45 setSecondsLeft(resp.expires_in); 46 setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user"); 47 if (resp.flow === "pkce") { 48 window.open(resp.auth_url, "_blank", "noopener,noreferrer"); 49 } else { 50 window.open(resp.verification_url, "_blank", "noopener,noreferrer"); 51 } 52 }) 53 .catch((e) => { 54 if (!isMounted.current) return; 55 setPhase("error"); 56 setErrorMsg(`Failed to start login: ${e}`); 57 }); 58 return () => { 59 isMounted.current = false; 60 if (pollTimer.current !== null) window.clearInterval(pollTimer.current); 61 }; 62 // eslint-disable-next-line react-hooks/exhaustive-deps 63 }, []); 64 65 // Tick the countdown 66 useEffect(() => { 67 if (secondsLeft === null) return; 68 if (phase === "approved" || phase === "error") return; 69 const tick = window.setInterval(() => { 70 if (!isMounted.current) return; 71 setSecondsLeft((s) => { 72 if (s !== null && s <= 1) { 73 setPhase("error"); 74 setErrorMsg(t.oauth.sessionExpired); 75 return 0; 76 } 77 return s !== null && s > 0 ? s - 1 : 0; 78 }); 79 }, 1000); 80 return () => window.clearInterval(tick); 81 }, [secondsLeft, phase, t]); 82 83 // Device-code: poll backend every 2s 84 useEffect(() => { 85 if (!start || start.flow !== "device_code" || phase !== "polling") return; 86 const sid = start.session_id; 87 pollTimer.current = window.setInterval(async () => { 88 try { 89 const resp = await api.pollOAuthSession(provider.id, sid); 90 if (!isMounted.current) return; 91 if (resp.status === "approved") { 92 setPhase("approved"); 93 if (pollTimer.current !== null) 94 window.clearInterval(pollTimer.current); 95 onSuccess(`${provider.name} connected`); 96 window.setTimeout(() => isMounted.current && onClose(), 1500); 97 } else if (resp.status !== "pending") { 98 setPhase("error"); 99 setErrorMsg(resp.error_message || `Login ${resp.status}`); 100 if (pollTimer.current !== null) 101 window.clearInterval(pollTimer.current); 102 } 103 } catch (e) { 104 if (!isMounted.current) return; 105 setPhase("error"); 106 setErrorMsg(`Polling failed: ${e}`); 107 if (pollTimer.current !== null) window.clearInterval(pollTimer.current); 108 } 109 }, 2000); 110 return () => { 111 if (pollTimer.current !== null) window.clearInterval(pollTimer.current); 112 }; 113 }, [start, phase, provider.id, provider.name, onSuccess, onClose]); 114 115 const handleSubmitPkceCode = async () => { 116 if (!start || start.flow !== "pkce") return; 117 if (!pkceCode.trim()) return; 118 setPhase("submitting"); 119 setErrorMsg(null); 120 try { 121 const resp = await api.submitOAuthCode( 122 provider.id, 123 start.session_id, 124 pkceCode.trim(), 125 ); 126 if (!isMounted.current) return; 127 if (resp.ok && resp.status === "approved") { 128 setPhase("approved"); 129 onSuccess(`${provider.name} connected`); 130 window.setTimeout(() => isMounted.current && onClose(), 1500); 131 } else { 132 setPhase("error"); 133 setErrorMsg(resp.message || "Token exchange failed"); 134 } 135 } catch (e) { 136 if (!isMounted.current) return; 137 setPhase("error"); 138 setErrorMsg(`Submit failed: ${e}`); 139 } 140 }; 141 142 const handleClose = async () => { 143 if (start && phase !== "approved" && phase !== "error") { 144 try { 145 await api.cancelOAuthSession(start.session_id); 146 } catch { 147 // ignore 148 } 149 } 150 onClose(); 151 }; 152 153 const handleBackdrop = (e: React.MouseEvent) => { 154 if (e.target === e.currentTarget) handleClose(); 155 }; 156 157 const fmtTime = (s: number | null) => { 158 if (s === null) return ""; 159 const m = Math.floor(s / 60); 160 const r = s % 60; 161 return `${m}:${String(r).padStart(2, "0")}`; 162 }; 163 164 return ( 165 <div 166 className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4" 167 onClick={handleBackdrop} 168 role="dialog" 169 aria-modal="true" 170 aria-labelledby="oauth-modal-title" 171 > 172 <div className="relative w-full max-w-md border border-border bg-card shadow-2xl"> 173 <Button 174 ghost 175 size="icon" 176 onClick={handleClose} 177 className="absolute right-2 top-2 text-muted-foreground hover:text-foreground" 178 aria-label={t.common.close} 179 > 180 <X /> 181 </Button> 182 <div className="p-6 flex flex-col gap-4"> 183 <div> 184 <H2 185 id="oauth-modal-title" 186 variant="sm" 187 mondwest 188 className="tracking-wider uppercase" 189 > 190 {t.oauth.connect} {provider.name} 191 </H2> 192 {secondsLeft !== null && 193 phase !== "approved" && 194 phase !== "error" && ( 195 <p className="text-xs text-muted-foreground mt-1"> 196 {t.oauth.sessionExpires.replace( 197 "{time}", 198 fmtTime(secondsLeft), 199 )} 200 </p> 201 )} 202 </div> 203 204 {phase === "starting" && ( 205 <div className="flex items-center gap-3 py-6 text-sm text-muted-foreground"> 206 <Spinner /> 207 {t.oauth.initiatingLogin} 208 </div> 209 )} 210 211 {start?.flow === "pkce" && phase === "awaiting_user" && ( 212 <> 213 <ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground"> 214 <li>{t.oauth.pkceStep1}</li> 215 <li>{t.oauth.pkceStep2}</li> 216 <li>{t.oauth.pkceStep3}</li> 217 </ol> 218 <div className="flex flex-col gap-2"> 219 <Input 220 value={pkceCode} 221 onChange={(e) => setPkceCode(e.target.value)} 222 placeholder={t.oauth.pasteCode} 223 onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()} 224 autoFocus 225 /> 226 <div className="flex items-center gap-2 justify-between"> 227 <a 228 href={ 229 (start as Extract<OAuthStartResponse, { flow: "pkce" }>) 230 .auth_url 231 } 232 target="_blank" 233 rel="noopener noreferrer" 234 className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" 235 > 236 <ExternalLink className="h-3 w-3" /> 237 {t.oauth.reOpenAuth} 238 </a> 239 <Button 240 onClick={handleSubmitPkceCode} 241 disabled={!pkceCode.trim()} 242 > 243 {t.oauth.submitCode} 244 </Button> 245 </div> 246 </div> 247 </> 248 )} 249 250 {phase === "submitting" && ( 251 <div className="flex items-center gap-3 py-6 text-sm text-muted-foreground"> 252 <Spinner /> 253 {t.oauth.exchangingCode} 254 </div> 255 )} 256 257 {start?.flow === "device_code" && phase === "polling" && ( 258 <> 259 <p className="text-sm text-muted-foreground"> 260 {t.oauth.enterCodePrompt} 261 </p> 262 <div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4"> 263 <code className="font-mono-ui text-2xl tracking-widest text-foreground"> 264 { 265 ( 266 start as Extract< 267 OAuthStartResponse, 268 { flow: "device_code" } 269 > 270 ).user_code 271 } 272 </code> 273 <CopyButton 274 text={ 275 ( 276 start as Extract< 277 OAuthStartResponse, 278 { flow: "device_code" } 279 > 280 ).user_code 281 } 282 /> 283 </div> 284 <a 285 href={ 286 ( 287 start as Extract< 288 OAuthStartResponse, 289 { flow: "device_code" } 290 > 291 ).verification_url 292 } 293 target="_blank" 294 rel="noopener noreferrer" 295 className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1" 296 > 297 <ExternalLink className="h-3 w-3" /> 298 {t.oauth.reOpenVerification} 299 </a> 300 <div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3"> 301 <Spinner className="text-xs" /> 302 {t.oauth.waitingAuth} 303 </div> 304 </> 305 )} 306 307 {phase === "approved" && ( 308 <div className="flex items-center gap-3 py-6 text-sm text-success"> 309 <Check className="h-5 w-5" /> 310 {t.oauth.connectedClosing} 311 </div> 312 )} 313 314 {phase === "error" && ( 315 <> 316 <div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> 317 {errorMsg || t.oauth.loginFailed} 318 </div> 319 <div className="flex justify-end gap-2"> 320 <Button outlined onClick={handleClose}> 321 {t.common.close} 322 </Button> 323 <Button 324 onClick={() => { 325 if (start?.session_id) { 326 api.cancelOAuthSession(start.session_id).catch(() => {}); 327 } 328 setErrorMsg(null); 329 setStart(null); 330 setPkceCode(""); 331 setPhase("starting"); 332 api 333 .startOAuthLogin(provider.id) 334 .then((resp) => { 335 if (!isMounted.current) return; 336 setStart(resp); 337 setSecondsLeft(resp.expires_in); 338 setPhase( 339 resp.flow === "device_code" 340 ? "polling" 341 : "awaiting_user", 342 ); 343 if (resp.flow === "pkce") { 344 window.open( 345 resp.auth_url, 346 "_blank", 347 "noopener,noreferrer", 348 ); 349 } else { 350 window.open( 351 resp.verification_url, 352 "_blank", 353 "noopener,noreferrer", 354 ); 355 } 356 }) 357 .catch((e) => { 358 if (!isMounted.current) return; 359 setPhase("error"); 360 setErrorMsg(`${t.common.retry} failed: ${e}`); 361 }); 362 }} 363 > 364 {t.common.retry} 365 </Button> 366 </div> 367 </> 368 )} 369 </div> 370 </div> 371 </div> 372 ); 373 }