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 }