From 25dfeec3f72bde42ef36d5c0e175e75a45da268d Mon Sep 17 00:00:00 2001 From: Alexis Date: Wed, 29 Apr 2026 12:19:47 +0200 Subject: [PATCH] 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. --- frontend/.env.example | 22 + frontend/src/App.jsx | 9 +- .../components/dashboard/ExportDropdown.jsx | 45 +- .../dashboard/PublicationsTable.jsx | 16 +- frontend/src/components/layout/AppHeader.jsx | 58 +- frontend/src/components/ui/Icons.jsx | 36 ++ frontend/src/contexts/AuthContext.jsx | 81 +++ frontend/src/pages/AuthCallbackPage.jsx | 154 ++++++ frontend/src/pages/DashboardPage.jsx | 77 ++- frontend/src/pages/GroupResultsPage.jsx | 496 ++++++++++++++++++ frontend/src/pages/LandingPage.jsx | 262 +++++++-- frontend/src/services/api.js | 40 +- 12 files changed, 1211 insertions(+), 85 deletions(-) create mode 100644 frontend/src/contexts/AuthContext.jsx create mode 100644 frontend/src/pages/AuthCallbackPage.jsx create mode 100644 frontend/src/pages/GroupResultsPage.jsx diff --git a/frontend/.env.example b/frontend/.env.example index bb24d07..be05084 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 # con los fixtures de src/services/mocks.js. En producción debe estar a "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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 696d090..ff5c2cb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,11 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { Toaster } from "sonner"; +import { AuthProvider } from "./contexts/AuthContext"; import { LandingPage } from "./pages/LandingPage"; 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 @@ -11,10 +14,12 @@ import { DashboardPage } from "./pages/DashboardPage"; */ export default function App() { return ( - <> + } /> } /> + } /> + } /> } /> @@ -25,6 +30,6 @@ export default function App() { theme="light" toastOptions={{ duration: 4000 }} /> - + ); } diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx index 3cbe68e..d76cdb9 100644 --- a/frontend/src/components/dashboard/ExportDropdown.jsx +++ b/frontend/src/components/dashboard/ExportDropdown.jsx @@ -4,6 +4,7 @@ import { DocumentIcon, DownloadIcon, PackageIcon, + SparkleIcon, } from "../ui/Icons"; import { Spinner } from "../ui/Spinner"; @@ -23,17 +24,20 @@ const FORMATS = [ ]; /** - * SWORD export dropdown. Delegates 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. + * SWORD export dropdown. Delegatea the actual download to `onExport(format)`. * - * `exportingFormat` (optional) lets the parent keep the button in a loading - * state between clicks (e.g. while waiting for the backend blob). + * Props: + * - `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({ onExport, exportingFormat = null, selectedCount = 0, + isAuthenticated = false, + newPublicationsCount = 0, }) { const [open, setOpen] = useState(false); const rootRef = useRef(null); @@ -56,19 +60,40 @@ export function ExportDropdown({ onExport(format); } - const idleLabel = hasSelection - ? `Exportar seleccionadas (${selectedCount})` - : "Exportar todas"; + // Label logic: + // manual selection → always "Exportar seleccionadas (N)" + // 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 (
+
+ )} - Sistema ORCID · SWORD + {variant === "group" ? "Búsqueda grupal · ORCID" : "Sistema ORCID · SWORD"} ); @@ -35,6 +65,22 @@ export function AppHeader({ variant = "landing" }) { Sistema de Integración ORCID · SWORD + {isAuthenticated && ( +
+ + + Sesión activa + + +
+ )} ); } 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} />