Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/adr/ADR-002-state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 3 additions & 3 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ index.html → src/main.tsx → <Providers><App /></Providers>
- `baseURL`: `import.meta.env.VITE_API_URL`
- Request interceptor: inyecta `Authorization: Bearer <token>` 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`):
Expand Down Expand Up @@ -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`):

Expand Down
2 changes: 2 additions & 0 deletions src/features/auth/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
35 changes: 35 additions & 0 deletions src/features/auth/hooks/useLogin.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
14 changes: 8 additions & 6 deletions src/features/auth/hooks/useLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
<Providers>
Expand Down
58 changes: 58 additions & 0 deletions src/shared/api/client.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
})
})
22 changes: 18 additions & 4 deletions src/shared/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'

import { clearSession, getAuthToken } from '../store/authStore'
import { notify } from '../store/notificationStore'
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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')
}
Expand Down
110 changes: 107 additions & 3 deletions src/shared/store/authStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'

import { sessionToken, tokenWith } from '../../test/tokens'

// El store se rehidrata de localStorage en el momento en que se crea, así que
// 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 }
}

/**
Expand All @@ -34,6 +39,7 @@ function persist(state: unknown, version = 1) {

beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.doUnmock('../api/session')
})

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading