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 (
+ 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.
-