- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
11 KiB
US-FUND-005: Sistema de sesiones y estado
Épica: EAI-001 - Fundamentos Sprint: Mes 1, Semana 1-2 Story Points: 6 SP Presupuesto: $2,200 MXN Prioridad: Alta (Alcance Inicial) Estado: ✅ Completada (Mes 1)
Descripción
Como usuario de la plataforma, quiero que mi sesión persista automáticamente para no tener que iniciar sesión cada vez que recargo la página o vuelvo a la aplicación.
Contexto del Alcance Inicial: El MVP implementa un sistema robusto de manejo de sesiones con refresh tokens, persistencia en localStorage, y estado global sincronizado con Zustand. Esto mejora significativamente la experiencia del usuario al mantener la sesión activa de manera segura.
Criterios de Aceptación
- CA-01: El token JWT se almacena en localStorage al iniciar sesión
- CA-02: Al recargar la página, la sesión se restaura automáticamente si el token es válido
- CA-03: Se implementan refresh tokens para renovar la sesión sin re-login
- CA-04: Los refresh tokens tienen validez de 7 días
- CA-05: Los access tokens se renuevan automáticamente antes de expirar
- CA-06: Al cerrar sesión, se eliminan todos los tokens de localStorage
- CA-07: El estado global (Zustand) se sincroniza con el estado de autenticación
- CA-08: Se maneja la expiración de refresh tokens (redirige a login)
- CA-09: Las peticiones fallidas por token expirado se reintentan automáticamente tras renovar
- CA-10: El sistema detecta múltiples pestañas y sincroniza el estado de sesión
Especificaciones Técnicas
Backend (NestJS)
Nuevos Endpoints:
POST /api/auth/refresh
- Body: { refreshToken: string }
- Response: { accessToken: string, refreshToken: string }
POST /api/auth/logout
- Headers: Authorization: Bearer {token}
- Body: { refreshToken: string }
- Response: { message: "Logged out successfully" }
Entidades Actualizadas:
@Entity('refresh_tokens')
class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User
@Column()
userId: string
@Column({ unique: true })
token: string
@Column()
expiresAt: Date
@Column({ default: false })
revoked: boolean
@Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt: Date
}
Servicios Actualizados:
// auth.service.ts
class AuthService {
async login(email, password): Promise<LoginResponse> {
// ... validación de credenciales
const accessToken = this.generateAccessToken(user)
const refreshToken = await this.generateRefreshToken(user)
return { accessToken, refreshToken, user }
}
async refreshTokens(refreshToken: string): Promise<TokenResponse> {
const tokenRecord = await this.validateRefreshToken(refreshToken)
if (!tokenRecord) throw new UnauthorizedException()
await this.revokeRefreshToken(tokenRecord.id)
const newAccessToken = this.generateAccessToken(tokenRecord.user)
const newRefreshToken = await this.generateRefreshToken(tokenRecord.user)
return { accessToken: newAccessToken, refreshToken: newRefreshToken }
}
private generateAccessToken(user: User): string {
return this.jwtService.sign(
{ sub: user.id, email: user.email, role: user.role },
{ expiresIn: '15m' } // Access token corto
)
}
private async generateRefreshToken(user: User): Promise<string> {
const token = uuidv4()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // 7 días
await this.refreshTokensRepository.save({
userId: user.id,
token,
expiresAt
})
return token
}
}
Frontend (React + Vite)
Auth Store Mejorado (Zustand):
// store/auth.store.ts
interface AuthStore {
user: User | null
accessToken: string | null
refreshToken: string | null
isAuthenticated: boolean
isLoading: boolean
// Actions
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
refreshAccessToken: () => Promise<void>
restoreSession: () => Promise<void>
// Internal setters
setTokens: (accessToken: string, refreshToken: string) => void
setUser: (user: User) => void
clearAuth: () => void
}
const useAuthStore = create<AuthStore>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
login: async (email, password) => {
const response = await authService.login(email, password)
set({
user: response.user,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
isAuthenticated: true
})
},
logout: async () => {
const { refreshToken } = get()
if (refreshToken) {
await authService.logout(refreshToken)
}
get().clearAuth()
},
refreshAccessToken: async () => {
const { refreshToken } = get()
if (!refreshToken) throw new Error('No refresh token')
const response = await authService.refreshTokens(refreshToken)
set({
accessToken: response.accessToken,
refreshToken: response.refreshToken
})
},
restoreSession: async () => {
set({ isLoading: true })
const { accessToken, refreshToken } = get()
if (!accessToken || !refreshToken) {
set({ isLoading: false })
return
}
try {
// Intentar obtener usuario actual
const user = await authService.getCurrentUser()
set({ user, isAuthenticated: true, isLoading: false })
} catch (error) {
// Token expirado, intentar refresh
try {
await get().refreshAccessToken()
const user = await authService.getCurrentUser()
set({ user, isAuthenticated: true, isLoading: false })
} catch {
get().clearAuth()
set({ isLoading: false })
}
}
},
setTokens: (accessToken, refreshToken) => {
set({ accessToken, refreshToken })
},
setUser: (user) => {
set({ user })
},
clearAuth: () => {
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false
})
}
}),
{
name: 'gamilit-auth',
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user
})
}
)
)
Axios Interceptor con Auto-Refresh:
// services/api.ts
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL
})
// Request interceptor: agregar token
api.interceptors.request.use((config) => {
const { accessToken } = useAuthStore.getState()
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`
}
return config
})
// Response interceptor: manejar 401 y refrescar token
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
await useAuthStore.getState().refreshAccessToken()
const { accessToken } = useAuthStore.getState()
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return api(originalRequest)
} catch (refreshError) {
useAuthStore.getState().clearAuth()
window.location.href = '/login'
return Promise.reject(refreshError)
}
}
return Promise.reject(error)
}
)
App Initialization:
// App.tsx
function App() {
const restoreSession = useAuthStore(state => state.restoreSession)
const isLoading = useAuthStore(state => state.isLoading)
useEffect(() => {
restoreSession()
}, [])
if (isLoading) {
return <LoadingScreen />
}
return (
<Router>
{/* ... routes */}
</Router>
)
}
Sincronización entre Pestañas:
// hooks/useCrossTabSync.ts
export function useCrossTabSync() {
const clearAuth = useAuthStore(state => state.clearAuth)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'gamilit-auth' && e.newValue === null) {
// Sesión cerrada en otra pestaña
clearAuth()
window.location.href = '/login'
}
}
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [clearAuth])
}
Dependencias
Antes:
- US-FUND-001 (Autenticación básica)
- US-FUND-004 (Infraestructura)
Después:
- Mejora la UX de todas las historias que requieren autenticación
Definición de Hecho (DoD)
- Refresh tokens implementados en backend
- Tabla de refresh_tokens creada (migración)
- Endpoints de refresh y logout funcionando
- Zustand store con persistencia configurado
- Axios interceptors para auto-refresh
- Restauración de sesión al cargar app
- Sincronización entre pestañas
- Tests unitarios (>80% coverage)
- Tests E2E para flujos de sesión
- Manejo de tokens expirados
- Documentación actualizada
Notas del Alcance Inicial
- ✅ Refresh tokens con validez de 7 días
- ✅ Access tokens cortos (15 minutos)
- ✅ Auto-refresh transparente al usuario
- ✅ Sincronización básica entre pestañas
- ✅ Sin "Remember me" (siempre se recuerda por 7 días)
- ⚠️ Extensión futura: EXT-010-Security (dispositivos confiables, logout remoto)
- ⚠️ Extensión futura: EXT-011-Sessions (gestión de sesiones activas)
Testing
Tests Unitarios
describe('AuthService - Refresh Tokens', () => {
it('should generate refresh token on login')
it('should refresh access token with valid refresh token')
it('should revoke old refresh token on refresh')
it('should reject expired refresh token')
it('should revoke refresh token on logout')
})
describe('AuthStore', () => {
it('should persist tokens to localStorage')
it('should restore session on app load')
it('should clear auth on logout')
it('should refresh token automatically')
})
Tests E2E
describe('Session Management', () => {
it('should maintain session after page reload')
it('should auto-refresh expired access token')
it('should logout when refresh token expires')
it('should sync logout across tabs')
it('should handle concurrent refresh requests')
})
Estimación
Desglose de Esfuerzo (6 SP = ~2 días):
- Backend: refresh token logic: 0.75 días
- Frontend: Zustand persistence: 0.5 días
- Axios interceptors: 0.25 días
- Restauración de sesión: 0.25 días
- Testing: 0.5 días
- Cross-tab sync: 0.25 días
Riesgos:
- Race conditions en refresh concurrentes
- Edge cases en sincronización de pestañas
Creado: 2025-11-02 Actualizado: 2025-11-02 Responsable: Equipo Fullstack