fix: update callback route and enhance user profile link in header

- Changed the OAuth callback route from `/auth/callback` to `/callback` in App component and .env.example.
- Added user profile link in AppHeader for authenticated users, directing to their dashboard.
- Removed bypass mode references from LandingPage to streamline the login flow.
- Introduced a utility function to extract ORCID from JWT in AuthContext for better user state management.
This commit is contained in:
Alexis
2026-04-29 13:29:23 +02:00
parent 4b1de64fb0
commit 7118d21f34
6 changed files with 47 additions and 45 deletions
+3 -1
View File
@@ -30,8 +30,10 @@ VITE_USE_MOCKS=false
# 5. /auth/callback extrae el code y llama al backend para obtener el JWT # 5. /auth/callback extrae el code y llama al backend para obtener el JWT
# #
# Para que el callback vuelva al frontend, el backend necesita: # Para que el callback vuelva al frontend, el backend necesita:
# ORCID_REDIRECT_URI=http://localhost:5173/auth/callback # ORCID_REDIRECT_URI=http://localhost:5173/callback
# (en backend/.env — debe coincidir con el redirect URI del app ORCID sandbox) # (en backend/.env — debe coincidir con el redirect URI del app ORCID sandbox)
# En producción con ngrok u otro túnel, el formato sería:
# ORCID_REDIRECT_URI=https://<tu-dominio>/callback
# #
# ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ───────── # ── Modo bypass (solo desarrollo sin credenciales OAuth configuradas) ─────────
# Cuando está a "true", el botón "Iniciar sesión" genera un token simulado # Cuando está a "true", el botón "Iniciar sesión" genera un token simulado
+1 -1
View File
@@ -19,7 +19,7 @@ export default function App() {
<Route path="/" element={<LandingPage />} /> <Route path="/" element={<LandingPage />} />
<Route path="/dashboard/:orcid" element={<DashboardPage />} /> <Route path="/dashboard/:orcid" element={<DashboardPage />} />
<Route path="/group" element={<GroupResultsPage />} /> <Route path="/group" element={<GroupResultsPage />} />
<Route path="/auth/callback" element={<AuthCallbackPage />} /> <Route path="/callback" element={<AuthCallbackPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+19 -1
View File
@@ -12,7 +12,7 @@ import { useAuth } from "../../contexts/AuthContext";
* - `group` → back button to `/` + group label + auth indicator. * - `group` → back button to `/` + group label + auth indicator.
*/ */
export function AppHeader({ variant = "landing" }) { export function AppHeader({ variant = "landing" }) {
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, userOrcidId, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
function handleLogout() { function handleLogout() {
@@ -36,6 +36,15 @@ export function AppHeader({ variant = "landing" }) {
<div className="flex-1" /> <div className="flex-1" />
{isAuthenticated && ( {isAuthenticated && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{userOrcidId && (
<Link
to={`/dashboard/${userOrcidId}`}
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"
>
<UserCheckIcon size={13} />
Mi perfil
</Link>
)}
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80"> <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} /> <UserCheckIcon size={13} />
Sesión activa Sesión activa
@@ -67,6 +76,15 @@ export function AppHeader({ variant = "landing" }) {
</span> </span>
{isAuthenticated && ( {isAuthenticated && (
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{userOrcidId && (
<Link
to={`/dashboard/${userOrcidId}`}
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"
>
<UserCheckIcon size={13} />
Mi perfil
</Link>
)}
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[12px] text-white/80"> <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} /> <UserCheckIcon size={13} />
Sesión activa Sesión activa
+21 -4
View File
@@ -16,6 +16,19 @@ export const AUTH_ERROR_TYPE = "ORCID_AUTH_ERROR";
const AuthContext = createContext(null); const AuthContext = createContext(null);
function extractOrcidFromToken(token) {
if (!token) return null;
try {
const payloadBase64 = token.split(".")[1];
if (!payloadBase64) return null;
const payloadJson = atob(payloadBase64.replace(/-/g, "+").replace(/_/g, "/"));
const payload = JSON.parse(payloadJson);
return payload?.sub ?? null;
} catch {
return null;
}
}
/** /**
* Provides JWT-based authentication state throughout the app. * Provides JWT-based authentication state throughout the app.
* *
@@ -30,8 +43,6 @@ const AuthContext = createContext(null);
* opener and closes itself. * opener and closes itself.
* 7. This provider's message listener stores the token and updates state. * 7. This provider's message listener stores the token and updates state.
* *
* For development / sandbox bypass (VITE_AUTH_BYPASS=true), the token is
* stored directly via storeToken() without going through ORCID.
*/ */
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem(STORAGE_KEY)); const [token, setToken] = useState(() => localStorage.getItem(STORAGE_KEY));
@@ -53,7 +64,7 @@ export function AuthProvider({ children }) {
}, []); }, []);
/** /**
* Stores a JWT directly (used by AuthCallbackPage and bypass mode). * Stores a JWT directly (used by AuthCallbackPage).
* Does NOT trigger any network request. * Does NOT trigger any network request.
*/ */
const storeToken = useCallback((accessToken) => { const storeToken = useCallback((accessToken) => {
@@ -67,7 +78,13 @@ export function AuthProvider({ children }) {
}, []); }, []);
const value = useMemo( const value = useMemo(
() => ({ token, isAuthenticated: Boolean(token), storeToken, logout }), () => ({
token,
isAuthenticated: Boolean(token),
userOrcidId: extractOrcidFromToken(token),
storeToken,
logout,
}),
[token, storeToken, logout], [token, storeToken, logout],
); );
+3 -37
View File
@@ -11,23 +11,17 @@ import { getOrcidAuthorizeUrl, searchResearcher } from "../services/api";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext"; import { AUTH_MESSAGE_TYPE, AUTH_ERROR_TYPE } from "../contexts/AuthContext";
// When VITE_AUTH_BYPASS=true, skip the real OAuth popup and simulate login
// with the ORCID entered in the text field. Use only in development.
const AUTH_BYPASS = import.meta.env.VITE_AUTH_BYPASS === "true";
/** /**
* Entry view: login con ORCID iD + búsqueda individual anónima + * Entry view: login con ORCID iD + búsqueda individual anónima +
* buscador grupal para múltiples investigadores. * buscador grupal para múltiples investigadores.
* *
* Flujo de login: * Flujo de login:
* - Modo normal: abre popup OAuth → sandbox.orcid.org → /auth/callback * - abre popup OAuth → sandbox.orcid.org → /callback
* JWT → cierra popup → estado actualizado aquí. * - recibe JWT → cierra popup → estado actualizado aquí.
* - VITE_AUTH_BYPASS=true (solo dev): genera un token simulado con el
* ORCID del campo de texto, sin tocar el backend de auth.
*/ */
export function LandingPage() { export function LandingPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { isAuthenticated, storeToken } = useAuth(); const { isAuthenticated } = useAuth();
const [orcidInput, setOrcidInput] = useState(""); const [orcidInput, setOrcidInput] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -76,24 +70,6 @@ export function LandingPage() {
} }
function handleOrcidLogin() { function handleOrcidLogin() {
// ── Modo bypass (solo desarrollo / sandbox sin credenciales OAuth) ──
if (AUTH_BYPASS) {
if (!isValidOrcid(orcidInput)) {
setError(
"Introduce un ORCID iD válido para simular el login (modo bypass).",
);
return;
}
// Genera un token simulado (no válido en el backend) solo para
// probar la UI en estado autenticado.
storeToken(`bypass_token_${orcidInput}`);
toast.success("Login simulado (modo bypass)", {
description: `Sesión activa para ${orcidInput}. El backend no reconocerá este token.`,
});
return;
}
// ── Flujo OAuth real (popup) ──
setLoginLoading(true); setLoginLoading(true);
const authorizeUrl = getOrcidAuthorizeUrl(); const authorizeUrl = getOrcidAuthorizeUrl();
@@ -228,16 +204,8 @@ export function LandingPage() {
{loginLoading ? <Spinner size={17} /> : <OrcidLogo />} {loginLoading ? <Spinner size={17} /> : <OrcidLogo />}
{loginLoading {loginLoading
? "Abriendo ventana de ORCID..." ? "Abriendo ventana de ORCID..."
: AUTH_BYPASS
? "Simular login (bypass)"
: "Iniciar sesión con ORCID"} : "Iniciar sesión con ORCID"}
</button> </button>
{AUTH_BYPASS && (
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-1.5 text-center text-xs text-amber-700">
Modo bypass activo introduce un ORCID abajo y pulsa el botón.
No se valida contra el backend.
</p>
)}
</> </>
)} )}
@@ -295,8 +263,6 @@ export function LandingPage() {
<p className="mt-2 text-xs text-ink-tertiary"> <p className="mt-2 text-xs text-ink-tertiary">
{isAuthenticated {isAuthenticated
? "Busca un investigador o usa «Cerrar sesión» arriba." ? "Busca un investigador o usa «Cerrar sesión» arriba."
: AUTH_BYPASS
? "Introduce tu ORCID y pulsa «Simular login» para probar la UI autenticada."
: "Pulsa «Iniciar sesión» para autenticarte, o «Buscar» de forma anónima."} : "Pulsa «Iniciar sesión» para autenticarte, o «Buscar» de forma anónima."}
</p> </p>
</div> </div>
-1
View File
@@ -145,7 +145,6 @@ function normalizePublication(p) {
hash_fingerprint: p.hash_fingerprint ?? null, hash_fingerprint: p.hash_fingerprint ?? null,
last_modified: p.last_modified ?? null, last_modified: p.last_modified ?? null,
status: p.status ?? null, status: p.status ?? null,
// null when request was made without a JWT (user not logged in)
downloaded_by_me: p.downloaded_by_me ?? null, downloaded_by_me: p.downloaded_by_me ?? null,
}; };
} }