Merge pull request #12 from uja-dev-practices/feature/frontend-v4
Feature/frontend v4
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db
|
||||||
REDIS_URL: redis://redis:6379/0
|
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:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ VITE_USE_MOCKS=false
|
|||||||
# 5. /auth/callback extrae el code y llama al backend para obtener el JWT
|
# 5. /auth/callback extrae el code y llama al backend para obtener el JWT
|
||||||
#
|
#
|
||||||
# Para que el callback vuelva al frontend, el backend necesita:
|
# 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 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://<tu-dominio>/callback
|
||||||
#
|
#
|
||||||
# ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ─────────
|
# ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ─────────
|
||||||
# Cuando está a "true", el botón "Iniciar sesión" genera un token simulado
|
# Cuando está a "true", el botón "Iniciar sesión" genera un token simulado
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<LandingPage />} />
|
<Route path="/" element={<LandingPage />} />
|
||||||
<Route path="/dashboard/:orcid" element={<DashboardPage />} />
|
<Route path="/dashboard/:orcid" element={<DashboardPage />} />
|
||||||
<Route path="/group" element={<GroupResultsPage />} />
|
<Route path="/group" element={<GroupResultsPage />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallbackPage />} />
|
<Route path="/callback" element={<AuthCallbackPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,16 @@ import { formatDate, getInitials } from "../../utils/formatters";
|
|||||||
* Export buttons without coupling this component to API logic.
|
* Export buttons without coupling this component to API logic.
|
||||||
*/
|
*/
|
||||||
export function ResearcherCard({ researcher, actions = null }) {
|
export function ResearcherCard({ researcher, actions = null }) {
|
||||||
|
const title = researcher.name || researcher.orcid_id || "Perfil ORCID";
|
||||||
return (
|
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">
|
<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">
|
<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>
|
||||||
|
|
||||||
<div className="min-w-[200px] flex-1">
|
<div className="min-w-[200px] flex-1">
|
||||||
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
|
<h2 className="mb-1 text-[22px] font-semibold text-ink-primary">
|
||||||
{researcher.name || "Investigador sin nombre"}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
<div className="inline-flex items-center gap-1.5">
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useAuth } from "../../contexts/AuthContext";
|
|||||||
* - `group` → back button to `/` + group label + auth indicator.
|
* - `group` → back button to `/` + group label + auth indicator.
|
||||||
*/
|
*/
|
||||||
export function AppHeader({ variant = "landing" }) {
|
export function AppHeader({ variant = "landing" }) {
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, userOrcidId, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
@@ -36,6 +36,15 @@ export function AppHeader({ variant = "landing" }) {
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{userOrcidId && (
|
||||||
|
<Link
|
||||||
|
to={`/dashboard/${userOrcidId}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<UserCheckIcon size={13} />
|
||||||
|
Mi perfil
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
|
||||||
<UserCheckIcon size={13} />
|
<UserCheckIcon size={13} />
|
||||||
Sesión activa
|
Sesión activa
|
||||||
@@ -67,6 +76,15 @@ export function AppHeader({ variant = "landing" }) {
|
|||||||
</span>
|
</span>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{userOrcidId && (
|
||||||
|
<Link
|
||||||
|
to={`/dashboard/${userOrcidId}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
|
||||||
|
>
|
||||||
|
<UserCheckIcon size={13} />
|
||||||
|
Mi perfil
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
|
||||||
<UserCheckIcon size={13} />
|
<UserCheckIcon size={13} />
|
||||||
Sesión activa
|
Sesión activa
|
||||||
|
|||||||
@@ -16,6 +16,19 @@ export const AUTH_ERROR_TYPE = "ORCID_AUTH_ERROR";
|
|||||||
|
|
||||||
const AuthContext = createContext(null);
|
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.
|
* Provides JWT-based authentication state throughout the app.
|
||||||
*
|
*
|
||||||
@@ -30,8 +43,6 @@ const AuthContext = createContext(null);
|
|||||||
* opener and closes itself.
|
* opener and closes itself.
|
||||||
* 7. This provider's message listener stores the token and updates state.
|
* 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 }) {
|
export function AuthProvider({ children }) {
|
||||||
const [token, setToken] = useState(() => localStorage.getItem(STORAGE_KEY));
|
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.
|
* Does NOT trigger any network request.
|
||||||
*/
|
*/
|
||||||
const storeToken = useCallback((accessToken) => {
|
const storeToken = useCallback((accessToken) => {
|
||||||
@@ -67,7 +78,13 @@ export function AuthProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({ token, isAuthenticated: Boolean(token), storeToken, logout }),
|
() => ({
|
||||||
|
token,
|
||||||
|
isAuthenticated: Boolean(token),
|
||||||
|
userOrcidId: extractOrcidFromToken(token),
|
||||||
|
storeToken,
|
||||||
|
logout,
|
||||||
|
}),
|
||||||
[token, storeToken, logout],
|
[token, storeToken, logout],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { Spinner } from "../components/ui/Spinner";
|
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";
|
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
|
* ORCID redirects here after the user authenticates. We extract the
|
||||||
* authorization `code`, exchange it for a JWT via the backend, store
|
* 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.
|
* 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
|
* For this page to be reached, the backend's ORCID_REDIRECT_URI env var
|
||||||
* must be set to <frontend-origin>/auth/callback, e.g.:
|
* must be set to <frontend-origin>/callback, e.g.:
|
||||||
* ORCID_REDIRECT_URI=http://localhost:5173/auth/callback
|
* ORCID_REDIRECT_URI=http://localhost:5173/callback
|
||||||
*/
|
*/
|
||||||
export function AuthCallbackPage() {
|
export function AuthCallbackPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -26,8 +26,15 @@ export function AuthCallbackPage() {
|
|||||||
|
|
||||||
const [status, setStatus] = useState("loading"); // loading | success | error
|
const [status, setStatus] = useState("loading"); // loading | success | error
|
||||||
const [errorMsg, setErrorMsg] = useState("");
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
const hasHandledCodeRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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 code = searchParams.get("code");
|
||||||
const oauthError = searchParams.get("error");
|
const oauthError = searchParams.get("error");
|
||||||
const errorDescription = searchParams.get("error_description");
|
const errorDescription = searchParams.get("error_description");
|
||||||
@@ -53,6 +60,15 @@ export function AuthCallbackPage() {
|
|||||||
return;
|
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)
|
exchangeOrcidCode(code)
|
||||||
.then(({ access_token }) => {
|
.then(({ access_token }) => {
|
||||||
storeToken(access_token);
|
storeToken(access_token);
|
||||||
@@ -60,6 +76,9 @@ export function AuthCallbackPage() {
|
|||||||
notifyAndClose({ type: AUTH_MESSAGE_TYPE, token: access_token });
|
notifyAndClose({ type: AUTH_MESSAGE_TYPE, token: access_token });
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.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.";
|
const msg = err?.message ?? "No se pudo completar el inicio de sesión.";
|
||||||
setStatus("error");
|
setStatus("error");
|
||||||
setErrorMsg(msg);
|
setErrorMsg(msg);
|
||||||
|
|||||||
@@ -11,23 +11,17 @@ import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } 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 +
|
* Entry view: login con ORCID iD + búsqueda individual anónima +
|
||||||
* buscador grupal para múltiples investigadores.
|
* buscador grupal para múltiples investigadores.
|
||||||
*
|
*
|
||||||
* Flujo de login:
|
* Flujo de login:
|
||||||
* - Modo normal: abre popup OAuth → sandbox.orcid.org → /auth/callback
|
* - abre popup OAuth → sandbox.orcid.org → /callback
|
||||||
* → JWT → cierra popup → estado actualizado aquí.
|
* - recibe 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.
|
|
||||||
*/
|
*/
|
||||||
export function LandingPage() {
|
export function LandingPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, storeToken } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
const [orcidInput, setOrcidInput] = useState("");
|
const [orcidInput, setOrcidInput] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -76,24 +70,6 @@ export function LandingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleOrcidLogin() {
|
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);
|
setLoginLoading(true);
|
||||||
|
|
||||||
const authorizeUrl = getOrcidAuthorizeUrl();
|
const authorizeUrl = getOrcidAuthorizeUrl();
|
||||||
@@ -228,16 +204,8 @@ export function LandingPage() {
|
|||||||
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
|
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
|
||||||
{loginLoading
|
{loginLoading
|
||||||
? "Abriendo ventana de ORCID..."
|
? "Abriendo ventana de ORCID..."
|
||||||
: AUTH_BYPASS
|
|
||||||
? "Simular login (bypass)"
|
|
||||||
: "Iniciar sesión con ORCID"}
|
: "Iniciar sesión con ORCID"}
|
||||||
</button>
|
</button>
|
||||||
{AUTH_BYPASS && (
|
|
||||||
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-1.5 text-center text-xs text-amber-700">
|
|
||||||
Modo bypass activo — introduce un ORCID abajo y pulsa el botón.
|
|
||||||
No se valida contra el backend.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -295,8 +263,6 @@ export function LandingPage() {
|
|||||||
<p className="mt-2 text-xs text-ink-tertiary">
|
<p className="mt-2 text-xs text-ink-tertiary">
|
||||||
{isAuthenticated
|
{isAuthenticated
|
||||||
? "Busca un investigador o usa «Cerrar sesión» arriba."
|
? "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."}
|
: "Pulsa «Iniciar sesión» para autenticarte, o «Buscar» de forma anónima."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ const API_KEY = import.meta.env.VITE_API_KEY ?? "";
|
|||||||
|
|
||||||
const USE_MOCKS = import.meta.env.VITE_USE_MOCKS === "true";
|
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 {
|
export class ApiError extends Error {
|
||||||
constructor(message, { status, payload } = {}) {
|
constructor(message, { status, payload } = {}) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -98,6 +130,7 @@ async function request(path, { method = "GET", body, signal, headers } = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* sin cuerpo JSON */
|
/* sin cuerpo JSON */
|
||||||
}
|
}
|
||||||
|
|
||||||
const detail =
|
const detail =
|
||||||
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
|
payload?.detail ?? payload?.message ?? response.statusText ?? "Error";
|
||||||
throw new ApiError(typeof detail === "string" ? detail : "Error de API", {
|
throw new ApiError(typeof detail === "string" ? detail : "Error de API", {
|
||||||
@@ -145,7 +178,6 @@ function normalizePublication(p) {
|
|||||||
hash_fingerprint: p.hash_fingerprint ?? null,
|
hash_fingerprint: p.hash_fingerprint ?? null,
|
||||||
last_modified: p.last_modified ?? null,
|
last_modified: p.last_modified ?? null,
|
||||||
status: p.status ?? 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,
|
downloaded_by_me: p.downloaded_by_me ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -283,6 +315,18 @@ export async function searchResearchersBulk(orcidIds, { signal } = {}) {
|
|||||||
? raw.results.map(normalizeResearcherBundle)
|
? 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 {
|
return {
|
||||||
results,
|
results,
|
||||||
errors: Array.isArray(raw?.errors) ? raw.errors : [],
|
errors: Array.isArray(raw?.errors) ? raw.errors : [],
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
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,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
// El backend agrupa todo bajo /api (researchers, export, …).
|
// El backend agrupa todo bajo /api (researchers, export, …).
|
||||||
|
|||||||
Reference in New Issue
Block a user