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:
38
apps/alta-clientes/src/components/AuthGate.tsx
Normal file
38
apps/alta-clientes/src/components/AuthGate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
apps/alta-clientes/src/components/LoginScreen.tsx
Normal file
88
apps/alta-clientes/src/components/LoginScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
124
apps/alta-clientes/src/services/auth.test.ts
Normal file
124
apps/alta-clientes/src/services/auth.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
77
apps/alta-clientes/src/services/auth.ts
Normal file
77
apps/alta-clientes/src/services/auth.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user