From de11af985e871aa747263eb7efc939946ff925d4 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Thu, 27 Aug 2026 08:20:25 +0000 Subject: [PATCH] feat(auth): login JWT en API y las 4 apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 4 + api-gateway/package-lock.json | 150 ++++++++++++++++++ api-gateway/package.json | 5 + api-gateway/prisma/schema.prisma | 17 ++ api-gateway/scripts/seed-usuarios.test.ts | 53 +++++++ api-gateway/scripts/seed-usuarios.ts | 123 ++++++++++++++ api-gateway/src/app.ts | 3 +- api-gateway/src/config/index.ts | 9 ++ .../src/controllers/auth.controller.ts | 22 +++ .../src/middleware/auth.middleware.test.ts | 78 +++++++++ api-gateway/src/middleware/auth.middleware.ts | 50 ++++++ api-gateway/src/routes/index.ts | 4 + api-gateway/src/services/auth.service.test.ts | 104 ++++++++++++ api-gateway/src/services/auth.service.ts | 80 ++++++++++ api-gateway/vitest.config.ts | 4 + apps/agente-ia/src/components/AuthGate.tsx | 38 +++++ apps/agente-ia/src/components/LoginScreen.tsx | 88 ++++++++++ apps/agente-ia/src/main.tsx | 5 +- apps/agente-ia/src/services/api.ts | 14 +- apps/agente-ia/src/services/auth.test.ts | 124 +++++++++++++++ apps/agente-ia/src/services/auth.ts | 77 +++++++++ .../alta-clientes/src/components/AuthGate.tsx | 38 +++++ .../src/components/LoginScreen.tsx | 88 ++++++++++ apps/alta-clientes/src/main.tsx | 11 +- apps/alta-clientes/src/services/api.ts | 27 ++-- apps/alta-clientes/src/services/auth.test.ts | 124 +++++++++++++++ apps/alta-clientes/src/services/auth.ts | 77 +++++++++ apps/cobranza/src/components/AuthGate.tsx | 38 +++++ apps/cobranza/src/components/LoginScreen.tsx | 88 ++++++++++ apps/cobranza/src/main.tsx | 21 +-- apps/cobranza/src/services/api.ts | 14 +- apps/cobranza/src/services/auth.test.ts | 124 +++++++++++++++ apps/cobranza/src/services/auth.ts | 77 +++++++++ apps/cotizador/src/components/AuthGate.tsx | 38 +++++ apps/cotizador/src/components/LoginScreen.tsx | 88 ++++++++++ apps/cotizador/src/main.tsx | 11 +- apps/cotizador/src/services/api.test.ts | 2 +- apps/cotizador/src/services/api.ts | 4 +- apps/cotizador/src/services/auth.test.ts | 124 +++++++++++++++ apps/cotizador/src/services/auth.ts | 77 +++++++++ 40 files changed, 2085 insertions(+), 38 deletions(-) create mode 100644 api-gateway/scripts/seed-usuarios.test.ts create mode 100644 api-gateway/scripts/seed-usuarios.ts create mode 100644 api-gateway/src/controllers/auth.controller.ts create mode 100644 api-gateway/src/middleware/auth.middleware.test.ts create mode 100644 api-gateway/src/middleware/auth.middleware.ts create mode 100644 api-gateway/src/services/auth.service.test.ts create mode 100644 api-gateway/src/services/auth.service.ts create mode 100644 apps/agente-ia/src/components/AuthGate.tsx create mode 100644 apps/agente-ia/src/components/LoginScreen.tsx create mode 100644 apps/agente-ia/src/services/auth.test.ts create mode 100644 apps/agente-ia/src/services/auth.ts create mode 100644 apps/alta-clientes/src/components/AuthGate.tsx create mode 100644 apps/alta-clientes/src/components/LoginScreen.tsx create mode 100644 apps/alta-clientes/src/services/auth.test.ts create mode 100644 apps/alta-clientes/src/services/auth.ts create mode 100644 apps/cobranza/src/components/AuthGate.tsx create mode 100644 apps/cobranza/src/components/LoginScreen.tsx create mode 100644 apps/cobranza/src/services/auth.test.ts create mode 100644 apps/cobranza/src/services/auth.ts create mode 100644 apps/cotizador/src/components/AuthGate.tsx create mode 100644 apps/cotizador/src/components/LoginScreen.tsx create mode 100644 apps/cotizador/src/services/auth.test.ts create mode 100644 apps/cotizador/src/services/auth.ts diff --git a/.env.example b/.env.example index 43c37f5..311e29a 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,10 @@ REDIS_URL="redis://redis:6379" # ============================================ JWT_SECRET="cambiar_por_secreto_largo_y_aleatorio" JWT_EXPIRES_IN="7d" +# 'false' desactiva la exigencia de token en /api (tests/arranque temprano) +AUTH_ENABLED=true +# Password inicial del usuario 'admin' sembrado por npm run seed:usuarios +ADMIN_PASSWORD="Frasa2026" # ============================================ # APP CONFIG diff --git a/api-gateway/package-lock.json b/api-gateway/package-lock.json index ab0bf94..0bce4f8 100644 --- a/api-gateway/package-lock.json +++ b/api-gateway/package-lock.json @@ -9,16 +9,20 @@ "version": "0.1.0", "dependencies": { "@prisma/client": "^6.14.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^5.1.0", + "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "node-cron": "^4.6.0", "zod": "^3.25.76" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.0.0", "@types/node": "^22.15.0", "@types/node-cron": "^3.0.11", @@ -939,6 +943,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1027,6 +1038,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/multer": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", @@ -1233,6 +1262,15 @@ "node": ">=12" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1270,6 +1308,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1592,6 +1636,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2061,6 +2114,91 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -2674,6 +2812,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", diff --git a/api-gateway/package.json b/api-gateway/package.json index 4a63ebc..a6db95d 100644 --- a/api-gateway/package.json +++ b/api-gateway/package.json @@ -13,22 +13,27 @@ "start": "node dist/index.js", "test": "vitest run", "seed:ai": "tsx scripts/seed-ai.ts", + "seed:usuarios": "tsx scripts/seed-usuarios.ts", "setup:crm": "tsx scripts/setup-crm.ts", "sync:desarrollos": "tsx scripts/sync-desarrollos.ts", "prisma:migrate": "prisma migrate dev" }, "dependencies": { "@prisma/client": "^6.14.0", + "bcryptjs": "^3.0.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^5.1.0", + "jsonwebtoken": "^9.0.3", "multer": "^2.0.2", "node-cron": "^4.6.0", "zod": "^3.25.76" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.0.0", "@types/node": "^22.15.0", "@types/node-cron": "^3.0.11", diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma index b8a93a9..f6dbfb2 100644 --- a/api-gateway/prisma/schema.prisma +++ b/api-gateway/prisma/schema.prisma @@ -133,6 +133,23 @@ model NotaPlan { @@index([planId]) } +// ---------- Autenticación ---------- + +// Usuario del ecosistema (vendedores, coordinadores, corporativo y admin). +// Sembrado por scripts/seed-usuarios.ts desde reference/vendedores.json. +model Usuario { + id String @id @default(uuid()) + username String @unique // nombre completo normalizado (minúsculas, sin acentos) + nombre String // nombre completo para mostrar + passwordHash String // bcrypt (cost 12) + rol String // vendedor | coordinador | corporativo | admin + asignacion String? // ATELIER | RESIDENCIAL | MONTEBELLO | BROKER | ACORPORATIVO + empleadoId Int? + activo Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // ---------- Agente IA interno (RAG) ---------- // Documento de la base de conocimiento; `hash` (sha256 del contenido) permite diff --git a/api-gateway/scripts/seed-usuarios.test.ts b/api-gateway/scripts/seed-usuarios.test.ts new file mode 100644 index 0000000..b3c085c --- /dev/null +++ b/api-gateway/scripts/seed-usuarios.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { upsertUsuario, type DatosUsuarioSeed } from './seed-usuarios.js' + +// ---------- Prisma mockeado (seed idempotente sin red ni BD) ---------- + +const prismaMock = vi.hoisted(() => ({ + usuario: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + count: vi.fn(), + }, + $disconnect: vi.fn(), +})) + +vi.mock('../src/services/prisma.js', () => ({ prisma: prismaMock })) + +const datos: DatosUsuarioSeed = { + username: 'erick israel pena ramos', + nombre: 'ERICK ISRAEL PEÑA RAMOS', + rol: 'vendedor', + asignacion: 'MONTEBELLO', + empleadoId: 13676, + passwordHash: 'hash-inicial', +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('upsertUsuario (seed idempotente)', () => { + it('crea el usuario si no existe', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(null) + const resultado = await upsertUsuario(datos, false) + expect(resultado).toBe('creado') + expect(prismaMock.usuario.create).toHaveBeenCalledWith({ data: datos }) + }) + + it('si ya existe actualiza datos sin tocar el password', async () => { + prismaMock.usuario.findUnique.mockResolvedValue({ id: 'u-1' }) + const resultado = await upsertUsuario({ ...datos, passwordHash: 'hash-nuevo' }, false) + expect(resultado).toBe('actualizado') + const update = prismaMock.usuario.update.mock.calls[0][0] + expect(update.data).not.toHaveProperty('passwordHash') + }) + + it('con --reset-passwords sí restablece el password', async () => { + prismaMock.usuario.findUnique.mockResolvedValue({ id: 'u-1' }) + await upsertUsuario({ ...datos, passwordHash: 'hash-nuevo' }, true) + const update = prismaMock.usuario.update.mock.calls[0][0] + expect(update.data.passwordHash).toBe('hash-nuevo') + }) +}) diff --git a/api-gateway/scripts/seed-usuarios.ts b/api-gateway/scripts/seed-usuarios.ts new file mode 100644 index 0000000..3ebff34 --- /dev/null +++ b/api-gateway/scripts/seed-usuarios.ts @@ -0,0 +1,123 @@ +/** + * Siembra los usuarios del ecosistema FRASA. + * + * - Lee reference/vendedores.json (gitignored). Si no existe, solo crea el + * usuario admin y avisa. + * - Username = nombre completo normalizado (minúsculas, sin acentos, espacios + * simples). Password inicial de todos: 'Frasa2026' (bcrypt cost 12). + * - Upsert por username: NO resetea el password de usuarios existentes. + * Con `--reset-passwords` sí lo restablece al inicial. + * - Admin: username 'admin', password de env ADMIN_PASSWORD (default + * 'Frasa2026'), rol 'admin'. + * + * Uso: npm run seed:usuarios [-- --reset-passwords] + */ +import fs from 'node:fs' +import path from 'node:path' +import bcrypt from 'bcryptjs' +import { prisma } from '../src/services/prisma.js' +import { normalizarUsername } from '../src/services/auth.service.js' +import { config } from '../src/config/index.js' + +const PASSWORD_INICIAL = 'Frasa2026' +const BCRYPT_COST = 12 + +export interface DatosUsuarioSeed { + username: string + nombre: string + rol: string + asignacion: string | null + empleadoId: number | null + passwordHash: string +} + +/** + * Upsert por username. NO toca el password de un usuario existente salvo que + * resetPasswords sea true (flag --reset-passwords). + */ +export async function upsertUsuario( + datos: DatosUsuarioSeed, + resetPasswords: boolean, +): Promise<'creado' | 'actualizado'> { + const existente = await prisma.usuario.findUnique({ where: { username: datos.username } }) + if (!existente) { + await prisma.usuario.create({ data: datos }) + return 'creado' + } + await prisma.usuario.update({ + where: { username: datos.username }, + data: { + nombre: datos.nombre, + rol: datos.rol, + asignacion: datos.asignacion, + empleadoId: datos.empleadoId, + ...(resetPasswords ? { passwordHash: datos.passwordHash } : {}), + }, + }) + return 'actualizado' +} + +interface VendedorJson { + empleadoId: number | null + nombre: string + asignacion: string | null + rol: string +} + +async function main(): Promise { + const resetPasswords = process.argv.includes('--reset-passwords') + const rutaVendedores = path.resolve(process.cwd(), '..', 'reference', 'vendedores.json') + const hashVendedores = bcrypt.hashSync(PASSWORD_INICIAL, BCRYPT_COST) + const hashAdmin = bcrypt.hashSync(config.auth.adminPassword, BCRYPT_COST) + + let creados = 0 + let actualizados = 0 + + if (fs.existsSync(rutaVendedores)) { + const json = JSON.parse(fs.readFileSync(rutaVendedores, 'utf8')) as { vendedores: VendedorJson[] } + for (const v of json.vendedores) { + const resultado = await upsertUsuario( + { + username: normalizarUsername(v.nombre), + nombre: v.nombre, + rol: v.rol, + asignacion: v.asignacion, + empleadoId: v.empleadoId, + passwordHash: hashVendedores, + }, + resetPasswords, + ) + if (resultado === 'creado') creados++ + else actualizados++ + } + console.log(`[seed-usuarios] vendedores: ${creados} creados, ${actualizados} actualizados`) + } else { + console.warn(`[seed-usuarios] AVISO: no existe ${rutaVendedores}; se crea solo el admin`) + } + + const admin = await upsertUsuario( + { + username: 'admin', + nombre: 'Administrador FRASA', + rol: 'admin', + asignacion: null, + empleadoId: null, + passwordHash: hashAdmin, + }, + resetPasswords, + ) + console.log(`[seed-usuarios] admin ${admin}${resetPasswords ? ' (password restablecido)' : ''}`) + + const total = await prisma.usuario.count() + console.log(`[seed-usuarios] total de usuarios en BD: ${total}`) +} + +// Solo se ejecuta como script (npm run seed:usuarios); al importarlo en tests no corre +if (process.argv[1]?.endsWith('seed-usuarios.ts')) { + main() + .catch((err) => { + console.error('[seed-usuarios] falló:', err) + process.exitCode = 1 + }) + .finally(() => prisma.$disconnect()) +} diff --git a/api-gateway/src/app.ts b/api-gateway/src/app.ts index 9f6afc5..d65e5de 100644 --- a/api-gateway/src/app.ts +++ b/api-gateway/src/app.ts @@ -1,6 +1,7 @@ import express from 'express' import cors from 'cors' import { apiRouter } from './routes/index.js' +import { authMiddleware } from './middleware/auth.middleware.js' import { errorHandler } from './middleware/errorHandler.js' import { requestLogger } from './middleware/requestLogger.js' @@ -11,7 +12,7 @@ export function crearApp(): express.Express { app.use(express.json({ limit: '10mb' })) app.use(requestLogger) - app.use('/api', apiRouter) + app.use('/api', authMiddleware, apiRouter) app.use((_req, res) => { res.status(404).json({ error: 'Ruta no encontrada' }) diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts index a38b358..de12984 100644 --- a/api-gateway/src/config/index.ts +++ b/api-gateway/src/config/index.ts @@ -53,6 +53,15 @@ export const config = { // Umbral mínimo de similitud coseno para recuperar contexto (0-1) minScore: Number(env('AI_MIN_SCORE', '0.3')), }, + auth: { + jwtSecret: env('JWT_SECRET'), + // Cadena aceptada por jsonwebtoken (p. ej. '7d') + jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'), + // 'false' desactiva la exigencia de token en /api (tests, arranque temprano) + enabled: env('AUTH_ENABLED', 'true') !== 'false', + // Password del usuario admin sembrado (seed-usuarios.ts) + adminPassword: env('ADMIN_PASSWORD', 'Frasa2026'), + }, } as const export type AppConfig = typeof config diff --git a/api-gateway/src/controllers/auth.controller.ts b/api-gateway/src/controllers/auth.controller.ts new file mode 100644 index 0000000..c4b6a16 --- /dev/null +++ b/api-gateway/src/controllers/auth.controller.ts @@ -0,0 +1,22 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { login, me } from '../services/auth.service.js' +import { BadRequestError } from '../utils/errors.js' + +const loginSchema = z.object({ + username: z.string().min(1), + password: z.string().min(1), +}) + +/** POST /api/auth/login → { token, usuario } */ +export async function loginEndpoint(req: Request, res: Response): Promise { + const { username, password } = loginSchema.parse(req.body) + const resultado = await login(username, password) + res.json(resultado) +} + +/** GET /api/auth/me (Bearer) → usuario público del token */ +export async function meEndpoint(req: Request, res: Response): Promise { + if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') + res.json(await me(req.usuarioToken)) +} diff --git a/api-gateway/src/middleware/auth.middleware.test.ts b/api-gateway/src/middleware/auth.middleware.test.ts new file mode 100644 index 0000000..1176d99 --- /dev/null +++ b/api-gateway/src/middleware/auth.middleware.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { NextFunction, Request, Response } from 'express' +import jwt from 'jsonwebtoken' +import { authMiddleware } from './auth.middleware.js' + +// Auth forzada a activa para este archivo (en vitest el default global es false) +vi.mock('../config/index.js', () => ({ + config: { + auth: { + enabled: true, + jwtSecret: 'test-jwt-secret', + jwtExpiresIn: '7d', + adminPassword: 'Frasa2026', + }, + }, +})) + +const SECRET = 'test-jwt-secret' + +function reqFake(overrides: Partial = {}): Request { + return { method: 'GET', path: '/unidades', headers: {}, ...overrides } as Request +} + +function resFake(): { res: Response; status: ReturnType; json: ReturnType } { + const json = vi.fn() + const status = vi.fn().mockReturnValue({ json }) + return { res: { status } as unknown as Response, status, json } +} + +const next: NextFunction = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('authMiddleware (AUTH_ENABLED=true)', () => { + it('401 sin header Authorization', () => { + const { res, status } = resFake() + authMiddleware(reqFake(), res, next) + expect(status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('401 con token inválido', () => { + const { res, status } = resFake() + authMiddleware(reqFake({ headers: { authorization: 'Bearer basura' } }), res, next) + expect(status).toHaveBeenCalledWith(401) + expect(next).not.toHaveBeenCalled() + }) + + it('pasa con un Bearer token válido y deja el payload en req', () => { + const token = jwt.sign({ sub: 'u-1', username: 'admin', rol: 'admin' }, SECRET) + const req = reqFake({ headers: { authorization: `Bearer ${token}` } }) + const { res } = resFake() + authMiddleware(req, res, next) + expect(next).toHaveBeenCalledOnce() + expect(req.usuarioToken?.username).toBe('admin') + }) + + it('deja pasar las rutas públicas sin token (health, login, webhook monday)', () => { + const casos: Partial[] = [ + { method: 'GET', path: '/health' }, + { method: 'POST', path: '/auth/login' }, + { method: 'POST', path: '/webhooks/monday' }, + ] + for (const c of casos) { + vi.clearAllMocks() + authMiddleware(reqFake(c), resFake().res, next) + expect(next).toHaveBeenCalledOnce() + } + }) + + it('protege POST /webhooks/monday con otro método no aplica: GET /webhooks/monday pide token', () => { + const { res, status } = resFake() + authMiddleware(reqFake({ method: 'GET', path: '/webhooks/monday' }), res, next) + expect(status).toHaveBeenCalledWith(401) + }) +}) diff --git a/api-gateway/src/middleware/auth.middleware.ts b/api-gateway/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..91c9770 --- /dev/null +++ b/api-gateway/src/middleware/auth.middleware.ts @@ -0,0 +1,50 @@ +import type { NextFunction, Request, Response } from 'express' +import { config } from '../config/index.js' +import { verificarToken, type TokenPayload } from '../services/auth.service.js' + +// Rutas públicas: no requieren token aunque AUTH_ENABLED=true. Se comparan +// contra req.path, que aquí ya está relativo al mount /api. +// Monday no puede mandar JWT, así que su webhook queda abierto (se valida por +// firma/challenge dentro del controlador). +const RUTAS_PUBLICAS: ReadonlyArray<{ metodo: string; ruta: string }> = [ + { metodo: 'GET', ruta: '/health' }, + { metodo: 'POST', ruta: '/auth/login' }, + { metodo: 'POST', ruta: '/webhooks/monday' }, +] + +declare module 'express-serve-static-core' { + interface Request { + usuarioToken?: TokenPayload + } +} + +/** + * Exige Bearer JWT en todo /api salvo las rutas públicas. + * Con AUTH_ENABLED=false (tests) deja pasar todo. + */ +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + if (!config.auth.enabled) { + next() + return + } + + const publica = RUTAS_PUBLICAS.some((r) => r.metodo === req.method && req.path === r.ruta) + if (publica) { + next() + return + } + + const header = req.headers.authorization + const token = header?.startsWith('Bearer ') ? header.slice('Bearer '.length).trim() : null + if (!token) { + res.status(401).json({ error: 'Token requerido' }) + return + } + + try { + req.usuarioToken = verificarToken(token) + next() + } catch { + res.status(401).json({ error: 'Token inválido o expirado' }) + } +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index d000339..221d7aa 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -38,11 +38,15 @@ import { obtenerConversacion, subirDocumento, } from '../controllers/ai.controller.js' +import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js' export const apiRouter = Router() apiRouter.get('/health', health) +apiRouter.post('/auth/login', loginEndpoint) +apiRouter.get('/auth/me', meEndpoint) + apiRouter.get('/unidades', listarUnidades) apiRouter.get('/unidades/:codigo', obtenerUnidad) diff --git a/api-gateway/src/services/auth.service.test.ts b/api-gateway/src/services/auth.service.test.ts new file mode 100644 index 0000000..6ce74a4 --- /dev/null +++ b/api-gateway/src/services/auth.service.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import bcrypt from 'bcryptjs' +import type { Usuario } from '@prisma/client' +import { login, me, normalizarUsername, verificarToken } from './auth.service.js' + +// ---------- Prisma mockeado (los tests no tocan la BD ni la red) ---------- + +const prismaMock = vi.hoisted(() => ({ + usuario: { + findUnique: vi.fn(), + }, +})) + +vi.mock('./prisma.js', () => ({ prisma: prismaMock })) + +function usuarioFake(overrides: Partial = {}): Usuario { + return { + id: 'u-1', + username: 'erick israel pena ramos', + nombre: 'ERICK ISRAEL PEÑA RAMOS', + passwordHash: bcrypt.hashSync('Frasa2026', 12), + rol: 'vendedor', + asignacion: 'MONTEBELLO', + empleadoId: 13676, + activo: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('normalizarUsername', () => { + it('minúsculas, sin acentos ni ñ, espacios simples', () => { + expect(normalizarUsername(' ERICK ISRAEL PEÑA RAMOS ')).toBe('erick israel pena ramos') + expect(normalizarUsername('Ma. Cristina Barrera López')).toBe('ma. cristina barrera lopez') + }) +}) + +describe('login', () => { + it('devuelve token y usuario público con credenciales correctas', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake()) + + const resultado = await login('erick israel pena ramos', 'Frasa2026') + + expect(resultado.token).toBeTruthy() + expect(resultado.usuario).toEqual({ + nombre: 'ERICK ISRAEL PEÑA RAMOS', + rol: 'vendedor', + asignacion: 'MONTEBELLO', + }) + expect(verificarToken(resultado.token).username).toBe('erick israel pena ramos') + }) + + it('es case-insensitive y tolerante a espacios extra y acentos', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake()) + + const resultado = await login(' ERICK Israel PEÑA ramos ', 'Frasa2026') + + expect(resultado.token).toBeTruthy() + expect(prismaMock.usuario.findUnique).toHaveBeenCalledWith({ + where: { username: 'erick israel pena ramos' }, + }) + }) + + it('rechaza password incorrecto con error genérico', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake()) + await expect(login('erick israel pena ramos', 'otra')).rejects.toThrow('Credenciales inválidas') + }) + + it('rechaza usuario inexistente con el mismo error genérico', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(null) + await expect(login('nadie', 'Frasa2026')).rejects.toThrow('Credenciales inválidas') + }) + + it('rechaza usuario inactivo con el mismo error genérico', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false })) + await expect(login('erick israel pena ramos', 'Frasa2026')).rejects.toThrow('Credenciales inválidas') + }) +}) + +describe('verificarToken', () => { + it('lanza 401 con token basura', () => { + expect(() => verificarToken('no-es-un-jwt')).toThrow('Token inválido o expirado') + }) +}) + +describe('me', () => { + it('devuelve el usuario público del token', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake()) + const resultado = await me({ sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' }) + expect(resultado.nombre).toBe('ERICK ISRAEL PEÑA RAMOS') + }) + + it('rechaza si el usuario ya no existe o está inactivo', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(null) + await expect(me({ sub: 'x', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas') + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false })) + await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas') + }) +}) diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts new file mode 100644 index 0000000..fb5752b --- /dev/null +++ b/api-gateway/src/services/auth.service.ts @@ -0,0 +1,80 @@ +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import type { Usuario } from '@prisma/client' +import { prisma } from './prisma.js' +import { config } from '../config/index.js' +import { AppError } from '../utils/errors.js' + +export interface UsuarioPublico { + nombre: string + rol: string + asignacion: string | null +} + +export interface LoginResult { + token: string + usuario: UsuarioPublico +} + +/** + * Normaliza un username: minúsculas, sin acentos, espacios simples. + * El login es case-insensitive y tolerante a espacios extra. + */ +export function normalizarUsername(nombre: string): string { + return nombre + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F + .toLowerCase() + .trim() + .replace(/\s+/g, ' ') +} + +export function aPublico(usuario: Usuario): UsuarioPublico { + return { nombre: usuario.nombre, rol: usuario.rol, asignacion: usuario.asignacion } +} + +/** + * Verifica credenciales y emite un JWT. Errores genéricos ("Credenciales + * inválidas") para no filtrar si el usuario existe o está inactivo. + */ +export async function login(username: string, password: string): Promise { + const invalidas = () => new AppError(401, 'Credenciales inválidas') + + const usuario = await prisma.usuario.findUnique({ + where: { username: normalizarUsername(username) }, + }) + if (!usuario || !usuario.activo) throw invalidas() + + const ok = await bcrypt.compare(password, usuario.passwordHash) + if (!ok) throw invalidas() + + const token = jwt.sign( + { sub: usuario.id, username: usuario.username, rol: usuario.rol }, + config.auth.jwtSecret, + { expiresIn: config.auth.jwtExpiresIn } as jwt.SignOptions, + ) + return { token, usuario: aPublico(usuario) } +} + +/** Payload del JWT emitido por login() */ +export interface TokenPayload { + sub: string + username: string + rol: string +} + +/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */ +export function verificarToken(token: string): TokenPayload { + try { + return jwt.verify(token, config.auth.jwtSecret) as TokenPayload + } catch { + throw new AppError(401, 'Token inválido o expirado') + } +} + +/** Devuelve el usuario público de un token válido (GET /auth/me) */ +export async function me(payload: TokenPayload): Promise { + const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } }) + if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') + return aPublico(usuario) +} diff --git a/api-gateway/vitest.config.ts b/api-gateway/vitest.config.ts index 0057803..e3268d5 100644 --- a/api-gateway/vitest.config.ts +++ b/api-gateway/vitest.config.ts @@ -16,6 +16,10 @@ export default defineConfig({ MONDAY_BOARD_ACTIVIDADES: '999', // Las automatizaciones no deben crear actividades salvo que el test lo pida ACTIVIDADES_ENABLED: 'false', + // Auth: desactivada por defecto en tests (los tests de auth la activan + // explícitamente); JWT_SECRET fijo para poder firmar/verificar tokens + AUTH_ENABLED: 'false', + JWT_SECRET: 'test-jwt-secret', }, }, }) diff --git a/apps/agente-ia/src/components/AuthGate.tsx b/apps/agente-ia/src/components/AuthGate.tsx new file mode 100644 index 0000000..cbe78f9 --- /dev/null +++ b/apps/agente-ia/src/components/AuthGate.tsx @@ -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(() => getSesion()) + + if (!sesion) { + return setSesion(getSesion())} /> + } + + return ( +
+
+
+ + {sesion.usuario.nombre} · {sesion.usuario.rol} + + +
+
+ {children} +
+ ) +} diff --git a/apps/agente-ia/src/components/LoginScreen.tsx b/apps/agente-ia/src/components/LoginScreen.tsx new file mode 100644 index 0000000..b19fcbb --- /dev/null +++ b/apps/agente-ia/src/components/LoginScreen.tsx @@ -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(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 ( +
+
+
+

FRASA

+

+ Acceso al ecosistema +

+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+
+ ) +} diff --git a/apps/agente-ia/src/main.tsx b/apps/agente-ia/src/main.tsx index 71a2b63..c8f6efc 100644 --- a/apps/agente-ia/src/main.tsx +++ b/apps/agente-ia/src/main.tsx @@ -6,9 +6,12 @@ import '@fontsource/inter/500.css' import '@fontsource/inter/700.css' import './index.css' import ChatPage from './pages/ChatPage' +import AuthGate from './components/AuthGate' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/apps/agente-ia/src/services/api.ts b/apps/agente-ia/src/services/api.ts index 7d400e4..6406e08 100644 --- a/apps/agente-ia/src/services/api.ts +++ b/apps/agente-ia/src/services/api.ts @@ -4,6 +4,8 @@ import type { Mensaje, RespuestaChat } from '../types' 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 es error de red: el servidor respondió y su respuesta manda. @@ -28,10 +30,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(ruta: string, init?: RequestInit): Promise { - 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 { diff --git a/apps/agente-ia/src/services/auth.test.ts b/apps/agente-ia/src/services/auth.test.ts new file mode 100644 index 0000000..8884d13 --- /dev/null +++ b/apps/agente-ia/src/services/auth.test.ts @@ -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() + 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() + }) +}) diff --git a/apps/agente-ia/src/services/auth.ts b/apps/agente-ia/src/services/auth.ts new file mode 100644 index 0000000..a6919e1 --- /dev/null +++ b/apps/agente-ia/src/services/auth.ts @@ -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 { + 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), 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() +} diff --git a/apps/alta-clientes/src/components/AuthGate.tsx b/apps/alta-clientes/src/components/AuthGate.tsx new file mode 100644 index 0000000..cbe78f9 --- /dev/null +++ b/apps/alta-clientes/src/components/AuthGate.tsx @@ -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(() => getSesion()) + + if (!sesion) { + return setSesion(getSesion())} /> + } + + return ( +
+
+
+ + {sesion.usuario.nombre} · {sesion.usuario.rol} + + +
+
+ {children} +
+ ) +} diff --git a/apps/alta-clientes/src/components/LoginScreen.tsx b/apps/alta-clientes/src/components/LoginScreen.tsx new file mode 100644 index 0000000..b19fcbb --- /dev/null +++ b/apps/alta-clientes/src/components/LoginScreen.tsx @@ -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(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 ( +
+
+
+

FRASA

+

+ Acceso al ecosistema +

+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+
+ ) +} diff --git a/apps/alta-clientes/src/main.tsx b/apps/alta-clientes/src/main.tsx index 22c867f..5c1465b 100644 --- a/apps/alta-clientes/src/main.tsx +++ b/apps/alta-clientes/src/main.tsx @@ -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( - - } /> - } /> - + + + } /> + } /> + + , ) diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index 83af100..e9df43a 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -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 { 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) diff --git a/apps/alta-clientes/src/services/auth.test.ts b/apps/alta-clientes/src/services/auth.test.ts new file mode 100644 index 0000000..8884d13 --- /dev/null +++ b/apps/alta-clientes/src/services/auth.test.ts @@ -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() + 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() + }) +}) diff --git a/apps/alta-clientes/src/services/auth.ts b/apps/alta-clientes/src/services/auth.ts new file mode 100644 index 0000000..a6919e1 --- /dev/null +++ b/apps/alta-clientes/src/services/auth.ts @@ -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 { + 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), 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() +} diff --git a/apps/cobranza/src/components/AuthGate.tsx b/apps/cobranza/src/components/AuthGate.tsx new file mode 100644 index 0000000..cbe78f9 --- /dev/null +++ b/apps/cobranza/src/components/AuthGate.tsx @@ -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(() => getSesion()) + + if (!sesion) { + return setSesion(getSesion())} /> + } + + return ( +
+
+
+ + {sesion.usuario.nombre} · {sesion.usuario.rol} + + +
+
+ {children} +
+ ) +} diff --git a/apps/cobranza/src/components/LoginScreen.tsx b/apps/cobranza/src/components/LoginScreen.tsx new file mode 100644 index 0000000..b19fcbb --- /dev/null +++ b/apps/cobranza/src/components/LoginScreen.tsx @@ -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(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 ( +
+
+
+

FRASA

+

+ Acceso al ecosistema +

+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+
+ ) +} diff --git a/apps/cobranza/src/main.tsx b/apps/cobranza/src/main.tsx index 5e038e5..8112bd8 100644 --- a/apps/cobranza/src/main.tsx +++ b/apps/cobranza/src/main.tsx @@ -7,6 +7,7 @@ import '@fontsource/inter/500.css' import '@fontsource/inter/700.css' import './index.css' import Layout from './components/Layout' +import AuthGate from './components/AuthGate' import DashboardPage from './pages/DashboardPage' import HoyPage from './pages/HoyPage' import PlanDetallePage from './pages/PlanDetallePage' @@ -15,15 +16,17 @@ import NuevoPlanPage from './pages/NuevoPlanPage' createRoot(document.getElementById('root')!).render( - - - } /> - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + } /> + + + , ) diff --git a/apps/cobranza/src/services/api.ts b/apps/cobranza/src/services/api.ts index facabfa..258546e 100644 --- a/apps/cobranza/src/services/api.ts +++ b/apps/cobranza/src/services/api.ts @@ -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(ruta: string, init?: RequestInit): Promise { - 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 { diff --git a/apps/cobranza/src/services/auth.test.ts b/apps/cobranza/src/services/auth.test.ts new file mode 100644 index 0000000..8884d13 --- /dev/null +++ b/apps/cobranza/src/services/auth.test.ts @@ -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() + 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() + }) +}) diff --git a/apps/cobranza/src/services/auth.ts b/apps/cobranza/src/services/auth.ts new file mode 100644 index 0000000..a6919e1 --- /dev/null +++ b/apps/cobranza/src/services/auth.ts @@ -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 { + 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), 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() +} diff --git a/apps/cotizador/src/components/AuthGate.tsx b/apps/cotizador/src/components/AuthGate.tsx new file mode 100644 index 0000000..cbe78f9 --- /dev/null +++ b/apps/cotizador/src/components/AuthGate.tsx @@ -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(() => getSesion()) + + if (!sesion) { + return setSesion(getSesion())} /> + } + + return ( +
+
+
+ + {sesion.usuario.nombre} · {sesion.usuario.rol} + + +
+
+ {children} +
+ ) +} diff --git a/apps/cotizador/src/components/LoginScreen.tsx b/apps/cotizador/src/components/LoginScreen.tsx new file mode 100644 index 0000000..b19fcbb --- /dev/null +++ b/apps/cotizador/src/components/LoginScreen.tsx @@ -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(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 ( +
+
+
+

FRASA

+

+ Acceso al ecosistema +

+
+
+
+ + 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" + /> +
+
+ + 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" + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+
+ ) +} diff --git a/apps/cotizador/src/main.tsx b/apps/cotizador/src/main.tsx index 7f955f7..aa7b81b 100644 --- a/apps/cotizador/src/main.tsx +++ b/apps/cotizador/src/main.tsx @@ -7,14 +7,17 @@ import '@fontsource/inter/500.css' import '@fontsource/inter/700.css' import './index.css' import CotizadorPage from './pages/CotizadorPage' +import AuthGate from './components/AuthGate' createRoot(document.getElementById('root')!).render( - - } /> - } /> - + + + } /> + } /> + + , ) diff --git a/apps/cotizador/src/services/api.test.ts b/apps/cotizador/src/services/api.test.ts index efc4fec..d230b72 100644 --- a/apps/cotizador/src/services/api.test.ts +++ b/apps/cotizador/src/services/api.test.ts @@ -35,7 +35,7 @@ describe('getUnidades', () => { const resultado = await getUnidades() - expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades') + expect(fetchMock).toHaveBeenCalledWith('http://192.168.10.123:3000/api/unidades', {}) expect(resultado.usandoSeed).toBe(false) expect(resultado.unidades).toEqual([unidadApi]) }) diff --git a/apps/cotizador/src/services/api.ts b/apps/cotizador/src/services/api.ts index bd840ff..013cb26 100644 --- a/apps/cotizador/src/services/api.ts +++ b/apps/cotizador/src/services/api.ts @@ -1,5 +1,6 @@ import type { Unidad } from '../types' import { INVENTARIO_SEED } from '../data/unidades' +import { conAuth, manejar401 } from './auth' // TODO produccion: '/api' same-origin detras de NGINX const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' @@ -17,7 +18,8 @@ export interface GetUnidadesResult { */ export async function getUnidades(): Promise { try { - const resp = await fetch(`${API_URL}/unidades`) + const resp = await fetch(`${API_URL}/unidades`, conAuth()) + manejar401(resp) if (!resp.ok) throw new Error(`HTTP ${resp.status}`) const unidades = (await resp.json()) as Unidad[] return { unidades, usandoSeed: false } diff --git a/apps/cotizador/src/services/auth.test.ts b/apps/cotizador/src/services/auth.test.ts new file mode 100644 index 0000000..8884d13 --- /dev/null +++ b/apps/cotizador/src/services/auth.test.ts @@ -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() + 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() + }) +}) diff --git a/apps/cotizador/src/services/auth.ts b/apps/cotizador/src/services/auth.ts new file mode 100644 index 0000000..a6919e1 --- /dev/null +++ b/apps/cotizador/src/services/auth.ts @@ -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 { + 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), 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() +}