Cambios en .env, gitlab-ci y dockercompose
This commit is contained in:
@@ -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)}"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
+11
-1
@@ -24,11 +24,21 @@ export default function App() {
|
||||
</Routes>
|
||||
|
||||
<Toaster
|
||||
position="top-right"
|
||||
position="bottom-right"
|
||||
richColors
|
||||
closeButton
|
||||
theme="light"
|
||||
toastOptions={{ duration: 4000 }}
|
||||
style={{
|
||||
/* SUCCESS — ORCID corporate green */
|
||||
'--success-bg': '#EAF3DE',
|
||||
'--success-border': '#C0DD97',
|
||||
'--success-text': '#3B6D11',
|
||||
/* ERROR — hue-0° mirror of the ORCID green (same saturation & lightness) */
|
||||
'--error-bg': '#F3DDDD',
|
||||
'--error-border': '#DD9797',
|
||||
'--error-text': '#6E1111',
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AppHeader({ variant = "landing" }) {
|
||||
{/* Brand — always navigates home */}
|
||||
<Link
|
||||
to="/"
|
||||
className="text-[15px] font-bold tracking-tight text-white transition-opacity hover:opacity-90"
|
||||
className="text-[16px] font-bold tracking-tight text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
ORCID<span className="text-orcid-green">2</span>SWORD
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export default function Footer() {
|
||||
const technologies = ["ORCID OAuth 2.0", "SWORD v2", "DSpace", "EPrints", "Dublin Core"];
|
||||
|
||||
return (
|
||||
<footer className="mt-auto w-full shrink-0 border-t border-surface-border bg-surface-primary py-6">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
|
||||
{/* Main row */}
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-start lg:justify-between">
|
||||
|
||||
{/* Brand */}
|
||||
<div className="flex flex-col gap-2 lg:max-w-xs">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-base font-extrabold tracking-tight text-ink-primary">
|
||||
ORCID<span className="text-orcid-green">2</span>SWORD
|
||||
</span>
|
||||
<span className="rounded border border-orcid-green-border bg-orcid-green-soft px-1.5 py-0.5 text-[10px] font-black uppercase tracking-widest text-orcid-green-text">
|
||||
Software Universitario
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Sincronización de publicaciones ORCID al repositorio institucional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Compatible con */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-ink-tertiary">
|
||||
Compatible con
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{technologies.map((tech) => (
|
||||
<span
|
||||
key={tech}
|
||||
className="rounded border border-surface-border bg-surface-secondary px-2 py-0.5 text-[11px] font-medium text-ink-tertiary"
|
||||
>
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Institutional links */}
|
||||
<div className="flex flex-row gap-6 sm:gap-8">
|
||||
|
||||
{/* Universidad de Jaén */}
|
||||
<a
|
||||
href="https://www.ujaen.es/"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2.5"
|
||||
title="Ir a la web oficial de la Universidad de Jaén"
|
||||
>
|
||||
<div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-accent">
|
||||
<span className="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-ink-primary">Universidad</span>
|
||||
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-ink-tertiary">de Jaén</span>
|
||||
</div>
|
||||
<img
|
||||
src="/uja-logo.png"
|
||||
alt="Logo UJA"
|
||||
className="h-7 w-7 object-contain grayscale opacity-80 transition-all group-hover:grayscale-0 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Repositorio Oficial */}
|
||||
<a
|
||||
href="https://github.com/uja-dev-practices/orcid_system"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-center gap-2.5"
|
||||
title="Ver repositorio oficial"
|
||||
>
|
||||
<div className="flex h-8 flex-col justify-center border-r-2 border-surface-border-strong pr-2.5 text-right transition-colors group-hover:border-brand-primary">
|
||||
<span className="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-ink-primary">Repositorio</span>
|
||||
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-ink-tertiary">Oficial</span>
|
||||
</div>
|
||||
<svg className="h-7 w-7 text-ink-tertiary transition-colors group-hover:text-brand-primary" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col bg-surface-tertiary">
|
||||
<AppHeader variant="dashboard" />
|
||||
<main className="flex-1">
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
|
||||
{researcher ? (
|
||||
<ResearcherCard
|
||||
researcher={researcher}
|
||||
actions={
|
||||
<>
|
||||
<SyncButton onClick={handleSync} status={syncStatus} />
|
||||
<ExportDropdown
|
||||
onExport={handleExport}
|
||||
exportingFormat={exportingFormat}
|
||||
selectedCount={selectedIds.size}
|
||||
isAuthenticated={isAuthenticated}
|
||||
newPublicationsCount={newPublicationIds.length}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ResearcherSkeleton />
|
||||
)}
|
||||
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
|
||||
{researcher ? (
|
||||
<ResearcherCard
|
||||
researcher={researcher}
|
||||
actions={
|
||||
<>
|
||||
<SyncButton onClick={handleSync} status={syncStatus} />
|
||||
<ExportDropdown
|
||||
onExport={handleExport}
|
||||
exportingFormat={exportingFormat}
|
||||
selectedCount={selectedIds.size}
|
||||
isAuthenticated={isAuthenticated}
|
||||
newPublicationsCount={newPublicationIds.length}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
<StatsRow publications={publications} />
|
||||
|
||||
<PublicationsTable
|
||||
publications={publications}
|
||||
loading={pubsLoading}
|
||||
error={pubsError}
|
||||
onRetry={() => loadBundle()}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdsChange={setSelectedIds}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
) : (
|
||||
<ResearcherSkeleton />
|
||||
)}
|
||||
|
||||
<StatsRow publications={publications} />
|
||||
|
||||
<PublicationsTable
|
||||
publications={publications}
|
||||
loading={pubsLoading}
|
||||
error={pubsError}
|
||||
onRetry={() => loadBundle()}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdsChange={setSelectedIds}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
|
||||
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2 px-1">
|
||||
<span className="text-xs text-ink-tertiary">
|
||||
Datos obtenidos vía ORCID Public API v3.0
|
||||
</span>
|
||||
<div className="flex gap-4">
|
||||
{["ORCID OAuth 2.0", "SWORD v2", "Dublin Core"].map((t) => (
|
||||
<span key={t} className="text-xs text-ink-tertiary">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col bg-surface-tertiary">
|
||||
<AppHeader variant="group" />
|
||||
<main className="flex-1">
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
|
||||
<UsersIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-ink-primary">
|
||||
Búsqueda grupal
|
||||
</h1>
|
||||
{!loading && (
|
||||
<p className="text-xs text-ink-tertiary">
|
||||
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
|
||||
{errors.length > 0 && (
|
||||
<span className="ml-1 text-ink-danger">
|
||||
· {errors.length} con error
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-7">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-primary text-white">
|
||||
<UsersIcon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-ink-primary">
|
||||
Búsqueda grupal
|
||||
</h1>
|
||||
{!loading && (
|
||||
<p className="text-xs text-ink-tertiary">
|
||||
{results.length} investigador{results.length !== 1 ? "es" : ""} encontrado{results.length !== 1 ? "s" : ""}
|
||||
{errors.length > 0 && (
|
||||
<span className="ml-1 text-ink-danger">
|
||||
· {errors.length} con error
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Global export buttons */}
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{["xml", "zip"].map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
onClick={() => handleGlobalExport(fmt)}
|
||||
disabled={globalDisabled}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{globalExporting === fmt ? (
|
||||
<Spinner size={14} />
|
||||
) : isAuthenticated && allNewIds.length > 0 ? (
|
||||
<SparkleIcon size={13} className="text-brand-accent" />
|
||||
) : (
|
||||
<DownloadIcon size={14} />
|
||||
)}
|
||||
{globalExporting === fmt
|
||||
? `Exportando ${fmt.toUpperCase()}...`
|
||||
: `${fmt.toUpperCase()} · ${globalLabel}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global export buttons */}
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
|
||||
<Spinner size={28} />
|
||||
<p className="text-sm">
|
||||
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
|
||||
</p>
|
||||
<p className="text-xs text-ink-tertiary/60">
|
||||
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results grid */}
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{["xml", "zip"].map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
type="button"
|
||||
onClick={() => handleGlobalExport(fmt)}
|
||||
disabled={globalDisabled}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-4 py-2 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{globalExporting === fmt ? (
|
||||
<Spinner size={14} />
|
||||
) : isAuthenticated && allNewIds.length > 0 ? (
|
||||
<SparkleIcon size={13} className="text-brand-accent" />
|
||||
) : (
|
||||
<DownloadIcon size={14} />
|
||||
)}
|
||||
{globalExporting === fmt
|
||||
? `Exportando ${fmt.toUpperCase()}...`
|
||||
: `${fmt.toUpperCase()} · ${globalLabel}`}
|
||||
</button>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{results.map((bundle) => (
|
||||
<ResearcherResultCard
|
||||
key={bundle.researcher?.orcid_id}
|
||||
bundle={bundle}
|
||||
isAuthenticated={isAuthenticated}
|
||||
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
|
||||
onExport={(fmt, newIds, totalIds) =>
|
||||
handleCardExport(
|
||||
bundle.researcher?.orcid_id,
|
||||
fmt,
|
||||
newIds,
|
||||
totalIds,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-24 text-ink-tertiary">
|
||||
<Spinner size={28} />
|
||||
<p className="text-sm">
|
||||
Sincronizando {orcidIds?.length ?? "?"} investigadores con ORCID...
|
||||
</p>
|
||||
<p className="text-xs text-ink-tertiary/60">
|
||||
Esto puede tardar unos segundos si hay muchos perfiles nuevos.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results grid */}
|
||||
{!loading && results.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{results.map((bundle) => (
|
||||
<ResearcherResultCard
|
||||
key={bundle.researcher?.orcid_id}
|
||||
bundle={bundle}
|
||||
isAuthenticated={isAuthenticated}
|
||||
exporting={cardExporting[bundle.researcher?.orcid_id] ?? null}
|
||||
onExport={(fmt, newIds, totalIds) =>
|
||||
handleCardExport(
|
||||
bundle.researcher?.orcid_id,
|
||||
fmt,
|
||||
newIds,
|
||||
totalIds,
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{!loading && errors.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
|
||||
ORCID iDs que no pudieron cargarse
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{errors.map((e) => (
|
||||
<div
|
||||
key={e.orcid_id}
|
||||
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
|
||||
>
|
||||
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-mono text-[13px] font-medium text-red-700">
|
||||
{e.orcid_id}
|
||||
</p>
|
||||
<p className="text-xs text-red-500">
|
||||
{e.detail ?? "No se pudo obtener información de este ORCID."}
|
||||
</p>
|
||||
{/* Errors */}
|
||||
{!loading && errors.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h2 className="mb-3 text-sm font-medium text-ink-secondary">
|
||||
ORCID iDs que no pudieron cargarse
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{errors.map((e) => (
|
||||
<div
|
||||
key={e.orcid_id}
|
||||
className="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3"
|
||||
>
|
||||
<AlertIcon size={16} className="mt-0.5 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="font-mono text-[13px] font-medium text-red-700">
|
||||
{e.orcid_id}
|
||||
</p>
|
||||
<p className="text-xs text-red-500">
|
||||
{e.detail ?? "No se pudo obtener información de este ORCID."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loading && results.length === 0 && errors.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
|
||||
<UsersIcon size={32} className="opacity-30" />
|
||||
<p className="text-sm">No se encontraron resultados.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
Volver al inicio
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Empty state */}
|
||||
{!loading && results.length === 0 && errors.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center text-ink-tertiary">
|
||||
<UsersIcon size={32} className="opacity-30" />
|
||||
<p className="text-sm">No se encontraron resultados.</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="mt-1 inline-flex items-center gap-1.5 rounded-md bg-brand-primary px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-primary-hover"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
Volver al inicio
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-screen flex-col bg-surface-tertiary">
|
||||
<AppHeader variant="landing" />
|
||||
|
||||
<main className="flex flex-1 flex-col items-center px-4 py-12">
|
||||
<main className="flex flex-1 flex-col items-center px-4 pb-12 pt-16">
|
||||
<div className="w-full max-w-7xl">
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-primary shadow-[0_4px_24px_rgba(11,61,107,0.18)]">
|
||||
<DocumentIcon size={32} className="text-white" />
|
||||
</div>
|
||||
<h1 className="mb-3 text-[32px] font-bold tracking-tight text-ink-primary md:text-[40px]">
|
||||
Tu producción científica,{" "}
|
||||
<span className="text-brand-primary">siempre al día.</span>
|
||||
<h1 className="mb-3 text-[36px] font-bold tracking-tight text-ink-primary md:text-[46px]">
|
||||
Tus publicaciones, listas para depositar.
|
||||
</h1>
|
||||
<p className="mx-auto max-w-xl text-[16px] leading-relaxed text-ink-secondary">
|
||||
Sincroniza tu perfil ORCID y deposita tus publicaciones
|
||||
automáticamente vía SWORD.
|
||||
Conecta tu ORCID y descárgalas en XML cuando quieras.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -190,26 +229,45 @@ export function LandingPage() {
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 md:gap-8">
|
||||
|
||||
{/* ── Left: individual search + login ── */}
|
||||
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
|
||||
<div className="flex flex-col rounded-2xl border border-surface-border/30 bg-surface-primary p-8 shadow-sm">
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center justify-between rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
|
||||
<span className="font-medium">Sesión activa</span>
|
||||
<span className="text-xs text-green-600">
|
||||
Verás publicaciones nuevas marcadas en el dashboard
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOrcidLogin}
|
||||
disabled={loginLoading}
|
||||
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-5 py-3.5 text-[15px] font-semibold tracking-wide text-orcid-green-dark transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
|
||||
<Link
|
||||
to={userOrcidId ? `/dashboard/${userOrcidId}` : "/"}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 -mx-2 transition-colors hover:bg-surface-secondary"
|
||||
>
|
||||
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
|
||||
{loginLoading
|
||||
? "Abriendo ventana de ORCID..."
|
||||
: "Iniciar sesión con ORCID"}
|
||||
</button>
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-brand-primary text-base font-semibold text-white">
|
||||
{getInitials(displayName ?? userOrcidId ?? "?")}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-ink-primary">
|
||||
{displayName ?? "Mi Perfil"}
|
||||
</p>
|
||||
<div className="mt-0.5 inline-flex items-center gap-1.5 text-sm text-ink-secondary">
|
||||
<OrcidLogo size={14} />
|
||||
<span className="font-mono">{userOrcidId ?? "—"}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-tertiary">
|
||||
Verás publicaciones nuevas marcadas en el dashboard
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOrcidLogin}
|
||||
disabled={loginLoading}
|
||||
className="flex w-full items-center justify-center gap-2.5 rounded-xl bg-orcid-green px-6 py-4 text-[16px] font-semibold tracking-wide text-orcid-green-dark shadow-sm transition-opacity enabled:hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-75"
|
||||
>
|
||||
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
|
||||
{loginLoading
|
||||
? "Abriendo ventana de ORCID..."
|
||||
: "Iniciar sesión con ORCID"}
|
||||
</button>
|
||||
<p className="mt-2.5 text-center text-sm text-ink-tertiary">
|
||||
Actualizamos tus publicaciones automáticamente cada mes.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="my-6 flex items-center gap-3">
|
||||
@@ -272,10 +330,10 @@ export function LandingPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Right: group search ── */}
|
||||
<div className="flex flex-col rounded-2xl border border-surface-border/60 bg-surface-primary p-8">
|
||||
<div className="flex flex-col rounded-2xl border border-surface-border/20 bg-surface-secondary p-8 shadow-sm">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<UsersIcon size={18} className="text-brand-accent" />
|
||||
<h2 className="text-[15px] font-semibold text-ink-primary">
|
||||
<UsersIcon size={16} className="text-ink-tertiary" />
|
||||
<h2 className="text-[14px] font-semibold text-ink-secondary">
|
||||
Búsqueda grupal de investigadores
|
||||
</h2>
|
||||
</div>
|
||||
@@ -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.
|
||||
</p>
|
||||
<textarea
|
||||
rows={5}
|
||||
placeholder={"0000-0002-1825-0097\n0000-0001-5000-0007\n0000-0003-4321-9876"}
|
||||
value={groupInput}
|
||||
onChange={(e) => {
|
||||
setGroupInput(e.target.value);
|
||||
if (groupError) setGroupError("");
|
||||
}}
|
||||
onKeyDown={handleGroupKeyDown}
|
||||
className={`w-full flex-1 resize-none rounded-lg border px-3.5 py-3 font-mono text-[13px] text-ink-primary outline-none transition-colors ${
|
||||
{/* ── Tag input area ── */}
|
||||
<div
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="ORCID iDs"
|
||||
onClick={() => groupInputRef.current?.focus()}
|
||||
className={`flex min-h-[120px] cursor-text flex-wrap content-start gap-1.5 overflow-y-auto rounded-lg border px-3 py-2.5 transition-colors ${
|
||||
groupError
|
||||
? "border-border-danger"
|
||||
: "border-surface-border-strong focus:border-brand-accent"
|
||||
: "border-surface-border-strong focus-within:border-brand-accent"
|
||||
}`}
|
||||
/>
|
||||
>
|
||||
{groupTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#b8d4ea] bg-[#deeef9] py-0.5 pl-1.5 pr-1 font-mono text-[11.5px] font-medium text-[#1a4a6b] transition-colors hover:bg-[#cce3f4]"
|
||||
>
|
||||
<OrcidLogo size={12} />
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); removeGroupTag(tag); }}
|
||||
aria-label={`Eliminar ${tag}`}
|
||||
className="ml-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full text-[#1a4a6b]/50 transition-colors hover:bg-[#1a4a6b]/15 hover:text-[#1a4a6b]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={groupInputRef}
|
||||
type="text"
|
||||
value={groupRawInput}
|
||||
onChange={handleGroupRawChange}
|
||||
onKeyDown={handleGroupTagKeyDown}
|
||||
onPaste={handleGroupPaste}
|
||||
placeholder={
|
||||
groupTags.length === 0
|
||||
? "Pega o escribe ORCID iDs separados por comas, espacios o saltos de línea"
|
||||
: ""
|
||||
}
|
||||
className="min-w-[200px] flex-1 bg-transparent font-mono text-[13px] text-ink-primary outline-none placeholder:text-ink-tertiary/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{groupError && (
|
||||
<p className="mt-1 text-xs text-ink-danger">{groupError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGroupSearch}
|
||||
disabled={groupLoading || !groupInput.trim()}
|
||||
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors ${
|
||||
groupInput.trim()
|
||||
? "bg-brand-primary text-white enabled:hover:bg-brand-primary-hover"
|
||||
: "bg-surface-secondary text-ink-tertiary"
|
||||
} disabled:cursor-not-allowed`}
|
||||
disabled={groupLoading || groupTags.length === 0}
|
||||
className={`mt-4 inline-flex w-full items-center justify-center gap-2 rounded-lg px-5 py-3 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
groupTags.length > 0
|
||||
? "bg-brand-primary text-white hover:bg-brand-primary-hover"
|
||||
: "border border-surface-border bg-surface-secondary text-ink-tertiary"
|
||||
}`}
|
||||
>
|
||||
{groupLoading && <Spinner size={14} />}
|
||||
<UsersIcon size={14} />
|
||||
{groupLoading ? "Preparando..." : "Buscar investigadores"}
|
||||
{groupLoading
|
||||
? "Preparando..."
|
||||
: groupTags.length > 1
|
||||
? `Buscar ${groupTags.length} investigadores`
|
||||
: "Buscar investigadores"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Info chips ── */}
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-4">
|
||||
{["ORCID OAuth 2.0", "SWORD v2", "DSpace · EPrints"].map((label) => (
|
||||
<span
|
||||
key={label}
|
||||
className="rounded-full border border-surface-border/60 bg-surface-secondary px-3.5 py-1.5 text-xs text-ink-tertiary"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,9 +237,11 @@ export function getOrcidAuthorizeUrl() {
|
||||
* Intercambia el authorization code (recibido de ORCID tras el OAuth)
|
||||
* por un JWT propio del backend. Devuelve `{ access_token, token_type }`.
|
||||
*/
|
||||
export async function exchangeOrcidCode(code, { signal } = {}) {
|
||||
export async function exchangeOrcidCode(code, { state, signal } = {}) {
|
||||
const params = { code };
|
||||
if (state) params.state = state;
|
||||
return request(
|
||||
`/auth/orcid/callback?${new URLSearchParams({ code }).toString()}`,
|
||||
`/auth/orcid/callback?${new URLSearchParams(params).toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user