Fix session expiration handling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user