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:
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
<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}
|
{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 || "—"}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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">
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<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">
|
||||||
|
<span className="font-medium">Sesión activa</span>
|
||||||
|
<span className="text-xs text-green-600">
|
||||||
|
Verás publicaciones nuevas marcadas en el dashboard
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOrcidLogin}
|
onClick={handleOrcidLogin}
|
||||||
disabled={oauthLoading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{oauthLoading ? <Spinner size={17} /> : <OrcidLogo />}
|
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
|
||||||
{oauthLoading
|
{loginLoading
|
||||||
? "Redirigiendo a ORCID..."
|
? "Abriendo ventana de ORCID..."
|
||||||
|
: AUTH_BYPASS
|
||||||
|
? "Simular login (bypass)"
|
||||||
: "Iniciar sesión con ORCID"}
|
: "Iniciar sesión con ORCID"}
|
||||||
</button>
|
</button>
|
||||||
|
{AUTH_BYPASS && (
|
||||||
|
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-1.5 text-center text-xs text-amber-700">
|
||||||
|
Modo bypass activo — introduce un ORCID abajo y pulsa el botón.
|
||||||
|
No se valida contra el backend.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<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) => (
|
||||||
|
|||||||
@@ -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 ─────────────────────────────── */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user