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/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx index 63509f4..3f7191e 100644 --- a/frontend/src/components/dashboard/PublicationsTable.jsx +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons"; +import { CustomSelect } from "../ui/CustomSelect"; import { Spinner } from "../ui/Spinner"; import { Badge } from "../ui/Badge"; @@ -70,11 +71,9 @@ function TriStateCheckbox({ checked, indeterminate = false, onChange, ariaLabel * retries and toasts can be handled in one place. * * Selection semantics: - * - The master checkbox toggles the WHOLE currently-filtered set (not - * just the visible page). This matches the user mental model of - * "filtrar por 2024 → marcar todas de 2024". - * - Selection survives filter changes: the stored IDs remain even if - * those rows are no longer visible. + * - The master checkbox toggles only the rows on the current page. + * - Selection is stored by ID in the parent and persists across pages, + * filters and sorts so the user can select page by page. */ export function PublicationsTable({ publications, @@ -151,20 +150,19 @@ export function PublicationsTable({ return filtered.slice(start, start + PAGE_SIZE); }, [filtered, currentPage]); - const selectionStats = useMemo(() => { - if (filtered.length === 0) { - return { allChecked: false, anyChecked: false, selectedInFiltered: 0 }; + const pageSelectionStats = useMemo(() => { + if (pageRows.length === 0) { + return { allChecked: false, anyChecked: false }; } let count = 0; - for (const pub of filtered) { + for (const pub of pageRows) { if (selectedIds.has(pub.id)) count += 1; } return { - allChecked: count === filtered.length, + allChecked: count === pageRows.length, anyChecked: count > 0, - selectedInFiltered: count, }; - }, [filtered, selectedIds]); + }, [pageRows, selectedIds]); function toggleSort(key) { if (sortKey === key) { @@ -188,12 +186,12 @@ export function PublicationsTable({ emit(next); } - function toggleAllFiltered() { + function toggleCurrentPage() { const next = new Set(selectedIds); - if (selectionStats.allChecked) { - for (const pub of filtered) next.delete(pub.id); + if (pageSelectionStats.allChecked) { + for (const pub of pageRows) next.delete(pub.id); } else { - for (const pub of filtered) next.add(pub.id); + for (const pub of pageRows) next.add(pub.id); } emit(next); } @@ -314,20 +312,16 @@ export function PublicationsTable({ > Desde año - + options={availableYears.map((y) => ({ + value: String(y), + label: String(y), + }))} + />
- + options={availableYears.map((y) => ({ + value: String(y), + label: String(y), + }))} + />
{hasYearFilter && ( + + {open && ( +
+ {options.map(({ value: optionValue, label, desc }, idx) => ( + + ))} +
+ )} + + ); } diff --git a/frontend/src/components/ui/CustomSelect.jsx b/frontend/src/components/ui/CustomSelect.jsx new file mode 100644 index 0000000..8597d01 --- /dev/null +++ b/frontend/src/components/ui/CustomSelect.jsx @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronDownIcon } from "./Icons"; + +/** + * Desplegable personalizado (sustituto del `