From 104070159a9594e0c01b2f86a1a2c0c129977f3a Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 7 May 2026 12:25:02 +0200 Subject: [PATCH] fix: update ORCID_REDIRECT_URI and enhance OAuth callback handling - Changed ORCID_REDIRECT_URI in docker-compose for updated ngrok URL. - Allowed all hosts in vite.config.js to support HTTPS tunnels during OAuth flows. - Improved handling of OAuth codes in AuthCallbackPage to prevent duplicate exchanges. - Added function to fetch ORCID display names to enrich researcher data in API service. --- docker-compose.yml | 2 +- .../components/dashboard/ResearcherCard.jsx | 5 ++- frontend/src/pages/AuthCallbackPage.jsx | 27 +++++++++-- frontend/src/services/api.js | 45 +++++++++++++++++++ frontend/vite.config.js | 3 ++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a911d87..da14276 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db REDIS_URL: redis://redis:6379/0 - ORCID_REDIRECT_URI: https://willfully-brunette-antennae.ngrok-free.dev/callback + ORCID_REDIRECT_URI: https://jargon-supreme-palpable.ngrok-free.dev/callback depends_on: db: condition: service_healthy diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx index 546971a..7328cfd 100644 --- a/frontend/src/components/dashboard/ResearcherCard.jsx +++ b/frontend/src/components/dashboard/ResearcherCard.jsx @@ -8,15 +8,16 @@ import { formatDate, getInitials } from "../../utils/formatters"; * Export buttons without coupling this component to API logic. */ export function ResearcherCard({ researcher, actions = null }) { + const title = researcher.name || researcher.orcid_id || "Perfil ORCID"; return (
- {getInitials(researcher.name)} + {getInitials(title)}

- {researcher.name || "Investigador sin nombre"} + {title}

diff --git a/frontend/src/pages/AuthCallbackPage.jsx b/frontend/src/pages/AuthCallbackPage.jsx index 89e2898..ae5bb8a 100644 --- a/frontend/src/pages/AuthCallbackPage.jsx +++ b/frontend/src/pages/AuthCallbackPage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Spinner } from "../components/ui/Spinner"; @@ -8,7 +8,7 @@ import { useAuth } from "../contexts/AuthContext"; import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; /** - * OAuth callback page — mounted at /auth/callback. + * 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 @@ -16,8 +16,8 @@ import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; * 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 /auth/callback, e.g.: - * ORCID_REDIRECT_URI=http://localhost:5173/auth/callback + * must be set to /callback, e.g.: + * ORCID_REDIRECT_URI=http://localhost:5173/callback */ export function AuthCallbackPage() { const [searchParams] = useSearchParams(); @@ -26,8 +26,15 @@ export function AuthCallbackPage() { 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"); @@ -53,6 +60,15 @@ export function AuthCallbackPage() { 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); @@ -60,6 +76,9 @@ export function AuthCallbackPage() { 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); diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 6ae0a1d..b6bde80 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -39,6 +39,38 @@ const API_KEY = import.meta.env.VITE_API_KEY ?? ""; const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true"; +const ORCID_PUBLIC_BASE = + import.meta.env.VITE_ORCID_PUBLIC_API_BASE ?? "https://pub.sandbox.orcid.org/v3.0"; + +const nameCache = new Map(); + +function extractDisplayNameFromOrcidRecord(record) { + const given = record?.person?.name?.["given-names"]?.value; + const family = record?.person?.name?.["family-name"]?.value; + const full = [given, family].filter(Boolean).join(" ").trim(); + return full || null; +} + +async function fetchOrcidDisplayName(orcidId, { signal } = {}) { + if (!orcidId) return null; + if (nameCache.has(orcidId)) return nameCache.get(orcidId); + + const url = `${ORCID_PUBLIC_BASE.replace(/\/$/, "")}/${encodeURIComponent(orcidId)}/record`; + try { + const res = await fetch(url, { signal, headers: { Accept: "application/json" } }); + if (!res.ok) { + nameCache.set(orcidId, null); + return null; + } + const json = await res.json(); + const name = extractDisplayNameFromOrcidRecord(json); + nameCache.set(orcidId, name); + return name; + } catch { + return null; + } +} + export class ApiError extends Error { constructor(message, { status, payload } = {}) { super(message); @@ -98,6 +130,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { } catch { /* sin cuerpo JSON */ } + const detail = payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; throw new ApiError(typeof detail === "string" ? detail : "Error de API", { @@ -282,6 +315,18 @@ export async function searchResearchersBulk(orcidIds, { signal } = {}) { ? raw.results.map(normalizeResearcherBundle) : []; + // Frontend enrichment: backend may create researchers with `name=null` + // when discovered via search. We best-effort fill display name from + // ORCID Public API to keep UI consistent with OAuth login cases. + await Promise.all( + results.map(async (bundle) => { + const r = bundle?.researcher; + if (!r || r.name) return; + const name = await fetchOrcidDisplayName(r.orcid_id, { signal }); + if (name) bundle.researcher = { ...r, name }; + }), + ); + return { results, errors: Array.isArray(raw?.errors) ? raw.errors : [], diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 2307b97..8183396 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -11,6 +11,9 @@ export default defineConfig(({ mode }) => { plugins: [react(), tailwindcss()], server: { host: true, + // Needed for HTTPS tunnels like ngrok during OAuth callback flows. + // We allow all hosts in dev to avoid host-blocking when ngrok URL rotates. + allowedHosts: true, port: 5173, proxy: { // El backend agrupa todo bajo /api (researchers, export, …).