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 891ef80..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,10 +257,10 @@ 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 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/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() {
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 95e9414..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,12 +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 }] = await Promise.all([
+ const [
+ { useAuthStore, getAuthToken, followSessionAcrossTabs },
+ { queryClient },
+ { useOrderDraftStore },
+ ] = await Promise.all([
import('./authStore'),
import('../api/queryClient'),
+ import('./orderDraftStore'),
])
- return { useAuthStore, getAuthToken, queryClient }
+ return { useAuthStore, getAuthToken, followSessionAcrossTabs, queryClient, useOrderDraftStore }
}
/**
@@ -34,6 +39,7 @@ function persist(state: unknown, version = 1) {
beforeEach(() => {
localStorage.clear()
+ sessionStorage.clear()
vi.doUnmock('../api/session')
})
@@ -103,6 +109,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 () => {
@@ -224,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 f269a02..47c3e91 100644
--- a/src/shared/store/authStore.ts
+++ b/src/shared/store/authStore.ts
@@ -5,6 +5,11 @@ import { queryClient } from '../api/queryClient'
import { revokeSession } from '../api/session'
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
@@ -65,6 +70,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
+// 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.
+// - 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,22 +103,23 @@ 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()
},
}),
{
- 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) }
},
@@ -118,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'
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
}