From b21df083324595270030d11a4e31c2b8cb785e1d Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Thu, 24 Sep 2026 19:34:42 -0300 Subject: [PATCH 1/3] fix: [TESIS-82] clear the order draft when the session ends The draft of the manual order lives in sessionStorage and survived the logout. Whoever logged in next in the same tab found the previous user's customer, document and lines waiting in /orders/new. The logout now empties everything that belongs to the user of the session: the React Query cache, as it already did, and the order draft. Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 2 +- src/shared/store/authStore.test.ts | 30 +++++++++++++++++++++++------ src/shared/store/authStore.ts | 21 ++++++++++++++++---- src/shared/store/orderDraftStore.ts | 2 +- 4 files changed, 43 insertions(+), 12 deletions(-) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 891ef80..2ebfb3c 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -260,7 +260,7 @@ const { slug, config, setConfig } = useTenantStore() - `authStore` — persiste **sólo** token y email (clave `'auth-store'`). `user` (id + `companyId`) se **deriva del JWT** al rehidratar, así no puede quedar desincronizado, y un token vencido o corrupto se descarta antes de arrancar. Expone `getAuthToken()` y `clearSession()` para consumidores fuera de React, como el interceptor HTTP. - `tenantStore` — `slug` (resuelto del host, no lo cambia la app) y `config` del tenant (clave `'tenant-store'`). La config persistida se restaura **sólo** si es del mismo slug, y se valida con el mismo schema que la respuesta del backend. Expone `useTenantName()` y `useTenantFeature(feature)` como selectores, y `setTenantConfig()` para escribirla desde afuera de React. - `notificationStore` — cola de notificaciones con `notify(mensaje, severidad)`, también invocable fuera de React. La renderiza `NotificationHost`, montado una vez en los providers. -- `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar o confirmar. +- `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar, al confirmar y al cerrar sesión (el logout lo vacía junto con la cache de React Query). **Conteos sobre listados paginados** (`shared/api/count.ts`): diff --git a/src/shared/store/authStore.test.ts b/src/shared/store/authStore.test.ts index 95e9414..974187f 100644 --- a/src/shared/store/authStore.test.ts +++ b/src/shared/store/authStore.test.ts @@ -6,12 +6,14 @@ import { sessionToken, tokenWith } from '../../test/tokens' // cada caso tiene que sembrar el storage y recién después importar el módulo. async function loadStore() { vi.resetModules() - const [{ useAuthStore, getAuthToken }, { queryClient }] = await Promise.all([ - import('./authStore'), - import('../api/queryClient'), - ]) - - return { useAuthStore, getAuthToken, queryClient } + const [{ useAuthStore, getAuthToken }, { queryClient }, { useOrderDraftStore }] = + await Promise.all([ + import('./authStore'), + import('../api/queryClient'), + import('./orderDraftStore'), + ]) + + return { useAuthStore, getAuthToken, queryClient, useOrderDraftStore } } /** @@ -34,6 +36,7 @@ function persist(state: unknown, version = 1) { beforeEach(() => { localStorage.clear() + sessionStorage.clear() vi.doUnmock('../api/session') }) @@ -103,6 +106,21 @@ describe('logout', () => { expect(queryClient.getQueryData(['products'])).toBeUndefined() }) + // QA de TESIS-82: el borrador vive en sessionStorage y sobrevivía al logout. + // Quien entraba después en la misma pestaña veía en el alta el cliente, el + // documento y las líneas del anterior. + it('drops the order draft of the user that is leaving', async () => { + const { useAuthStore, useOrderDraftStore } = await loadStore() + useAuthStore.getState().login(sessionToken()) + useOrderDraftStore + .getState() + .setCustomer({ firstName: 'Ana', lastName: 'Pérez', document: '30111222' }) + + useAuthStore.getState().logout() + + expect(useOrderDraftStore.getState().customer).toBeNull() + }) + // Hasta TESIS-116 el logout sólo limpiaba el navegador: el token seguía // siendo válido contra la API hasta que vencía. it('revokes the token against the backend', async () => { diff --git a/src/shared/store/authStore.ts b/src/shared/store/authStore.ts index f269a02..ea02ac7 100644 --- a/src/shared/store/authStore.ts +++ b/src/shared/store/authStore.ts @@ -5,6 +5,8 @@ import { queryClient } from '../api/queryClient' import { revokeSession } from '../api/session' import { decodeJwt, isExpired } from '../utils/jwt' +import { useOrderDraftStore } from './orderDraftStore' + /** La identidad de la sesión, tal como la confirma `GET /me` (TESIS-117). */ export interface SessionUser { id: number @@ -65,6 +67,20 @@ function sessionFromToken(token: string): Session { return { token, user: null, isAuthenticated: true } } +// Lo que pertenece al usuario de la sesión, aparte de la sesión misma. Se vacía +// cuando la sesión termina, porque dejarlo vivo le mostraría al próximo usuario +// lo del anterior: +// +// - La cache de React Query es por tenant: el JWT lleva `company_id`, y la +// empresa del próximo usuario puede ser otra. +// - El borrador de la orden manual vive en sessionStorage y sobrevivía al +// logout: quien entraba después en la misma pestaña veía en el alta el +// cliente, el documento y las líneas del anterior (QA de TESIS-82). +function clearUserData(): void { + queryClient.clear() + useOrderDraftStore.getState().clearDraft() +} + export const useAuthStore = create()( persist( (set, get) => ({ @@ -84,10 +100,7 @@ export const useAuthStore = create()( if (token) void revokeSession(token) set(EMPTY_SESSION) - // La cache de React Query es por tenant: el JWT lleva `company_id`, así - // que dejarla viva le mostraría al próximo usuario los datos de la - // empresa anterior hasta el primer refetch. - queryClient.clear() + clearUserData() }, }), { diff --git a/src/shared/store/orderDraftStore.ts b/src/shared/store/orderDraftStore.ts index d93a924..f5e7530 100644 --- a/src/shared/store/orderDraftStore.ts +++ b/src/shared/store/orderDraftStore.ts @@ -75,7 +75,7 @@ interface OrderDraftState { patch: Partial>, ) => void removeItem: (productId: number) => void - /** Cancelar la orden o confirmarla: el borrador vuelve a cero. */ + /** Cancelar la orden, confirmarla o cerrar sesión: el borrador vuelve a cero. */ clearDraft: () => void } From 5d62fd5d9c5f8e8793b9f503a895faeec4e35b50 Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Thu, 24 Sep 2026 19:40:17 -0300 Subject: [PATCH 2/3] fix: [TESIS-82] keep the open tabs in agreement about the session Tabs share localStorage but each one has its own copy of the store in memory, and persist does not update it when another tab writes. Logging out in one tab left the other showing data with a revoked token. When that other tab got its 401, it wrote the empty session to localStorage and wiped the saved session of whoever had logged in meanwhile. Three changes close it: - followSessionAcrossTabs() listens to the storage event, which only reaches the other tabs, and rehydrates the store. When the token changed it also empties the data of the previous user, like the logout does. main.tsx registers it once. - Rehydrating from a storage with no token now ends the session of the tab, instead of keeping whatever it had in memory. - The interceptor only ends the session on a 401 of a request that left with the current token. A late 401 of an older token says nothing about the session that is open now. ADR-002 and the architecture guide describe the sync; the authStore entry of the guide was also still describing the pre-TESIS-117 store. Co-Authored-By: Claude Opus 5.5 --- docs/adr/ADR-002-state-management.md | 19 +++++ docs/guidelines/architecture.md | 4 +- src/main.tsx | 6 ++ src/shared/api/client.test.ts | 58 +++++++++++++++ src/shared/api/client.ts | 22 ++++-- src/shared/store/authStore.test.ts | 104 ++++++++++++++++++++++++--- src/shared/store/authStore.ts | 45 ++++++++++-- src/shared/store/index.ts | 2 +- 8 files changed, 240 insertions(+), 20 deletions(-) diff --git a/docs/adr/ADR-002-state-management.md b/docs/adr/ADR-002-state-management.md index fa6da1e..7e1a0ee 100644 --- a/docs/adr/ADR-002-state-management.md +++ b/docs/adr/ADR-002-state-management.md @@ -67,3 +67,22 @@ Zustand.** Se puede espejar ahí cuando una sola lectura de React Query es su origen y el store es sólo el punto desde donde el resto lo consume. Por eso la identidad tampoco se persiste —sólo el token—: una copia vieja en `localStorage` no puede contradecir lo que la API dice hoy. + +### La sesión entre pestañas (TESIS-82) + +`persist` guarda el store en `localStorage`, que las pestañas comparten, pero +cada pestaña tiene su propia copia en memoria y `persist` no la actualiza cuando +otra escribe. Con la sesión eso era un problema: la QA de TESIS-82 encontró que +cerrar sesión en una pestaña dejaba a la otra mostrando datos con un token +revocado, y que cuando esa otra recibía el 401, escribía la sesión vacía y le +borraba la sesión guardada a quien había entrado mientras tanto. + +`followSessionAcrossTabs()` (en `authStore`) escucha el evento `storage`, que +el navegador entrega sólo a las otras pestañas, y rehidrata el store desde +`localStorage`. Si el token cambió, vacía lo que era del usuario anterior (la +cache de React Query y el borrador de la orden), igual que el logout. Se +registra una vez en `main.tsx`. + +Es la única sincronización entre pestañas: los demás stores persistidos +(`uiStore`, `tenantStore`) guardan preferencias o datos del portal, que no +cambian de dueño. diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 2ebfb3c..ab8787b 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -188,7 +188,7 @@ index.html → src/main.tsx → - `baseURL`: `import.meta.env.VITE_API_URL` - Request interceptor: inyecta `Authorization: Bearer ` leyendo el token del `authStore` (no de `localStorage`, para no tener dos fuentes de verdad sobre la sesión), y `X-Tenant-Slug` con el slug del tenant activo en **todos** los requests — el backend lo ignora donde manda el JWT (§1 del contrato de tenant) - Response interceptor: normaliza errores a `ApiRequestError`, que **conserva el `status`** para que cada feature elija su mensaje en vez de mostrar el texto crudo de la API -- **401**: limpia la sesión completa; el redirect lo hace el guard, así el interceptor no conoce el router +- **401**: limpia la sesión completa; el redirect lo hace el guard, así el interceptor no conoce el router. Sólo si el request salió con el token de la sesión abierta ahora: el 401 de un request que salió con otro token (una sesión que ya se cerró, en esta pestaña o en otra) no cierra la actual - **403**: no desloguea — notifica "sin permisos" vía `notify()` **Tipos de API** (`shared/api/types.ts`): @@ -257,7 +257,7 @@ const { slug, config, setConfig } = useTenantStore() ``` - `uiStore` — `themeMode` persiste en `localStorage` (clave `'ui-store'`); `sidebarOpen` no persiste. -- `authStore` — persiste **sólo** token y email (clave `'auth-store'`). `user` (id + `companyId`) se **deriva del JWT** al rehidratar, así no puede quedar desincronizado, y un token vencido o corrupto se descarta antes de arrancar. Expone `getAuthToken()` y `clearSession()` para consumidores fuera de React, como el interceptor HTTP. +- `authStore` — persiste **sólo** el token (clave `'auth-store'`); un token vencido o corrupto se descarta antes de arrancar. `user` es la identidad que devuelve `GET /me` (ver ADR-002) y no se persiste. El logout vacía también lo del usuario que se va: la cache de React Query y el borrador de la orden. `followSessionAcrossTabs()`, registrado en `main.tsx`, mantiene de acuerdo a las pestañas: si otra cierra sesión o entra con otra cuenta, ésta relee la sesión guardada y vacía lo del usuario anterior. Expone `getAuthToken()` y `clearSession()` para consumidores fuera de React, como el interceptor HTTP. - `tenantStore` — `slug` (resuelto del host, no lo cambia la app) y `config` del tenant (clave `'tenant-store'`). La config persistida se restaura **sólo** si es del mismo slug, y se valida con el mismo schema que la respuesta del backend. Expone `useTenantName()` y `useTenantFeature(feature)` como selectores, y `setTenantConfig()` para escribirla desde afuera de React. - `notificationStore` — cola de notificaciones con `notify(mensaje, severidad)`, también invocable fuera de React. La renderiza `NotificationHost`, montado una vez en los providers. - `orderDraftStore` — borrador del alta manual de una orden (cliente, líneas, depósito de origen y domicilio de entrega), compartido por los tres pasos del asistente (`/orders/new`, S05 → S07). Persiste en **`sessionStorage`** (clave `'order-draft-store'`): un reload entre pasos no pierde lo cargado, pero un borrador a medias no reaparece días después en otra pestaña. `clearDraft()` al cancelar, al confirmar y al cerrar sesión (el logout lo vacía junto con la cache de React Query). diff --git a/src/main.tsx b/src/main.tsx index 02c42df..aae8e93 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,12 +2,18 @@ import 'app/theme/fonts' import { Providers } from 'app/providers/Providers' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { followSessionAcrossTabs } from 'shared/store' import App from './App.tsx' const rootElement = document.getElementById('root') if (!rootElement) throw new Error('Root element not found') +// Cada pestaña tiene su propio store en memoria sobre la misma sesión guardada: +// esto las mantiene de acuerdo cuando otra cierra sesión o entra con otra cuenta. +// Vive lo que vive la página, así que no hace falta dejar de seguirlas. +followSessionAcrossTabs() + createRoot(rootElement).render( diff --git a/src/shared/api/client.test.ts b/src/shared/api/client.test.ts index 7fffd66..eee9c28 100644 --- a/src/shared/api/client.test.ts +++ b/src/shared/api/client.test.ts @@ -1,8 +1,10 @@ +import { AxiosError } from 'axios' import type { AxiosAdapter, InternalAxiosRequestConfig } from 'axios' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { sessionToken } from '../../test/tokens' import { useAuthStore } from '../store/authStore' +import { useNotificationStore } from '../store/notificationStore' import { TENANT_CONFIG_PATH, TENANT_HEADER } from '../utils/tenant' import { client } from './client' @@ -84,3 +86,59 @@ describe('authorization header', () => { expect(headerOf(sent[1], 'Authorization')).toBeUndefined() }) }) + +describe('a 401 answer', () => { + function unauthorized(config: InternalAxiosRequestConfig) { + return new AxiosError( + 'Request failed with status code 401', + AxiosError.ERR_BAD_REQUEST, + config, + null, + { + data: { error: 'Signature has expired' }, + status: 401, + statusText: 'Unauthorized', + headers: {}, + config, + }, + ) + } + + beforeEach(() => { + useNotificationStore.setState({ notifications: [] }) + }) + + it('ends the session that sent the request', async () => { + useAuthStore.getState().login(sessionToken()) + client.defaults.adapter = (config) => Promise.reject(unauthorized(config)) + + await expect(client.get('/products')).rejects.toMatchObject({ status: 401 }) + + expect(useAuthStore.getState().isAuthenticated).toBe(false) + }) + + // QA de TESIS-82: si mientras el request viajaba la sesión cambió de dueño, + // en esta pestaña o en otra, el 401 es de la sesión vieja y no de la actual. + it('leaves alone a session that was opened while the request was on its way', async () => { + useAuthStore.getState().login(sessionToken({ userId: 7 })) + const nueva = sessionToken({ userId: 8 }) + client.defaults.adapter = (config) => { + if (config.url === '/products') useAuthStore.getState().login(nueva) + return Promise.reject(unauthorized(config)) + } + + await expect(client.get('/products')).rejects.toMatchObject({ status: 401 }) + + expect(useAuthStore.getState().token).toBe(nueva) + }) + + // Un 401 sin sesión son credenciales mal tipeadas en el login: el formulario + // ya muestra su error, y avisar "tu sesión expiró" sería mentir. + it('does not warn about an expired session to someone who had none', async () => { + client.defaults.adapter = (config) => Promise.reject(unauthorized(config)) + + await expect(client.post('/auth/login', {})).rejects.toMatchObject({ status: 401 }) + + expect(useNotificationStore.getState().notifications).toHaveLength(0) + }) +}) diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 593a6dd..668fe1f 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import type { InternalAxiosRequestConfig } from 'axios' import { clearSession, getAuthToken } from '../store/authStore' import { notify } from '../store/notificationStore' @@ -34,6 +35,19 @@ function toRequestError(message: string, status?: number): ApiRequestError { return error } +// Si el request salió con el token de la sesión que está abierta ahora. +// +// Un 401 habla de la credencial con la que salió el request, no de la sesión +// de ahora. Si en el medio la sesión cambió (se cerró y entró otra persona, en +// esta pestaña o en otra), ese 401 no dice nada de la sesión actual, y tratarlo +// como vencida la cerraba (QA de TESIS-82). Sin token, además, no había sesión +// que cerrar: un 401 del propio login son credenciales mal tipeadas, y el +// formulario ya muestra su error. +function sentWithCurrentSession(config: InternalAxiosRequestConfig | undefined): boolean { + const token = getAuthToken() + return token !== null && config?.headers.get('Authorization') === `Bearer ${token}` +} + export const client = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { @@ -92,16 +106,16 @@ client.interceptors.response.use( // limpia la sesión y el guard de rutas se encarga del redirect, así el // interceptor no necesita conocer el router. // - // Sólo se actúa si **había** sesión: un 401 del propio login son - // credenciales mal tipeadas, y el formulario ya muestra su error. Avisarle - // "tu sesión expiró" a quien nunca la tuvo sería mentirle. + // Sólo se actúa si el request salió con la sesión que está abierta ahora + // (ver `sentWithCurrentSession`). Avisarle "tu sesión expiró" a quien nunca + // la tuvo, o a quien acaba de abrir otra, sería mentirle. // Un 401 del propio logout se ignora: el token ya no sirve, que es // exactamente lo que se estaba pidiendo. Avisar "tu sesión expiró" a quien // acaba de cerrarla a propósito sería ruido, y `logout()` ya limpia el // store por su cuenta. const isLogout = error.config?.url === LOGOUT_PATH - if (status === 401 && !isLogout && getAuthToken()) { + if (status === 401 && !isLogout && sentWithCurrentSession(error.config)) { clearSession() notify(SESSION_EXPIRED_MESSAGE, 'warning') } diff --git a/src/shared/store/authStore.test.ts b/src/shared/store/authStore.test.ts index 974187f..b261d7b 100644 --- a/src/shared/store/authStore.test.ts +++ b/src/shared/store/authStore.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { sessionToken, tokenWith } from '../../test/tokens' @@ -6,14 +6,17 @@ import { sessionToken, tokenWith } from '../../test/tokens' // cada caso tiene que sembrar el storage y recién después importar el módulo. async function loadStore() { vi.resetModules() - const [{ useAuthStore, getAuthToken }, { queryClient }, { useOrderDraftStore }] = - await Promise.all([ - import('./authStore'), - import('../api/queryClient'), - import('./orderDraftStore'), - ]) - - return { useAuthStore, getAuthToken, queryClient, useOrderDraftStore } + const [ + { useAuthStore, getAuthToken, followSessionAcrossTabs }, + { queryClient }, + { useOrderDraftStore }, + ] = await Promise.all([ + import('./authStore'), + import('../api/queryClient'), + import('./orderDraftStore'), + ]) + + return { useAuthStore, getAuthToken, followSessionAcrossTabs, queryClient, useOrderDraftStore } } /** @@ -242,6 +245,89 @@ describe('rehydration from localStorage', () => { }) }) +// Las pestañas comparten localStorage, pero cada una tiene su store en memoria. +// El evento `storage` sólo llega a las otras pestañas: acá se simula lo que ve +// esta pestaña cuando otra escribe la sesión. +describe('another tab', () => { + function otherTabSaves(state: unknown) { + persist(state) + window.dispatchEvent(new StorageEvent('storage', { key: 'auth-store' })) + } + + async function loadFollowingTabs() { + const loaded = await loadStore() + onTestFinished(loaded.followSessionAcrossTabs()) + return loaded + } + + // QA de TESIS-82: sin esto la otra pestaña seguía mostrando datos con un + // token revocado. + it('closes this session when the other tab logs out', async () => { + const { useAuthStore } = await loadFollowingTabs() + useAuthStore.getState().login(sessionToken()) + + otherTabSaves({ token: null }) + + expect(useAuthStore.getState().isAuthenticated).toBe(false) + }) + + it('takes the session that the other tab opened', async () => { + const { useAuthStore } = await loadFollowingTabs() + const token = sessionToken({ userId: 8 }) + + otherTabSaves({ token }) + + expect(useAuthStore.getState()).toMatchObject({ token, isAuthenticated: true }) + }) + + it('drops the data of the previous user when the session changes hands', async () => { + const { useAuthStore, queryClient, useOrderDraftStore } = await loadFollowingTabs() + useAuthStore.getState().login(sessionToken({ userId: 7 })) + queryClient.setQueryData(['products'], [{ id: 1, name: 'Cable UTP Cat6' }]) + useOrderDraftStore + .getState() + .setCustomer({ firstName: 'Ana', lastName: 'Pérez', document: '30111222' }) + + otherTabSaves({ token: sessionToken({ userId: 8 }) }) + + expect(queryClient.getQueryData(['products'])).toBeUndefined() + expect(useOrderDraftStore.getState().customer).toBeNull() + }) + + it('keeps the data when the session did not change', async () => { + const { useAuthStore, queryClient } = await loadFollowingTabs() + const token = sessionToken() + useAuthStore.getState().login(token) + queryClient.setQueryData(['products'], [{ id: 1, name: 'Cable UTP Cat6' }]) + + otherTabSaves({ token }) + + expect(queryClient.getQueryData(['products'])).toHaveLength(1) + }) + + it('closes this session when the other tab clears the storage', async () => { + const { useAuthStore } = await loadFollowingTabs() + useAuthStore.getState().login(sessionToken()) + + localStorage.clear() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + + expect(useAuthStore.getState().isAuthenticated).toBe(false) + }) + + // Sin la sesión guardada, releerla por una clave ajena la cerraría: tiene + // que ignorarse. + it('ignores the changes to other keys', async () => { + const { useAuthStore } = await loadFollowingTabs() + useAuthStore.getState().login(sessionToken()) + localStorage.removeItem('auth-store') + + window.dispatchEvent(new StorageEvent('storage', { key: 'ui-store' })) + + expect(useAuthStore.getState().isAuthenticated).toBe(true) + }) +}) + describe('getAuthToken', () => { // El interceptor HTTP lo lee desde afuera de React. Que salga del store y no // de localStorage es lo que evita que haya dos fuentes de verdad. diff --git a/src/shared/store/authStore.ts b/src/shared/store/authStore.ts index ea02ac7..47c3e91 100644 --- a/src/shared/store/authStore.ts +++ b/src/shared/store/authStore.ts @@ -7,6 +7,9 @@ import { decodeJwt, isExpired } from '../utils/jwt' import { useOrderDraftStore } from './orderDraftStore' +/** Clave de la sesión en localStorage. La sincronización entre pestañas escucha esta. */ +const STORAGE_KEY = 'auth-store' + /** La identidad de la sesión, tal como la confirma `GET /me` (TESIS-117). */ export interface SessionUser { id: number @@ -68,8 +71,8 @@ function sessionFromToken(token: string): Session { } // Lo que pertenece al usuario de la sesión, aparte de la sesión misma. Se vacía -// cuando la sesión termina, porque dejarlo vivo le mostraría al próximo usuario -// lo del anterior: +// cada vez que la sesión termina o cambia de dueño, porque dejarlo vivo le +// mostraría al próximo usuario lo del anterior: // // - La cache de React Query es por tenant: el JWT lleva `company_id`, y la // empresa del próximo usuario puede ser otra. @@ -104,15 +107,19 @@ export const useAuthStore = create()( }, }), { - name: 'auth-store', + name: STORAGE_KEY, version: 1, partialize: (state): PersistedAuth => ({ token: state.token }), // Al volver de localStorage el token puede estar vencido, corrupto o no // ser un string. Se rearma la sesión desde el token para no arrancar // autenticado con una credencial que el backend va a rechazar. + // + // Sin token guardado no hay sesión, aunque esta pestaña tuviera una: si + // se está rehidratando por un cambio de otra pestaña (ver + // `followSessionAcrossTabs`), es que la otra la cerró. merge: (persisted, current) => { const saved = readPersisted(persisted) - if (!saved) return current + if (!saved) return { ...current, ...EMPTY_SESSION } return { ...current, ...sessionFromToken(saved.token) } }, @@ -131,3 +138,33 @@ export function getAuthToken(): string | null { export function clearSession(): void { useAuthStore.getState().logout() } + +/** + * Mantiene a las pestañas abiertas de acuerdo sobre quién tiene la sesión. + * Devuelve la función que deja de seguirlas. + * + * Las pestañas comparten localStorage pero no el store: cada una tiene su copia + * en memoria. Sin esto, cerrar sesión en una dejaba a la otra mostrando datos + * con un token revocado. Y cuando esa otra recibía el 401, escribía la sesión + * vacía en localStorage y le borraba la sesión guardada a quien se hubiera + * logueado mientras tanto (QA de TESIS-82). + * + * El evento `storage` llega sólo a las otras pestañas, nunca a la que escribió. + * Se relee la sesión guardada y, si cambió de token, se vacía lo del usuario + * anterior. + */ +export function followSessionAcrossTabs(): () => void { + const onStorage = (event: StorageEvent) => { + // `key === null` es un `localStorage.clear()` hecho en otra pestaña. + if (event.key !== null && event.key !== STORAGE_KEY) return + + const before = useAuthStore.getState().token + // Con localStorage la rehidratación es sincrónica: al volver de acá el + // store ya tiene la sesión que dejó la otra pestaña. + void useAuthStore.persist.rehydrate() + if (useAuthStore.getState().token !== before) clearUserData() + } + + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) +} diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 4de6bb2..2c7f091 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -1,5 +1,5 @@ export { useUiStore } from './uiStore' -export { clearSession, getAuthToken, useAuthStore } from './authStore' +export { clearSession, followSessionAcrossTabs, getAuthToken, useAuthStore } from './authStore' export type { SessionUser } from './authStore' export { setTenantConfig, useTenantFeature, useTenantName, useTenantStore } from './tenantStore' export { notify, useNotificationStore } from './notificationStore' From abfd8d3035b7aa3abb60f5fa2a7c9101875f1e7e Mon Sep 17 00:00:00 2001 From: LorenzoBellomo Date: Thu, 24 Sep 2026 19:41:22 -0300 Subject: [PATCH 3/3] fix: [TESIS-82] ask to wait when the login answers 429 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API now stops the login after 10 attempts in 3 minutes per IP and answers 429. The form showed its generic message, "Probá de nuevo en unos segundos", which is wrong while the limit lasts minutes. A 429 now gets its own message. Co-Authored-By: Claude Opus 5.5 --- src/features/auth/content.ts | 2 ++ src/features/auth/hooks/useLogin.test.ts | 35 ++++++++++++++++++++++++ src/features/auth/hooks/useLogin.ts | 14 ++++++---- 3 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 src/features/auth/hooks/useLogin.test.ts diff --git a/src/features/auth/content.ts b/src/features/auth/content.ts index 5496d4e..f9edd18 100644 --- a/src/features/auth/content.ts +++ b/src/features/auth/content.ts @@ -23,6 +23,8 @@ export const authContent = { // El backend responde 401 con un texto en inglés; se traduce acá en vez de // mostrárselo crudo al usuario. invalidCredentials: 'Email o contraseña incorrectos.', + // El backend frena el login después de 10 intentos en 3 minutos (TESIS-82). + tooManyAttempts: 'Hubo demasiados intentos. Esperá unos minutos y volvé a probar.', unexpected: 'No pudimos iniciar sesión. Probá de nuevo en unos segundos.', }, } as const diff --git a/src/features/auth/hooks/useLogin.test.ts b/src/features/auth/hooks/useLogin.test.ts new file mode 100644 index 0000000..98323ac --- /dev/null +++ b/src/features/auth/hooks/useLogin.test.ts @@ -0,0 +1,35 @@ +import type { ApiRequestError } from 'shared/api' +import { describe, expect, it } from 'vitest' + +import { authContent } from '../content' + +import { loginErrorMessage } from './useLogin' + +function failedWith(status?: number): ApiRequestError { + const error: ApiRequestError = new Error('Request failed') + error.status = status + return error +} + +describe('loginErrorMessage', () => { + it('says nothing while there is no error', () => { + expect(loginErrorMessage(null)).toBeNull() + }) + + it('reports wrong credentials on a 401', () => { + expect(loginErrorMessage(failedWith(401))).toBe(authContent.errors.invalidCredentials) + }) + + // El backend frena el login después de 10 intentos en 3 minutos (TESIS-82). + // El mensaje genérico dice «probá en unos segundos», y el freno dura minutos. + it('asks to wait a few minutes on a 429', () => { + expect(loginErrorMessage(failedWith(429))).toBe(authContent.errors.tooManyAttempts) + }) + + it.each([ + ['a server error', 500], + ['a network failure', undefined], + ])('does not blame the credentials on %s', (_name, status) => { + expect(loginErrorMessage(failedWith(status))).toBe(authContent.errors.unexpected) + }) +}) diff --git a/src/features/auth/hooks/useLogin.ts b/src/features/auth/hooks/useLogin.ts index 8fb7087..85d5f80 100644 --- a/src/features/auth/hooks/useLogin.ts +++ b/src/features/auth/hooks/useLogin.ts @@ -8,17 +8,19 @@ import { authContent } from '../content' import type { LoginCredentials, LoginResponse } from '../types' const UNAUTHORIZED = 401 +const TOO_MANY_REQUESTS = 429 /** - * Traduce el fallo al idioma de la app y distingue el caso esperado (credenciales - * incorrectas) de cualquier otro, para no mostrar un error de red como si el - * usuario hubiera tipeado mal la contraseña. + * Traduce el fallo al idioma de la app y distingue los casos esperados + * (credenciales incorrectas, demasiados intentos) de cualquier otro, para no + * mostrar un error de red como si el usuario hubiera tipeado mal la contraseña. */ export function loginErrorMessage(error: ApiRequestError | null): string | null { if (!error) return null - return error.status === UNAUTHORIZED - ? authContent.errors.invalidCredentials - : authContent.errors.unexpected + if (error.status === UNAUTHORIZED) return authContent.errors.invalidCredentials + // El freno dura minutos: «probá de nuevo en unos segundos» sería mentirle. + if (error.status === TOO_MANY_REQUESTS) return authContent.errors.tooManyAttempts + return authContent.errors.unexpected } export function useLogin() {