diff --git a/backend/app/security/jwt.py b/backend/app/security/jwt.py index 7edab3d..e49042a 100644 --- a/backend/app/security/jwt.py +++ b/backend/app/security/jwt.py @@ -129,10 +129,30 @@ def get_optional_current_researcher( db: Session = Depends(get_db), ) -> Researcher | None: """ - Devuelve el investigador autenticado si hay Bearer válido. - Si no hay Bearer, devuelve None. - Si hay Bearer inválido, lanza 401 (no se acepta como anónimo). + Devuelve el investigador autenticado si hay Bearer válido y la sesión sigue activa. + + Sin Bearer, token inválido/expirado o investigador no autenticado → None. + Las rutas públicas (p. ej. búsqueda) deben seguir funcionando aunque el navegador + conserve un JWT caducado en localStorage. """ if not creds or not creds.credentials: return None - return get_current_researcher(request=request, creds=creds, db=db) + + try: + payload = _decode_token(creds.credentials) + except HTTPException: + return None + + if payload.get("typ") != "access": + return None + + orcid_id = payload.get("sub") + if not isinstance(orcid_id, str) or not is_valid_orcid(orcid_id): + return None + + researcher = db.query(Researcher).filter(Researcher.orcid_id == orcid_id).first() + if not researcher or not researcher.authenticated: + return None + + request.state.researcher = researcher + return researcher diff --git a/frontend/public/eprints-logo.svg b/frontend/public/eprints-logo.svg new file mode 100644 index 0000000..1ef81d4 --- /dev/null +++ b/frontend/public/eprints-logo.svg @@ -0,0 +1 @@ +eprints-logo \ No newline at end of file diff --git a/frontend/src/components/dashboard/ExportDropdown.jsx b/frontend/src/components/dashboard/ExportDropdown.jsx index 954d071..d9a5c23 100644 --- a/frontend/src/components/dashboard/ExportDropdown.jsx +++ b/frontend/src/components/dashboard/ExportDropdown.jsx @@ -1,40 +1,17 @@ -import { useEffect, useRef, useState } from "react"; import { - ChevronDownIcon, - DocumentIcon, DownloadIcon, - PackageIcon, SparkleIcon, } from "../ui/Icons"; import { Spinner } from "../ui/Spinner"; import { SwordProfileSelect } from "./SwordProfileSelect"; -import { DEFAULT_EXPORT_PROFILE } from "../../utils/exportProfiles"; - -const FORMATS = [ - { - format: "xml", - icon: , - label: "SWORD XML", - desc: "Según destino seleccionado", - }, - { - format: "zip", - icon: , - label: "Paquete ZIP", - desc: "XML + ficheros adjuntos", - }, -]; +import { + DEFAULT_EXPORT_DESTINATION, + resolveExportFromDestination, +} from "../../utils/exportProfiles"; /** - * SWORD export dropdown. Delegatea the actual download to `onExport(format)`. - * - * 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). - * - `swordProfile` → perfil SWORD (dublin_core, dspace, eprints…). - * - `onSwordProfileChange` → callback al cambiar destino. + * Controles de exportación: selector de destino + botón único de descarga. + * Delega la descarga en `onExport(format, profile)`. */ export function ExportDropdown({ onExport, @@ -42,38 +19,24 @@ export function ExportDropdown({ selectedCount = 0, isAuthenticated = false, newPublicationsCount = 0, - swordProfile = DEFAULT_EXPORT_PROFILE, - onSwordProfileChange, + exportDestination = DEFAULT_EXPORT_DESTINATION, + onExportDestinationChange, }) { - const [open, setOpen] = useState(false); - const rootRef = useRef(null); - - useEffect(() => { - function handleClick(event) { - if (rootRef.current && !rootRef.current.contains(event.target)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, []); - const isBusy = Boolean(exportingFormat); const hasSelection = selectedCount > 0; - function handlePick(format) { - setOpen(false); - onExport(format, format === "xml" ? swordProfile : undefined); + const nothingToDownload = + isAuthenticated && !hasSelection && newPublicationsCount === 0; + + function handleDownload() { + const { format, profile } = resolveExportFromDestination(exportDestination); + onExport(format, profile); } - // 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})`; + idleLabel = `Descargar selección (${selectedCount})`; } else if (isAuthenticated) { if (newPublicationsCount > 0) { idleLabel = `Descargar lo nuevo (${newPublicationsCount})`; @@ -86,18 +49,18 @@ export function ExportDropdown({ } return ( -
+
-
- - {open && ( -
- {FORMATS.map(({ format, icon, label, desc }, idx) => ( - - ))} -
- )} -
); } diff --git a/frontend/src/components/dashboard/ResearcherCard.jsx b/frontend/src/components/dashboard/ResearcherCard.jsx index 7328cfd..c869fbf 100644 --- a/frontend/src/components/dashboard/ResearcherCard.jsx +++ b/frontend/src/components/dashboard/ResearcherCard.jsx @@ -44,7 +44,7 @@ export function ResearcherCard({ researcher, actions = null }) {
{actions && ( -
+
{actions}
)} diff --git a/frontend/src/components/dashboard/SwordProfileSelect.jsx b/frontend/src/components/dashboard/SwordProfileSelect.jsx index 5cf4dbf..ce8e4f4 100644 --- a/frontend/src/components/dashboard/SwordProfileSelect.jsx +++ b/frontend/src/components/dashboard/SwordProfileSelect.jsx @@ -1,35 +1,103 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronDownIcon } from "../ui/Icons"; +import { ExportProfileIcon } from "../ui/destination-logos/ExportProfileIcon"; import { DEFAULT_EXPORT_PROFILE, EXPORT_PROFILE_OPTIONS, + ZIP_DESTINATION_OPTION, } from "../../utils/exportProfiles"; /** - * Selector de destino para exportación SWORD XML (DSpace, EPrints, Dublin Core…). + * Selector de destino para exportación (perfiles SWORD XML y, opcionalmente, ZIP). */ export function SwordProfileSelect({ value = DEFAULT_EXPORT_PROFILE, onChange, id = "sword-export-profile", className = "", + includeZip = false, }) { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + + const options = useMemo(() => { + const items = [...EXPORT_PROFILE_OPTIONS]; + if (includeZip) { + items.push(ZIP_DESTINATION_OPTION); + } + return items; + }, [includeZip]); + + const selected = options.find((opt) => opt.value === value) ?? options[0]; + + useEffect(() => { + function handleClick(event) { + if (rootRef.current && !rootRef.current.contains(event.target)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function handlePick(optionValue) { + onChange(optionValue); + setOpen(false); + } + return ( - +
+ + + {open && ( +
+ {options.map(({ value: optionValue, label, desc }, idx) => ( + + ))} +
+ )} +
+
); } diff --git a/frontend/src/components/ui/destination-logos/DSpaceLogo.jsx b/frontend/src/components/ui/destination-logos/DSpaceLogo.jsx new file mode 100644 index 0000000..68d9e7a --- /dev/null +++ b/frontend/src/components/ui/destination-logos/DSpaceLogo.jsx @@ -0,0 +1,18 @@ +import { DSPACE_LOGO_PATH, DSPACE_VIEWBOX } from "./dspace-path"; + +/** Logotipo oficial DSpace (#92C642). */ +export function DSpaceLogo({ size = 20, className = "" }) { + return ( + + + + ); +} diff --git a/frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx b/frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx new file mode 100644 index 0000000..2880590 --- /dev/null +++ b/frontend/src/components/ui/destination-logos/DublinCoreLogo.jsx @@ -0,0 +1,48 @@ +/** + * Logotipo DCMI Dublin Core: círculo central y anillos de puntos sobre fondo naranja. + * Réplica del icono oficial (sunburst). + */ + +const ORANGE = "#FF6600"; +const CENTER = 50; +const INNER = { count: 12, radius: 22, dotR: 4.8 }; +const OUTER = { count: 16, radius: 36, dotR: 3.6 }; +const CORE_R = 11.5; + +function ringDots({ count, radius, dotR }) { + return Array.from({ length: count }, (_, i) => { + const angle = (i / count) * Math.PI * 2 - Math.PI / 2; + return { + key: `${radius}-${i}`, + cx: CENTER + radius * Math.cos(angle), + cy: CENTER + radius * Math.sin(angle), + r: dotR, + }; + }); +} + +export function DublinCoreLogo({ size = 20, className = "" }) { + const innerDots = ringDots(INNER); + const outerDots = ringDots(OUTER); + + return ( + + + + {innerDots.map(({ key, cx, cy, r }) => ( + + ))} + {outerDots.map(({ key, cx, cy, r }) => ( + + ))} + + ); +} diff --git a/frontend/src/components/ui/destination-logos/EPrintsLogo.jsx b/frontend/src/components/ui/destination-logos/EPrintsLogo.jsx new file mode 100644 index 0000000..cee3131 --- /dev/null +++ b/frontend/src/components/ui/destination-logos/EPrintsLogo.jsx @@ -0,0 +1,19 @@ +/** + * Logotipo EPrints (vectorizado en `public/eprints-logo.svg`). + */ +const EPRINTS_LOGO_SRC = `${import.meta.env.BASE_URL}eprints-logo.svg`; + +export function EPrintsLogo({ size = 20, className = "" }) { + return ( + + ); +} diff --git a/frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx b/frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx new file mode 100644 index 0000000..5baff7a --- /dev/null +++ b/frontend/src/components/ui/destination-logos/ExportProfileIcon.jsx @@ -0,0 +1,26 @@ +import { DocumentIcon, PackageIcon } from "../Icons"; +import { OrcidLogo } from "../OrcidLogo"; +import { DublinCoreLogo } from "./DublinCoreLogo"; +import { DSpaceLogo } from "./DSpaceLogo"; +import { EPrintsLogo } from "./EPrintsLogo"; +import { EXPORT_ZIP_DESTINATION } from "../../../utils/exportProfiles"; + +/** + * Icono del destino de exportación (logos de repositorio o genérico). + */ +export function ExportProfileIcon({ profile, size = 20, className = "shrink-0" }) { + switch (profile) { + case "generic": + return ; + case "dublin_core": + return ; + case "dspace": + return ; + case "eprints": + return ; + case EXPORT_ZIP_DESTINATION: + return ; + default: + return ; + } +} diff --git a/frontend/src/components/ui/destination-logos/dspace-path.js b/frontend/src/components/ui/destination-logos/dspace-path.js new file mode 100644 index 0000000..1f87ed6 --- /dev/null +++ b/frontend/src/components/ui/destination-logos/dspace-path.js @@ -0,0 +1,5 @@ +/** Path oficial del logotipo DSpace (ver logos_preview_dropdown.html). */ +export const DSPACE_LOGO_PATH = + "M120.726,58.569l0.11-0.006l0.116-0.01l0.106-0.013l0.111-0.01l0.11-0.023l0.109-0.019l0.107-0.023l0.106-0.029l0.106-0.023l0.106-0.033l0.103-0.034l0.096-0.035l0.104-0.04l0.101-0.042l0.099-0.042v-0.001l0.096-0.045v0l0.095-0.044l0.096-0.049l0.091-0.056v-0.001l0.094-0.05v-0.002l0.09-0.056v-0.001l0.092-0.06l0.083-0.056v-0.001l0.085-0.063l0.088-0.065v-0.002l0.087-0.063v-0.001c0.817-0.683,1.393-1.646,1.561-2.738l0.012-0.104v-0.009l0.014-0.101v-0.011l0.009-0.098v-0.012l0.009-0.101V54.38l0.005-0.095v-0.016l0.002-0.105v-16.46l-0.002-0.105v-0.016l-0.005-0.095v-0.013l-0.009-0.101v-0.012l-0.009-0.098v-0.011l-0.014-0.1v-0.01l-0.012-0.104c-0.167-1.092-0.744-2.057-1.561-2.738v-0.001l-0.087-0.063v-0.002l-0.088-0.065l-0.085-0.063v-0.001l-0.083-0.056l-0.092-0.061v0l-0.09-0.056v-0.003l-0.094-0.05v-0.001l-0.091-0.056l-0.096-0.049l-0.095-0.043v-0.001l-0.096-0.045v-0.001l-0.099-0.043l-0.101-0.042l-0.104-0.04l-0.096-0.035l-0.103-0.031l-0.106-0.036l-0.106-0.023l-0.106-0.028l-0.107-0.024l-0.109-0.019l-0.11-0.023l-0.111-0.009l-0.106-0.014l-0.116-0.01l-0.11-0.006l-0.114-0.005h-7.89c-9.715,0-15.858-7.838-15.858-17.15V6.92c0-3.812-3.102-6.915-6.914-6.915H74.085c-3.814,0-6.92,3.106-6.92,6.915v16.682c0,3.806,3.104,6.909,6.92,6.909h8.414c9.169,0,16.906,5.95,17.146,15.403v0.04c-0.24,9.453-7.977,15.402-17.146,15.402h-8.414c-3.816,0-6.92,3.103-6.92,6.909v16.682c0,3.809,3.106,6.915,6.92,6.915H89.95c3.812,0,6.914-3.104,6.914-6.915v-9.223c0-9.312,6.144-17.149,15.858-17.149h7.89L120.726,58.569z M154.772,9.956C148.631,3.814,140.15,0,130.816,0h-15.024v17.424h15.024c4.527,0,8.648,1.858,11.64,4.849c2.99,2.99,4.848,7.112,4.848,11.639v24.042c0,4.538-1.852,8.665-4.832,11.655l-0.016-0.016c-2.991,2.991-7.113,4.849-11.64,4.849h-15.024v17.424h15.024c9.333,0,17.815-3.814,23.956-9.956v-0.033c6.142-6.143,9.955-14.614,9.955-23.923V33.912C164.727,24.578,160.914,16.097,154.772,9.956z"; + +export const DSPACE_VIEWBOX = "67 0 98 93"; diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 2fd3e27..bedf4dd 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -16,7 +16,10 @@ import { syncResearcher, } from "../services/api"; import { isValidOrcid } from "../utils/orcid"; -import { DEFAULT_EXPORT_PROFILE, swordXmlFilename } from "../utils/exportProfiles"; +import { + DEFAULT_EXPORT_DESTINATION, + swordXmlFilename, +} from "../utils/exportProfiles"; import { useAuth } from "../contexts/AuthContext"; const SUCCESS_FLASH_MS = 3000; @@ -50,7 +53,9 @@ export function DashboardPage() { const [syncStatus, setSyncStatus] = useState("idle"); // idle | loading | success const [exportingFormat, setExportingFormat] = useState(null); - const [swordProfile, setSwordProfile] = useState(DEFAULT_EXPORT_PROFILE); + const [exportDestination, setExportDestination] = useState( + DEFAULT_EXPORT_DESTINATION, + ); const [selectedIds, setSelectedIds] = useState(() => new Set()); @@ -140,7 +145,7 @@ export function DashboardPage() { } } - async function handleExport(format, profile = DEFAULT_EXPORT_PROFILE) { + async function handleExport(format, profile = DEFAULT_EXPORT_DESTINATION) { setExportingFormat(format); try { let ids; @@ -226,8 +231,8 @@ export function DashboardPage() { selectedCount={selectedIds.size} isAuthenticated={isAuthenticated} newPublicationsCount={newPublicationIds.length} - swordProfile={swordProfile} - onSwordProfileChange={setSwordProfile} + exportDestination={exportDestination} + onExportDestinationChange={setExportDestination} /> } diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index e6da373..2045fa0 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -129,7 +129,19 @@ async function request(path, { method = "GET", body, signal, headers } = {}) { const detail = payload?.detail ?? payload?.message ?? response.statusText ?? "Error"; - throw new ApiError(typeof detail === "string" ? detail : "Error de API", { + const detailText = typeof detail === "string" ? detail : "Error de API"; + + // Sesión caducada: no bloquear rutas públicas; el backend ya ignora Bearer inválido + // en búsqueda, pero otras rutas pueden seguir devolviendo 401. + if ( + response.status === 401 && + /invalid|expired|token/i.test(detailText) && + localStorage.getItem("orcid_auth_token") + ) { + localStorage.removeItem("orcid_auth_token"); + } + + throw new ApiError(detailText, { status: response.status, payload, }); diff --git a/frontend/src/utils/exportProfiles.js b/frontend/src/utils/exportProfiles.js index 1ab647d..71fa4a3 100644 --- a/frontend/src/utils/exportProfiles.js +++ b/frontend/src/utils/exportProfiles.js @@ -1,12 +1,45 @@ /** Perfiles de exportación SWORD XML (query `profile` en el backend). */ export const EXPORT_PROFILE_OPTIONS = [ - { value: "generic", label: "Genérico (ORCID)" }, - { value: "dublin_core", label: "Dublin Core" }, - { value: "dspace", label: "DSpace" }, - { value: "eprints", label: "EPrints" }, + { + value: "generic", + label: "Genérico (ORCID)", + desc: "Metadatos ORCID en SWORD XML", + }, + { + value: "dublin_core", + label: "Dublin Core", + desc: "Esquema Dublin Core", + }, + { + value: "dspace", + label: "DSpace", + desc: "Compatible con repositorio DSpace", + }, + { + value: "eprints", + label: "EPrints", + desc: "Compatible con repositorio EPrints", + }, ]; +export const EXPORT_ZIP_DESTINATION = "zip"; + +export const ZIP_DESTINATION_OPTION = { + value: EXPORT_ZIP_DESTINATION, + label: "Paquete ZIP", + desc: "Todos los formatos en un único paquete", +}; + export const DEFAULT_EXPORT_PROFILE = "generic"; +export const DEFAULT_EXPORT_DESTINATION = DEFAULT_EXPORT_PROFILE; + +/** Convierte el valor del selector de destino en formato + perfil SWORD. */ +export function resolveExportFromDestination(destination) { + if (destination === EXPORT_ZIP_DESTINATION) { + return { format: "zip", profile: undefined }; + } + return { format: "xml", profile: destination }; +} export function swordXmlFilename(baseName, profile = DEFAULT_EXPORT_PROFILE) { const suffix =