From 820e335d9a281c7c6a1aa9efeba8738481f74c9b Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 23 Apr 2026 01:10:15 -0500 Subject: [PATCH] Fix session expiration handling --- CHANGELOG.md | 8 ++- VERSION | 2 +- android/app/build.gradle | 4 +- .../linkedsign/data/LinkedSignRepository.kt | 13 +++- .../net/linkedcorp/linkedsign/data/Models.kt | 3 +- .../linkedsign/data/SessionStore.kt | 30 ++++++++- .../linkedcorp/linkedsign/ui/MainViewModel.kt | 9 ++- android_react/src/api/client.js | 3 + android_react/src/context/AuthContext.js | 64 +++++++++++++++++-- android_react/src/utils/sessionEvents.js | 18 ++++++ backend/.env.example | 2 +- backend/package.json | 2 +- backend/src/config/env.js | 21 +++++- backend/src/middlewares/authenticate.js | 14 +++- backend/src/modules/auth/auth.controller.js | 12 ++-- backend/src/modules/auth/auth.service.js | 20 +++--- frontend/package.json | 2 +- frontend/src/api/client.js | 24 +++++-- frontend/src/contexts/AuthContext.jsx | 39 +++++++++-- frontend/src/pages/auth/LoginPage.jsx | 15 ++++- frontend/src/pages/auth/RegisterPage.jsx | 3 +- frontend/src/utils/session.js | 51 +++++++++++++++ 22 files changed, 308 insertions(+), 51 deletions(-) create mode 100644 android_react/src/utils/sessionEvents.js create mode 100644 frontend/src/utils/session.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8344869..ff9281a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/VERSION b/VERSION index 6d7de6e..21e8796 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.2 +1.0.3 diff --git a/android/app/build.gradle b/android/app/build.gradle index ebef117..9d6b3b3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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}\"" } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt index cfe2091..92cc5fe 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt @@ -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 execute(block: suspend () -> T): T { return try { block() diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt index 61461f3..7814c62 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt @@ -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( diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt index 9b58ee3..502166d 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt @@ -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 } } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt index eb7197f..a4dd751 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt @@ -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.") + } } diff --git a/android_react/src/api/client.js b/android_react/src/api/client.js index 17db0e6..7a61bb7 100644 --- a/android_react/src/api/client.js +++ b/android_react/src/api/client.js @@ -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; diff --git a/android_react/src/context/AuthContext.js b/android_react/src/context/AuthContext.js index ae52e60..025edc9 100644 --- a/android_react/src/context/AuthContext.js +++ b/android_react/src/context/AuthContext.js @@ -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 }); diff --git a/android_react/src/utils/sessionEvents.js b/android_react/src/utils/sessionEvents.js new file mode 100644 index 0000000..be653ee --- /dev/null +++ b/android_react/src/utils/sessionEvents.js @@ -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(); + } +}; diff --git a/backend/.env.example b/backend/.env.example index dd7f357..1f881fe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/package.json b/backend/package.json index 3d9e23d..b8dbf84 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/config/env.js b/backend/src/config/env.js index 29e45dc..d004d6b 100644 --- a/backend/src/config/env.js +++ b/backend/src/config/env.js @@ -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', diff --git a/backend/src/middlewares/authenticate.js b/backend/src/middlewares/authenticate.js index 156bdd2..e1795db 100644 --- a/backend/src/middlewares/authenticate.js +++ b/backend/src/middlewares/authenticate.js @@ -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', + }); } }; diff --git a/backend/src/modules/auth/auth.controller.js b/backend/src/modules/auth/auth.controller.js index 6e13713..928cee3 100644 --- a/backend/src/modules/auth/auth.controller.js +++ b/backend/src/modules/auth/auth.controller.js @@ -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); } diff --git a/backend/src/modules/auth/auth.service.js b/backend/src/modules/auth/auth.service.js index f163c7a..3f96091 100644 --- a/backend/src/modules/auth/auth.service.js +++ b/backend/src/modules/auth/auth.service.js @@ -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 }; diff --git a/frontend/package.json b/frontend/package.json index 7bd40b4..23f3e9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.0.2", + "version": "1.0.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 6b6384e..3a80735 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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); } ); diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index 8aa0384..af08459 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -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); }, []); diff --git a/frontend/src/pages/auth/LoginPage.jsx b/frontend/src/pages/auth/LoginPage.jsx index cb38d66..4c9df3a 100644 --- a/frontend/src/pages/auth/LoginPage.jsx +++ b/frontend/src/pages/auth/LoginPage.jsx @@ -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.'); diff --git a/frontend/src/pages/auth/RegisterPage.jsx b/frontend/src/pages/auth/RegisterPage.jsx index a181fd4..dea05c7 100644 --- a/frontend/src/pages/auth/RegisterPage.jsx +++ b/frontend/src/pages/auth/RegisterPage.jsx @@ -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); } diff --git a/frontend/src/utils/session.js b/frontend/src/utils/session.js new file mode 100644 index 0000000..89760b6 --- /dev/null +++ b/frontend/src/utils/session.js @@ -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'); + } +};