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:
@@ -44,6 +44,10 @@ REDIS_URL="redis://redis:6379"
|
|||||||
# ============================================
|
# ============================================
|
||||||
JWT_SECRET="cambiar_por_secreto_largo_y_aleatorio"
|
JWT_SECRET="cambiar_por_secreto_largo_y_aleatorio"
|
||||||
JWT_EXPIRES_IN="7d"
|
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
|
# APP CONFIG
|
||||||
|
|||||||
150
api-gateway/package-lock.json
generated
150
api-gateway/package-lock.json
generated
@@ -9,16 +9,20 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.14.0",
|
"@prisma/client": "^6.14.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
@@ -939,6 +943,13 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -1027,6 +1038,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/multer": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz",
|
||||||
@@ -1233,6 +1262,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
@@ -1270,6 +1308,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
@@ -1592,6 +1636,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ee-first": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||||
@@ -2061,6 +2114,91 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/loupe": {
|
||||||
"version": "3.2.1",
|
"version": "3.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||||
@@ -2674,6 +2812,18 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
|
|||||||
@@ -13,22 +13,27 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"seed:ai": "tsx scripts/seed-ai.ts",
|
"seed:ai": "tsx scripts/seed-ai.ts",
|
||||||
|
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
|
||||||
"setup:crm": "tsx scripts/setup-crm.ts",
|
"setup:crm": "tsx scripts/setup-crm.ts",
|
||||||
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",
|
"sync:desarrollos": "tsx scripts/sync-desarrollos.ts",
|
||||||
"prisma:migrate": "prisma migrate dev"
|
"prisma:migrate": "prisma migrate dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.14.0",
|
"@prisma/client": "^6.14.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^4.6.0",
|
"node-cron": "^4.6.0",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.15.0",
|
"@types/node": "^22.15.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
|
|||||||
@@ -133,6 +133,23 @@ model NotaPlan {
|
|||||||
@@index([planId])
|
@@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) ----------
|
// ---------- Agente IA interno (RAG) ----------
|
||||||
|
|
||||||
// Documento de la base de conocimiento; `hash` (sha256 del contenido) permite
|
// Documento de la base de conocimiento; `hash` (sha256 del contenido) permite
|
||||||
|
|||||||
53
api-gateway/scripts/seed-usuarios.test.ts
Normal file
53
api-gateway/scripts/seed-usuarios.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
123
api-gateway/scripts/seed-usuarios.ts
Normal file
123
api-gateway/scripts/seed-usuarios.ts
Normal file
@@ -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<void> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
import { apiRouter } from './routes/index.js'
|
import { apiRouter } from './routes/index.js'
|
||||||
|
import { authMiddleware } from './middleware/auth.middleware.js'
|
||||||
import { errorHandler } from './middleware/errorHandler.js'
|
import { errorHandler } from './middleware/errorHandler.js'
|
||||||
import { requestLogger } from './middleware/requestLogger.js'
|
import { requestLogger } from './middleware/requestLogger.js'
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export function crearApp(): express.Express {
|
|||||||
app.use(express.json({ limit: '10mb' }))
|
app.use(express.json({ limit: '10mb' }))
|
||||||
app.use(requestLogger)
|
app.use(requestLogger)
|
||||||
|
|
||||||
app.use('/api', apiRouter)
|
app.use('/api', authMiddleware, apiRouter)
|
||||||
|
|
||||||
app.use((_req, res) => {
|
app.use((_req, res) => {
|
||||||
res.status(404).json({ error: 'Ruta no encontrada' })
|
res.status(404).json({ error: 'Ruta no encontrada' })
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export const config = {
|
|||||||
// Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
// Umbral mínimo de similitud coseno para recuperar contexto (0-1)
|
||||||
minScore: Number(env('AI_MIN_SCORE', '0.3')),
|
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
|
} as const
|
||||||
|
|
||||||
export type AppConfig = typeof config
|
export type AppConfig = typeof config
|
||||||
|
|||||||
22
api-gateway/src/controllers/auth.controller.ts
Normal file
22
api-gateway/src/controllers/auth.controller.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado')
|
||||||
|
res.json(await me(req.usuarioToken))
|
||||||
|
}
|
||||||
78
api-gateway/src/middleware/auth.middleware.test.ts
Normal file
78
api-gateway/src/middleware/auth.middleware.test.ts
Normal file
@@ -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> = {}): Request {
|
||||||
|
return { method: 'GET', path: '/unidades', headers: {}, ...overrides } as Request
|
||||||
|
}
|
||||||
|
|
||||||
|
function resFake(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
|
||||||
|
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<Request>[] = [
|
||||||
|
{ 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
50
api-gateway/src/middleware/auth.middleware.ts
Normal file
50
api-gateway/src/middleware/auth.middleware.ts
Normal file
@@ -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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,11 +38,15 @@ import {
|
|||||||
obtenerConversacion,
|
obtenerConversacion,
|
||||||
subirDocumento,
|
subirDocumento,
|
||||||
} from '../controllers/ai.controller.js'
|
} from '../controllers/ai.controller.js'
|
||||||
|
import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js'
|
||||||
|
|
||||||
export const apiRouter = Router()
|
export const apiRouter = Router()
|
||||||
|
|
||||||
apiRouter.get('/health', health)
|
apiRouter.get('/health', health)
|
||||||
|
|
||||||
|
apiRouter.post('/auth/login', loginEndpoint)
|
||||||
|
apiRouter.get('/auth/me', meEndpoint)
|
||||||
|
|
||||||
apiRouter.get('/unidades', listarUnidades)
|
apiRouter.get('/unidades', listarUnidades)
|
||||||
apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
apiRouter.get('/unidades/:codigo', obtenerUnidad)
|
||||||
|
|
||||||
|
|||||||
104
api-gateway/src/services/auth.service.test.ts
Normal file
104
api-gateway/src/services/auth.service.test.ts
Normal file
@@ -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> = {}): 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
80
api-gateway/src/services/auth.service.ts
Normal file
80
api-gateway/src/services/auth.service.ts
Normal file
@@ -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<LoginResult> {
|
||||||
|
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<UsuarioPublico> {
|
||||||
|
const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } })
|
||||||
|
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||||||
|
return aPublico(usuario)
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
|||||||
MONDAY_BOARD_ACTIVIDADES: '999',
|
MONDAY_BOARD_ACTIVIDADES: '999',
|
||||||
// Las automatizaciones no deben crear actividades salvo que el test lo pida
|
// Las automatizaciones no deben crear actividades salvo que el test lo pida
|
||||||
ACTIVIDADES_ENABLED: 'false',
|
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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
38
apps/agente-ia/src/components/AuthGate.tsx
Normal file
38
apps/agente-ia/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/agente-ia/src/components/LoginScreen.tsx
Normal file
88
apps/agente-ia/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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ import '@fontsource/inter/500.css'
|
|||||||
import '@fontsource/inter/700.css'
|
import '@fontsource/inter/700.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import ChatPage from './pages/ChatPage'
|
import ChatPage from './pages/ChatPage'
|
||||||
|
import AuthGate from './components/AuthGate'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<AuthGate>
|
||||||
<ChatPage />
|
<ChatPage />
|
||||||
|
</AuthGate>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||||
// TODO produccion: '/api' same-origin detras de NGINX
|
// 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).
|
* 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.
|
* 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 */
|
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
|
||||||
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
|
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_URL}${ruta}`, {
|
const res = await fetch(
|
||||||
|
`${API_URL}${ruta}`,
|
||||||
|
conAuth({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...init,
|
...init,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let mensaje = `Error ${res.status} al llamar ${ruta}`
|
let mensaje = `Error ${res.status} al llamar ${ruta}`
|
||||||
try {
|
try {
|
||||||
|
|||||||
124
apps/agente-ia/src/services/auth.test.ts
Normal file
124
apps/agente-ia/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/agente-ia/src/services/auth.ts
Normal file
77
apps/agente-ia/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()
|
||||||
|
}
|
||||||
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 '@fontsource/inter/700.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import AltaClientePage from './pages/AltaClientePage'
|
import AltaClientePage from './pages/AltaClientePage'
|
||||||
|
import AuthGate from './components/AuthGate'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter basename="/client">
|
<BrowserRouter basename="/client">
|
||||||
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AltaClientePage />} />
|
<Route path="/" element={<AltaClientePage />} />
|
||||||
<Route path="*" element={<AltaClientePage />} />
|
<Route path="*" element={<AltaClientePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</AuthGate>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AltaCliente } from '../types'
|
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'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||||
// TODO produccion: '/api' same-origin detras de NGINX
|
// 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> {
|
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada | null> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/clientes`, {
|
const res = await fetch(
|
||||||
|
`${API_URL}/clientes`,
|
||||||
|
conAuth({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(serializarAlta(payload)),
|
body: JSON.stringify(serializarAlta(payload)),
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const data = (await res.json()) as { id: string }
|
const data = (await res.json()) as { id: string }
|
||||||
return { id: data.id }
|
return { id: data.id }
|
||||||
@@ -59,10 +64,14 @@ export async function subirDocumentos(clienteId: string, payload: AltaCliente):
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('tipo', doc.slot)
|
form.append('tipo', doc.slot)
|
||||||
form.append('archivos', doc.archivo, doc.nombre)
|
form.append('archivos', doc.archivo, doc.nombre)
|
||||||
const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, {
|
const res = await fetch(
|
||||||
|
`${API_URL}/clientes/${clienteId}/documentos`,
|
||||||
|
conAuth({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: form,
|
body: form,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, 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()
|
||||||
|
}
|
||||||
38
apps/cobranza/src/components/AuthGate.tsx
Normal file
38
apps/cobranza/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/cobranza/src/components/LoginScreen.tsx
Normal file
88
apps/cobranza/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,6 +7,7 @@ import '@fontsource/inter/500.css'
|
|||||||
import '@fontsource/inter/700.css'
|
import '@fontsource/inter/700.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
|
import AuthGate from './components/AuthGate'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import HoyPage from './pages/HoyPage'
|
import HoyPage from './pages/HoyPage'
|
||||||
import PlanDetallePage from './pages/PlanDetallePage'
|
import PlanDetallePage from './pages/PlanDetallePage'
|
||||||
@@ -15,6 +16,7 @@ import NuevoPlanPage from './pages/NuevoPlanPage'
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter basename="/pay">
|
<BrowserRouter basename="/pay">
|
||||||
|
<AuthGate>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
@@ -24,6 +26,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="*" element={<DashboardPage />} />
|
<Route path="*" element={<DashboardPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
</AuthGate>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
||||||
// TODO produccion: '/api' same-origin detras de NGINX
|
// 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).
|
* 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.
|
* 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 */
|
/** Llama al API Gateway; lanza ErrorApi con el mensaje del servidor si el status no es 2xx */
|
||||||
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
|
export async function request<T>(ruta: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_URL}${ruta}`, {
|
const res = await fetch(
|
||||||
|
`${API_URL}${ruta}`,
|
||||||
|
conAuth({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
...init,
|
...init,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
manejar401(res)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let mensaje = `Error ${res.status} al llamar ${ruta}`
|
let mensaje = `Error ${res.status} al llamar ${ruta}`
|
||||||
try {
|
try {
|
||||||
|
|||||||
124
apps/cobranza/src/services/auth.test.ts
Normal file
124
apps/cobranza/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/cobranza/src/services/auth.ts
Normal file
77
apps/cobranza/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()
|
||||||
|
}
|
||||||
38
apps/cotizador/src/components/AuthGate.tsx
Normal file
38
apps/cotizador/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/cotizador/src/components/LoginScreen.tsx
Normal file
88
apps/cotizador/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 '@fontsource/inter/700.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import CotizadorPage from './pages/CotizadorPage'
|
import CotizadorPage from './pages/CotizadorPage'
|
||||||
|
import AuthGate from './components/AuthGate'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter basename="/quote">
|
<BrowserRouter basename="/quote">
|
||||||
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<CotizadorPage />} />
|
<Route path="/" element={<CotizadorPage />} />
|
||||||
<Route path="*" element={<CotizadorPage />} />
|
<Route path="*" element={<CotizadorPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</AuthGate>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe('getUnidades', () => {
|
|||||||
|
|
||||||
const resultado = await 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.usandoSeed).toBe(false)
|
||||||
expect(resultado.unidades).toEqual([unidadApi])
|
expect(resultado.unidades).toEqual([unidadApi])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Unidad } from '../types'
|
import type { Unidad } from '../types'
|
||||||
import { INVENTARIO_SEED } from '../data/unidades'
|
import { INVENTARIO_SEED } from '../data/unidades'
|
||||||
|
import { conAuth, manejar401 } from './auth'
|
||||||
|
|
||||||
// TODO produccion: '/api' same-origin detras de NGINX
|
// TODO produccion: '/api' same-origin detras de NGINX
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api'
|
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<GetUnidadesResult> {
|
export async function getUnidades(): Promise<GetUnidadesResult> {
|
||||||
try {
|
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}`)
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||||
const unidades = (await resp.json()) as Unidad[]
|
const unidades = (await resp.json()) as Unidad[]
|
||||||
return { unidades, usandoSeed: false }
|
return { unidades, usandoSeed: false }
|
||||||
|
|||||||
124
apps/cotizador/src/services/auth.test.ts
Normal file
124
apps/cotizador/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/cotizador/src/services/auth.ts
Normal file
77
apps/cotizador/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