From dbd8bd5992aa23ae0bd089a094ae20e60fe77680 Mon Sep 17 00:00:00 2001 From: Alexis Date: Tue, 19 May 2026 12:06:54 +0200 Subject: [PATCH] feat(ui): mejoras dashboard y entorno local con ngrok/ORCID sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Añade enlace Volver al inicio y márgenes max-w-7xl en dashboard y group - Corrige hora de última sincronización (UTC en formatDate) - Evita scroll horizontal en tabla de publicaciones - Soporta backend/.env.local y compose opcional para sandbox/ngrok - Cookie OAuth Secure en redirects HTTPS; README y .env.example --- README.md | 13 +++---------- backend/.env.example | 5 +++++ backend/app/core/config.py | 9 ++++++++- backend/app/security/oauth_state.py | 9 ++++++++- docker-compose.yml | 7 ++++++- frontend/.env.example | 3 +++ .../src/components/dashboard/PublicationsTable.jsx | 14 +++++++------- frontend/src/pages/DashboardPage.jsx | 13 +++++++++++-- frontend/src/pages/GroupResultsPage.jsx | 2 +- frontend/src/utils/formatters.js | 9 ++++++++- 10 files changed, 60 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3a3e436..3ea851f 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Default local URLs: Backend: - Main file: `backend/.env` +- Optional local overrides (gitignored): `backend/.env.local` (loaded after `.env`; Docker Compose also picks it up when the file exists) - Reference: `backend/.env.example` Frontend: @@ -118,17 +119,9 @@ Important frontend variables: --- -## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). ngrok Bridge for Local OAuth Callback +## ![certificate](https://www.readmecodegen.com/api/social-icon?name=certificate&size=20). Development with ngrok (OAuth) -To test OAuth callback from ORCID in local environments, compose can inject a public callback URL: - -```yaml -environment: - ORCID_REDIRECT_URI: https://jargon-supreme-palpable.ngrok-free.dev/callback -``` - -> [!NOTE] -> Values under `docker-compose.yml -> services.backend.environment` override `backend/.env` inside the container. +For ORCID OAuth in local development you need a **public HTTPS URL** that hits the same origin as the SPA. Run `docker compose up` as usual, then point ngrok at the **frontend** host port (for example `ngrok http 8073` when compose maps the UI to `8073`). Open the app **only** at `https://.ngrok-free.dev/orcid2sword/` so login, `/api` proxy, and the OAuth callback stay on one host (mixing `localhost` with an ngrok `ORCID_REDIRECT_URI` breaks the `state` cookie). Put `ORCID_REDIRECT_URI` to exactly `https://.ngrok-free.dev/orcid2sword/callback` in `backend/.env.local` (gitignored), register that **same** redirect URL on your ORCID sandbox app, and add the ngrok host to `CORS_ALLOWED_ORIGINS` and `TRUSTED_HOSTS`; restart the backend after edits. If the ngrok subdomain changes, update ORCID, `.env.local`, and restart again. --- diff --git a/backend/.env.example b/backend/.env.example index 3e798fc..efe2676 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,6 +6,11 @@ ORCID_CLIENT_SECRET= ORCID_REDIRECT_URI=https://app.tudominio.com/callback ORCID_OAUTH_STATE_ENABLED=true +# ── Desarrollo local (sandbox / ngrok) sin tocar este fichero en Git ── +# Crea `backend/.env.local` (gitignored) con ORCID_ENVIRONMENT=sandbox, +# ORCID_REDIRECT_URI=https:///orcid2sword/callback, CORS, etc. +# Docker Compose carga `.env.local` si existe (ver docker-compose.yml). + API_KEY_NAME=X-API-Key API_KEY_VALUE= diff --git a/backend/app/core/config.py b/backend/app/core/config.py index edba96c..5c706fb 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -19,8 +19,15 @@ from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict -_ENV_PATH = Path(__file__).resolve().parents[2] / ".env" +_ENV_DIR = Path(__file__).resolve().parents[2] +_ENV_PATH = _ENV_DIR / ".env" +_ENV_LOCAL_PATH = _ENV_DIR / ".env.local" + +# Carga en cascada: `.env` (versionado en GitLab con valores de prod) y +# opcionalmente `.env.local` (gitignored) para sandbox / ngrok en local. load_dotenv(dotenv_path=_ENV_PATH, override=False) +if _ENV_LOCAL_PATH.is_file(): + load_dotenv(dotenv_path=_ENV_LOCAL_PATH, override=True) def _split_csv(value: str | List[str] | None) -> List[str]: diff --git a/backend/app/security/oauth_state.py b/backend/app/security/oauth_state.py index 92475b8..2fc9c03 100644 --- a/backend/app/security/oauth_state.py +++ b/backend/app/security/oauth_state.py @@ -12,6 +12,7 @@ from __future__ import annotations import hmac import secrets from datetime import datetime, timezone +from urllib.parse import urlparse from fastapi import HTTPException, status from starlette.requests import Request @@ -30,12 +31,18 @@ def generate_state() -> str: def attach_state_cookie(response: Response, state: str) -> None: """ Persiste el `state` en una cookie segura y devuelve el valor crudo. + + `Secure` debe ser True en cualquier flujo HTTPS (p. ej. ngrok en local); + no basta con `ENVIRONMENT=production`, o el navegador puede descartar + la cookie y el callback fallará con «OAuth state missing». """ + redirect_https = urlparse(settings.ORCID_REDIRECT_URI).scheme == "https" + use_secure = settings.is_production or redirect_https response.set_cookie( key=settings.ORCID_OAUTH_STATE_COOKIE, value=state, max_age=settings.ORCID_OAUTH_STATE_TTL_SECONDS, - secure=settings.is_production, + secure=use_secure, httponly=True, samesite="lax", path="/", diff --git a/docker-compose.yml b/docker-compose.yml index 75d8478..5432ded 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,12 @@ services: ports: - "0.0.0.0:8072:8000" env_file: - - ./backend/.env + - path: ./backend/.env + required: true + # Sobrescribe claves de `.env` en local (sandbox, ngrok). En el servidor + # no existe el fichero → Compose lo omite sin error. + - path: ./backend/.env.local + required: false environment: DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db REDIS_URL: redis://redis:6379/0 diff --git a/frontend/.env.example b/frontend/.env.example index 5095694..33f96d5 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -2,4 +2,7 @@ VITE_API_URL=https://api.tudominio.com/api VITE_API_PROXY_TARGET= # Debe coincidir con API_KEY_VALUE del backend. La inyecta el proxy (Vite/nginx). VITE_API_KEY= +# Producción: https://pub.orcid.org/v3.0 · Sandbox local: https://pub.sandbox.orcid.org/v3.0 +# (en `npm run dev` puedes ponerlo en `frontend/.env.local` sin tocar el .env del repo) +VITE_ORCID_PUBLIC_API_BASE=https://pub.orcid.org/v3.0 VITE_USE_MOCKS=false \ No newline at end of file diff --git a/frontend/src/components/dashboard/PublicationsTable.jsx b/frontend/src/components/dashboard/PublicationsTable.jsx index 7c0f110..63509f4 100644 --- a/frontend/src/components/dashboard/PublicationsTable.jsx +++ b/frontend/src/components/dashboard/PublicationsTable.jsx @@ -4,11 +4,11 @@ import { Spinner } from "../ui/Spinner"; import { Badge } from "../ui/Badge"; const COLUMNS = [ - { key: "title", label: "Título" }, - { key: "journal", label: "Revista / Fuente" }, - { key: "publication_year", label: "Año" }, + { key: "title", label: "Título", thClass: "w-[35%]" }, + { key: "journal", label: "Revista / Fuente", thClass: "w-[28%]", tdClass: "break-words" }, + { key: "publication_year", label: "Año", thClass: "w-16" }, { key: "doi", label: "DOI" }, - { key: "type", label: "Tipo" }, + { key: "type", label: "Tipo", thClass: "w-20" }, ]; const PAGE_SIZE = 15; @@ -376,7 +376,7 @@ export function PublicationsTable({ ) : loading ? ( ) : ( - +
toggleSort(col.key)} - className="select-none whitespace-nowrap border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary" + className={`select-none border-b border-surface-border/60 px-4 py-2.5 text-left text-xs font-medium tracking-wide text-ink-secondary${col.thClass ? ` ${col.thClass}` : ""}`} > {col.label.toUpperCase()} @@ -461,7 +461,7 @@ export function PublicationsTable({ {pub.title} - + {pub.journal || "—"} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 276d7e9..edfb574 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useLocation, useParams, Navigate } from "react-router-dom"; +import { useLocation, useParams, Navigate, Link } from "react-router-dom"; import { toast } from "sonner"; import { AppHeader } from "../components/layout/AppHeader"; @@ -9,6 +9,7 @@ import { StatsRow } from "../components/dashboard/StatsRow"; import { PublicationsTable } from "../components/dashboard/PublicationsTable"; import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { SyncButton } from "../components/dashboard/SyncButton"; +import { ArrowLeftIcon } from "../components/ui/Icons"; import { downloadExport, searchResearcher, @@ -198,7 +199,15 @@ export function DashboardPage() {
-
+
+ + + Volver al inicio + + {researcher ? (
-
+
{/* Page header */}
diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js index 34e01da..6e3a5d2 100644 --- a/frontend/src/utils/formatters.js +++ b/frontend/src/utils/formatters.js @@ -1,9 +1,16 @@ /** * Locale-aware full date + time formatter (used in dashboard headers). + * + * The backend stores datetimes in UTC but serialises them without a timezone + * suffix (e.g. "2026-05-19T08:20:00"). JS Date treats those naive strings as + * *local* time, which would show the wrong hour in non-UTC browsers. We + * normalise by appending "Z" so the engine always interprets the value as UTC + * and toLocaleString then converts it correctly to the user's local timezone. */ export function formatDate(iso) { if (!iso) return "—"; - const d = new Date(iso); + const normalized = /[Zz]|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`; + const d = new Date(normalized); if (Number.isNaN(d.getTime())) return "—"; return d.toLocaleString("es-ES", { day: "2-digit",