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"
>
- {isBusy ? : }
+ {isBusy ? (
+
+ ) : showSparkle ? (
+
+ ) : (
+
+ )}
{isBusy
? `Exportando ${exportingFormat.toUpperCase()}...`
: idleLabel}
diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx
index 89e4ac7..7c0f110 100644
--- a/frontend/src/components/dashboard/PublicationsTable.jsx
+++ b/frontend/src/components/dashboard/PublicationsTable.jsx
@@ -1,5 +1,5 @@
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 { Badge } from "../ui/Badge";
@@ -83,6 +83,7 @@ export function PublicationsTable({
onRetry,
selectedIds = EMPTY_SELECTION,
onSelectedIdsChange,
+ isAuthenticated = false,
}) {
const [filter, setFilter] = useState("");
const [sortKey, setSortKey] = useState("publication_year");
@@ -447,7 +448,18 @@ export function PublicationsTable({
/>
- {pub.title}
+
+ {isAuthenticated && pub.downloaded_by_me === false && (
+
+
+ Nuevo
+
+ )}
+ {pub.title}
+
{pub.journal || "—"}
diff --git a/frontend/src/components/layout/AppHeader.jsx b/frontend/src/components/layout/AppHeader.jsx
index acac7ff..2a94a1d 100644
--- a/frontend/src/components/layout/AppHeader.jsx
+++ b/frontend/src/components/layout/AppHeader.jsx
@@ -1,15 +1,29 @@
-import { Link } from "react-router-dom";
-import { ArrowLeftIcon, LayersIcon } from "../ui/Icons";
+import { Link, useNavigate } from "react-router-dom";
+import { toast } from "sonner";
+import { ArrowLeftIcon, LayersIcon, LogoutIcon, UserCheckIcon } from "../ui/Icons";
+import { useAuth } from "../../contexts/AuthContext";
/**
* Institutional navy header used across all views.
*
* Variants:
- * - `landing` → logo + full product name (centered brand title).
- * - `dashboard`→ back button to `/` + discrete product label on the right.
+ * - `landing` → logo + full product name.
+ * - `dashboard` → back button to `/` + auth indicator + logout (if logged in).
+ * - `group` → back button to `/` + group label + auth indicator.
*/
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 (
);
@@ -35,6 +65,22 @@ export function AppHeader({ variant = "landing" }) {
Sistema de Integración ORCID · SWORD
+ {isAuthenticated && (
+
+
+
+ Sesión activa
+
+
+
+ Cerrar sesión
+
+
+ )}
);
}
diff --git a/frontend/src/components/ui/Icons.jsx b/frontend/src/components/ui/Icons.jsx
index ac50b72..1611b5d 100644
--- a/frontend/src/components/ui/Icons.jsx
+++ b/frontend/src/components/ui/Icons.jsx
@@ -120,3 +120,39 @@ export function PackageIcon({ size = 18, className = "" }) {
);
}
+
+export function LogoutIcon({ size = 15, className = "" }) {
+ return (
+
+
+
+ );
+}
+
+export function UsersIcon({ size = 16, className = "" }) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function SparkleIcon({ size = 12, className = "" }) {
+ return (
+
+
+
+ );
+}
+
+export function UserCheckIcon({ size = 15, className = "" }) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
new file mode 100644
index 0000000..5b0ddc0
--- /dev/null
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -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 {children} ;
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth must be used inside ");
+ return ctx;
+}
diff --git a/frontend/src/pages/AuthCallbackPage.jsx b/frontend/src/pages/AuthCallbackPage.jsx
new file mode 100644
index 0000000..89e2898
--- /dev/null
+++ b/frontend/src/pages/AuthCallbackPage.jsx
@@ -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 /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 (
+
+ {status === "loading" && (
+ <>
+
+
+
+ Completando inicio de sesión...
+
+
+ Verificando credenciales con ORCID.
+
+
+ >
+ )}
+
+ {status === "success" && (
+ <>
+
+
+
+
+
+ ¡Sesión iniciada correctamente!
+
+
+ Cerrando ventana...
+
+
+ >
+ )}
+
+ {status === "error" && (
+ <>
+
+
+
+ Error al iniciar sesión
+
+
{errorMsg}
+
+ Cerrando ventana...
+
+
+ >
+ )}
+
+ );
+}
+
+/* ─────────────────────────── 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;
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index 5f0ca43..cdd9a17 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -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 { toast } from "sonner";
@@ -14,24 +14,27 @@ import {
syncResearcher,
} from "../services/api";
import { isValidOrcid } from "../utils/orcid";
+import { useAuth } from "../contexts/AuthContext";
const SUCCESS_FLASH_MS = 3000;
/**
* Researcher detail page. Owns:
- * - Carga inicial vía `searchResearcher` (todo en uno: researcher +
- * publications + resumen de cambios). Si llegamos desde la landing
+ * - Carga inicial vía `searchResearcher`. Si llegamos desde la landing
* usamos el bundle ya cargado en `location.state` para evitar
* duplicar la petición.
* - 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() {
const { orcid } = useParams();
const location = useLocation();
- // El bundle del Landing solo lo consumimos UNA vez: la primera vez
- // que se monta el componente. Si el usuario refresca, navega o vuelve
- // atrás, queremos que se vuelva a pedir al backend.
+ const { isAuthenticated } = useAuth();
+
const initialBundleRef = useRef(location.state?.bundle ?? null);
const initialBundle = initialBundleRef.current;
@@ -47,11 +50,17 @@ export function DashboardPage() {
const [selectedIds, setSelectedIds] = useState(() => new Set());
- /**
- * Carga (o recarga) el bundle completo del investigador. Centralizamos
- * la lógica aquí para que tanto el `useEffect` inicial como el botón
- * "Reintentar" del estado de error compartan código.
- */
+ // IDs de publicaciones que el usuario no ha descargado todavía
+ const newPublicationIds = useMemo(
+ () =>
+ isAuthenticated
+ ? publications
+ .filter((p) => p.downloaded_by_me === false)
+ .map((p) => p.id)
+ : [],
+ [publications, isAuthenticated],
+ );
+
const loadBundle = useCallback(
async (signal) => {
setPubsLoading(true);
@@ -61,8 +70,6 @@ export function DashboardPage() {
if (signal?.aborted) return;
setResearcher(bundle.researcher);
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) => {
if (prev.size === 0) return prev;
const alive = new Set(bundle.publications.map((p) => p.id));
@@ -85,9 +92,6 @@ export function DashboardPage() {
useEffect(() => {
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) {
initialBundleRef.current = null;
return;
@@ -135,15 +139,32 @@ export function DashboardPage() {
async function handleExport(format) {
setExportingFormat(format);
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, {
- publicationIds: ids.length > 0 ? ids : undefined,
+ publicationIds: ids,
});
if (blob) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
- // Usamos extensiones reales: el endpoint SWORD devuelve XML.
const extension = format === "xml" ? "xml" : format;
anchor.download = `sword-${orcid}.${extension}`;
document.body.appendChild(anchor);
@@ -151,10 +172,15 @@ export function DashboardPage() {
anchor.remove();
URL.revokeObjectURL(objectUrl);
}
- const scope =
- ids.length > 0
- ? `${ids.length} publicación${ids.length === 1 ? "" : "es"} seleccionada${ids.length === 1 ? "" : "s"}`
- : "todo el investigador";
+
+ let scope;
+ if (selectedIds.size > 0) {
+ 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`, {
description: scope,
});
@@ -182,6 +208,8 @@ export function DashboardPage() {
onExport={handleExport}
exportingFormat={exportingFormat}
selectedCount={selectedIds.size}
+ isAuthenticated={isAuthenticated}
+ newPublicationsCount={newPublicationIds.length}
/>
>
}
@@ -199,6 +227,7 @@ export function DashboardPage() {
onRetry={() => loadBundle()}
selectedIds={selectedIds}
onSelectedIdsChange={setSelectedIds}
+ isAuthenticated={isAuthenticated}
/>
diff --git a/frontend/src/pages/GroupResultsPage.jsx b/frontend/src/pages/GroupResultsPage.jsx
new file mode 100644
index 0000000..8a29ad4
--- /dev/null
+++ b/frontend/src/pages/GroupResultsPage.jsx
@@ -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 (
+
+
+
+
+ {/* Page header */}
+
+
+
+
+
+
+
+ Búsqueda grupal
+
+ {!loading && (
+
+ {results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
+ {errors.length > 0 && (
+
+ · {errors.length} con error
+
+ )}
+
+ )}
+
+
+
+ {/* Global export buttons */}
+ {!loading && results.length > 0 && (
+
+ {["xml", "zip"].map((fmt) => (
+ 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 ? (
+
+ ) : isAuthenticated && allNewIds.length > 0 ? (
+
+ ) : (
+
+ )}
+ {globalExporting === fmt
+ ? `Exportando ${fmt.toUpperCase()}...`
+ : `${fmt.toUpperCase()} · ${globalLabel}`}
+
+ ))}
+
+ )}
+
+
+ {/* Loading state */}
+ {loading && (
+
+
+
+ Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
+
+
+ Esto puede tardar unos segundos si hay muchos perfiles nuevos.
+
+
+ )}
+
+ {/* Results grid */}
+ {!loading && results.length > 0 && (
+
+ {results.map((bundle) => (
+
+ handleCardExport(
+ bundle.researcher?.orcid_id,
+ fmt,
+ newIds,
+ totalIds,
+ )
+ }
+ />
+ ))}
+
+ )}
+
+ {/* Errors */}
+ {!loading && errors.length > 0 && (
+
+
+ ORCID iDs que no pudieron cargarse
+
+
+ {errors.map((e) => (
+
+
+
+
+ {e.orcid_id}
+
+
+ {e.detail ?? "No se pudo obtener información de este ORCID."}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* Empty state */}
+ {!loading && results.length === 0 && errors.length === 0 && (
+
+
+
No se encontraron resultados.
+
+
+ Volver al inicio
+
+
+ )}
+
+
+ );
+}
+
+/* ─────────────────────────── 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 (
+
+ {/* Researcher identity */}
+
+
+ {initials}
+
+
+
+ {researcher.name || "Sin nombre"}
+
+
+
+
+ {researcher.orcid_id}
+
+
+ {researcher.affiliation && (
+
+ {researcher.affiliation}
+
+ )}
+
+
+
+ {/* Stats row */}
+
+
+
{totalRecords}
+
publicaciones
+
+ {isAuthenticated && (
+ <>
+
+
+
+ {newCount}
+
+
nuevas
+
+ >
+ )}
+
+
+ {/* Actions */}
+
+
+ Ver detalle
+
+ onExport(fmt, newIds, allPubIds)}
+ exporting={exporting}
+ isAuthenticated={isAuthenticated}
+ hasNew={hasNew}
+ newCount={newCount}
+ totalCount={totalRecords}
+ />
+
+
+ );
+}
+
+/* ─────────────────────── 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 (
+
+
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 ? (
+
+ ) : hasNew ? (
+
+ ) : (
+
+ )}
+ {label}
+
+
+ {open && (
+
+ {["xml", "zip"].map((fmt, idx) => (
+ {
+ 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" : ""
+ }`}
+ >
+
+ {fmt.toUpperCase()}
+
+ ))}
+
+ )}
+
+ );
+}
+
+/* ─────────────────────────── 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;
diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx
index f21418b..be0f237 100644
--- a/frontend/src/pages/LandingPage.jsx
+++ b/frontend/src/pages/LandingPage.jsx
@@ -1,31 +1,54 @@
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
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 { Spinner } from "../components/ui/Spinner";
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
- * para 1 solo ORCID) es "todo en uno":
- * valida el formato + dígito de control en el servidor, lo crea en BD si
- * no existe, sincroniza con ORCID y devuelve `researcher + publications`.
- * Por eso aquí basta con una sola llamada y, una vez que tenemos el
- * bundle, navegamos al dashboard pasándoselo por `state` para evitar
- * la doble petición.
+ * 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.
*/
export function LandingPage() {
const navigate = useNavigate();
+ const { isAuthenticated, storeToken } = useAuth();
+
const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState("");
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) {
setOrcidInput(formatOrcidInput(event.target.value));
@@ -44,7 +67,7 @@ export function LandingPage() {
const bundle = await searchResearcher(orcidInput);
navigate(`/dashboard/${orcidInput}`, { state: { bundle } });
} 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.",
});
} finally {
@@ -52,19 +75,105 @@ export function LandingPage() {
}
}
- async function handleOrcidLogin() {
- setOauthLoading(true);
- try {
- // Real implementation will redirect to ORCID OAuth (handled by backend).
- // For now we emulate the flow locally with a known sample ORCID.
- await new Promise((r) => setTimeout(r, 800));
- navigate(`/dashboard/0000-0002-1234-5678`);
- } catch (err) {
- toast.error("No se pudo iniciar sesión con ORCID", {
- description: err?.message ?? "Inténtalo de nuevo.",
+ 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();
+ 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 {
- setOauthLoading(false);
+ setGroupLoading(false);
}
}
@@ -72,6 +181,13 @@ export function LandingPage() {
if (event.key === "Enter") handleValidate();
}
+ function handleGroupKeyDown(event) {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ handleGroupSearch();
+ }
+ }
+
return (
@@ -94,22 +210,41 @@ export function LandingPage() {
{/* Main card */}
-
- {oauthLoading ? : }
- {oauthLoading
- ? "Redirigiendo a ORCID..."
- : "Iniciar sesión con ORCID"}
-
+ {isAuthenticated ? (
+
+ Sesión activa
+
+ Verás publicaciones nuevas marcadas en el dashboard
+
+
+ ) : (
+ <>
+
+ {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.
+
+ )}
+ >
+ )}
- O INTRODUCE TU ORCID iD
+ {isAuthenticated ? "TU ORCID iD" : "O INTRODUCE TU ORCID iD"}
@@ -141,7 +276,7 @@ export function LandingPage() {
)}
- Formato: 16 dígitos separados con guiones (ej.
- 0000-0002-1234-5678)
+ {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."}
+ {/* Group search card */}
+
+
+
+
+ Búsqueda grupal de investigadores
+
+
+
+ Pega varios ORCID iDs separados por comas, espacios o saltos de
+ línea para buscar y comparar varios investigadores a la vez.
+
+
+
{/* Info chips */}
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js
index 44ea0bb..0effa9f 100644
--- a/frontend/src/services/api.js
+++ b/frontend/src/services/api.js
@@ -50,8 +50,8 @@ export class ApiError extends Error {
/**
* Construye la cabecera base que llevan TODAS las peticiones (incluidas
- * las descargas de blob). Si la API key está sin definir lo avisamos en
- * consola para no fallar silenciosamente con un 401 críptico.
+ * las descargas de blob). Incluye X-API-Key siempre y, si existe un JWT
+ * en localStorage, también Authorization: Bearer .
*/
function buildAuthHeaders(extra = {}) {
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.",
);
}
+ const token = localStorage.getItem("orcid_auth_token");
return {
Accept: "application/json",
...(API_KEY ? { "X-API-Key": API_KEY } : {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
...extra,
};
}
@@ -143,6 +145,8 @@ 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,
};
}
@@ -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=
+ *
+ * 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 ─────────────────────────────── */
/**