From 7118d21f34e1357eed6629a9e777330d47186966 Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 29 Apr 2026 13:29:23 +0200 Subject: [PATCH 1/2] fix: update callback route and enhance user profile link in header - Changed the OAuth callback route from `/auth/callback` to `/callback` in App component and .env.example. - Added user profile link in AppHeader for authenticated users, directing to their dashboard. - Removed bypass mode references from LandingPage to streamline the login flow. - Introduced a utility function to extract ORCID from JWT in AuthContext for better user state management. --- frontend/.env.example | 4 +- frontend/src/App.jsx | 2 +- frontend/src/components/layout/AppHeader.jsx | 20 +++++++++- frontend/src/contexts/AuthContext.jsx | 25 ++++++++++-- frontend/src/pages/LandingPage.jsx | 40 ++------------------ frontend/src/services/api.js | 1 - 6 files changed, 47 insertions(+), 45 deletions(-) diff --git a/frontend/.env.example b/frontend/.env.example index be05084..159ff5a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -30,8 +30,10 @@ VITE_USE_MOCKS=false # 5. /auth/callback extrae el code y llama al backend para obtener el JWT # # Para que el callback vuelva al frontend, el backend necesita: -# ORCID_REDIRECT_URI=http://localhost:5173/auth/callback +# ORCID_REDIRECT_URI=http://localhost:5173/callback # (en backend/.env — debe coincidir con el redirect URI del app ORCID sandbox) +# En producción con ngrok u otro túnel, el formato sería: +# ORCID_REDIRECT_URI=https:///callback # # ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ───────── # Cuando está a "true", el botón "Iniciar sesión" genera un token simulado diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ff5c2cb..17b6d82 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,7 +19,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> diff --git a/frontend/src/components/layout/AppHeader.jsx b/frontend/src/components/layout/AppHeader.jsx index 2a94a1d..fd10421 100644 --- a/frontend/src/components/layout/AppHeader.jsx +++ b/frontend/src/components/layout/AppHeader.jsx @@ -12,7 +12,7 @@ import { useAuth } from "../../contexts/AuthContext"; * - `group` → back button to `/` + group label + auth indicator. */ export function AppHeader({ variant = "landing" }) { - const { isAuthenticated, logout } = useAuth(); + const { isAuthenticated, userOrcidId, logout } = useAuth(); const navigate = useNavigate(); function handleLogout() { @@ -36,6 +36,15 @@ export function AppHeader({ variant = "landing" }) {
{isAuthenticated && (
+ {userOrcidId && ( + + + Mi perfil + + )} Sesión activa @@ -67,6 +76,15 @@ export function AppHeader({ variant = "landing" }) { {isAuthenticated && (
+ {userOrcidId && ( + + + Mi perfil + + )} Sesión activa diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 5b0ddc0..206cba1 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -16,6 +16,19 @@ export const AUTH_ERROR_TYPE = "ORCID_AUTH_ERROR"; const AuthContext = createContext(null); +function extractOrcidFromToken(token) { + if (!token) return null; + try { + const payloadBase64 = token.split(".")[1]; + if (!payloadBase64) return null; + const payloadJson = atob(payloadBase64.replace(/-/g, "+").replace(/_/g, "/")); + const payload = JSON.parse(payloadJson); + return payload?.sub ?? null; + } catch { + return null; + } +} + /** * Provides JWT-based authentication state throughout the app. * @@ -30,8 +43,6 @@ const AuthContext = createContext(null); * opener and closes itself. * 7. This provider's message listener stores the token and updates state. * - * For development / sandbox bypass (VITE_AUTH_BYPASS=true), the token is - * stored directly via storeToken() without going through ORCID. */ export function AuthProvider({ children }) { const [token, setToken] = useState(() => localStorage.getItem(STORAGE_KEY)); @@ -53,7 +64,7 @@ export function AuthProvider({ children }) { }, []); /** - * Stores a JWT directly (used by AuthCallbackPage and bypass mode). + * Stores a JWT directly (used by AuthCallbackPage). * Does NOT trigger any network request. */ const storeToken = useCallback((accessToken) => { @@ -67,7 +78,13 @@ export function AuthProvider({ children }) { }, []); const value = useMemo( - () => ({ token, isAuthenticated: Boolean(token), storeToken, logout }), + () => ({ + token, + isAuthenticated: Boolean(token), + userOrcidId: extractOrcidFromToken(token), + storeToken, + logout, + }), [token, storeToken, logout], ); diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index be0f237..cd0a219 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -11,23 +11,17 @@ import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api"; import { useAuth } from "../contexts/AuthContext"; import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; -// When VITE_AUTH_BYPASS=true, skip the real OAuth popup and simulate login -// with the ORCID entered in the text field. Use only in development. -const AUTH_BYPASS = import.meta.env.VITE_AUTH_BYPASS === "true"; - /** * Entry view: login con ORCID iD + búsqueda individual anónima + * buscador grupal para múltiples investigadores. * * Flujo de login: - * - Modo normal: abre popup OAuth → sandbox.orcid.org → /auth/callback - * → JWT → cierra popup → estado actualizado aquí. - * - VITE_AUTH_BYPASS=true (solo dev): genera un token simulado con el - * ORCID del campo de texto, sin tocar el backend de auth. + * - abre popup OAuth → sandbox.orcid.org → /callback + * - recibe JWT → cierra popup → estado actualizado aquí. */ export function LandingPage() { const navigate = useNavigate(); - const { isAuthenticated, storeToken } = useAuth(); + const { isAuthenticated } = useAuth(); const [orcidInput, setOrcidInput] = useState(""); const [error, setError] = useState(""); @@ -76,24 +70,6 @@ export function LandingPage() { } function handleOrcidLogin() { - // ── Modo bypass (solo desarrollo / sandbox sin credenciales OAuth) ── - if (AUTH_BYPASS) { - if (!isValidOrcid(orcidInput)) { - setError( - "Introduce un ORCID iD válido para simular el login (modo bypass).", - ); - return; - } - // Genera un token simulado (no válido en el backend) solo para - // probar la UI en estado autenticado. - storeToken(`bypass_token_${orcidInput}`); - toast.success("Login simulado (modo bypass)", { - description: `Sesión activa para ${orcidInput}. El backend no reconocerá este token.`, - }); - return; - } - - // ── Flujo OAuth real (popup) ── setLoginLoading(true); const authorizeUrl = getOrcidAuthorizeUrl(); @@ -228,16 +204,8 @@ export function LandingPage() { {loginLoading ? : } {loginLoading ? "Abriendo ventana de ORCID..." - : AUTH_BYPASS - ? "Simular login (bypass)" : "Iniciar sesión con ORCID"} - {AUTH_BYPASS && ( -

- Modo bypass activo — introduce un ORCID abajo y pulsa el botón. - No se valida contra el backend. -

- )} )} @@ -295,8 +263,6 @@ export function LandingPage() {

{isAuthenticated ? "Busca un investigador o usa «Cerrar sesión» arriba." - : AUTH_BYPASS - ? "Introduce tu ORCID y pulsa «Simular login» para probar la UI autenticada." : "Pulsa «Iniciar sesión» para autenticarte, o «Buscar» de forma anónima."}

diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 0effa9f..6ae0a1d 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -145,7 +145,6 @@ function normalizePublication(p) { hash_fingerprint: p.hash_fingerprint ?? null, last_modified: p.last_modified ?? null, status: p.status ?? null, - // null when request was made without a JWT (user not logged in) downloaded_by_me: p.downloaded_by_me ?? null, }; } From 104070159a9594e0c01b2f86a1a2c0c129977f3a Mon Sep 17 00:00:00 2001 From: Alexis Date: Thu, 7 May 2026 12:25:02 +0200 Subject: [PATCH 2/2] 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, …).