Fix session expiration handling

This commit is contained in:
2026-04-23 01:10:15 -05:00
parent da4b06094e
commit 820e335d9a
22 changed files with 308 additions and 51 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.2",
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "vite",
+19 -5
View File
@@ -1,4 +1,10 @@
import axios from 'axios';
import {
clearAccessToken,
expireSession,
getAccessToken,
setAccessToken,
} from '../utils/session';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:4000';
@@ -9,7 +15,7 @@ const client = axios.create({
});
client.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
const token = getAccessToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
@@ -18,22 +24,30 @@ client.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (!original) {
return Promise.reject(error);
}
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const hadAccessToken = Boolean(localStorage.getItem('accessToken'));
const hadAccessToken = Boolean(getAccessToken());
if (hadAccessToken) {
try {
const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true });
const { accessToken } = data.data;
localStorage.setItem('accessToken', accessToken);
setAccessToken(accessToken);
original.headers.Authorization = `Bearer ${accessToken}`;
return client(original);
} catch {
localStorage.removeItem('accessToken');
window.location.href = '/login';
expireSession();
}
}
}
if (error.response?.status === 401) {
clearAccessToken();
}
return Promise.reject(error);
}
);
+35 -4
View File
@@ -1,6 +1,14 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth';
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
import {
clearAccessToken,
expireSession,
getAccessToken,
getAccessTokenExpiryDelay,
SESSION_EXPIRED_EVENT,
setAccessToken,
} from '../utils/session';
const AuthContext = createContext(null);
@@ -8,13 +16,24 @@ export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const scheduleSessionExpiry = useCallback((token) => {
const delay = getAccessTokenExpiryDelay(token);
if (delay === null) return undefined;
const timeoutId = window.setTimeout(() => {
expireSession();
}, delay);
return () => window.clearTimeout(timeoutId);
}, []);
useEffect(() => {
const token = localStorage.getItem('accessToken');
const token = getAccessToken();
if (token) {
getMe()
.then(({ data }) => setUser(data.data.user))
.catch(() => {
localStorage.removeItem('accessToken');
clearAccessToken();
})
.finally(() => setLoading(false));
} else {
@@ -22,17 +41,29 @@ export function AuthProvider({ children }) {
}
}, []);
useEffect(() => {
const handleSessionExpired = () => setUser(null);
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired);
}, []);
useEffect(() => {
const token = getAccessToken();
if (!user || !token) return undefined;
return scheduleSessionExpiry(token);
}, [scheduleSessionExpiry, user]);
const login = useCallback(async (email, password) => {
const { data } = await apiLogin({ email, password });
const { accessToken, user: userData } = data.data;
localStorage.setItem('accessToken', accessToken);
setAccessToken(accessToken);
setUser(userData);
return userData;
}, []);
const logout = useCallback(async () => {
try { await apiLogout(); } catch { /* ignore */ }
localStorage.removeItem('accessToken');
clearAccessToken();
setUser(null);
}, []);
+12 -3
View File
@@ -1,12 +1,14 @@
import { useMemo, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Download, PenTool } from 'lucide-react';
import toast from 'react-hot-toast';
import { SESSION_EXPIRED_MESSAGE, translateApiError } from '../../utils/session';
export default function LoginPage() {
const { login } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [form, setForm] = useState({ email: '', password: '' });
const [loading, setLoading] = useState(false);
const apiBaseUrl = (import.meta.env.VITE_API_URL || 'http://localhost:4000').replace(/\/$/, '');
@@ -16,6 +18,13 @@ export default function LoginPage() {
return /Android/i.test(navigator.userAgent || '');
}, []);
useEffect(() => {
if (searchParams.get('sessionExpired') === '1') {
toast.error(SESSION_EXPIRED_MESSAGE);
setSearchParams({}, { replace: true });
}
}, [searchParams, setSearchParams]);
const handleChange = (e) => setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const handleSubmit = async (e) => {
@@ -26,7 +35,7 @@ export default function LoginPage() {
navigate('/dashboard');
} catch (err) {
const code = err.response?.data?.code;
const message = err.response?.data?.error || 'Error al iniciar sesión';
const message = translateApiError(err, 'Error al iniciar sesión');
if (code === 'EMAIL_NOT_VERIFIED') {
toast.success('Tu cuenta aún no está validada. Te enviamos un nuevo correo de confirmación.');
+2 -1
View File
@@ -5,6 +5,7 @@ import { CheckSquare, FileCheck2, FileText, PenTool, ShieldCheck, Tag, X } from
import { Document, Page, pdfjs } from 'react-pdf';
import { getLegalTemplatePreview, getLegalTemplates } from '../../api/public';
import toast from 'react-hot-toast';
import { translateApiError } from '../../utils/session';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
@@ -149,7 +150,7 @@ export default function RegisterPage() {
toast.success('Cuenta creada. Revisa tu correo para confirmar tu usuario y recibir los documentos legales firmados.');
navigate('/login');
} catch (err) {
toast.error(err.response?.data?.error || 'Error al registrarse');
toast.error(translateApiError(err, 'Error al registrarse'));
} finally {
setLoading(false);
}
+51
View File
@@ -0,0 +1,51 @@
export const ACCESS_TOKEN_KEY = 'accessToken';
export const SESSION_EXPIRED_MESSAGE = 'Tu sesión expiró. Vuelve a iniciar sesión.';
export const SESSION_EXPIRED_EVENT = 'linkedsign:session-expired';
export const getAccessToken = () => localStorage.getItem(ACCESS_TOKEN_KEY);
export const setAccessToken = (token) => {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
};
export const clearAccessToken = () => {
localStorage.removeItem(ACCESS_TOKEN_KEY);
};
export const getAccessTokenExpiryDelay = (token) => {
try {
const [, payload] = token.split('.');
const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/');
const paddedPayload = normalizedPayload.padEnd(
normalizedPayload.length + ((4 - (normalizedPayload.length % 4)) % 4),
'='
);
const decoded = JSON.parse(window.atob(paddedPayload));
if (!decoded.exp) return null;
return Math.max(decoded.exp * 1000 - Date.now(), 0);
} catch {
return null;
}
};
export const translateApiError = (error, fallback = 'Ocurrió un error. Intenta nuevamente.') => {
const code = error?.response?.data?.code;
const message = error?.response?.data?.error;
if (code === 'SESSION_EXPIRED' || /token|session|sesión/i.test(message || '')) {
return SESSION_EXPIRED_MESSAGE;
}
return message || fallback;
};
export const expireSession = ({ redirect = true } = {}) => {
clearAccessToken();
window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT, {
detail: { message: SESSION_EXPIRED_MESSAGE },
}));
if (redirect && window.location.pathname !== '/login') {
window.location.replace('/login?sessionExpired=1');
}
};