feat: enhance authentication flow and UI components
- Updated .env.example to include OAuth authentication details and bypass mode for development. - Integrated AuthProvider in App component to manage authentication state. - Added AuthCallbackPage for handling OAuth callback. - Enhanced ExportDropdown and PublicationsTable components to display new publication indicators for authenticated users. - Updated AppHeader to show authentication status and logout functionality. - Improved LandingPage to support group search and simulate login in bypass mode. - Refactored DashboardPage to conditionally handle publication exports based on user authentication status.
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
DocumentIcon,
|
||||
DownloadIcon,
|
||||
PackageIcon,
|
||||
SparkleIcon,
|
||||
} from "../ui/Icons";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
|
||||
@@ -23,17 +24,20 @@ const FORMATS = [
|
||||
];
|
||||
|
||||
/**
|
||||
* SWORD export dropdown. Delegates the actual download to `onExport(format)`
|
||||
* so it can be wired up either to the real API or to a mock layer from the
|
||||
* parent page.
|
||||
* SWORD export dropdown. Delegatea the actual download to `onExport(format)`.
|
||||
*
|
||||
* `exportingFormat` (optional) lets the parent keep the button in a loading
|
||||
* state between clicks (e.g. while waiting for the backend blob).
|
||||
* Props:
|
||||
* - `isAuthenticated` → cambia el texto del botón principal.
|
||||
* - `newPublicationsCount` → cuántas publicaciones tiene downloaded_by_me=false.
|
||||
* - `selectedCount` → publicaciones seleccionadas manualmente.
|
||||
* - `exportingFormat` → formato en curso (pone el botón en loading).
|
||||
*/
|
||||
export function ExportDropdown({
|
||||
onExport,
|
||||
exportingFormat = null,
|
||||
selectedCount = 0,
|
||||
isAuthenticated = false,
|
||||
newPublicationsCount = 0,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef(null);
|
||||
@@ -56,19 +60,40 @@ export function ExportDropdown({
|
||||
onExport(format);
|
||||
}
|
||||
|
||||
const idleLabel = hasSelection
|
||||
? `Exportar seleccionadas (${selectedCount})`
|
||||
: "Exportar todas";
|
||||
// Label logic:
|
||||
// manual selection → always "Exportar seleccionadas (N)"
|
||||
// logged in, no selection → "Descargar lo nuevo (N)" or "Todo descargado"
|
||||
// not logged in, no selection → "Descargar todo"
|
||||
let idleLabel;
|
||||
let showSparkle = false;
|
||||
if (hasSelection) {
|
||||
idleLabel = `Exportar seleccionadas (${selectedCount})`;
|
||||
} else if (isAuthenticated) {
|
||||
if (newPublicationsCount > 0) {
|
||||
idleLabel = `Descargar lo nuevo (${newPublicationsCount})`;
|
||||
showSparkle = true;
|
||||
} else {
|
||||
idleLabel = "Todo descargado";
|
||||
}
|
||||
} else {
|
||||
idleLabel = "Descargar todo";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || (isAuthenticated && !hasSelection && newPublicationsCount === 0)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-surface-border-strong bg-surface-primary px-[18px] py-2.5 text-sm font-medium text-ink-primary transition-colors enabled:hover:bg-surface-secondary disabled:cursor-not-allowed disabled:opacity-70"
|
||||
>
|
||||
{isBusy ? <Spinner size={15} /> : <DownloadIcon />}
|
||||
{isBusy ? (
|
||||
<Spinner size={15} />
|
||||
) : showSparkle ? (
|
||||
<SparkleIcon size={15} className="text-brand-accent" />
|
||||
) : (
|
||||
<DownloadIcon />
|
||||
)}
|
||||
{isBusy
|
||||
? `Exportando ${exportingFormat.toUpperCase()}...`
|
||||
: idleLabel}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon } from "../ui/Icons";
|
||||
import { AlertIcon, ChevronDownIcon, FilterIcon, SearchIcon, SparkleIcon } from "../ui/Icons";
|
||||
import { Spinner } from "../ui/Spinner";
|
||||
import { Badge } from "../ui/Badge";
|
||||
|
||||
@@ -83,6 +83,7 @@ export function PublicationsTable({
|
||||
onRetry,
|
||||
selectedIds = EMPTY_SELECTION,
|
||||
onSelectedIdsChange,
|
||||
isAuthenticated = false,
|
||||
}) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [sortKey, setSortKey] = useState("publication_year");
|
||||
@@ -447,7 +448,18 @@ export function PublicationsTable({
|
||||
/>
|
||||
</td>
|
||||
<td className="max-w-[280px] px-4 py-3.5 text-[13px] font-medium leading-relaxed text-ink-primary">
|
||||
{pub.title}
|
||||
<span className="flex flex-wrap items-start gap-1.5">
|
||||
{isAuthenticated && pub.downloaded_by_me === false && (
|
||||
<span
|
||||
title="No descargada aún por ti"
|
||||
className="mt-0.5 inline-flex shrink-0 items-center gap-0.5 rounded-full bg-brand-accent/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-brand-accent"
|
||||
>
|
||||
<SparkleIcon size={9} />
|
||||
Nuevo
|
||||
</span>
|
||||
)}
|
||||
{pub.title}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3.5 text-[13px] text-ink-secondary">
|
||||
{pub.journal || "—"}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeftIcon, LayersIcon } from "../ui/Icons";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeftIcon, LayersIcon, LogoutIcon, UserCheckIcon } from "../ui/Icons";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
|
||||
/**
|
||||
* Institutional navy header used across all views.
|
||||
*
|
||||
* Variants:
|
||||
* - `landing` → logo + full product name (centered brand title).
|
||||
* - `dashboard`→ back button to `/` + discrete product label on the right.
|
||||
* - `landing` → logo + full product name.
|
||||
* - `dashboard` → back button to `/` + auth indicator + logout (if logged in).
|
||||
* - `group` → back button to `/` + group label + auth indicator.
|
||||
*/
|
||||
export function AppHeader({ variant = "landing" }) {
|
||||
if (variant === "dashboard") {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function handleLogout() {
|
||||
logout();
|
||||
toast.success("Sesión cerrada", {
|
||||
description: "Has cerrado sesión correctamente.",
|
||||
});
|
||||
navigate("/");
|
||||
}
|
||||
|
||||
if (variant === "dashboard" || variant === "group") {
|
||||
return (
|
||||
<header className="flex h-14 items-center gap-4 bg-brand-primary px-7 text-white">
|
||||
<Link
|
||||
@@ -20,8 +34,24 @@ export function AppHeader({ variant = "landing" }) {
|
||||
Inicio
|
||||
</Link>
|
||||
<div className="flex-1" />
|
||||
{isAuthenticated && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
|
||||
<UserCheckIcon size={13} />
|
||||
Sesión activa
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
|
||||
>
|
||||
<LogoutIcon />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[13px] text-white/60">
|
||||
Sistema ORCID · SWORD
|
||||
{variant === "group" ? "Búsqueda grupal · ORCID" : "Sistema ORCID · SWORD"}
|
||||
</span>
|
||||
</header>
|
||||
);
|
||||
@@ -35,6 +65,22 @@ export function AppHeader({ variant = "landing" }) {
|
||||
<span className="text-sm font-medium tracking-wide text-white">
|
||||
Sistema de Integración ORCID · SWORD
|
||||
</span>
|
||||
{isAuthenticated && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80">
|
||||
<UserCheckIcon size={13} />
|
||||
Sesión activa
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-white/10 px-2.5 py-1.5 text-[13px] transition-colors hover:bg-white/20"
|
||||
>
|
||||
<LogoutIcon />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,3 +120,39 @@ export function PackageIcon({ size = 18, className = "" }) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogoutIcon({ size = 15, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersIcon({ size = 16, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} strokeWidth={1.8} className={className}>
|
||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SparkleIcon({ size = 12, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} strokeWidth={2} className={className}>
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserCheckIcon({ size = 15, className = "" }) {
|
||||
return (
|
||||
<svg {...base} width={size} height={size} strokeWidth={1.8} className={className}>
|
||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<polyline points="16 11 18 13 22 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user