feat: enhance authentication flow and UI components

- Updated .env.example to include OAuth authentication details and bypass mode for development.
- Integrated AuthProvider in App component to manage authentication state.
- Added AuthCallbackPage for handling OAuth callback.
- Enhanced ExportDropdown and PublicationsTable components to display new publication indicators for authenticated users.
- Updated AppHeader to show authentication status and logout functionality.
- Improved LandingPage to support group search and simulate login in bypass mode.
- Refactored DashboardPage to conditionally handle publication exports based on user authentication status.
This commit is contained in:
Alexis
2026-04-29 12:19:47 +02:00
parent d743afd446
commit 25dfeec3f7
12 changed files with 1211 additions and 85 deletions
+22
View File
@@ -19,3 +19,25 @@ VITE_API_KEY=12ao.9-8a7b-4c&d-9e,f-?89abc
# Pon "true" SOLO si el backend no está disponible y quieres trabajar # Pon "true" SOLO si el backend no está disponible y quieres trabajar
# con los fixtures de src/services/mocks.js. En producción debe estar a "false". # con los fixtures de src/services/mocks.js. En producción debe estar a "false".
VITE_USE_MOCKS=false VITE_USE_MOCKS=false
# ── Autenticación OAuth ORCID ────────────────────────────────────────────────
#
# El flujo real es:
# 1. Frontend abre popup → GET /api/auth/orcid/authorize
# 2. Backend redirige a sandbox.orcid.org (o pub.orcid.org en producción)
# 3. Usuario se autentica en ORCID
# 4. ORCID redirige a ORCID_REDIRECT_URI (debe apuntar a esta app)
# 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
# (en backend/.env — debe coincidir con el redirect URI del app ORCID sandbox)
#
# ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ─────────
# Cuando está a "true", el botón "Iniciar sesión" genera un token simulado
# a partir del ORCID introducido en el campo de texto, sin abrir popup ni
# contactar al backend de auth. Útil para probar la UI autenticada
# (badges "Nuevo", botón "Descargar lo nuevo") sin OAuth real.
# ADVERTENCIA: el token simulado NO es válido en el backend, por lo que
# downloaded_by_me siempre será null (sin datos reales de "novedad").
VITE_AUTH_BYPASS=false
+7 -2
View File
@@ -1,8 +1,11 @@
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { AuthProvider } from "./contexts/AuthContext";
import { LandingPage } from "./pages/LandingPage"; import { LandingPage } from "./pages/LandingPage";
import { DashboardPage } from "./pages/DashboardPage"; import { DashboardPage } from "./pages/DashboardPage";
import { GroupResultsPage } from "./pages/GroupResultsPage";
import { AuthCallbackPage } from "./pages/AuthCallbackPage";
/** /**
* App shell. Declares the top-level routes and mounts the global * App shell. Declares the top-level routes and mounts the global
@@ -11,10 +14,12 @@ import { DashboardPage } from "./pages/DashboardPage";
*/ */
export default function App() { export default function App() {
return ( return (
<> <AuthProvider>
<Routes> <Routes>
<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="/auth/callback" element={<AuthCallbackPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -25,6 +30,6 @@ export default function App() {
theme="light" theme="light"
toastOptions={{ duration: 4000 }} toastOptions={{ duration: 4000 }}
/> />
</> </AuthProvider>
); );
} }
@@ -4,6 +4,7 @@ import {
DocumentIcon, DocumentIcon,
DownloadIcon, DownloadIcon,
PackageIcon, PackageIcon,
SparkleIcon,
} from "../ui/Icons"; } from "../ui/Icons";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "../ui/Spinner";
@@ -23,17 +24,20 @@ const FORMATS = [
]; ];
/** /**
* SWORD export dropdown. Delegates the actual download to `onExport(format)` * SWORD export dropdown. Delegatea the actual download to `onExport(format)`.
* so it can be wired up either to the real API or to a mock layer from the
* parent page.
* *
* `exportingFormat` (optional) lets the parent keep the button in a loading * Props:
* state between clicks (e.g. while waiting for the backend blob). * - `isAuthenticated` → cambia el texto del botón principal.
* - `newPublicationsCount` → cuántas publicaciones tiene downloaded_by_me=false.
* - `selectedCount` → publicaciones seleccionadas manualmente.
* - `exportingFormat` → formato en curso (pone el botón en loading).
*/ */
export function ExportDropdown({ export function ExportDropdown({
onExport, onExport,
exportingFormat = null, exportingFormat = null,
selectedCount = 0, selectedCount = 0,
isAuthenticated = false,
newPublicationsCount = 0,
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const rootRef = useRef(null); const rootRef = useRef(null);
@@ -56,19 +60,40 @@ export function ExportDropdown({
onExport(format); onExport(format);
} }
const idleLabel = hasSelection // Label logic:
? `Exportar seleccionadas (${selectedCount})` // manual selection → always "Exportar seleccionadas (N)"
: "Exportar todas"; // logged in, no selection → "Descargar lo nuevo (N)" or "Todo descargado"
// not logged in, no selection → "Descargar todo"
let idleLabel;
let showSparkle = false;
if (hasSelection) {
idleLabel = `Exportar seleccionadas (${selectedCount})`;
} else if (isAuthenticated) {
if (newPublicationsCount > 0) {
idleLabel = `Descargar lo nuevo (${newPublicationsCount})`;
showSparkle = true;
} else {
idleLabel = "Todo descargado";
}
} else {
idleLabel = "Descargar todo";
}
return ( return (
<div className="relative" ref={rootRef}> <div className="relative" ref={rootRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((o) => !o)} onClick={() => setOpen((o) => !o)}
disabled={isBusy} disabled={isBusy || (isAuthenticated && !hasSelection && newPublicationsCount === 0)}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70" className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70"
> >
{isBusy ? <Spinner size={15} /> : <DownloadIcon />} {isBusy ? (
<Spinner size={15} />
) : showSparkle ? (
<SparkleIcon size={15} className="text-brand-accent" />
) : (
<DownloadIcon />
)}
{isBusy {isBusy
? `Exportando ${exportingFormat.toUpperCase()}...` ? `Exportando ${exportingFormat.toUpperCase()}...`
: idleLabel} : idleLabel}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon } from "../ui/Icons"; import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons";
import { Spinner } from "../ui/Spinner"; import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge"; import { Badge } from "../ui/Badge";
@@ -83,6 +83,7 @@ export function PublicationsTable({
onRetry, onRetry,
selectedIds = EMPTY_SELECTION, selectedIds = EMPTY_SELECTION,
onSelectedIdsChange, onSelectedIdsChange,
isAuthenticated = false,
}) { }) {
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year"); const [sortKey, setSortKey] = useState("publication_year");
@@ -447,7 +448,18 @@ export function PublicationsTable({
/> />
</td> </td>
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary"> <td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
{pub.title} <span className="flex flex-wrap items-start gap-1.5">
{isAuthenticated && pub.downloaded_by_me === false && (
<span
title="No descargada aún por ti"
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
>
<SparkleIcon size={9} />
Nuevo
</span>
)}
{pub.title}
</span>
</td> </td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary"> <td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal || "—"} {pub.journal || "—"}
+52 -6
View File
@@ -1,15 +1,29 @@
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, LayersIcon } from "../ui/Icons"; import { toast } from "sonner";
import { ArrowLeftIcon, LayersIcon, LogoutIcon, UserCheckIcon } from "../ui/Icons";
import { useAuth } from "../../contexts/AuthContext";
/** /**
* Institutional navy header used across all views. * Institutional navy header used across all views.
* *
* Variants: * Variants:
* - `landing` → logo + full product name (centered brand title). * - `landing` → logo + full product name.
* - `dashboard`→ back button to `/` + discrete product label on the right. * - `dashboard` → back button to `/` + auth indicator + logout (if logged in).
* - `group` → back button to `/` + group label + auth indicator.
*/ */
export function AppHeader({ variant = "landing" }) { export function AppHeader({ variant = "landing" }) {
if (variant === "dashboard") { const { isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
function handleLogout() {
logout();
toast.success("Sesión cerrada", {
description: "Has cerrado sesión correctamente.",
});
navigate("/");
}
if (variant === "dashboard" || variant === "group") {
return ( return (
<header className="flex h-14 items-center gap-4 bg-brand-primary px-7 text-white"> <header className="flex h-14 items-center gap-4 bg-brand-primary px-7 text-white">
<Link <Link
@@ -20,8 +34,24 @@ export function AppHeader({ variant = "landing" }) {
Inicio Inicio
</Link> </Link>
<div className="flex-1" /> <div className="flex-1" />
{isAuthenticated && (
<div className="flex items-center gap-3">
<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} />
Sesión activa
</span>
<button
type="button"
onClick={handleLogout}
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"
>
<LogoutIcon />
Cerrar sesión
</button>
</div>
)}
<span className="text-[13px] text-white/60"> <span className="text-[13px] text-white/60">
Sistema ORCID · SWORD {variant === "group" ? "Búsqueda grupal · ORCID" : "Sistema ORCID · SWORD"}
</span> </span>
</header> </header>
); );
@@ -35,6 +65,22 @@ export function AppHeader({ variant = "landing" }) {
<span className="text-sm font-medium tracking-wide text-white"> <span className="text-sm font-medium tracking-wide text-white">
Sistema de Integración ORCID · SWORD Sistema de Integración ORCID · SWORD
</span> </span>
{isAuthenticated && (
<div className="ml-auto flex items-center gap-2">
<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} />
Sesión activa
</span>
<button
type="button"
onClick={handleLogout}
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"
>
<LogoutIcon />
Cerrar sesión
</button>
</div>
)}
</header> </header>
); );
} }
+36
View File
@@ -120,3 +120,39 @@ export function PackageIcon({ size = 18, className = "" }) {
</svg> </svg>
); );
} }
export function LogoutIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
);
}
export function UsersIcon({ size = 16, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.8} className={className}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
);
}
export function SparkleIcon({ size = 12, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
);
}
export function UserCheckIcon({ size = 15, className = "" }) {
return (
<svg {...base} width={size} height={size} strokeWidth={1.8} className={className}>
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<polyline points="16 11 18 13 22 9" />
</svg>
);
}
+81
View File
@@ -0,0 +1,81 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
const STORAGE_KEY = "orcid_auth_token";
// Message type sent by AuthCallbackPage (runs in the OAuth popup)
// to notify the parent window that authentication succeeded.
export const AUTH_MESSAGE_TYPE = "ORCID_AUTH_TOKEN";
export const AUTH_ERROR_TYPE = "ORCID_AUTH_ERROR";
const AuthContext = createContext(null);
/**
* Provides JWT-based authentication state throughout the app.
*
* Authentication flow (OAuth 3-legged):
* 1. User clicks "Iniciar sesión" → frontend opens popup at
* GET /api/auth/orcid/authorize.
* 2. Backend redirects to ORCID (sandbox or production).
* 3. User authenticates at orcid.org.
* 4. ORCID redirects to ORCID_REDIRECT_URI (= frontend /auth/callback).
* 5. AuthCallbackPage exchanges the `code` for a JWT via the backend.
* 6. Popup sends postMessage({ type: "ORCID_AUTH_TOKEN", token }) to
* 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));
// Listen for messages from the OAuth popup window.
useEffect(() => {
function handleMessage(event) {
// Only accept messages from the same origin (the React app itself,
// running in the popup after the OAuth redirect lands there).
if (event.origin !== window.location.origin) return;
if (event.data?.type === AUTH_MESSAGE_TYPE && event.data?.token) {
localStorage.setItem(STORAGE_KEY, event.data.token);
setToken(event.data.token);
}
}
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
/**
* Stores a JWT directly (used by AuthCallbackPage and bypass mode).
* Does NOT trigger any network request.
*/
const storeToken = useCallback((accessToken) => {
localStorage.setItem(STORAGE_KEY, accessToken);
setToken(accessToken);
}, []);
const logout = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setToken(null);
}, []);
const value = useMemo(
() => ({ token, isAuthenticated: Boolean(token), storeToken, logout }),
[token, storeToken, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx;
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, 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 /auth/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 <frontend-origin>/auth/callback, e.g.:
* ORCID_REDIRECT_URI=http://localhost:5173/auth/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("");
useEffect(() => {
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;
}
exchangeOrcidCode(code)
.then(({ access_token }) => {
storeToken(access_token);
setStatus("success");
notifyAndClose({ type: AUTH_MESSAGE_TYPE, token: access_token });
})
.catch((err) => {
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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-5 bg-surface-tertiary p-8 text-center">
{status === "loading" && (
<>
<Spinner size={32} />
<div>
<p className="text-base font-medium text-ink-primary">
Completando inicio de sesión...
</p>
<p className="mt-1 text-sm text-ink-secondary">
Verificando credenciales con ORCID.
</p>
</div>
</>
)}
{status === "success" && (
<>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-green-100 text-green-600">
<CheckIcon size={28} />
</div>
<div>
<p className="text-base font-medium text-ink-primary">
¡Sesión iniciada correctamente!
</p>
<p className="mt-1 text-sm text-ink-secondary">
Cerrando ventana...
</p>
</div>
</>
)}
{status === "error" && (
<>
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-red-100 text-red-500">
<AlertIcon size={28} />
</div>
<div>
<p className="text-base font-medium text-ink-primary">
Error al iniciar sesión
</p>
<p className="mt-1 text-sm text-ink-secondary">{errorMsg}</p>
<p className="mt-2 text-xs text-ink-tertiary">
Cerrando ventana...
</p>
</div>
</>
)}
</div>
);
}
/* ─────────────────────────── 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;
+53 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useParams, Navigate } from "react-router-dom"; import { useLocation, useParams, Navigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -14,24 +14,27 @@ import {
syncResearcher, syncResearcher,
} from "../services/api"; } from "../services/api";
import { isValidOrcid } from "../utils/orcid"; import { isValidOrcid } from "../utils/orcid";
import { useAuth } from "../contexts/AuthContext";
const SUCCESS_FLASH_MS = 3000; const SUCCESS_FLASH_MS = 3000;
/** /**
* Researcher detail page. Owns: * Researcher detail page. Owns:
* - Carga inicial vía `searchResearcher` (todo en uno: researcher + * - Carga inicial vía `searchResearcher`. Si llegamos desde la landing
* publications + resumen de cambios). Si llegamos desde la landing
* usamos el bundle ya cargado en `location.state` para evitar * usamos el bundle ya cargado en `location.state` para evitar
* duplicar la petición. * duplicar la petición.
* - Re-sync manual (POST + actualización de estado in-place + toast). * - Re-sync manual (POST + actualización de estado in-place + toast).
* - Exportación SWORD/ZIP (selectiva si hay selección, masiva si no). * - Exportación SWORD/ZIP:
* · Si hay selección manual → exporta esos IDs.
* · Si el usuario está autenticado y sin selección → exporta solo
* los IDs con downloaded_by_me=false ("lo nuevo").
* · Si no está autenticado y sin selección → exporta todo.
*/ */
export function DashboardPage() { export function DashboardPage() {
const { orcid } = useParams(); const { orcid } = useParams();
const location = useLocation(); const location = useLocation();
// El bundle del Landing solo lo consumimos UNA vez: la primera vez const { isAuthenticated } = useAuth();
// que se monta el componente. Si el usuario refresca, navega o vuelve
// atrás, queremos que se vuelva a pedir al backend.
const initialBundleRef = useRef(location.state?.bundle ?? null); const initialBundleRef = useRef(location.state?.bundle ?? null);
const initialBundle = initialBundleRef.current; const initialBundle = initialBundleRef.current;
@@ -47,11 +50,17 @@ export function DashboardPage() {
const [selectedIds, setSelectedIds] = useState(() => new Set()); const [selectedIds, setSelectedIds] = useState(() => new Set());
/** // IDs de publicaciones que el usuario no ha descargado todavía
* Carga (o recarga) el bundle completo del investigador. Centralizamos const newPublicationIds = useMemo(
* la lógica aquí para que tanto el `useEffect` inicial como el botón () =>
* "Reintentar" del estado de error compartan código. isAuthenticated
*/ ? publications
.filter((p) => p.downloaded_by_me === false)
.map((p) => p.id)
: [],
[publications, isAuthenticated],
);
const loadBundle = useCallback( const loadBundle = useCallback(
async (signal) => { async (signal) => {
setPubsLoading(true); setPubsLoading(true);
@@ -61,8 +70,6 @@ export function DashboardPage() {
if (signal?.aborted) return; if (signal?.aborted) return;
setResearcher(bundle.researcher); setResearcher(bundle.researcher);
setPublications(bundle.publications); setPublications(bundle.publications);
// La selección sobrevive recargas: nos quedamos con los IDs que
// siguen existiendo tras el sync, descartamos los que no.
setSelectedIds((prev) => { setSelectedIds((prev) => {
if (prev.size === 0) return prev; if (prev.size === 0) return prev;
const alive = new Set(bundle.publications.map((p) => p.id)); const alive = new Set(bundle.publications.map((p) => p.id));
@@ -85,9 +92,6 @@ export function DashboardPage() {
useEffect(() => { useEffect(() => {
if (!isValidOrcid(orcid)) return; if (!isValidOrcid(orcid)) return;
// Si venimos del Landing con el bundle precargado, evitamos la
// segunda petición y consumimos el ref para que un refresh sí pegue
// al backend.
if (initialBundleRef.current) { if (initialBundleRef.current) {
initialBundleRef.current = null; initialBundleRef.current = null;
return; return;
@@ -135,15 +139,32 @@ export function DashboardPage() {
async function handleExport(format) { async function handleExport(format) {
setExportingFormat(format); setExportingFormat(format);
try { try {
const ids = Array.from(selectedIds); let ids;
if (selectedIds.size > 0) {
// Manual selection takes priority
ids = Array.from(selectedIds);
} else if (isAuthenticated) {
// Authenticated → only download publications not yet downloaded by me
ids = newPublicationIds;
if (ids.length === 0) {
toast.info("No hay publicaciones nuevas", {
description: "Ya has descargado todas las publicaciones de este investigador.",
});
setExportingFormat(null);
return;
}
} else {
// Anonymous → download everything
ids = undefined;
}
const { blob } = await downloadExport(orcid, format, { const { blob } = await downloadExport(orcid, format, {
publicationIds: ids.length > 0 ? ids : undefined, publicationIds: ids,
}); });
if (blob) { if (blob) {
const objectUrl = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = objectUrl; anchor.href = objectUrl;
// Usamos extensiones reales: el endpoint SWORD devuelve XML.
const extension = format === "xml" ? "xml" : format; const extension = format === "xml" ? "xml" : format;
anchor.download = `sword-${orcid}.${extension}`; anchor.download = `sword-${orcid}.${extension}`;
document.body.appendChild(anchor); document.body.appendChild(anchor);
@@ -151,10 +172,15 @@ export function DashboardPage() {
anchor.remove(); anchor.remove();
URL.revokeObjectURL(objectUrl); URL.revokeObjectURL(objectUrl);
} }
const scope =
ids.length > 0 let scope;
? `${ids.length} publicación${ids.length === 1 ? "" : "es"} seleccionada${ids.length === 1 ? "" : "s"}` if (selectedIds.size > 0) {
: "todo el investigador"; scope = `${selectedIds.size} publicación${selectedIds.size === 1 ? "" : "es"} seleccionada${selectedIds.size === 1 ? "" : "s"}`;
} else if (isAuthenticated) {
scope = `${newPublicationIds.length} publicación${newPublicationIds.length === 1 ? "" : "es"} nueva${newPublicationIds.length === 1 ? "" : "s"}`;
} else {
scope = "todo el investigador";
}
toast.success(`Exportación ${format.toUpperCase()} completada`, { toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: scope, description: scope,
}); });
@@ -182,6 +208,8 @@ export function DashboardPage() {
onExport={handleExport} onExport={handleExport}
exportingFormat={exportingFormat} exportingFormat={exportingFormat}
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
isAuthenticated={isAuthenticated}
newPublicationsCount={newPublicationIds.length}
/> />
</> </>
} }
@@ -199,6 +227,7 @@ export function DashboardPage() {
onRetry={() => loadBundle()} onRetry={() => loadBundle()}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds} onSelectedIdsChange={setSelectedIds}
isAuthenticated={isAuthenticated}
/> />
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1"> <footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
+496
View File
@@ -0,0 +1,496 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate, Link } from "react-router-dom";
import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader";
import { Spinner } from "../components/ui/Spinner";
import { OrcidLogo } from "../components/ui/OrcidLogo";
import {
AlertIcon,
ArrowLeftIcon,
DownloadIcon,
SparkleIcon,
UsersIcon,
} from "../components/ui/Icons";
import { downloadExport, searchResearchersBulk } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
/**
* Group results view: shows one summary card per researcher, plus a global
* "download all new" (or "download everything") action in the header.
*
* Receives `{ orcidIds: string[] }` via `location.state` (set by LandingPage).
* If no state is present the user is redirected back to `/`.
*/
export function GroupResultsPage() {
const location = useLocation();
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const orcidIds = location.state?.orcidIds;
const [results, setResults] = useState([]);
const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(true);
const [globalExporting, setGlobalExporting] = useState(null); // format | null
// Track per-researcher export state (format | null)
const [cardExporting, setCardExporting] = useState({});
const abortRef = useRef(null);
useEffect(() => {
if (!orcidIds || orcidIds.length === 0) {
navigate("/", { replace: true });
return;
}
const ctrl = new AbortController();
abortRef.current = ctrl;
(async () => {
setLoading(true);
try {
const data = await searchResearchersBulk(orcidIds, {
signal: ctrl.signal,
});
if (ctrl.signal.aborted) return;
setResults(data.results ?? []);
setErrors(data.errors ?? []);
const failCount = (data.errors ?? []).length;
const okCount = (data.results ?? []).length;
if (failCount > 0) {
toast.warning(
`${okCount} investigador${okCount !== 1 ? "es" : ""} cargado${okCount !== 1 ? "s" : ""}, ${failCount} con error`,
{
description: "Comprueba los ORCID iDs que fallaron abajo.",
},
);
}
} catch (err) {
if (ctrl.signal.aborted) return;
toast.error("Error al buscar investigadores", {
description: err?.message ?? "Inténtalo de nuevo.",
});
setResults([]);
setErrors([]);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
})();
return () => ctrl.abort();
}, [orcidIds, navigate]);
// All new publication IDs across all loaded researchers
const allNewIds = useMemo(() => {
if (!isAuthenticated) return [];
return results.flatMap((r) =>
(r.publications ?? [])
.filter((p) => p.downloaded_by_me === false)
.map((p) => p.id),
);
}, [results, isAuthenticated]);
const allIds = useMemo(
() => results.flatMap((r) => (r.publications ?? []).map((p) => p.id)),
[results],
);
async function handleGlobalExport(format) {
const ids = isAuthenticated ? allNewIds : allIds;
if (ids.length === 0) {
toast.info(
isAuthenticated
? "No hay publicaciones nuevas"
: "No hay publicaciones para exportar",
{ description: "No se encontraron publicaciones en los investigadores cargados." },
);
return;
}
setGlobalExporting(format);
try {
// For bulk export we send all IDs together, passing a placeholder orcid
// since the endpoint is POST /export/{format}/publications (no orcid needed)
const { blob } = await downloadExport(null, format, {
publicationIds: ids,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `sword-group.${format === "xml" ? "xml" : format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: `${ids.length} publicaciones exportadas.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setGlobalExporting(null);
}
}
async function handleCardExport(orcidId, format, newIds, totalIds) {
const ids = isAuthenticated ? newIds : totalIds;
if (ids.length === 0) {
toast.info("No hay publicaciones para exportar");
return;
}
setCardExporting((prev) => ({ ...prev, [orcidId]: format }));
try {
const { blob } = await downloadExport(orcidId, format, {
publicationIds: ids,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = `sword-${orcidId}.${format === "xml" ? "xml" : format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
toast.success(`Exportación ${format.toUpperCase()} completada`, {
description: `${ids.length} publicaciones de ${orcidId}.`,
});
} catch (err) {
toast.error(`Error al exportar ${format.toUpperCase()}`, {
description: err?.message ?? "No se pudo generar el fichero.",
});
} finally {
setCardExporting((prev) => {
const next = { ...prev };
delete next[orcidId];
return next;
});
}
}
const globalLabel = isAuthenticated
? allNewIds.length > 0
? `Descargar lo nuevo de todos (${allNewIds.length})`
: "Todo descargado"
: `Descargar todo (${allIds.length})`;
const globalDisabled =
Boolean(globalExporting) ||
(isAuthenticated ? allNewIds.length === 0 : allIds.length === 0);
return (
<div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="group" />
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
{/* Page header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
<UsersIcon size={20} />
</div>
<div>
<h1 className="text-xl font-semibold text-ink-primary">
Búsqueda grupal
</h1>
{!loading && (
<p className="text-xs text-ink-tertiary">
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
{errors.length > 0 && (
<span className="ml-1 text-ink-danger">
· {errors.length} con error
</span>
)}
</p>
)}
</div>
</div>
{/* Global export buttons */}
{!loading && results.length > 0 && (
<div className="flex gap-2">
{["xml", "zip"].map((fmt) => (
<button
key={fmt}
type="button"
onClick={() => handleGlobalExport(fmt)}
disabled={globalDisabled}
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{globalExporting === fmt ? (
<Spinner size={14} />
) : isAuthenticated && allNewIds.length > 0 ? (
<SparkleIcon size={13} className="text-brand-accent" />
) : (
<DownloadIcon size={14} />
)}
{globalExporting === fmt
? `Exportando ${fmt.toUpperCase()}...`
: `${fmt.toUpperCase()} · ${globalLabel}`}
</button>
))}
</div>
)}
</div>
{/* Loading state */}
{loading && (
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
<Spinner size={28} />
<p className="text-sm">
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
</p>
<p className="text-xs text-ink-tertiary/60">
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
</p>
</div>
)}
{/* Results grid */}
{!loading && results.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{results.map((bundle) => (
<ResearcherResultCard
key={bundle.researcher?.orcid_id}
bundle={bundle}
isAuthenticated={isAuthenticated}
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
onExport={(fmt, newIds, totalIds) =>
handleCardExport(
bundle.researcher?.orcid_id,
fmt,
newIds,
totalIds,
)
}
/>
))}
</div>
)}
{/* Errors */}
{!loading && errors.length > 0 && (
<div className="mt-6">
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
ORCID iDs que no pudieron cargarse
</h2>
<div className="space-y-2">
{errors.map((e) => (
<div
key={e.orcid_id}
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
>
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
<div>
<p className="font-mono text-[13px] font-medium text-red-700">
{e.orcid_id}
</p>
<p className="text-xs text-red-500">
{e.detail ?? "No se pudo obtener información de este ORCID."}
</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Empty state */}
{!loading && results.length === 0 && errors.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
<UsersIcon size={32} className="opacity-30" />
<p className="text-sm">No se encontraron resultados.</p>
<Link
to="/"
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
>
<ArrowLeftIcon />
Volver al inicio
</Link>
</div>
)}
</div>
</div>
);
}
/* ─────────────────────────── Researcher card ─────────────────────────── */
function ResearcherResultCard({ bundle, isAuthenticated, exporting, onExport }) {
const researcher = bundle.researcher ?? {};
const publications = bundle.publications ?? [];
const totalRecords = bundle.totalRecords ?? publications.length;
const newIds = isAuthenticated
? publications.filter((p) => p.downloaded_by_me === false).map((p) => p.id)
: [];
const allPubIds = publications.map((p) => p.id);
const newCount = newIds.length;
const hasNew = isAuthenticated && newCount > 0;
const initials = getInitials(researcher.name);
return (
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-5 shadow-sm">
{/* Researcher identity */}
<div className="mb-4 flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-primary text-base font-semibold text-white">
{initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-semibold text-ink-primary">
{researcher.name || "Sin nombre"}
</p>
<div className="mt-0.5 flex items-center gap-1">
<OrcidLogo />
<span className="truncate font-mono text-[12px] text-ink-secondary">
{researcher.orcid_id}
</span>
</div>
{researcher.affiliation && (
<p className="mt-0.5 truncate text-[12px] text-ink-tertiary">
{researcher.affiliation}
</p>
)}
</div>
</div>
{/* Stats row */}
<div className="mb-4 flex items-center gap-3 rounded-lg bg-surface-secondary px-3 py-2">
<div className="flex-1 text-center">
<p className="text-lg font-bold text-ink-primary">{totalRecords}</p>
<p className="text-[11px] text-ink-tertiary">publicaciones</p>
</div>
{isAuthenticated && (
<>
<div className="h-8 w-px bg-surface-border" />
<div className="flex-1 text-center">
<p className={`text-lg font-bold ${hasNew ? "text-brand-accent" : "text-ink-tertiary"}`}>
{newCount}
</p>
<p className="text-[11px] text-ink-tertiary">nuevas</p>
</div>
</>
)}
</div>
{/* Actions */}
<div className="mt-auto flex flex-wrap gap-2">
<Link
to={`/dashboard/${researcher.orcid_id}`}
state={{ bundle }}
className="flex-1 rounded-lg border border-surface-border-strong bg-surface-secondary px-3 py-2 text-center text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-primary"
>
Ver detalle
</Link>
<ExportFormatMenu
onExport={(fmt) => onExport(fmt, newIds, allPubIds)}
exporting={exporting}
isAuthenticated={isAuthenticated}
hasNew={hasNew}
newCount={newCount}
totalCount={totalRecords}
/>
</div>
</div>
);
}
/* ─────────────────────── Inline export format picker ─────────────────── */
function ExportFormatMenu({
onExport,
exporting,
isAuthenticated,
hasNew,
newCount,
totalCount,
}) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
function handleClick(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
const isBusy = Boolean(exporting);
const disabled =
isBusy || (isAuthenticated && !hasNew && totalCount > 0 && newCount === 0);
let label;
if (isBusy) {
label = `Exportando ${exporting.toUpperCase()}...`;
} else if (isAuthenticated) {
label = hasNew ? `Nuevo (${newCount})` : "Descargado";
} else {
label = `Todo (${totalCount})`;
}
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-border-strong bg-surface-primary px-3 py-2 text-[13px] font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{isBusy ? (
<Spinner size={13} />
) : hasNew ? (
<SparkleIcon size={11} className="text-brand-accent" />
) : (
<DownloadIcon size={13} />
)}
{label}
</button>
{open && (
<div className="absolute right-0 top-[calc(100%+4px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-surface-border-strong bg-surface-primary shadow-lg">
{["xml", "zip"].map((fmt, idx) => (
<button
key={fmt}
type="button"
onClick={() => {
setOpen(false);
onExport(fmt);
}}
className={`flex w-full items-center gap-2 px-3 py-2.5 text-left text-[13px] font-medium text-ink-primary transition-colors hover:bg-surface-secondary ${
idx === 0 ? "border-b border-surface-border/60" : ""
}`}
>
<DownloadIcon size={13} />
{fmt.toUpperCase()}
</button>
))}
</div>
)}
</div>
);
}
/* ─────────────────────────── Helpers ─────────────────────────────────── */
function getInitials(name) {
if (!name) return "?";
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("");
}
export default GroupResultsPage;
+223 -39
View File
@@ -1,31 +1,54 @@
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader"; import { AppHeader } from "../components/layout/AppHeader";
import { DocumentIcon } from "../components/ui/Icons"; import { DocumentIcon, UsersIcon } from "../components/ui/Icons";
import { OrcidLogo } from "../components/ui/OrcidLogo"; import { OrcidLogo } from "../components/ui/OrcidLogo";
import { Spinner } from "../components/ui/Spinner"; import { Spinner } from "../components/ui/Spinner";
import { formatOrcidInput, isValidOrcid } from "../utils/orcid"; import { formatOrcidInput, isValidOrcid } from "../utils/orcid";
import { searchResearcher } from "../services/api"; 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: OAuth button + manual ORCID iD entry. * Entry view: login con ORCID iD + búsqueda individual anónima +
* buscador grupal para múltiples investigadores.
* *
* El endpoint de búsqueda grupal `POST /api/researchers/search` (usado * Flujo de login:
* para 1 solo ORCID) es "todo en uno": * - Modo normal: abre popup OAuth → sandbox.orcid.org → /auth/callback
* valida el formato + dígito de control en el servidor, lo crea en BD si * → JWT → cierra popup → estado actualizado aquí.
* no existe, sincroniza con ORCID y devuelve `researcher + publications`. * - VITE_AUTH_BYPASS=true (solo dev): genera un token simulado con el
* Por eso aquí basta con una sola llamada y, una vez que tenemos el * ORCID del campo de texto, sin tocar el backend de auth.
* bundle, navegamos al dashboard pasándoselo por `state` para evitar
* la doble petición.
*/ */
export function LandingPage() { export function LandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { isAuthenticated, storeToken } = useAuth();
const [orcidInput, setOrcidInput] = useState(""); const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false); const [loginLoading, setLoginLoading] = useState(false);
// Group search state
const [groupInput, setGroupInput] = useState("");
const [groupError, setGroupError] = useState("");
const [groupLoading, setGroupLoading] = useState(false);
// Cleanup refs for popup polling interval
const popupRef = useRef(null);
const popupTimerRef = useRef(null);
// Clean up popup polling on unmount
useEffect(() => {
return () => {
if (popupTimerRef.current) clearInterval(popupTimerRef.current);
};
}, []);
function handleOrcidChange(event) { function handleOrcidChange(event) {
setOrcidInput(formatOrcidInput(event.target.value)); setOrcidInput(formatOrcidInput(event.target.value));
@@ -44,7 +67,7 @@ export function LandingPage() {
const bundle = await searchResearcher(orcidInput); const bundle = await searchResearcher(orcidInput);
navigate(`/dashboard/${orcidInput}`, { state: { bundle } }); navigate(`/dashboard/${orcidInput}`, { state: { bundle } });
} catch (err) { } catch (err) {
toast.error("No se pudo validar el ORCID iD", { toast.error("No se pudo buscar el ORCID iD", {
description: err?.message ?? "Inténtalo de nuevo en unos segundos.", description: err?.message ?? "Inténtalo de nuevo en unos segundos.",
}); });
} finally { } finally {
@@ -52,19 +75,105 @@ export function LandingPage() {
} }
} }
async function handleOrcidLogin() { function handleOrcidLogin() {
setOauthLoading(true); // ── Modo bypass (solo desarrollo / sandbox sin credenciales OAuth) ──
try { if (AUTH_BYPASS) {
// Real implementation will redirect to ORCID OAuth (handled by backend). if (!isValidOrcid(orcidInput)) {
// For now we emulate the flow locally with a known sample ORCID. setError(
await new Promise((r) => setTimeout(r, 800)); "Introduce un ORCID iD válido para simular el login (modo bypass).",
navigate(`/dashboard/0000-0002-1234-5678`); );
} catch (err) { return;
toast.error("No se pudo iniciar sesión con ORCID", { }
description: err?.message ?? "Inténtalo de nuevo.", // 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();
const popup = window.open(
authorizeUrl,
"orcid_oauth",
"width=600,height=700,scrollbars=yes,resizable=yes",
);
if (!popup || popup.closed) {
// El navegador bloqueó el popup → hacemos redirect completo
setLoginLoading(false);
window.location.href = authorizeUrl;
return;
}
popupRef.current = popup;
// Escuchamos el postMessage que AuthCallbackPage envía al completar
function handleMessage(event) {
if (event.origin !== window.location.origin) return;
if (event.data?.type === AUTH_MESSAGE_TYPE) {
cleanup();
setLoginLoading(false);
toast.success("Sesión iniciada con ORCID", {
description: "Ya puedes ver qué publicaciones son nuevas para ti.",
});
} else if (event.data?.type === AUTH_ERROR_TYPE) {
cleanup();
setLoginLoading(false);
toast.error("No se pudo iniciar sesión", {
description: event.data.error ?? "Inténtalo de nuevo.",
});
}
}
window.addEventListener("message", handleMessage);
// Detectamos si el usuario cierra el popup manualmente antes de autenticar
popupTimerRef.current = setInterval(() => {
if (popup.closed) {
cleanup();
setLoginLoading(false);
}
}, 500);
function cleanup() {
window.removeEventListener("message", handleMessage);
if (popupTimerRef.current) {
clearInterval(popupTimerRef.current);
popupTimerRef.current = null;
}
}
}
function parseGroupOrcids(raw) {
return raw
.split(/[\s,\n]+/)
.map((s) => s.trim())
.filter(Boolean);
}
async function handleGroupSearch() {
const ids = parseGroupOrcids(groupInput);
if (ids.length === 0) {
setGroupError("Introduce al menos un ORCID iD.");
return;
}
const invalid = ids.filter((id) => !isValidOrcid(id));
if (invalid.length > 0) {
setGroupError(`ORCID iDs con formato incorrecto: ${invalid.join(", ")}`);
return;
}
setGroupError("");
setGroupLoading(true);
try {
navigate("/group", { state: { orcidIds: ids } });
} finally { } finally {
setOauthLoading(false); setGroupLoading(false);
} }
} }
@@ -72,6 +181,13 @@ export function LandingPage() {
if (event.key === "Enter") handleValidate(); if (event.key === "Enter") handleValidate();
} }
function handleGroupKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleGroupSearch();
}
}
return ( return (
<div className="flex min-h-screen flex-col bg-surface-tertiary"> <div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="landing" /> <AppHeader variant="landing" />
@@ -94,22 +210,41 @@ export function LandingPage() {
{/* Main card */} {/* Main card */}
<div className="rounded-2xl border border-surface-border/60 bg-surface-primary p-8"> <div className="rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
<button {isAuthenticated ? (
type="button" <div className="flex items-center justify-between rounded-xl border border-green-200 bg-green-50 px-4 py-2.5 text-sm text-green-800">
onClick={handleOrcidLogin} <span className="font-medium">Sesión activa</span>
disabled={oauthLoading} <span className="text-xs text-green-600">
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75" Verás publicaciones nuevas marcadas en el dashboard
> </span>
{oauthLoading ? <Spinner size={17} /> : <OrcidLogo />} </div>
{oauthLoading ) : (
? "Redirigiendo a ORCID..." <>
: "Iniciar sesión con ORCID"} <button
</button> type="button"
onClick={handleOrcidLogin}
disabled={loginLoading}
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
>
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading
? "Abriendo ventana de ORCID..."
: AUTH_BYPASS
? "Simular login (bypass)"
: "Iniciar sesión con ORCID"}
</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>
)}
</>
)}
<div className="my-6 flex items-center gap-3"> <div className="my-6 flex items-center gap-3">
<div className="h-px flex-1 bg-surface-border" /> <div className="h-px flex-1 bg-surface-border" />
<span className="text-xs tracking-widest text-ink-tertiary"> <span className="text-xs tracking-widest text-ink-tertiary">
O INTRODUCE TU ORCID iD {isAuthenticated ? "TU ORCID iD" : "O INTRODUCE TU ORCID iD"}
</span> </span>
<div className="h-px flex-1 bg-surface-border" /> <div className="h-px flex-1 bg-surface-border" />
</div> </div>
@@ -141,7 +276,7 @@ export function LandingPage() {
<button <button
type="button" type="button"
onClick={handleValidate} onClick={handleValidate}
disabled={validating || !orcidInput} disabled={validating || loginLoading || !orcidInput}
className={`inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${ className={`inline-flex items-center gap-2 whitespace-nowrap rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${
orcidInput orcidInput
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover" ? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
@@ -158,12 +293,61 @@ export function LandingPage() {
</p> </p>
)} )}
<p className="mt-2 text-xs text-ink-tertiary"> <p className="mt-2 text-xs text-ink-tertiary">
Formato: 16 dígitos separados con guiones (ej. {isAuthenticated
0000-0002-1234-5678) ? "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."}
</p> </p>
</div> </div>
</div> </div>
{/* Group search card */}
<div className="mt-4 rounded-2xl border border-surface-border/60 bg-surface-primary p-6">
<div className="mb-3 flex items-center gap-2">
<UsersIcon size={17} className="text-brand-accent" />
<h2 className="text-[14px] font-semibold text-ink-primary">
Búsqueda grupal de investigadores
</h2>
</div>
<p className="mb-3 text-xs leading-relaxed text-ink-secondary">
Pega varios ORCID iDs separados por comas, espacios o saltos de
línea para buscar y comparar varios investigadores a la vez.
</p>
<textarea
rows={3}
placeholder={"0000-0002-1825-0097\n0000-0001-5000-0007, 0000-0003-4321-9876"}
value={groupInput}
onChange={(e) => {
setGroupInput(e.target.value);
if (groupError) setGroupError("");
}}
onKeyDown={handleGroupKeyDown}
className={`w-full resize-none rounded-lg border px-3.5 py-2.5 font-mono text-[13px] text-ink-primary outline-none transition-colors ${
groupError
? "border-border-danger"
: "border-surface-border-strong focus:border-brand-accent"
}`}
/>
{groupError && (
<p className="mt-1 text-xs text-ink-danger">{groupError}</p>
)}
<button
type="button"
onClick={handleGroupSearch}
disabled={groupLoading || !groupInput.trim()}
className={`mt-3 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-medium transition-colors ${
groupInput.trim()
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
: "bg-surface-secondary text-ink-tertiary"
} disabled:cursor-not-allowed`}
>
{groupLoading && <Spinner size={14} />}
<UsersIcon size={14} />
{groupLoading ? "Preparando..." : "Buscar investigadores"}
</button>
</div>
{/* Info chips */} {/* Info chips */}
<div className="mt-6 flex flex-wrap justify-center gap-4"> <div className="mt-6 flex flex-wrap justify-center gap-4">
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => ( {["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
+38 -2
View File
@@ -50,8 +50,8 @@ export class ApiError extends Error {
/** /**
* Construye la cabecera base que llevan TODAS las peticiones (incluidas * Construye la cabecera base que llevan TODAS las peticiones (incluidas
* las descargas de blob). Si la API key está sin definir lo avisamos en * las descargas de blob). Incluye X-API-Key siempre y, si existe un JWT
* consola para no fallar silenciosamente con un 401 críptico. * en localStorage, también Authorization: Bearer <token>.
*/ */
function buildAuthHeaders(extra = {}) { function buildAuthHeaders(extra = {}) {
if (!API_KEY && import.meta.env.DEV) { if (!API_KEY && import.meta.env.DEV) {
@@ -59,9 +59,11 @@ function buildAuthHeaders(extra = {}) {
"[api] VITE_API_KEY no está definida; las peticiones serán rechazadas por el backend.", "[api] VITE_API_KEY no está definida; las peticiones serán rechazadas por el backend.",
); );
} }
const token = localStorage.getItem("orcid_auth_token");
return { return {
Accept: "application/json", Accept: "application/json",
...(API_KEY ? { "X-API-Key": API_KEY } : {}), ...(API_KEY ? { "X-API-Key": API_KEY } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extra, ...extra,
}; };
} }
@@ -143,6 +145,8 @@ 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,
}; };
} }
@@ -176,6 +180,38 @@ function normalizeResearcherBundle(raw) {
}; };
} }
/* ───────────────────────────── Auth ─────────────────────────────── */
/**
* URL a la que debe redirigirse (o abrirse en popup) para iniciar el
* flujo OAuth 3-legged de ORCID.
*
* Secuencia completa:
* 1. Frontend abre/redirige a GET /api/auth/orcid/authorize
* 2. Backend construye la URL de ORCID y redirige al navegador.
* 3. El usuario se autentica en orcid.org (o sandbox.orcid.org).
* 4. ORCID redirige a ORCID_REDIRECT_URI (debe apuntar a la página
* /auth/callback del frontend).
* 5. El frontend extrae el `code` y llama a exchangeOrcidCode(code).
* 6. El backend intercambia el code access_token y lo devuelve.
*/
export function getOrcidAuthorizeUrl() {
return `${BASE_URL}/auth/orcid/authorize`;
}
/**
* GET /auth/orcid/callback?code=<code>
*
* Intercambia el authorization code (recibido de ORCID tras el OAuth)
* por un JWT propio del backend. Devuelve `{ access_token, token_type }`.
*/
export async function exchangeOrcidCode(code, { signal } = {}) {
return request(
`/auth/orcid/callback?${new URLSearchParams({ code }).toString()}`,
{ signal },
);
}
/* ───────────────────────────── Endpoints ─────────────────────────────── */ /* ───────────────────────────── Endpoints ─────────────────────────────── */
/** /**