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

@@ -0,0 +1,38 @@
import { useState, type ReactNode } from 'react'
import { getSesion, logout, type Sesion } from '../services/auth'
import LoginScreen from './LoginScreen'
/**
* Gate de autenticación: sin sesión solo se ve el login; con sesión se ve la
* app con una barra discreta "nombre · rol" y botón "Salir".
*/
export default function AuthGate({ children }: { children: ReactNode }) {
const [sesion, setSesion] = useState<Sesion | null>(() => getSesion())
if (!sesion) {
return <LoginScreen onLogin={() => setSesion(getSesion())} />
}
return (
<div className="min-h-screen bg-white text-neutral-900">
<div className="border-b border-neutral-200">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
<span className="text-xs font-bold uppercase tracking-widest text-neutral-500">
{sesion.usuario.nombre} · {sesion.usuario.rol}
</span>
<button
type="button"
onClick={() => {
logout()
setSesion(null)
}}
className="text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:text-accent-hover"
>
Salir
</button>
</div>
</div>
{children}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState, type FormEvent } from 'react'
import { login, ErrorLogin, type UsuarioSesion } from '../services/auth'
/** Pantalla de login StudioBlank: marca FRASA, usuario + contraseña, Enter envía */
export default function LoginScreen({ onLogin }: { onLogin: (usuario: UsuarioSesion) => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [cargando, setCargando] = useState(false)
async function enviar(e: FormEvent) {
e.preventDefault()
setError(null)
setCargando(true)
try {
onLogin(await login(username, password))
} catch (err) {
setError(err instanceof ErrorLogin ? err.message : 'Usuario o contraseña incorrectos')
} finally {
setCargando(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-white px-4">
<div className="w-full max-w-sm">
<div className="mb-10 text-center">
<p className="text-lg font-bold tracking-widest">FRASA</p>
<p className="mt-2 text-xs font-bold uppercase tracking-widest text-neutral-500">
Acceso al ecosistema
</p>
</div>
<form onSubmit={enviar} className="space-y-6">
<div>
<label
htmlFor="login-usuario"
className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500"
>
Usuario
</label>
<input
id="login-usuario"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
required
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
/>
</div>
<div>
<label
htmlFor="login-password"
className="mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500"
>
Contraseña
</label>
<input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
className="w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none"
/>
</div>
{error && (
<p
role="alert"
className="border border-red-700 px-4 py-3 text-xs font-bold uppercase tracking-widest text-red-700"
>
{error}
</p>
)}
<button
type="submit"
disabled={cargando}
className="w-full bg-accent px-5 py-3 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-accent-hover disabled:opacity-50"
>
{cargando ? 'Entrando…' : 'Entrar'}
</button>
</form>
</div>
</div>
)
}

View File

@@ -7,14 +7,17 @@ import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import AltaClientePage from './pages/AltaClientePage'
import AuthGate from './components/AuthGate'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/client">
<Routes>
<Route path="/" element={<AltaClientePage />} />
<Route path="*" element={<AltaClientePage />} />
</Routes>
<AuthGate>
<Routes>
<Route path="/" element={<AltaClientePage />} />
<Route path="*" element={<AltaClientePage />} />
</Routes>
</AuthGate>
</BrowserRouter>
</StrictMode>,
)

View File

@@ -1,4 +1,5 @@
import type { AltaCliente } from '../types'
import { conAuth, manejar401 } from './auth'
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
// TODO produccion: '/api' same-origin detras de NGINX
@@ -32,11 +33,15 @@ const serializarAlta = (payload: AltaCliente) => ({
*/
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
try {
const res = await fetch(`${API_URL}/clientes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializarAlta(payload)),
})
const res = await fetch(
`${API_URL}/clientes`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(serializarAlta(payload)),
}),
)
manejar401(res)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = (await res.json()) as { id: string }
return { id: data.id }
@@ -59,10 +64,14 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente):
const form = new FormData()
form.append('tipo', doc.slot)
form.append('archivos', doc.archivo, doc.nombre)
const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, {
method: 'POST',
body: form,
})
const res = await fetch(
`${API_URL}/clientes/${clienteId}/documentos`,
conAuth({
method: 'POST',
body: form,
}),
)
manejar401(res)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
} catch (err) {
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err)

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()
}