/ web / src / components / OAuthLoginModal.tsx
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  }