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.
This commit is contained in:
Alexis
2026-05-07 12:25:02 +02:00
parent 7118d21f34
commit 104070159a
5 changed files with 75 additions and 7 deletions
+1 -1
View File
@@ -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
@@ -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 (
<section className="mb-5 flex flex-wrap items-start gap-5 rounded-2xl border border-surface-border/60 bg-surface-primary px-7 py-6">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-brand-primary text-xl font-semibold text-white">
{getInitials(researcher.name)}
{getInitials(title)}
</div>
<div className="min-w-[200px] flex-1">
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
{researcher.name || "Investigador sin nombre"}
{title}
</h2>
<div className="flex flex-wrap items-center gap-2.5">
<div className="inline-flex items-center gap-1.5">
+23 -4
View File
@@ -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 <frontend-origin>/auth/callback, e.g.:
* ORCID_REDIRECT_URI=http://localhost:5173/auth/callback
* must be set to <frontend-origin>/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);
+45
View File
@@ -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 : [],
+3
View File
@@ -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, …).