diff --git a/backend/app/core/rate_limit.py b/backend/app/core/rate_limit.py index 92b2e82..942bb2b 100644 --- a/backend/app/core/rate_limit.py +++ b/backend/app/core/rate_limit.py @@ -25,11 +25,17 @@ def _key_func(request: Request) -> str: Devuelve la clave de rate limit para el request. - Si hay un investigador autenticado en el state, usa su orcid_id. - - En caso contrario, usa la IP remota. + - Si hay cabecera X-Forwarded-For (ngrok, nginx, cualquier proxy inverso), + usa la primera IP de la cadena (la del cliente real). + - En caso contrario, usa la IP remota del socket. """ researcher = getattr(request.state, "researcher", None) if researcher is not None: return f"user:{getattr(researcher, 'orcid_id', None) or researcher.id}" + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + client_ip = forwarded_for.split(",")[0].strip() + return f"ip:{client_ip}" return f"ip:{get_remote_address(request)}" diff --git a/backend/app/core/security_headers.py b/backend/app/core/security_headers.py index 18742c9..9a20ea8 100644 --- a/backend/app/core/security_headers.py +++ b/backend/app/core/security_headers.py @@ -49,7 +49,8 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): "geolocation=(), microphone=(), camera=(), payment=(), usb=(), " "accelerometer=(), gyroscope=(), magnetometer=(), interest-cohort=()", ) - response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin") + + response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin-allow-popups") response.headers.setdefault("Cross-Origin-Resource-Policy", "same-site") response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none") @@ -66,7 +67,6 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): hsts += "; preload" response.headers.setdefault("Strict-Transport-Security", hsts) - # `MutableHeaders` no implementa `.pop()`. Eliminamos de forma segura. if "server" in response.headers: del response.headers["server"] if "x-powered-by" in response.headers: diff --git a/docker-compose.yml b/docker-compose.yml index 31f0586..c45f304 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: security_opt: - no-new-privileges:true healthcheck: - test: ["CMD", "curl", "-fsS", "http://0.0.0.0:8000/health"] + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"] interval: 30s timeout: 5s retries: 3 diff --git a/frontend/.gitlab-ci.yml b/frontend/.gitlab-ci.yml new file mode 100644 index 0000000..eae3f23 --- /dev/null +++ b/frontend/.gitlab-ci.yml @@ -0,0 +1,37 @@ +stages: + - deploy + +variables: + APP_NAME: "orcid-system" + BACKEND_PORT: "8072" + FRONTEND_PORT: "8073" + +deploy_to_sinbad2: + stage: deploy + + before_script: + - eval $(ssh-agent -s) + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add - + - mkdir -p ~/.ssh + - chmod 700 ~/.ssh + - ssh-keyscan $SSH_HOST >> ~/.ssh/known_hosts + + script: + - echo "Enviando código a Sinbad2..." + - ssh $REMOTE_USER@$SSH_HOST "mkdir -p ~/deploy_$APP_NAME" + + - scp -r ./* $REMOTE_USER@$SSH_HOST:~/deploy_$APP_NAME/ + + - echo "Levantando contenedores con Docker Compose..." + - ssh $REMOTE_USER@$SSH_HOST " + cd ~/deploy_$APP_NAME && + docker compose down --remove-orphans && + docker compose up --build -d + " + + - echo "Despliegue completado." + - echo "Backend -> http://$SSH_HOST:$BACKEND_PORT" + - echo "Frontend -> http://$SSH_HOST:$FRONTEND_PORT" + + only: + - branches diff --git a/frontend/public/uja-logo.png b/frontend/public/uja-logo.png new file mode 100644 index 0000000..77d17fd Binary files /dev/null and b/frontend/public/uja-logo.png differ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 17b6d82..76683e6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,11 +24,21 @@ export default function App() { ); diff --git a/frontend/src/components/layout/AppHeader.jsx b/frontend/src/components/layout/AppHeader.jsx index 029e66c..a846ae6 100644 --- a/frontend/src/components/layout/AppHeader.jsx +++ b/frontend/src/components/layout/AppHeader.jsx @@ -30,7 +30,7 @@ export function AppHeader({ variant = "landing" }) { {/* Brand — always navigates home */} ORCID2SWORD diff --git a/frontend/src/components/layout/Footer.jsx b/frontend/src/components/layout/Footer.jsx new file mode 100644 index 0000000..c8b7654 --- /dev/null +++ b/frontend/src/components/layout/Footer.jsx @@ -0,0 +1,86 @@ +export default function Footer() { + const technologies = ["ORCID OAuth 2.0", "SWORD v2", "DSpace", "EPrints", "Dublin Core"]; + + return ( + + ); + } \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 4e4c833..5648c96 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -78,6 +78,20 @@ export function AuthProvider({ children }) { return () => window.removeEventListener("message", handleMessage); }, []); + // Fallback when postMessage cannot reach the opener (e.g. browser policy + // severs window.opener during the OAuth redirect chain). localStorage is + // shared between same-origin windows, so the popup's `setItem(...)` fires + // a storage event in this window and we can pick up the new token. + useEffect(() => { + function handleStorage(event) { + if (event.key !== STORAGE_KEY) return; + if (event.newValue) setToken(event.newValue); + else setToken(null); + } + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + /** * Stores a JWT directly (used by AuthCallbackPage). * Does NOT trigger any network request. diff --git a/frontend/src/index.css b/frontend/src/index.css index 32818c8..704dfbe 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -55,6 +55,12 @@ --color-tag-default-text: #5F5E5A; --color-tag-default-border: #D3D1C7; + /* Error (hue-0° mirrors of the ORCID green palette — same HSL lightness & saturation) */ + --color-error-vivid: #CE3939; + --color-error-soft: #F3DDDD; + --color-error-border: #DD9797; + --color-error-text: #6E1111; + /* Fonts */ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; diff --git a/frontend/src/pages/AuthCallbackPage.jsx b/frontend/src/pages/AuthCallbackPage.jsx index ae5bb8a..21d852e 100644 --- a/frontend/src/pages/AuthCallbackPage.jsx +++ b/frontend/src/pages/AuthCallbackPage.jsx @@ -36,6 +36,7 @@ export function AuthCallbackPage() { hasHandledCodeRef.current = true; const code = searchParams.get("code"); + const state = searchParams.get("state"); const oauthError = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); @@ -69,7 +70,7 @@ export function AuthCallbackPage() { } sessionStorage.setItem(consumedKey, "1"); - exchangeOrcidCode(code) + exchangeOrcidCode(code, { state }) .then(({ access_token }) => { storeToken(access_token); setStatus("success"); @@ -87,16 +88,29 @@ export function AuthCallbackPage() { // 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). + // After a short delay, always attempt window.close(): + // - If we're in the OAuth popup (opened by window.open()), the browser + // allows close() and the window disappears. + // - If the window doesn't close (browser blocked it, or the user opened + // /callback directly as a plain tab), we detect that via window.closed + // and fall back to navigating to "/" so the user sees the landing page. + // + // NOTE: Neither window.opener nor window.name are reliable here. + // - window.name is cleared by Chrome on cross-origin navigation + // (our domain → sandbox.orcid.org → our domain clears the name). + // - window.opener is severed by ORCID Sandbox's own COOP header + // while the popup passes through their domain. useEffect(() => { - if (status === "success" || status === "error") { - const isPopup = Boolean(window.opener); - if (!isPopup) { - const timer = setTimeout(() => navigate("/"), 2000); - return () => clearTimeout(timer); - } - } + if (status !== "success" && status !== "error") return; + const outer = setTimeout(() => { + window.close(); + // Give the browser a tick to process the close. If the window + // is still open, we're in a plain tab — navigate to home instead. + setTimeout(() => { + if (!window.closed) navigate("/"); + }, 300); + }, 1500); + return () => clearTimeout(outer); }, [status, navigate]); return ( @@ -154,9 +168,16 @@ export function AuthCallbackPage() { /* ─────────────────────────── 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 "/". + * If running in a popup, posts a message to the opener so the parent + * window can update its auth state without waiting for the storage event + * fallback in AuthContext. The actual `window.close()` is handled by the + * delayed effect above so we don't race with the success/error UI. + * + * `window.opener` may be `null` here when the browser severed the opener + * relationship during the OAuth redirect chain (some COOP combinations + * trigger this). In that case AuthContext picks up the new token via the + * `storage` event instead — that's why we still call `storeToken()` even + * when we can't postMessage. */ function notifyAndClose(message) { if (window.opener && !window.opener.closed) { @@ -165,8 +186,6 @@ function notifyAndClose(message) { } catch { /* opener may have navigated away */ } - // Small delay so the user sees the success/error state before close. - setTimeout(() => window.close(), 1200); } } diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index cdd9a17..276d7e9 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -3,6 +3,7 @@ import { useLocation, useParams, Navigate } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; +import Footer from "../components/layout/Footer"; import { ResearcherCard } from "../components/dashboard/ResearcherCard"; import { StatsRow } from "../components/dashboard/StatsRow"; import { PublicationsTable } from "../components/dashboard/PublicationsTable"; @@ -196,53 +197,42 @@ export function DashboardPage() { return (
+
+
+ {researcher ? ( + + + + + } + /> + ) : ( + + )} -
- {researcher ? ( - - - - - } + + + loadBundle()} + selectedIds={selectedIds} + onSelectedIdsChange={setSelectedIds} + isAuthenticated={isAuthenticated} /> - ) : ( - - )} - - - - loadBundle()} - selectedIds={selectedIds} - onSelectedIdsChange={setSelectedIds} - isAuthenticated={isAuthenticated} - /> - -
- - Datos obtenidos vía ORCID Public API v3.0 - -
- {["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => ( - - {t} - - ))} -
-
-
+
+
+
); } diff --git a/frontend/src/pages/GroupResultsPage.jsx b/frontend/src/pages/GroupResultsPage.jsx index 8a29ad4..5991b95 100644 --- a/frontend/src/pages/GroupResultsPage.jsx +++ b/frontend/src/pages/GroupResultsPage.jsx @@ -3,6 +3,7 @@ import { useLocation, useNavigate, Link } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; +import Footer from "../components/layout/Footer"; import { Spinner } from "../components/ui/Spinner"; import { OrcidLogo } from "../components/ui/OrcidLogo"; import { @@ -188,135 +189,137 @@ export function GroupResultsPage() { return (
+
+
+ {/* Page header */} +
+
+
+ +
+
+

+ Búsqueda grupal +

+ {!loading && ( +

+ {results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""} + {errors.length > 0 && ( + + · {errors.length} con error + + )} +

+ )} +
+
-
- {/* Page header */} -
-
-
- -
-
-

- Búsqueda grupal -

- {!loading && ( -

- {results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""} - {errors.length > 0 && ( - - · {errors.length} con error - - )} -

- )} -
+ {/* Global export buttons */} + {!loading && results.length > 0 && ( +
+ {["xml", "zip"].map((fmt) => ( + + ))} +
+ )}
- {/* Global export buttons */} + {/* Loading state */} + {loading && ( +
+ +

+ Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID... +

+

+ Esto puede tardar unos segundos si hay muchos perfiles nuevos. +

+
+ )} + + {/* Results grid */} {!loading && results.length > 0 && ( -
- {["xml", "zip"].map((fmt) => ( - +
+ {results.map((bundle) => ( + + handleCardExport( + bundle.researcher?.orcid_id, + fmt, + newIds, + totalIds, + ) + } + /> ))}
)} -
- {/* Loading state */} - {loading && ( -
- -

- Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID... -

-

- Esto puede tardar unos segundos si hay muchos perfiles nuevos. -

-
- )} - - {/* Results grid */} - {!loading && results.length > 0 && ( -
- {results.map((bundle) => ( - - handleCardExport( - bundle.researcher?.orcid_id, - fmt, - newIds, - totalIds, - ) - } - /> - ))} -
- )} - - {/* Errors */} - {!loading && errors.length > 0 && ( -
-

- ORCID iDs que no pudieron cargarse -

-
- {errors.map((e) => ( -
- -
-

- {e.orcid_id} -

-

- {e.detail ?? "No se pudo obtener información de este ORCID."} -

+ {/* Errors */} + {!loading && errors.length > 0 && ( +
+

+ ORCID iDs que no pudieron cargarse +

+
+ {errors.map((e) => ( +
+ +
+

+ {e.orcid_id} +

+

+ {e.detail ?? "No se pudo obtener información de este ORCID."} +

+
-
- ))} + ))} +
-
- )} + )} - {/* Empty state */} - {!loading && results.length === 0 && errors.length === 0 && ( -
- -

No se encontraron resultados.

- - - Volver al inicio - -
- )} -
+ {/* Empty state */} + {!loading && results.length === 0 && errors.length === 0 && ( +
+ +

No se encontraron resultados.

+ + + Volver al inicio + +
+ )} +
+
+
); } diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index 6793a2c..8a9ce27 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -1,15 +1,17 @@ import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; -import { DocumentIcon, UsersIcon } from "../components/ui/Icons"; +import { UsersIcon } from "../components/ui/Icons"; import { OrcidLogo } from "../components/ui/OrcidLogo"; import { Spinner } from "../components/ui/Spinner"; +import { getInitials } from "../utils/formatters"; import { formatOrcidInput, isValidOrcid } from "../utils/orcid"; import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api"; import { useAuth } from "../contexts/AuthContext"; import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; +import Footer from "../components/layout/Footer"; /** * Entry view: login con ORCID iD + búsqueda individual anónima + @@ -21,7 +23,22 @@ import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; */ export function LandingPage() { const navigate = useNavigate(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, userName, userOrcidId } = useAuth(); + + // If the JWT doesn't carry the name (ORCID sandbox omits it), fetch it + // lazily from the public researcher endpoint so the identity block is correct. + const [resolvedName, setResolvedName] = useState(null); + useEffect(() => { + if (!isAuthenticated || !userOrcidId) { setResolvedName(null); return; } + if (userName) { setResolvedName(userName); return; } + let cancelled = false; + searchResearcher(userOrcidId) + .then((bundle) => { if (!cancelled) setResolvedName(bundle.researcher?.name ?? null); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [isAuthenticated, userOrcidId, userName]); + + const displayName = resolvedName ?? userName; const [orcidInput, setOrcidInput] = useState(""); const [error, setError] = useState(""); @@ -29,9 +46,11 @@ export function LandingPage() { const [loginLoading, setLoginLoading] = useState(false); // Group search state - const [groupInput, setGroupInput] = useState(""); + const [groupTags, setGroupTags] = useState([]); + const [groupRawInput, setGroupRawInput] = useState(""); const [groupError, setGroupError] = useState(""); const [groupLoading, setGroupLoading] = useState(false); + const groupInputRef = useRef(null); // Cleanup refs for popup polling interval const popupRef = useRef(null); @@ -126,28 +145,60 @@ export function LandingPage() { } } - function parseGroupOrcids(raw) { - return raw - .split(/[\s,\n]+/) - .map((s) => s.trim()) - .filter(Boolean); + /** + * Splits a raw string on comma/space/newline separators, promotes valid + * ORCIDs to tags (deduplicating against existing ones), and returns any + * leftover invalid tokens joined by a space so the user can correct them. + */ + function commitRawInput(raw) { + const parts = raw.split(/[\s,\n]+/).map((s) => s.trim()).filter(Boolean); + const valid = parts.filter(isValidOrcid); + const invalid = parts.filter((p) => !isValidOrcid(p)); + if (valid.length > 0) { + setGroupTags((prev) => [...new Set([...prev, ...valid])]); + if (groupError) setGroupError(""); + } + return invalid.join(" "); + } + + function handleGroupTagKeyDown(event) { + const { key } = event; + if (key === "Enter" || key === "," || key === " ") { + event.preventDefault(); + const leftover = commitRawInput(groupRawInput); + setGroupRawInput(leftover); + } else if (key === "Backspace" && groupRawInput === "" && groupTags.length > 0) { + setGroupTags((prev) => prev.slice(0, -1)); + } + } + + function handleGroupRawChange(event) { + setGroupRawInput(event.target.value); + if (groupError) setGroupError(""); + } + + function handleGroupPaste(event) { + event.preventDefault(); + const pasted = event.clipboardData.getData("text"); + const combined = groupRawInput ? `${groupRawInput} ${pasted}` : pasted; + const leftover = commitRawInput(combined); + setGroupRawInput(leftover); + } + + function removeGroupTag(tag) { + setGroupTags((prev) => prev.filter((t) => t !== tag)); + if (groupError) setGroupError(""); } 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(", ")}`); + if (groupTags.length === 0) { + setGroupError("Introduce al menos un ORCID iD válido."); return; } setGroupError(""); setGroupLoading(true); try { - navigate("/group", { state: { orcidIds: ids } }); + navigate("/group", { state: { orcidIds: groupTags } }); } finally { setGroupLoading(false); } @@ -157,32 +208,20 @@ export function LandingPage() { if (event.key === "Enter") handleValidate(); } - function handleGroupKeyDown(event) { - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - handleGroupSearch(); - } - } - return (
-
+
{/* ── Hero ── */}
-
- -
-

- Tu producción científica,{" "} - siempre al día. +

+ Tus publicaciones, listas para depositar.

- Sincroniza tu perfil ORCID y deposita tus publicaciones - automáticamente vía SWORD. + Conecta tu ORCID y descárgalas en XML cuando quieras.

@@ -190,26 +229,45 @@ export function LandingPage() {
{/* ── Left: individual search + login ── */} -
+
{isAuthenticated ? ( -
- Sesión activa - - Verás publicaciones nuevas marcadas en el dashboard - -
- ) : ( - +
+ {getInitials(displayName ?? userOrcidId ?? "?")} +
+
+

+ {displayName ?? "Mi Perfil"} +

+
+ + {userOrcidId ?? "—"} +
+

+ Verás publicaciones nuevas marcadas en el dashboard +

+
+ + ) : ( + <> + +

+ Actualizamos tus publicaciones automáticamente cada mes. +

+ )}
@@ -272,10 +330,10 @@ export function LandingPage() {
{/* ── Right: group search ── */} -
+
- -

+ +

Búsqueda grupal de investigadores

@@ -283,55 +341,79 @@ export function LandingPage() { Pega varios ORCID iDs separados por comas, espacios o saltos de línea para buscar y comparar varios investigadores a la vez.

-