import { useEffect, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Spinner } from "../components/ui/Spinner"; import { AlertIcon, CheckIcon } from "../components/ui/Icons"; import { exchangeOrcidCode } from "../services/api"; import { useAuth } from "../contexts/AuthContext"; import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; /** * OAuth callback page — mounted at /callback. * * ORCID redirects here after the user authenticates. We extract the * authorization `code`, exchange it for a JWT via the backend, store * the token and — if running inside a popup — notify the opener and * close the window. Otherwise we navigate back to the landing page. * * For this page to be reached, the backend's ORCID_REDIRECT_URI env var * must be set to /callback, e.g.: * ORCID_REDIRECT_URI=http://localhost:5173/callback */ export function AuthCallbackPage() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { storeToken } = useAuth(); const [status, setStatus] = useState("loading"); // loading | success | error const [errorMsg, setErrorMsg] = useState(""); const hasHandledCodeRef = useRef(false); useEffect(() => { // React StrictMode may remount components in development. OAuth codes // are single-use, so a second exchange attempt triggers backend errors. // This in-memory guard handles duplicate effect runs in same mount. if (hasHandledCodeRef.current) return; hasHandledCodeRef.current = true; const code = searchParams.get("code"); const oauthError = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); // User denied access at ORCID if (oauthError) { const msg = errorDescription ?? (oauthError === "access_denied" ? "Acceso denegado en ORCID." : `Error OAuth: ${oauthError}`); setStatus("error"); setErrorMsg(msg); notifyAndClose({ type: AUTH_ERROR_TYPE, error: msg }); return; } if (!code) { const msg = "No se recibió el código de autorización de ORCID."; setStatus("error"); setErrorMsg(msg); notifyAndClose({ type: AUTH_ERROR_TYPE, error: msg }); return; } // Persistent dedupe across remounts/reloads in the popup. const consumedKey = `orcid_oauth_code_consumed:${code}`; if (sessionStorage.getItem(consumedKey) === "1") { setStatus("success"); notifyAndClose({ type: AUTH_MESSAGE_TYPE }); return; } sessionStorage.setItem(consumedKey, "1"); exchangeOrcidCode(code) .then(({ access_token }) => { storeToken(access_token); setStatus("success"); notifyAndClose({ type: AUTH_MESSAGE_TYPE, token: access_token }); }) .catch((err) => { // Allow re-trying if the first attempt failed before code exchange // actually happened on the backend (network cut, popup close, etc.). sessionStorage.removeItem(consumedKey); const msg = err?.message ?? "No se pudo completar el inicio de sesión."; setStatus("error"); setErrorMsg(msg); notifyAndClose({ type: AUTH_ERROR_TYPE, error: msg }); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // After a short delay, redirect to home if we're NOT in a popup // (fallback for browsers that block window.open). useEffect(() => { if (status === "success" || status === "error") { const isPopup = Boolean(window.opener); if (!isPopup) { const timer = setTimeout(() => navigate("/"), 2000); return () => clearTimeout(timer); } } }, [status, navigate]); return (
{status === "loading" && ( <>

Completando inicio de sesión...

Verificando credenciales con ORCID.

)} {status === "success" && ( <>

¡Sesión iniciada correctamente!

Cerrando ventana...

)} {status === "error" && ( <>

Error al iniciar sesión

{errorMsg}

Cerrando ventana...

)}
); } /* ─────────────────────────── Helpers ───────────────────────────── */ /** * If running in a popup, posts a message to the opener and closes the * window. If not in a popup (e.g. browser blocked it), the message is * irrelevant — the useEffect above handles the redirect to "/". */ function notifyAndClose(message) { if (window.opener && !window.opener.closed) { try { window.opener.postMessage(message, window.location.origin); } catch { /* opener may have navigated away */ } // Small delay so the user sees the success/error state before close. setTimeout(() => window.close(), 1200); } } export default AuthCallbackPage;