feat(auth): login JWT en API y las 4 apps

- Modelo Usuario + seed desde lista de vendedores (18 usuarios, bcrypt cost 12)
- POST /api/auth/login + /api/auth/me; middleware Bearer en todo /api
  excepto health, login y webhook Monday; flag AUTH_ENABLED
- Username = nombre completo normalizado (case/acentos insensible),
  password inicial Frasa2026, admin vía ADMIN_PASSWORD
- Apps: LoginScreen + AuthGate (StudioBlank), token en localStorage,
  header Authorization en todas las llamadas, logout en 401
- Tests: api 136, cotizador 71, alta 65, cobranza 71, agente-ia 13, landing 4
This commit is contained in:
2026-08-27 08:20:25 +00:00
parent b39fe6334f
commit de11af985e
40 changed files with 2085 additions and 38 deletions

View File

@@ -2,6 +2,8 @@
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
import { conAuth, manejar401 } from './auth'
/**
* Error HTTP del API (respuesta con status >= 400, p. ej. validación o 404).
* NO amerita fallback offline: el servidor respondió y su respuesta manda.
@@ -26,10 +28,14 @@ export function esErrorDeRed(err: unknown): boolean {
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_URL}${ruta}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
const res = await fetch(
`${API_URL}${ruta}`,
conAuth({
headers: { 'Content-Type': 'application/json' },
...init,
}),
)
manejar401(res)
if (!res.ok) {
let mensaje = `Error ${res.status} al llamar ${ruta}`
try {

View File

@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ErrorLogin,
conAuth,
getSesion,
getToken,
login,
logout,
manejar401,
} from './auth'
/** Stub sencillo de localStorage para correr en entorno node */
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() {
return mapa.size
},
} as Storage
}
const sesionOk = {
token: 'jwt-de-prueba',
usuario: { nombre: 'ERICK ISRAEL PEÑA RAMOS', rol: 'vendedor', asignacion: 'MONTEBELLO' },
}
function respuestaLoginOk(): Response {
return new Response(JSON.stringify(sesionOk), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
beforeEach(() => {
vi.stubGlobal('localStorage', crearStorageStub())
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('almacenamiento de sesión', () => {
it('sin sesión: getSesion/getToken devuelven null', () => {
expect(getSesion()).toBeNull()
expect(getToken()).toBeNull()
})
it('JSON corrupto en storage se trata como "sin sesión"', () => {
localStorage.setItem('frasa_auth', '{basura')
expect(getSesion()).toBeNull()
})
it('logout limpia la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
expect(getToken()).toBe('jwt-de-prueba')
logout()
expect(getSesion()).toBeNull()
})
})
describe('login', () => {
it('guarda token + usuario y devuelve el usuario', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(respuestaLoginOk()))
const usuario = await login('erick israel pena ramos', 'Frasa2026')
expect(usuario.nombre).toBe('ERICK ISRAEL PEÑA RAMOS')
expect(getToken()).toBe('jwt-de-prueba')
expect(getSesion()?.usuario.rol).toBe('vendedor')
})
it('credenciales malas: ErrorLogin genérico y no guarda nada', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response('{"error":"Credenciales inválidas"}', { status: 401 })),
)
await expect(login('admin', 'mala')).rejects.toThrow('Usuario o contraseña incorrectos')
await expect(login('admin', 'mala')).rejects.toBeInstanceOf(ErrorLogin)
expect(getSesion()).toBeNull()
})
it('sin red: ErrorLogin de conexión', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')))
await expect(login('admin', 'x')).rejects.toThrow('No se pudo conectar con el servidor')
})
})
describe('conAuth', () => {
it('sin token devuelve el init tal cual', () => {
const init = { method: 'POST', headers: { 'Content-Type': 'application/json' } }
expect(conAuth(init)).toBe(init)
expect(conAuth()).toEqual({})
})
it('con token agrega Authorization: Bearer conservando headers', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
const resultado = conAuth({ headers: { 'Content-Type': 'application/json' } })
expect(resultado.headers).toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer jwt-de-prueba',
})
})
})
describe('manejar401', () => {
it('con 401 limpia la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
manejar401(new Response('', { status: 401 }))
expect(getSesion()).toBeNull()
})
it('con otro status no toca la sesión', () => {
localStorage.setItem('frasa_auth', JSON.stringify(sesionOk))
manejar401(new Response('', { status: 500 }))
expect(getSesion()).not.toBeNull()
})
})

View File

@@ -0,0 +1,77 @@
// Sesión de autenticación contra el API Gateway (JWT guardado en localStorage)
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
const STORAGE_KEY = 'frasa_auth'
export interface UsuarioSesion {
nombre: string
rol: string
asignacion: string | null
}
export interface Sesion {
token: string
usuario: UsuarioSesion
}
/** Error de login con mensaje genérico (no filtra si el usuario existe) */
export class ErrorLogin extends Error {
constructor(message = 'Usuario o contraseña incorrectos') {
super(message)
this.name = 'ErrorLogin'
}
}
export function getSesion(): Sesion | null {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return null
const sesion = JSON.parse(raw) as Sesion
if (typeof sesion?.token !== 'string' || !sesion.usuario) return null
return sesion
} catch {
return null
}
}
export function getToken(): string | null {
return getSesion()?.token ?? null
}
export function logout(): void {
localStorage.removeItem(STORAGE_KEY)
}
/** POST /auth/login; guarda token + usuario en localStorage */
export async function login(username: string, password: string): Promise<UsuarioSesion> {
let res: Response
try {
res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
} catch {
throw new ErrorLogin('No se pudo conectar con el servidor')
}
if (!res.ok) throw new ErrorLogin()
const data = (await res.json()) as Sesion
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: data.token, usuario: data.usuario }))
return data.usuario
}
/**
* Agrega Authorization: Bearer a un RequestInit si hay sesión.
* Sin token devuelve el init tal cual (las llamadas siguen iguales que antes).
*/
export function conAuth(init: RequestInit = {}): RequestInit {
const token = getToken()
if (!token) return init
return { ...init, headers: { ...(init.headers as Record<string, string>), Authorization: `Bearer ${token}` } }
}
/** Si el API responde 401: cierra sesión y recarga para volver al login */
export function manejar401(res: Response): void {
if (res.status !== 401) return
logout()
if (typeof window !== 'undefined') window.location.reload()
}