feat(ui): mejoras dashboard y entorno local con ngrok/ORCID sandbox

- 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
This commit is contained in:
Alexis
2026-05-19 12:06:54 +02:00
parent 59eda988d2
commit dbd8bd5992
10 changed files with 60 additions and 24 deletions
+3 -10
View File
@@ -86,6 +86,7 @@ Default local URLs:
Backend: Backend:
- Main file: `backend/.env` - 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` - Reference: `backend/.env.example`
Frontend: 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: 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://<your-subdomain>.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://<your-subdomain>.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.
```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.
--- ---
+5
View File
@@ -6,6 +6,11 @@ ORCID_CLIENT_SECRET=<secreto-fuerte>
ORCID_REDIRECT_URI=https://app.tudominio.com/callback ORCID_REDIRECT_URI=https://app.tudominio.com/callback
ORCID_OAUTH_STATE_ENABLED=true 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://<tu-ngrok>/orcid2sword/callback, CORS, etc.
# Docker Compose carga `.env.local` si existe (ver docker-compose.yml).
API_KEY_NAME=X-API-Key API_KEY_NAME=X-API-Key
API_KEY_VALUE=<random-largo-48+> API_KEY_VALUE=<random-largo-48+>
+8 -1
View File
@@ -19,8 +19,15 @@ from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict 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) 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]: def _split_csv(value: str | List[str] | None) -> List[str]:
+8 -1
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import hmac import hmac
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from urllib.parse import urlparse
from fastapi import HTTPException, status from fastapi import HTTPException, status
from starlette.requests import Request from starlette.requests import Request
@@ -30,12 +31,18 @@ def generate_state() -> str:
def attach_state_cookie(response: Response, state: str) -> None: def attach_state_cookie(response: Response, state: str) -> None:
""" """
Persiste el `state` en una cookie segura y devuelve el valor crudo. 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( response.set_cookie(
key=settings.ORCID_OAUTH_STATE_COOKIE, key=settings.ORCID_OAUTH_STATE_COOKIE,
value=state, value=state,
max_age=settings.ORCID_OAUTH_STATE_TTL_SECONDS, max_age=settings.ORCID_OAUTH_STATE_TTL_SECONDS,
secure=settings.is_production, secure=use_secure,
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path="/", path="/",
+6 -1
View File
@@ -7,7 +7,12 @@ services:
ports: ports:
- "0.0.0.0:8072:8000" - "0.0.0.0:8072:8000"
env_file: 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: environment:
DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db DATABASE_URL: postgresql://postgres:postgres@db:5432/orcid_db
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
+3
View File
@@ -2,4 +2,7 @@ VITE_API_URL=https://api.tudominio.com/api
VITE_API_PROXY_TARGET= VITE_API_PROXY_TARGET=
# Debe coincidir con API_KEY_VALUE del backend. La inyecta el proxy (Vite/nginx). # Debe coincidir con API_KEY_VALUE del backend. La inyecta el proxy (Vite/nginx).
VITE_API_KEY= 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 VITE_USE_MOCKS=false
@@ -4,11 +4,11 @@ import { Spinner } from "../ui/Spinner";
import { Badge } from "../ui/Badge"; import { Badge } from "../ui/Badge";
const COLUMNS = [ const COLUMNS = [
{ key: "title", label: "Título" }, { key: "title", label: "Título", thClass: "w-[35%]" },
{ key: "journal", label: "Revista / Fuente" }, { key: "journal", label: "Revista / Fuente", thClass: "w-[28%]", tdClass: "break-words" },
{ key: "publication_year", label: "Año" }, { key: "publication_year", label: "Año", thClass: "w-16" },
{ key: "doi", label: "DOI" }, { key: "doi", label: "DOI" },
{ key: "type", label: "Tipo" }, { key: "type", label: "Tipo", thClass: "w-20" },
]; ];
const PAGE_SIZE = 15; const PAGE_SIZE = 15;
@@ -376,7 +376,7 @@ export function PublicationsTable({
) : loading ? ( ) : loading ? (
<LoadingState /> <LoadingState />
) : ( ) : (
<table className="w-full min-w-[720px] border-collapse"> <table className="w-full border-collapse">
<thead> <thead>
<tr className="bg-surface-secondary"> <tr className="bg-surface-secondary">
<th <th
@@ -395,7 +395,7 @@ export function PublicationsTable({
<th <th
key={col.key} key={col.key}
onClick={() => toggleSort(col.key)} onClick={() => 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}` : ""}`}
> >
<span className="flex cursor-pointer items-center"> <span className="flex cursor-pointer items-center">
{col.label.toUpperCase()} {col.label.toUpperCase()}
@@ -461,7 +461,7 @@ export function PublicationsTable({
{pub.title} {pub.title}
</span> </span>
</td> </td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary"> <td className="px-4 py-3.5 text-[13px] text-ink-secondary">
{pub.journal || "—"} {pub.journal || "—"}
</td> </td>
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary"> <td className="whitespace-nowrap px-4 py-3.5 text-[13px] font-medium text-ink-primary">
+11 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { toast } from "sonner";
import { AppHeader } from "../components/layout/AppHeader"; import { AppHeader } from "../components/layout/AppHeader";
@@ -9,6 +9,7 @@ import { StatsRow } from "../components/dashboard/StatsRow";
import { PublicationsTable } from "../components/dashboard/PublicationsTable"; import { PublicationsTable } from "../components/dashboard/PublicationsTable";
import { ExportDropdown } from "../components/dashboard/ExportDropdown"; import { ExportDropdown } from "../components/dashboard/ExportDropdown";
import { SyncButton } from "../components/dashboard/SyncButton"; import { SyncButton } from "../components/dashboard/SyncButton";
import { ArrowLeftIcon } from "../components/ui/Icons";
import { import {
downloadExport, downloadExport,
searchResearcher, searchResearcher,
@@ -198,7 +199,15 @@ export function DashboardPage() {
<div className="flex min-h-screen flex-col bg-surface-tertiary"> <div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="dashboard" /> <AppHeader variant="dashboard" />
<main className="flex-1"> <main className="flex-1">
<div className="mx-auto w-full max-w-[1100px] px-5 py-7"> <div className="mx-auto w-full max-w-7xl px-4 py-7">
<Link
to="/"
className="mb-5 inline-flex items-center gap-1.5 text-sm text-ink-tertiary transition-colors hover:text-ink-primary"
>
<ArrowLeftIcon size={14} />
Volver al inicio
</Link>
{researcher ? ( {researcher ? (
<ResearcherCard <ResearcherCard
researcher={researcher} researcher={researcher}
+1 -1
View File
@@ -190,7 +190,7 @@ export function GroupResultsPage() {
<div className="flex min-h-screen flex-col bg-surface-tertiary"> <div className="flex min-h-screen flex-col bg-surface-tertiary">
<AppHeader variant="group" /> <AppHeader variant="group" />
<main className="flex-1"> <main className="flex-1">
<div className="mx-auto w-full max-w-[1100px] px-5 py-7"> <div className="mx-auto w-full max-w-7xl px-4 py-7">
{/* Page header */} {/* Page header */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-4"> <div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+8 -1
View File
@@ -1,9 +1,16 @@
/** /**
* Locale-aware full date + time formatter (used in dashboard headers). * 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) { export function formatDate(iso) {
if (!iso) return "—"; 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 "—"; if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("es-ES", { return d.toLocaleString("es-ES", {
day: "2-digit", day: "2-digit",