Merge branch 'style/mejoras-uiux' into 'main'
feat(ui): mejoras dashboard y entorno local con ngrok/ORCID sandbox See merge request fjmimbre/orcid_system!1
This commit is contained in:
@@ -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:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## . ngrok Bridge for Local OAuth Callback
|
## . 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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+>
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user