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
+7 -1
View File
@@ -2,10 +2,16 @@
## Versión actual
- Versión base: `1.0.2`
- Versión base: `1.0.3`
## Historial
### 1.0.3 - 2026-04-23
- Se ajusta la duración completa de sesión web y Android a 15 minutos.
- Se fuerza cierre local y retorno al inicio de sesión cuando la sesión expira o no puede renovarse.
- Se traducen mensajes de expiración de sesión al español.
### 1.0.2 - 2026-04-08
- Se endurece el flujo biométrico Android para usar sesión por token en lugar de guardar la contraseña del usuario.
+1 -1
View File
@@ -1 +1 @@
1.0.2
1.0.3
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign"
minSdk 26
targetSdk 35
versionCode 10002
versionName "1.0.2"
versionCode 10003
versionName "1.0.3"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
}
@@ -214,8 +214,13 @@ class LinkedSignRepository(private val context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw mapHttpException(error)
val refreshToken = session.refreshToken ?: throw AppException("Tu sesión expiró. Ingresa nuevamente.")
val refresh = unwrap(service.refresh(RefreshRequest(refreshToken)))
val refreshToken = session.refreshToken ?: throwSessionExpired()
val refresh = try {
unwrap(service.refresh(RefreshRequest(refreshToken)))
} catch (refreshError: Exception) {
sessionStore.clearSession()
throwSessionExpired()
}
val updated = session.copy(
accessToken = refresh.accessToken,
refreshToken = refresh.refreshToken
@@ -226,6 +231,10 @@ class LinkedSignRepository(private val context: Context) {
}
}
private fun throwSessionExpired(): Nothing {
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
}
private suspend fun <T> execute(block: suspend () -> T): T {
return try {
block()
@@ -156,7 +156,8 @@ data class SignData(
data class SessionSnapshot(
val accessToken: String? = null,
val refreshToken: String? = null,
val user: SessionUser? = null
val user: SessionUser? = null,
val expiresAt: Long? = null
)
data class BiometricAccessState(
@@ -3,8 +3,10 @@ package net.linkedcorp.linkedsign.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import android.util.Base64
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.json.JSONObject
class SessionStore(context: Context) {
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
@@ -26,15 +28,27 @@ class SessionStore(context: Context) {
val accessToken = preferences.getString(KEY_ACCESS, null)
val refreshToken = preferences.getString(KEY_REFRESH, null)
val userJson = preferences.getString(KEY_USER, null)
val expiresAt = preferences.getLong(KEY_EXPIRES_AT, 0L).takeIf { it > 0L }
if (expiresAt != null && expiresAt <= System.currentTimeMillis()) {
clearSession()
return SessionSnapshot()
}
val user = userJson?.let { userAdapter.fromJson(it) }
return SessionSnapshot(accessToken, refreshToken, user)
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
}
fun save(session: SessionSnapshot) {
val expiresAt = session.expiresAt
?: session.accessToken?.let { readAccessTokenExpiry(it) }
?: (System.currentTimeMillis() + DEFAULT_SESSION_MS)
preferences.edit()
.putString(KEY_ACCESS, session.accessToken)
.putString(KEY_REFRESH, session.refreshToken)
.putString(KEY_USER, session.user?.let { userAdapter.toJson(it) })
.putLong(KEY_EXPIRES_AT, expiresAt)
.apply()
}
@@ -47,9 +61,21 @@ class SessionStore(context: Context) {
.remove(KEY_ACCESS)
.remove(KEY_REFRESH)
.remove(KEY_USER)
.remove(KEY_EXPIRES_AT)
.apply()
}
private fun readAccessTokenExpiry(accessToken: String): Long? {
return try {
val payload = accessToken.split(".").getOrNull(1) ?: return null
val decoded = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
val exp = JSONObject(decoded).optLong("exp", 0L)
if (exp > 0L) exp * 1000L else null
} catch (_: Exception) {
null
}
}
fun loadBiometricState(): BiometricAccessState {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
@@ -106,9 +132,11 @@ class SessionStore(context: Context) {
private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token"
private const val KEY_USER = "user"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_BIO_ENABLED = "bio_enabled"
private const val KEY_BIO_CONFIGURED = "bio_configured"
private const val KEY_BIO_EMAIL = "bio_email"
private const val KEY_BIO_REFRESH = "bio_refresh_token"
private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L
}
}
@@ -189,6 +189,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
}
private fun normalize(error: Exception): AppException =
if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
private fun normalize(error: Exception): AppException {
if (error is AppException && error.code == "SESSION_EXPIRED") {
refreshState()
}
return if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
}
}
+3
View File
@@ -1,6 +1,7 @@
import axios from 'axios';
import { API_BASE_URL } from '../config/env';
import { loadSession, saveSession, clearSession } from '../storage/session';
import { notifySessionExpired } from '../utils/sessionEvents';
const client = axios.create({
baseURL: API_BASE_URL,
@@ -38,6 +39,7 @@ client.interceptors.response.use(
const session = await loadSession();
if (!session.refreshToken) {
await clearSession();
notifySessionExpired();
throw error;
}
@@ -68,6 +70,7 @@ client.interceptors.response.use(
} catch (refreshError) {
resolveQueue(refreshError, null);
await clearSession();
notifySessionExpired();
throw refreshError;
} finally {
isRefreshing = false;
+57 -7
View File
@@ -1,8 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Alert } from 'react-native';
import { Alert, AppState } from 'react-native';
import { changePassword as apiChangePassword, getMe, login as apiLogin, logout as apiLogout, register as apiRegister } from '../api/auth';
import { initLocalDb, saveProfileCache } from '../storage/localDb';
import { clearSession, loadSession, saveSession } from '../storage/session';
import { SESSION_EXPIRED_MESSAGE, setSessionExpiredHandler } from '../utils/sessionEvents';
const AuthContext = createContext(null);
@@ -11,6 +12,15 @@ export const AuthProvider = ({ children }) => {
const [tokens, setTokens] = useState({ accessToken: null, refreshToken: null });
const [loading, setLoading] = useState(true);
const clearLocalAuthState = useCallback(async ({ showAlert = false } = {}) => {
await clearSession();
setUser(null);
setTokens({ accessToken: null, refreshToken: null });
if (showAlert) {
Alert.alert('Sesión expirada', SESSION_EXPIRED_MESSAGE);
}
}, []);
useEffect(() => {
const bootstrap = async () => {
initLocalDb();
@@ -27,14 +37,56 @@ export const AuthProvider = ({ children }) => {
setUser(data.data.user);
saveProfileCache(data.data.user);
} catch (error) {
await clearSession();
await clearLocalAuthState();
} finally {
setLoading(false);
}
};
bootstrap();
}, []);
}, [clearLocalAuthState]);
useEffect(() => {
return setSessionExpiredHandler(() => {
clearLocalAuthState({ showAlert: true });
});
}, [clearLocalAuthState]);
useEffect(() => {
if (!user || !tokens.accessToken) return undefined;
let timeoutId = null;
let isMounted = true;
const enforceExpiry = async ({ showAlert = false } = {}) => {
const session = await loadSession();
if (!session.accessToken || !session.expiresAt || session.expiresAt <= Date.now()) {
if (isMounted) {
await clearLocalAuthState({ showAlert });
}
return;
}
timeoutId = setTimeout(() => {
clearLocalAuthState({ showAlert: true });
}, Math.max(session.expiresAt - Date.now(), 0));
};
enforceExpiry();
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
if (timeoutId) clearTimeout(timeoutId);
enforceExpiry({ showAlert: true });
}
});
return () => {
isMounted = false;
if (timeoutId) clearTimeout(timeoutId);
subscription.remove();
};
}, [clearLocalAuthState, tokens.accessToken, user]);
const login = useCallback(async (email, password) => {
const { data } = await apiLogin({ email, password });
@@ -59,11 +111,9 @@ export const AuthProvider = ({ children }) => {
} catch (error) {
Alert.alert('Cerrar sesión', 'No se pudo notificar el cierre al backend. La sesión local se cerrará igual.');
} finally {
await clearSession();
setUser(null);
setTokens({ accessToken: null, refreshToken: null });
await clearLocalAuthState();
}
}, []);
}, [clearLocalAuthState]);
const requestPasswordChange = useCallback(async ({ currentPassword, newPassword }) => {
return apiChangePassword({ currentPassword, newPassword });
+18
View File
@@ -0,0 +1,18 @@
export const SESSION_EXPIRED_MESSAGE = 'Tu sesión expiró. Vuelve a iniciar sesión.';
let sessionExpiredHandler = null;
export const setSessionExpiredHandler = (handler) => {
sessionExpiredHandler = handler;
return () => {
if (sessionExpiredHandler === handler) {
sessionExpiredHandler = null;
}
};
};
export const notifySessionExpired = () => {
if (sessionExpiredHandler) {
sessionExpiredHandler();
}
};
+1 -1
View File
@@ -8,7 +8,7 @@ DB_USER=SignLinked
DB_PASSWORD=
JWT_SECRET=change_me
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
JWT_REFRESH_EXPIRES=15m
STORAGE_PATH=/tmp/linkedsign_storage
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "linkedsign-backend",
"version": "1.0.2",
"version": "1.0.3",
"private": true,
"description": "Initial backend for LinkedSign",
"main": "src/server.js",
+20 -1
View File
@@ -11,6 +11,24 @@ const requireEnv = (name) => {
return value;
};
const parseDurationMs = (value, fallbackMs) => {
if (!value) return fallbackMs;
const match = String(value).trim().match(/^(\d+)(ms|s|m|h|d)?$/i);
if (!match) return fallbackMs;
const amount = Number(match[1]);
const unit = (match[2] || 'ms').toLowerCase();
const multipliers = {
ms: 1,
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};
return amount * multipliers[unit];
};
if (process.env.NODE_ENV === 'production') {
[
'JWT_SECRET',
@@ -53,7 +71,8 @@ const env = {
jwt: {
secret: process.env.JWT_SECRET || 'change_me',
accessExpires: process.env.JWT_ACCESS_EXPIRES || '15m',
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d',
refreshExpires: process.env.JWT_REFRESH_EXPIRES || '15m',
refreshExpiresMs: parseDurationMs(process.env.JWT_REFRESH_EXPIRES || '15m', 15 * 60 * 1000),
},
storagePath: process.env.STORAGE_PATH || '/tmp/linkedsign_storage',
+12 -2
View File
@@ -3,7 +3,12 @@ const { verify } = require('../utils/jwt');
const authenticate = (req, res, next) => {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ success: false, data: null, error: 'Authorization token required.' });
return res.status(401).json({
success: false,
data: null,
error: 'La sesión expiró. Vuelve a iniciar sesión.',
code: 'SESSION_EXPIRED',
});
}
const token = authHeader.slice(7);
@@ -12,7 +17,12 @@ const authenticate = (req, res, next) => {
req.user = { id: decoded.sub, email: decoded.email, role: decoded.role, name: decoded.name };
return next();
} catch {
return res.status(401).json({ success: false, data: null, error: 'Invalid or expired token.' });
return res.status(401).json({
success: false,
data: null,
error: 'La sesión expiró. Vuelve a iniciar sesión.',
code: 'SESSION_EXPIRED',
});
}
};
+6 -6
View File
@@ -13,7 +13,7 @@ const getRefreshCookieOptions = () => ({
secure: env.nodeEnv === 'production',
sameSite: 'lax',
path: '/',
maxAge: 7 * 24 * 60 * 60 * 1000,
maxAge: env.jwt.refreshExpiresMs,
});
const readRefreshToken = (req) => {
@@ -51,7 +51,7 @@ const register = async (req, res, next) => {
certificateFile: req.file,
}, getContext(req));
return sendSuccess(res, {
message: 'Registration successful. Please check your email to verify your account.',
message: 'Registro exitoso. Revisa tu correo para verificar tu cuenta.',
userId: result.userId,
}, 201);
} catch (err) {
@@ -83,7 +83,7 @@ const logout = async (req, res, next) => {
try {
await authService.logout(readRefreshToken(req));
res.clearCookie(env.refreshCookieName, getRefreshCookieOptions());
return sendSuccess(res, { message: 'Logged out.' }, 200);
return sendSuccess(res, { message: 'Sesión cerrada.' }, 200);
} catch (err) {
return next(err);
}
@@ -92,7 +92,7 @@ const logout = async (req, res, next) => {
const changePassword = async (req, res, next) => {
try {
await authService.changePassword(req.user.id, req.body, getContext(req));
return sendSuccess(res, { message: 'Password change requested. Please confirm the change from the email sent to your account.' }, 200);
return sendSuccess(res, { message: 'Cambio de contraseña solicitado. Confirma el cambio desde el correo enviado a tu cuenta.' }, 200);
} catch (err) {
return next(err);
}
@@ -101,7 +101,7 @@ const changePassword = async (req, res, next) => {
const confirmPasswordChange = async (req, res, next) => {
try {
await authService.confirmPasswordChange(req.params.token);
return sendSuccess(res, { message: 'Password changed successfully.' }, 200);
return sendSuccess(res, { message: 'Contraseña cambiada correctamente.' }, 200);
} catch (err) {
return next(err);
}
@@ -128,7 +128,7 @@ const requestUnlockPasswordChange = async (req, res, next) => {
const verifyEmail = async (req, res, next) => {
try {
await authService.verifyEmail(req.params.token);
return sendSuccess(res, { message: 'Email verified successfully.' }, 200);
return sendSuccess(res, { message: 'Correo verificado correctamente.' }, 200);
} catch (err) {
return next(err);
}
+11 -9
View File
@@ -11,6 +11,7 @@ const { getCertificatePath } = require('../../utils/storagePath');
const { signAccess, signRefresh } = require('../../utils/jwt');
const fs = require('fs/promises');
const path = require('path');
const env = require('../../config/env');
const SALT_ROUNDS = 12;
const MAX_FAILED_LOGIN_ATTEMPTS = 3;
@@ -157,7 +158,7 @@ const login = async ({ email, password }, context = {}) => {
if (!user || !user.is_active) {
await auditService.logEvent({ type: 'LOGIN_FAILED', email, ip: context.ip, userAgent: context.userAgent });
const err = new Error('Invalid credentials.');
const err = new Error('Credenciales inválidas.');
err.statusCode = 401;
throw err;
}
@@ -210,7 +211,7 @@ const login = async ({ email, password }, context = {}) => {
throw err;
}
const err = new Error('Invalid credentials.');
const err = new Error('Credenciales inválidas.');
err.statusCode = 401;
throw err;
}
@@ -222,7 +223,7 @@ const login = async ({ email, password }, context = {}) => {
try {
({ verificationUrl } = await issueVerificationEmail(user));
} catch (error) {
const err = new Error('Your email is not verified and the verification email could not be resent. Please contact support.');
const err = new Error('Tu correo no está verificado y no se pudo reenviar el correo de confirmación. Contacta soporte.');
err.statusCode = 503;
err.code = 'MAIL_SEND_FAILED';
throw err;
@@ -235,7 +236,7 @@ const login = async ({ email, password }, context = {}) => {
userAgent: context.userAgent,
metadata: { source: 'login' },
});
const err = new Error('Your email is not verified yet. A new verification link has been sent to your email.');
const err = new Error('Tu correo aún no está verificado. Te enviamos un nuevo enlace de confirmación.');
err.statusCode = 403;
err.code = 'EMAIL_NOT_VERIFIED';
err.data = { verificationUrl };
@@ -246,7 +247,7 @@ const login = async ({ email, password }, context = {}) => {
const accessToken = signAccess(payload);
const refreshToken = signRefresh(payload);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs);
await authRepository.saveRefreshToken(user.id, refreshToken, expiresAt);
await auditService.logEvent({ type: 'LOGIN_SUCCESS', userId: user.id, email, ip: context.ip, userAgent: context.userAgent });
@@ -268,21 +269,22 @@ const login = async ({ email, password }, context = {}) => {
const refresh = async (token) => {
if (!token) {
const err = new Error('Invalid or expired refresh token.');
const err = new Error('La sesión expiró. Vuelve a iniciar sesión.');
err.statusCode = 401;
throw err;
}
const record = await authRepository.findRefreshToken(token);
if (!record) {
const err = new Error('Invalid or expired refresh token.');
await authRepository.deleteRefreshToken(token);
const err = new Error('La sesión expiró. Vuelve a iniciar sesión.');
err.statusCode = 401;
throw err;
}
if (!record.is_active) {
await authRepository.deleteUserRefreshTokens(record.user_id);
const err = new Error('Session invalidated.');
const err = new Error('La sesión fue invalidada. Vuelve a iniciar sesión.');
err.statusCode = 401;
throw err;
}
@@ -292,7 +294,7 @@ const refresh = async (token) => {
const payload = { sub: record.uid, email: record.email, role: record.role };
const accessToken = signAccess(payload);
const newRefreshToken = signRefresh(payload);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const expiresAt = new Date(Date.now() + env.jwt.refreshExpiresMs);
await authRepository.saveRefreshToken(record.uid, newRefreshToken, expiresAt);
return { accessToken, refreshToken: newRefreshToken };
+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');
}
};