From cb86a5190a8dc94d8f4284d674a5e03192b43155 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Fri, 28 Aug 2026 08:37:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20PWA=20instalable,=20cambio=20de=20contr?= =?UTF-8?q?ase=C3=B1a=20y=20dashboards=20por=20rol?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/auth/cambiar-password (bcrypt 12, política ≥8+1 número) + modal en las 6 apps con auth - PWA: manifest + íconos de marca en las 7 apps (sin service worker a propósito, documentado); instalable en celular - Dashboards: 'Mi tablero' para vendedor (sus KPIs y funnel) y sección Equipo para coordinador/corporativo/admin - mi-dia: equipo.porVendedor gana leadsNuevosSemana - Tests: api 271, cotizador 88, alta 88, cobranza 81, agente-ia 19, dashboards 15, mi-dia 11, landing 4 --- README.md | 15 + .../src/controllers/auth.controller.test.ts | 73 +++++ .../src/controllers/auth.controller.ts | 15 +- api-gateway/src/routes/index.ts | 3 +- api-gateway/src/services/auth.service.test.ts | 63 +++- api-gateway/src/services/auth.service.ts | 37 +++ .../src/services/mi-dia.service.test.ts | 5 +- api-gateway/src/services/mi-dia.service.ts | 16 +- apps/agente-ia/index.html | 3 + apps/agente-ia/public/icons/icon-192.png | Bin 0 -> 590 bytes apps/agente-ia/public/icons/icon-512.png | Bin 0 -> 1925 bytes apps/agente-ia/public/manifest.webmanifest | 13 + apps/agente-ia/src/components/AuthGate.tsx | 35 ++- .../src/components/CambiarPasswordModal.tsx | 140 +++++++++ apps/agente-ia/src/lib/password.test.ts | 22 ++ apps/agente-ia/src/lib/password.ts | 16 + apps/agente-ia/src/services/auth.test.ts | 35 +++ apps/agente-ia/src/services/auth.ts | 25 ++ apps/alta-clientes/index.html | 3 + apps/alta-clientes/public/icons/icon-192.png | Bin 0 -> 2224 bytes apps/alta-clientes/public/icons/icon-512.png | Bin 0 -> 6960 bytes .../alta-clientes/public/manifest.webmanifest | 13 + .../alta-clientes/src/components/AuthGate.tsx | 35 ++- .../src/components/CambiarPasswordModal.tsx | 140 +++++++++ apps/alta-clientes/src/lib/password.test.ts | 22 ++ apps/alta-clientes/src/lib/password.ts | 16 + apps/alta-clientes/src/services/auth.test.ts | 35 +++ apps/alta-clientes/src/services/auth.ts | 25 ++ apps/cobranza/index.html | 3 + apps/cobranza/public/icons/icon-192.png | Bin 0 -> 2798 bytes apps/cobranza/public/icons/icon-512.png | Bin 0 -> 7790 bytes apps/cobranza/public/manifest.webmanifest | 13 + apps/cobranza/src/components/AuthGate.tsx | 35 ++- .../src/components/CambiarPasswordModal.tsx | 140 +++++++++ apps/cobranza/src/lib/password.test.ts | 22 ++ apps/cobranza/src/lib/password.ts | 16 + apps/cobranza/src/services/auth.test.ts | 35 +++ apps/cobranza/src/services/auth.ts | 25 ++ apps/cotizador/index.html | 3 + apps/cotizador/public/icons/icon-192.png | Bin 0 -> 2620 bytes apps/cotizador/public/icons/icon-512.png | Bin 0 -> 7680 bytes apps/cotizador/public/manifest.webmanifest | 13 + apps/cotizador/src/components/AuthGate.tsx | 35 ++- .../src/components/CambiarPasswordModal.tsx | 140 +++++++++ apps/cotizador/src/lib/password.test.ts | 22 ++ apps/cotizador/src/lib/password.ts | 16 + apps/cotizador/src/services/auth.test.ts | 35 +++ apps/cotizador/src/services/auth.ts | 25 ++ apps/dashboards/index.html | 3 + apps/dashboards/public/icons/icon-192.png | Bin 0 -> 2187 bytes apps/dashboards/public/icons/icon-512.png | Bin 0 -> 6368 bytes apps/dashboards/public/manifest.webmanifest | 13 + apps/dashboards/src/components/AuthGate.tsx | 35 ++- .../src/components/CambiarPasswordModal.tsx | 140 +++++++++ apps/dashboards/src/components/MiTablero.tsx | 121 ++++++++ .../src/components/SeccionEquipo.tsx | 74 +++++ .../src/components/TableroGeneral.tsx | 252 +++++++++++++++ apps/dashboards/src/components/comunes.tsx | 13 + apps/dashboards/src/lib/mi-tablero.test.ts | 36 +++ apps/dashboards/src/lib/mi-tablero.ts | 81 +++++ apps/dashboards/src/lib/password.test.ts | 22 ++ apps/dashboards/src/lib/password.ts | 16 + apps/dashboards/src/pages/DashboardPage.tsx | 292 +++--------------- apps/dashboards/src/services/auth.ts | 25 ++ apps/dashboards/src/services/mi-dia.ts | 18 ++ apps/landing/index.html | 3 + apps/landing/public/icons/icon-192.png | Bin 0 -> 638 bytes apps/landing/public/icons/icon-512.png | Bin 0 -> 1977 bytes apps/landing/public/manifest.webmanifest | 13 + apps/mi-dia/index.html | 3 + apps/mi-dia/public/icons/icon-192.png | Bin 0 -> 643 bytes apps/mi-dia/public/icons/icon-512.png | Bin 0 -> 1982 bytes apps/mi-dia/public/manifest.webmanifest | 13 + apps/mi-dia/src/components/AuthGate.tsx | 35 ++- .../src/components/CambiarPasswordModal.tsx | 140 +++++++++ apps/mi-dia/src/lib/mi-dia.ts | 7 +- apps/mi-dia/src/lib/password.test.ts | 22 ++ apps/mi-dia/src/lib/password.ts | 16 + apps/mi-dia/src/services/auth.ts | 25 ++ scripts/generar-iconos-pwa.py | 55 ++++ 80 files changed, 2533 insertions(+), 323 deletions(-) create mode 100644 api-gateway/src/controllers/auth.controller.test.ts create mode 100644 apps/agente-ia/public/icons/icon-192.png create mode 100644 apps/agente-ia/public/icons/icon-512.png create mode 100644 apps/agente-ia/public/manifest.webmanifest create mode 100644 apps/agente-ia/src/components/CambiarPasswordModal.tsx create mode 100644 apps/agente-ia/src/lib/password.test.ts create mode 100644 apps/agente-ia/src/lib/password.ts create mode 100644 apps/alta-clientes/public/icons/icon-192.png create mode 100644 apps/alta-clientes/public/icons/icon-512.png create mode 100644 apps/alta-clientes/public/manifest.webmanifest create mode 100644 apps/alta-clientes/src/components/CambiarPasswordModal.tsx create mode 100644 apps/alta-clientes/src/lib/password.test.ts create mode 100644 apps/alta-clientes/src/lib/password.ts create mode 100644 apps/cobranza/public/icons/icon-192.png create mode 100644 apps/cobranza/public/icons/icon-512.png create mode 100644 apps/cobranza/public/manifest.webmanifest create mode 100644 apps/cobranza/src/components/CambiarPasswordModal.tsx create mode 100644 apps/cobranza/src/lib/password.test.ts create mode 100644 apps/cobranza/src/lib/password.ts create mode 100644 apps/cotizador/public/icons/icon-192.png create mode 100644 apps/cotizador/public/icons/icon-512.png create mode 100644 apps/cotizador/public/manifest.webmanifest create mode 100644 apps/cotizador/src/components/CambiarPasswordModal.tsx create mode 100644 apps/cotizador/src/lib/password.test.ts create mode 100644 apps/cotizador/src/lib/password.ts create mode 100644 apps/dashboards/public/icons/icon-192.png create mode 100644 apps/dashboards/public/icons/icon-512.png create mode 100644 apps/dashboards/public/manifest.webmanifest create mode 100644 apps/dashboards/src/components/CambiarPasswordModal.tsx create mode 100644 apps/dashboards/src/components/MiTablero.tsx create mode 100644 apps/dashboards/src/components/SeccionEquipo.tsx create mode 100644 apps/dashboards/src/components/TableroGeneral.tsx create mode 100644 apps/dashboards/src/components/comunes.tsx create mode 100644 apps/dashboards/src/lib/mi-tablero.test.ts create mode 100644 apps/dashboards/src/lib/mi-tablero.ts create mode 100644 apps/dashboards/src/lib/password.test.ts create mode 100644 apps/dashboards/src/lib/password.ts create mode 100644 apps/dashboards/src/services/mi-dia.ts create mode 100644 apps/landing/public/icons/icon-192.png create mode 100644 apps/landing/public/icons/icon-512.png create mode 100644 apps/landing/public/manifest.webmanifest create mode 100644 apps/mi-dia/public/icons/icon-192.png create mode 100644 apps/mi-dia/public/icons/icon-512.png create mode 100644 apps/mi-dia/public/manifest.webmanifest create mode 100644 apps/mi-dia/src/components/CambiarPasswordModal.tsx create mode 100644 apps/mi-dia/src/lib/password.test.ts create mode 100644 apps/mi-dia/src/lib/password.ts create mode 100644 scripts/generar-iconos-pwa.py diff --git a/README.md b/README.md index 822dee4..6c7097d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,23 @@ Ecosistema comercial de Frasa Desarrollos S.A.P.I. de C.V. | `apps/alta-clientes` | `/client/` | Alta de clientes (wizard, firma digital, PDFs PLD) | | `apps/cobranza` | `/pay/` | Planes de enganche, vencidos, estados de cuenta | | `apps/dashboards` | `/dash/` | Tableros KPI (leads, oportunidades, inventario, cobranza) | +| `apps/mi-dia` | `/hoy/` | Agenda del vendedor (mis leads, actividades, acciones CRM) | +| `apps/agente-ia` | `/ai/` | Asistente IA con documentos del ecosistema | | `api-gateway` | `/api/` | Express 5 + Prisma + integración Monday.com | +## PWA (instalabilidad básica) + +Las 7 apps son instalables (manifest + íconos): cada una tiene +`public/manifest.webmanifest` ("Frasa · \", `display: standalone`, +`theme_color`/`background_color`) e íconos 192/512 en `public/icons/` +(generados con `scripts/generar-iconos-pwa.py`, venv `/root/.venv-tools`). + +**Decisión: NO hay service worker.** Solo se busca instalabilidad básica +(ícono en el teléfono que abre la app a pantalla completa). Un SW con caché +arriesga servir bundles viejos tras cada deploy (los usuarios son vendedores +en campo; no van a limpiar caché a mano). Si algún día se quiere modo offline, +hay que diseñar primero la estrategia de invalidación de caché. + ## Desarrollo local Cada app es un proyecto Vite independiente: `npm install && npm run dev` (o `npm run preview` tras `npm run build`). Tests: `npx vitest run` en cada paquete. El API: `cd api-gateway && npm install && npx prisma migrate dev && npm run dev`. diff --git a/api-gateway/src/controllers/auth.controller.test.ts b/api-gateway/src/controllers/auth.controller.test.ts new file mode 100644 index 0000000..ec9f039 --- /dev/null +++ b/api-gateway/src/controllers/auth.controller.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Request, Response } from 'express' +import { cambiarPasswordEndpoint } from './auth.controller.js' +import { AppError } from '../utils/errors.js' + +// ---------- Servicio mockeado (la lógica se prueba en auth.service.test) ---------- + +const servicioMock = vi.hoisted(() => ({ cambiarPassword: vi.fn() })) + +vi.mock('../services/auth.service.js', () => ({ + cambiarPassword: servicioMock.cambiarPassword, +})) + +function reqFake(overrides: Partial = {}): Request { + return { + body: { actual: 'Frasa2026', nueva: 'NuevaPass2026' }, + usuarioToken: { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, + ...overrides, + } as Request +} + +function resFake() { + const json = vi.fn() + const end = vi.fn() + const status = vi.fn().mockReturnValue({ json, end }) + return { res: { json, status } as unknown as Response, status, json, end } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('cambiarPasswordEndpoint', () => { + it('204 sin cuerpo cuando el servicio cambia la contraseña', async () => { + servicioMock.cambiarPassword.mockResolvedValue(undefined) + const { res, status, end } = resFake() + + await cambiarPasswordEndpoint(reqFake(), res) + + expect(servicioMock.cambiarPassword).toHaveBeenCalledWith( + { sub: 'u-1', username: 'asesor uno', rol: 'vendedor' }, + 'Frasa2026', + 'NuevaPass2026', + ) + expect(status).toHaveBeenCalledWith(204) + expect(end).toHaveBeenCalled() + }) + + it('sin token verificado: 400', async () => { + const { res } = resFake() + await expect(cambiarPasswordEndpoint(reqFake({ usuarioToken: undefined }), res)).rejects.toThrow( + 'Falta el token verificado', + ) + expect(servicioMock.cambiarPassword).not.toHaveBeenCalled() + }) + + it('body inválido: ZodError (el error handler lo traduce a 400)', async () => { + const { res } = resFake() + await expect( + cambiarPasswordEndpoint(reqFake({ body: { actual: '' } }), res), + ).rejects.toThrow() + expect(servicioMock.cambiarPassword).not.toHaveBeenCalled() + }) + + it('propaga el AppError del servicio (actual incorrecta → 400)', async () => { + servicioMock.cambiarPassword.mockRejectedValue( + new AppError(400, 'La contraseña actual no es correcta'), + ) + const { res } = resFake() + const error = await cambiarPasswordEndpoint(reqFake(), res).catch((e: unknown) => e) + expect(error).toMatchObject({ status: 400 }) + }) +}) diff --git a/api-gateway/src/controllers/auth.controller.ts b/api-gateway/src/controllers/auth.controller.ts index c4b6a16..553a2a5 100644 --- a/api-gateway/src/controllers/auth.controller.ts +++ b/api-gateway/src/controllers/auth.controller.ts @@ -1,6 +1,6 @@ import type { Request, Response } from 'express' import { z } from 'zod' -import { login, me } from '../services/auth.service.js' +import { cambiarPassword, login, me } from '../services/auth.service.js' import { BadRequestError } from '../utils/errors.js' const loginSchema = z.object({ @@ -20,3 +20,16 @@ export async function meEndpoint(req: Request, res: Response): Promise { if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') res.json(await me(req.usuarioToken)) } + +const cambiarPasswordSchema = z.object({ + actual: z.string().min(1), + nueva: z.string().min(1), +}) + +/** POST /api/auth/cambiar-password (Bearer) { actual, nueva } → 204 */ +export async function cambiarPasswordEndpoint(req: Request, res: Response): Promise { + if (!req.usuarioToken) throw new BadRequestError('Falta el token verificado') + const { actual, nueva } = cambiarPasswordSchema.parse(req.body) + await cambiarPassword(req.usuarioToken, actual, nueva) + res.status(204).end() +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index c8da20c..009e348 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -62,7 +62,7 @@ import { obtenerConversacion, subirDocumento, } from '../controllers/ai.controller.js' -import { loginEndpoint, meEndpoint } from '../controllers/auth.controller.js' +import { loginEndpoint, meEndpoint, cambiarPasswordEndpoint } from '../controllers/auth.controller.js' import { resumenReportes } from '../controllers/reportes.controller.js' export const apiRouter = Router() @@ -71,6 +71,7 @@ apiRouter.get('/health', health) apiRouter.post('/auth/login', loginEndpoint) apiRouter.get('/auth/me', meEndpoint) +apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint) apiRouter.get('/unidades', listarUnidades) apiRouter.get('/unidades/:codigo', obtenerUnidad) diff --git a/api-gateway/src/services/auth.service.test.ts b/api-gateway/src/services/auth.service.test.ts index 6ce74a4..41d0bdd 100644 --- a/api-gateway/src/services/auth.service.test.ts +++ b/api-gateway/src/services/auth.service.test.ts @@ -1,13 +1,14 @@ 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' +import { cambiarPassword, login, me, normalizarUsername, validarPasswordNueva, verificarToken } from './auth.service.js' // ---------- Prisma mockeado (los tests no tocan la BD ni la red) ---------- const prismaMock = vi.hoisted(() => ({ usuario: { findUnique: vi.fn(), + update: vi.fn(), }, })) @@ -102,3 +103,63 @@ describe('me', () => { await expect(me({ sub: 'u-1', username: 'x', rol: 'vendedor' })).rejects.toThrow('Credenciales inválidas') }) }) + +describe('validarPasswordNueva', () => { + it('exige al menos 8 caracteres y un número', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta passwords que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('cambiarPassword', () => { + const payload = { sub: 'u-1', username: 'erick israel pena ramos', rol: 'vendedor' } + + it('con actual correcta re-hashea y guarda la nueva (cost 12)', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake()) + prismaMock.usuario.update.mockResolvedValue(usuarioFake()) + + await cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026') + + expect(prismaMock.usuario.update).toHaveBeenCalledTimes(1) + const llamada = prismaMock.usuario.update.mock.calls[0]?.[0] as { + where: { id: string } + data: { passwordHash: string } + } + expect(llamada.where).toEqual({ id: 'u-1' }) + expect(llamada.data.passwordHash).not.toBe('NuevaPass2026') + expect(await bcrypt.compare('NuevaPass2026', llamada.data.passwordHash)).toBe(true) + // Cost 12: el hash bcrypt lleva el factor en el propio string ($2b$12$…) + expect(llamada.data.passwordHash).toMatch(/^\$2[aby]\$12\$/) + }) + + it('rechaza una nueva que no cumple la política sin tocar la BD', async () => { + await expect(cambiarPassword(payload, 'Frasa2026', 'corta')).rejects.toThrow('8 caracteres') + expect(prismaMock.usuario.findUnique).not.toHaveBeenCalled() + expect(prismaMock.usuario.update).not.toHaveBeenCalled() + }) + + it('con actual incorrecta responde 400 y no actualiza', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake()) + + const error = await cambiarPassword(payload, 'mala', 'NuevaPass2026').catch((e: unknown) => e) + expect(error).toMatchObject({ status: 400, message: 'La contraseña actual no es correcta' }) + expect(prismaMock.usuario.update).not.toHaveBeenCalled() + }) + + it('usuario inexistente o inactivo: 401 genérico', async () => { + prismaMock.usuario.findUnique.mockResolvedValue(null) + await expect(cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')).rejects.toThrow( + 'Credenciales inválidas', + ) + prismaMock.usuario.findUnique.mockResolvedValue(usuarioFake({ activo: false })) + await expect(cambiarPassword(payload, 'Frasa2026', 'NuevaPass2026')).rejects.toThrow( + 'Credenciales inválidas', + ) + }) +}) diff --git a/api-gateway/src/services/auth.service.ts b/api-gateway/src/services/auth.service.ts index 87d4971..8e33364 100644 --- a/api-gateway/src/services/auth.service.ts +++ b/api-gateway/src/services/auth.service.ts @@ -81,3 +81,40 @@ export async function me(payload: TokenPayload): Promise { if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') return aPublico(usuario) } + +const BCRYPT_COST = 12 + +/** + * Regla de la contraseña nueva: ≥8 caracteres y al menos 1 número. + * Devuelve el mensaje de error o null si es válida (la app cliente replica + * esta misma regla para validar antes de llamar al API). + */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** + * POST /auth/cambiar-password: verifica la contraseña actual contra bcrypt y + * re-hashea la nueva (cost 12). Errores genéricos: no se filtra si el usuario + * existe; una actual incorrecta responde 400 sin más detalle. + */ +export async function cambiarPassword( + payload: TokenPayload, + actual: string, + nueva: string, +): Promise { + const errorPolitica = validarPasswordNueva(nueva) + if (errorPolitica) throw new AppError(400, errorPolitica) + + const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } }) + if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas') + + const ok = await bcrypt.compare(actual, usuario.passwordHash) + if (!ok) throw new AppError(400, 'La contraseña actual no es correcta') + + const passwordHash = await bcrypt.hash(nueva, BCRYPT_COST) + await prisma.usuario.update({ where: { id: usuario.id }, data: { passwordHash } }) +} diff --git a/api-gateway/src/services/mi-dia.service.test.ts b/api-gateway/src/services/mi-dia.service.test.ts index dbfa859..6f24bc6 100644 --- a/api-gateway/src/services/mi-dia.service.test.ts +++ b/api-gateway/src/services/mi-dia.service.test.ts @@ -166,6 +166,7 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { lead({ id: '3', vendedorIds: [MI_ID] }), lead({ id: '4', vendedorIds: [MI_ID], estado: 'DESCARTADO' }), // terminal: no cuenta lead({ id: '5', vendedorIds: [OTRO_ID] }), + lead({ id: '6', vendedorIds: [OTRO_ID], createdAt: '2026-08-10T08:00:00' }), // viejo: no es nuevo de la semana ], }) const nombres = new Map([[MI_ID, 'Martin Zavala']]) @@ -176,8 +177,8 @@ describe('resumenMiDia (GET /api/mi-dia/resumen)', () => { const rCoord = await resumenMiDia(monday, coordinador, nombres, AHORA) expect(rCoord.equipo?.leadsNuevosSinAsignar).toBe(1) expect(rCoord.equipo?.porVendedor).toEqual([ - { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1 }, - { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 1 }, + { mondayUserId: OTRO_ID, nombre: null, leadsAsignados: 2, leadsNuevosSemana: 1 }, + { mondayUserId: MI_ID, nombre: 'Martin Zavala', leadsAsignados: 1, leadsNuevosSemana: 1 }, ]) }) diff --git a/api-gateway/src/services/mi-dia.service.ts b/api-gateway/src/services/mi-dia.service.ts index 085aa7a..cdc0af3 100644 --- a/api-gateway/src/services/mi-dia.service.ts +++ b/api-gateway/src/services/mi-dia.service.ts @@ -64,7 +64,13 @@ export interface ResumenMiDia { // Solo roles coordinador/corporativo/admin equipo?: { leadsNuevosSinAsignar: number - porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[] + porVendedor: { + mondayUserId: number + nombre: string | null + leadsAsignados: number + /** Leads del vendedor creados en los últimos 7 días naturales */ + leadsNuevosSemana: number + }[] } } @@ -170,9 +176,14 @@ export async function resumenMiDia( if (ROLES_CON_EQUIPO.includes(usuario.rol)) { const sinAsignar = leads.filter((l) => l.vendedorIds.length === 0 && !esEstadoTerminalLead(l.estado)) const conteoPorId = new Map() + const nuevosPorId = new Map() for (const l of leads) { if (esEstadoTerminalLead(l.estado)) continue - for (const id of l.vendedorIds) conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) + const esNuevoSemana = Date.parse(l.createdAt) >= hace7DiasMs + for (const id of l.vendedorIds) { + conteoPorId.set(id, (conteoPorId.get(id) ?? 0) + 1) + if (esNuevoSemana) nuevosPorId.set(id, (nuevosPorId.get(id) ?? 0) + 1) + } } resumen.equipo = { leadsNuevosSinAsignar: sinAsignar.filter((l) => l.estado.trim().toUpperCase() === 'NUEVO').length, @@ -181,6 +192,7 @@ export async function resumenMiDia( mondayUserId, nombre: nombresPorMondayId.get(mondayUserId) ?? null, leadsAsignados, + leadsNuevosSemana: nuevosPorId.get(mondayUserId) ?? 0, })) .sort((a, b) => b.leadsAsignados - a.leadsAsignados), } diff --git a/apps/agente-ia/index.html b/apps/agente-ia/index.html index 18a7f84..5d49b99 100644 --- a/apps/agente-ia/index.html +++ b/apps/agente-ia/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Asistente IA diff --git a/apps/agente-ia/public/icons/icon-192.png b/apps/agente-ia/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..41d9bab48d3540af2acb9d97022760168466d767 GIT binary patch literal 590 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfl1WU#WAE}&f6;+IS(a>xCJWb zP1N1g6gI6dDOEG#mSx-8ske7JulpcuvAq6+=ydb5$5Yv}-@5ZB+-7l6QV?RC$kE~A zpu(giDA3v9L1&?piC-E%>Mj5L`ddVKb?v?T_s`dV&*|nLaCea&5-GF#Pru;JnK4M* b{)sv8B*(%NdQI)Xbi&~2>gTe~DWM4fLX?>0 literal 0 HcmV?d00001 diff --git a/apps/agente-ia/public/icons/icon-512.png b/apps/agente-ia/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..47693c36b7ab8f93a019b8e664ef15659a97d4a4 GIT binary patch literal 1925 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_pK?PZ!6KiaBquFXTNGz~CC_ zbz_lSqeDrn-w~Dywgko$T}K01;Ry}D9?dg8SZtm@>v}4?!}+=|3=GEv7#cJf89IbU zsnH++rGgcQUod`X|NQaGE_=82KeDRr^|!x%A^Jg^pFzQkfngELC^Z@cLpv2LtBiHa mXGmZeP0>R;9%+;hUonRSaw{qsmF@>tuMD29elF{r5}E*UNKlIa literal 0 HcmV?d00001 diff --git a/apps/agente-ia/public/manifest.webmanifest b/apps/agente-ia/public/manifest.webmanifest new file mode 100644 index 0000000..bfe5e13 --- /dev/null +++ b/apps/agente-ia/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Frasa · Asistente IA", + "short_name": "Asistente IA", + "start_url": "/ai/", + "scope": "/ai/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1B4332", + "icons": [ + { "src": "/ai/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/ai/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/agente-ia/src/components/AuthGate.tsx b/apps/agente-ia/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/agente-ia/src/components/AuthGate.tsx +++ b/apps/agente-ia/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' 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". + * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) + * y botón "Salir". */ export default function AuthGate({ children }: { children: ReactNode }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/agente-ia/src/components/CambiarPasswordModal.tsx b/apps/agente-ia/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/agente-ia/src/components/CambiarPasswordModal.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' +import { validarConfirmacion, validarPasswordNueva } from '../lib/password' +import { cambiarPassword } from '../services/auth' + +const CLASE_INPUT = + 'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none' +const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente + * con la misma regla del API y muestra el mensaje del API si la actual no es + * correcta. Al éxito la sesión sigue intacta (el JWT no cambia). + */ +export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) { + const [actual, setActual] = useState('') + const [nueva, setNueva] = useState('') + const [confirmar, setConfirmar] = useState('') + const [error, setError] = useState(null) + const [exito, setExito] = useState(false) + const [cargando, setCargando] = useState(false) + + async function enviar(e: FormEvent) { + e.preventDefault() + const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar) + if (errorCliente) { + setError(errorCliente) + return + } + setError(null) + setCargando(true) + try { + await cambiarPassword(actual, nueva) + setExito(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña') + } finally { + setCargando(false) + } + } + + return ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/agente-ia/src/lib/password.test.ts b/apps/agente-ia/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/agente-ia/src/lib/password.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validarConfirmacion, validarPasswordNueva } from './password' + +describe('validarPasswordNueva', () => { + it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta contraseñas que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('validarConfirmacion', () => { + it('null si coinciden; mensaje si difieren', () => { + expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull() + expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide') + }) +}) diff --git a/apps/agente-ia/src/lib/password.ts b/apps/agente-ia/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/agente-ia/src/lib/password.ts @@ -0,0 +1,16 @@ +// Validación cliente de la contraseña nueva: misma regla que el API +// (api-gateway/src/services/auth.service.ts). El API vuelve a validar; +// esto solo evita un viaje de red con errores obvios. + +/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** null si la confirmación coincide con la nueva; mensaje si difiere */ +export function validarConfirmacion(nueva: string, confirmar: string): string | null { + return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña' +} diff --git a/apps/agente-ia/src/services/auth.test.ts b/apps/agente-ia/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/agente-ia/src/services/auth.test.ts +++ b/apps/agente-ia/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { expect(getSesion()).not.toBeNull() }) }) + +describe('cambiarPassword', () => { + it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionOk)) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + + await cambiarPassword('Frasa2026', 'NuevaPass2026') + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/auth/cambiar-password') + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' }) + expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' }) + expect(getSesion()).not.toBeNull() + }) + + it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }), + ), + ) + await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow( + 'La contraseña actual no es correcta', + ) + }) + + it('sin red: Error de conexión', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))) + await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar') + }) +}) diff --git a/apps/agente-ia/src/services/auth.ts b/apps/agente-ia/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/agente-ia/src/services/auth.ts +++ b/apps/agente-ia/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() if (typeof window !== 'undefined') window.location.reload() } + +/** + * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando + * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. + */ +export async function cambiarPassword(actual: string, nueva: string): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/auth/cambiar-password`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actual, nueva }), + }), + ) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} diff --git a/apps/alta-clientes/index.html b/apps/alta-clientes/index.html index 89c95eb..5f80773 100644 --- a/apps/alta-clientes/index.html +++ b/apps/alta-clientes/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Alta de Clientes diff --git a/apps/alta-clientes/public/icons/icon-192.png b/apps/alta-clientes/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..a15da421a46bf9662e3b80847da4e23d3e7d6708 GIT binary patch literal 2224 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 G#Qy*>u7E87 literal 0 HcmV?d00001 diff --git a/apps/alta-clientes/public/manifest.webmanifest b/apps/alta-clientes/public/manifest.webmanifest new file mode 100644 index 0000000..54df02e --- /dev/null +++ b/apps/alta-clientes/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Frasa · Alta de clientes", + "short_name": "Alta clientes", + "start_url": "/client/", + "scope": "/client/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1B4332", + "icons": [ + { "src": "/client/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/client/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/alta-clientes/src/components/AuthGate.tsx b/apps/alta-clientes/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/alta-clientes/src/components/AuthGate.tsx +++ b/apps/alta-clientes/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' 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". + * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) + * y botón "Salir". */ export default function AuthGate({ children }: { children: ReactNode }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/alta-clientes/src/components/CambiarPasswordModal.tsx b/apps/alta-clientes/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/alta-clientes/src/components/CambiarPasswordModal.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' +import { validarConfirmacion, validarPasswordNueva } from '../lib/password' +import { cambiarPassword } from '../services/auth' + +const CLASE_INPUT = + 'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none' +const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente + * con la misma regla del API y muestra el mensaje del API si la actual no es + * correcta. Al éxito la sesión sigue intacta (el JWT no cambia). + */ +export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) { + const [actual, setActual] = useState('') + const [nueva, setNueva] = useState('') + const [confirmar, setConfirmar] = useState('') + const [error, setError] = useState(null) + const [exito, setExito] = useState(false) + const [cargando, setCargando] = useState(false) + + async function enviar(e: FormEvent) { + e.preventDefault() + const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar) + if (errorCliente) { + setError(errorCliente) + return + } + setError(null) + setCargando(true) + try { + await cambiarPassword(actual, nueva) + setExito(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña') + } finally { + setCargando(false) + } + } + + return ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/alta-clientes/src/lib/password.test.ts b/apps/alta-clientes/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/alta-clientes/src/lib/password.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validarConfirmacion, validarPasswordNueva } from './password' + +describe('validarPasswordNueva', () => { + it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta contraseñas que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('validarConfirmacion', () => { + it('null si coinciden; mensaje si difieren', () => { + expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull() + expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide') + }) +}) diff --git a/apps/alta-clientes/src/lib/password.ts b/apps/alta-clientes/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/alta-clientes/src/lib/password.ts @@ -0,0 +1,16 @@ +// Validación cliente de la contraseña nueva: misma regla que el API +// (api-gateway/src/services/auth.service.ts). El API vuelve a validar; +// esto solo evita un viaje de red con errores obvios. + +/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** null si la confirmación coincide con la nueva; mensaje si difiere */ +export function validarConfirmacion(nueva: string, confirmar: string): string | null { + return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña' +} diff --git a/apps/alta-clientes/src/services/auth.test.ts b/apps/alta-clientes/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/alta-clientes/src/services/auth.test.ts +++ b/apps/alta-clientes/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { expect(getSesion()).not.toBeNull() }) }) + +describe('cambiarPassword', () => { + it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionOk)) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + + await cambiarPassword('Frasa2026', 'NuevaPass2026') + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/auth/cambiar-password') + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' }) + expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' }) + expect(getSesion()).not.toBeNull() + }) + + it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }), + ), + ) + await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow( + 'La contraseña actual no es correcta', + ) + }) + + it('sin red: Error de conexión', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))) + await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar') + }) +}) diff --git a/apps/alta-clientes/src/services/auth.ts b/apps/alta-clientes/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/alta-clientes/src/services/auth.ts +++ b/apps/alta-clientes/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() if (typeof window !== 'undefined') window.location.reload() } + +/** + * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando + * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. + */ +export async function cambiarPassword(actual: string, nueva: string): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/auth/cambiar-password`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actual, nueva }), + }), + ) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} diff --git a/apps/cobranza/index.html b/apps/cobranza/index.html index 61c2647..6add54d 100644 --- a/apps/cobranza/index.html +++ b/apps/cobranza/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Cobranza diff --git a/apps/cobranza/public/icons/icon-192.png b/apps/cobranza/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..6231754eb4ed29a53d87ba82ea2d78fe71c876d3 GIT binary patch literal 2798 zcmd6p_ct5*8^;kNwyU(YB3gAzaP3h-)T&XmHaE3eZcC(8W3NQ*qNTOzrKs9_hMI|6 zEp3U-tyMxot|Uf{8|Qp~`u+po?>W!s`JCtZJfCqs&pGe&O0uys<>eCPVq#+AHM?bW zmoa}hA3zp{U+DR1!^FhZVrFDu7g9i42(=ON5yeqJFh1dU5kBFpp8_uoVa)2Fn2#<#WYQL&-GGIdy(!mkqBgVoa$jT}W1l|%5 z$N+=E1~3>pCWe)nne%_M;CXeQY5k8$wDF$LDETO)RYrg9Q^(gaug{O!84n#a{L=;x z?N^l?!p%(iV3Bj4VZG1D0GLJinJF;6 zVcQTHlMyJUpjya5uL!`>9l=heZ7fw3&yF~6ZU}*dmTOUK3q1x?PLZXx9mEfaG6Pe1@>L&uLvzho z%-1*c*TX&HwWVUH!VZrN?f~5gmOO()zM;EMRzY#j@*F~!Kv?9$MO+b<5kDAZXT*_U zlA(^PYX^sic)q0cQCJuW-o#0}71I|}T~pnP#aeA{NbiF$#`eXmTZT&oy1$#Zb>bqh z1lBi2M-p^9D(b=2aH9%ot03FTu7(v1fne(_(!1(b-5#Z`{_tyRKxUVJX3a=(SKy1T z*_OJh{I1zaI7#&HHL;6YN*_Kt()n{&`(0qFFGEjvt*1OTruRv``damE22<0A_>G+J z*0b#mW>eE|Ay^m61_ssIDisy$5i8Z;VQ7LItSZR5=@Wud7S3oY{Wd-hH0Ww3y8fth zMyaS`0_QnuYC^B&bqjv-G37&@R%j@r?t5lx+Pne!sLB{-;HbX18H`Z+?0rm=W?s5f=j zj2KmO>7n4qK06pxLYx(0?Un+xWSbapnT7<`r}y50AnP8>f#M4|QI*zryP! zmBaO}qs(}mPDi1t+~D$Gl`f(yW8QCV#gSj;scjZ&o_fy#xb-`eDr4lT>YjSlY1{e~ zO`oiBuT*GF&*Vo)uPf8EZSEzNhwHHO?9OykB_DEU7ymZ;Ei0Rs52!(M=kbzzWAR|< zQ?P3%7C76QK*CE6nDB|v-|~jd=(JZ(Xo?}92E*7JP_ARGZBu2^RzfrZO{BVzJ2B|r`@4kJmqo=#=-gPjjWt^cwAm})j z^Ky#F?}KHA*VSzt%>u?03Qm%@gR2Si-7mC|-g|J5D)!ObGS&%MIqB_mTW?G8-0`jo zKi^-1C66OWwF-d}3#;;wH&%=mGd5qtrGr$1+Fdr~DPL%aqiN^NE#!$DCBWD-i5Yso zte%S(n9QA?#w#YMtzAXR?$nM)Vvju{8p32A ztDW4xW1Ra29=1yA|Xgdc}AWiak z_ZfCISD~DR_%jgoz^#;4+~pX6{|sIz#&pKXHQ|UO;pl9AY4b<}OSXFQ|UpY0cDjB6h{j-^YWcs?uq`@q>vUty4oVRm$HlH>-E60$0vj?G? zQKcA>*J9(gZxw#m3;$=QQ?ICI#5(jzEp;zPH6jB)P;w8G$N-Mja|8kSfLEl>SxWaY zy9$V9IW{IWAtpo$>?H*TX`RCegRYiieb0+PKzs-=F@gEg7ZV1?{HHuBHDOI0hF1N* fAhQ3)!BhUkVq&-I4(>lq7`+m96Ys1`SHcI@3rf zON?#o%g8?dXY~EO_`UqU|9w8^Gv|5cxzByhbIy70>$&xWK#LSnu?z$zHElRx=dCu(MaI`sN@>Kr-zIT1SF6hGM zVdZdKst<8_DWnzEY|l@lYt2NJ^ai(n(h<@JV96|<1ptSA09-tP0N^ag|1SPF3IDI1 z5NC`9B&(e}*n8*OMP6+juNxCF?)u?d&x;9ALgMTa+nlJoaQPhIAa}tQP5}w(_xilpb}T_K2MvQ`!hF1 zp^i)sj!aXj(}Sn3owppTS*%`GH0iuz`YJ#F{X6V$#ubCVbGCdP5E-k0{czuGDQ zf{c$cLlI1mTr2u#9Hn;js0-$I7@cQ7DGXy9Q)8&{lvAB8;_drUUsuNbgn1LIc>O}i52zP`Q_@edy_+IKDn-lXK`li&W#sQi$bU3#o0 zU9=p3v>Y!pd*)LAgU==UW9b2i0RcebTkgL>p>Sp$i%ycccE!O3A9YE+qixVD&ct|_ zo5z7`n6HJXY!dU#IHvJ7l9>aHhu6Cwl09}qcc{DR0cF4aee*nB%U3s*cuC2ug_{!1k*qB|KKNE084_X8B@QI6T`sAOC_%5c>gqd&X2vw`1HKP?+(QSQTez*! zjK0_?b!H^778EwwG^|#K16WpS&*HaUD0k@<0Ivsd8ooU?wOS{=sY*8mcwc4^FDUE3 z@-S6hfPlngL2qU@(z7+{OUTqYCM8uQj%|Ij&Tz+{#dvE(S_vyqanf8!9^Taf8;>KI z0pIV*zU6o8p;EAM*HJKzb6l2~rcj0d-ntZYM`lkJBr|T~1%OL|V}Nv7!YT{XK^6_7 zOZ>_>9gCvx7eELU6o zVxLk@2ue?XGgV{5zK5T*3gk~I!Z;OYY+Sk&<~7-=+N~j@lol5FwKkhHmt7c{P4bd@ zUMIE>SoXG+j7`o>g+=9!bV%CVnyb$W?zp_eSw2NI$to_@Dz3(FYi29-8?jMQMP5)& zJ|ytUh10F1$e4@#&zblH0iHFgr}$`4H0|M5fkfHra33hO-fh|787CTYKEov8 zX;~FyKU}M+NKDb#dT8Ez_F+4z@0aRp)FnF(GUtWaPCNdnN0!p=s~<8##H%!kFJC5gx&JJ5>exvY@G{{=r0FxQv$U|C zZ)tW{3o!9#vbBYy*N z4n1^04TDA6Ixr0Uyq~7nK8%W-=_`q+?XE3^n<%#;!yFslbiFJ($}r9v0TH+MUfIuS zY~aF>pU%k)5e7n&G8y4N4R4{q}9sCAG#d+&Z&(ap|Ys;Le; z;-7HXgD}_HZd{YYg75Om;xl5+*5_t?qzhFN*C7Nu(bOrT-QANEtMsR(Yan>BCqDC7 z^r@savJY?;-nwl=%1eF|e!t-yyP_ghfX7xL>6yp{y%d=|{M7K;bW%-k#R#cLD9^u& zCRLlb50o~|6r$8qWM%D2Y5OGM7U!16`^|@2OdcUl8EW{>nXNrdrX&XSh=@05WFfHH zAWitxNvko$inO8PGSyzh0Hr#=VPoMgIg%BaE|Yf84Ul4ACQ6|P_b(OxVcj(@O+b=P zA%W!j6W*aODz;ac!PV;%lhx>CLBeS=B>?T!(OOX_7=i!e^}HYHE9>J$K2P0CDzg~1 zthq+zB#RHjt?v`HCwgRz0p!xH=#*58z=gYgp9VRS4&N1c!_?vD__iIcTdx%HrupHX_)hb`esP}iMd;FyE*%L_pA~PD{>ZM zx#AAYp6^?qy;bF(Inz<+^8>fY3Vx1=Eb^$Atnfe=)?tRrtWhy+OGfS36^Qo`7eYR|ZdRohG)ara_9KlU zBG$%zDVnV*gwf`yGHA#RL&SuZ@w9yh_oC*)+4P!}HYU4lURb}5nV)}fWUF|hf`xzHcR8jXsU-mL z2R!pEHs>>!$l?cvt`+slE;mSehSJ{3a%Pm*Dk<%_tLNb7y}J0UrQ&GFDxutNxMt4RvYKmibEM~-$-CkVE)zli6zt47yL+(bAV(H5yYr~uriJc z?fBj+K{zXElC#AAL>(QlW=z)loy!U;8`h6#RE&Q5wP8y!e|CDgKLW-(5n%Urv5oVlQA!&Lk zjG#LI!i_%PbUnrAX3&{?_Y2I{?n1uD84XC&;hRh-JT>}Aeq}>hL=KIc61=iZ%%?9H zQMtU>;QZ*=YHU@(PQG<61{Qjizt1a3^8MA@;a3LVkr{po;*e7Bi7lvbY!8v*2uKZ3 zUw7$A4HYLK4R8R{&~3MCPeF>J&w?)lvC0qX=k&oh)^A+!NsqwHvVvn3%l=8Hgk7!4 zt-FNIRhi+1U$;!D;J5B;&%j7a5CWtfHnY_yMGRA+(Z<}Flv6*1M|8yn4O0U`Mo5q+G1tUL$OjJQi2U4~;^-!>ZR9(;CC^<$QJ zd5r&soIdrzQDtE+N3vF$4KpyPEsL1^nqWtvXh-wEjM;ZGE`X=JqnUlcoL;V>Q-BNQ z07{3VdiJA5uQ{|9a%c?=y}kDKvtX{26#1?1`^@SaU!D{c_P9f5M$Di+FDu8*{md21 zGrsWGJ#aWi^C_ei(9&Vhe%Vu_&=kAF_u((e-@Onf*`4?ydmG-cX>(evrGF|Kyi~+- zX_wYe=Hry3s)m*eUr-#N^qvqnXks>pdo`Q;%vvC~v^b>ov$@)91kV=>Parfo%OfKp zcaGr{fSvL3{%>f^<1o=JO?S#n&6Oxuog!;VS$&SNBXg!1Su*b|?WMP*nsogz%lkp1 zf704{_FTkSMlN+is{cWM2Cv336D*S9ipRO6=VsI%7*!SB2SVuLg95LOv>zKDR`vcI zc}XRV^td2kAO=SlY1Ye#zyz=YFR=xK1Ea4*L)`kVOS2Q*GOdC-THPpbDe2l}V(Kc< z;CGn!UN9b4d4J7#va9v#&B-TW-ozO#F|v=8g!;oHx4jU|FdrD;S1@g_- zEeJ4OZbfAhp)>Ob`qW}th>h`Mxr1RGAk6~ulo|SxP}!0nS?e!6(`LsY099%M+{|V3 zTh~_yY@S=sqaXu^{i?!#&*jvZnOge85D6Jz#nOZ@8o@BW@%?&v&1w@1Ssc1~XKIMV zpwI*E2xDeQgcI*`$kEQa1u{#c2lmlf5TMfPx>F^MIT~!gw7;wQ zf;K?!0)HQQS8P1Sd@xCZ>&V71Wk3(JQPgAbI|4eJK1_V|JjM39tLxJg&?f*e<*HrZ zkD)(Z3CPrhD;EJkUwh|IEo}I^{MXjof28S$1r8@NzVeA7LG%}D#nKGqrB{q>#JAsr zl&LAeWP@Fy^3CR2z!=G|vcmTxh&p(uyV=lK*AO=YQ-v?M+BBZf5AI!6sr{kT;f##i zC;75V%pB(-2w4~ptC#>S(vlGa}>-iWid zQ}LD1V(cs-;JX)+7f3O}T9POC=sxTUEt#k2A9<*&m#*EkPL*7y&C_E0E)E3VcLVsN z&~g3eaF&gYAHe}N>pda`8 zH5D6Cj_O`tn(;_lp32V7kLCAUy&G|tK27D3bXpxK`he<&>!bq7D8Vuq-+oXrr~%)^ z79k`#Io?%O?)kH1)8)nsTK){z5soF|n9cnBj1c+@OgcXBbqj5`^GZx;8->{@EZQ?> zmX|Mmp#GY+Sf1@q^~QgwHo#uxNs5>Efl!0)?M;tJxNsa&M{g6&eA;dpMsPUWt=eA& zR$l96d#2dd=Qn!MuJ3|7jFvM|xw-1X=scsH?5r04M6!5;;Zy#FS4+PX^$ZyS^L%lF zr0n2RSOQuJFbNuoKQOJMr;mOaDlfers2R<#ey--#xyJed|LJh$j(p#uuc@@HYQypn z_;IbB1!+Qe!dcjM^9MrW2PyPSAy3=^R9jnnYfF185&lX^Jqs(IfvIxONp%$|*Qs=v zwF~aNt4plFX*J5pEf!~(Vrp1{Z}YVea5C>l+-*-$p=_A8CwoYUNuw`W`@eZZI}_xp z6GVkt6X!?ixMeenhfB6cb`Ikrdp1_Phj+upXsqTz zWOGNSTTPGXHaPvJ<(lLa1lT~3BWuw{+RA1XPbumZ;PR=H;aoT=@5=l?uZZfg+fax@ zY*Gn|lSKLR1-O0=Q0ubWFePxkBMgEQ=TU(BrZ(Taz-#^VNfK4^kwi?7?-k)L0;Q6(zKiq%LH8kd= zo7OhLTdId%&*GdO5%#kv0B}C4Uh>m3&itd*hL%VqP%65m%_fNr(V^$+cSX5sF;CeF-zJMWz*${vlV zoA1oe3$t7*hq4}IpfJf-ar~P030epnCPSs}95#vr$}eG+BRMr8D+d0Ts%hK@Q3y;I z>(rQr$M+Q-p~ee`l{0NnD5`RMR(9?%VaZKn<(ieB$733IHcaWF65B?hqAB*kUgQa{ zs*~C2*%U%L5A~{+J*9zf0J8hBVDe=cMrpj+_dwc_ia%cZkN6a@X|uH&?i5NTk0vU^W?Lg$+32`DH&eO3mg z9?5loKBbpGu9xdchflBq2mG>s)UGFwVA;#~<&_g9GjzKvT@Y{Lo(8K43wap+yiK68 z1aV6H-UAq(R;NV>6HpXO(7aQpbz1Ar>CfRiR|1!x_IKL(?OjS-RfgOzdekYyk*}?_ z+4cfs;!7WMm=bZu9`ldO1}{IezpD34aAf6PsP zQ#$9M6RL9wmK)x7tM7EFR+wN&&QLHPM^tZa+*;eQ0?VJpX$oa}aF9B~DAR+hsT%lT zEIt@-rV^ZJ2a}WMkfI%#xIQsq(A9Ybf|=T__irlSZ(I@P@f@6js<_+F@m)D4AAdG^ z%7ujPR~)U&{$9D4Nq6uAmZ81h<^Y&I3`w1NTi=(k-a;@0{d!0)7>YcvCDUJ_Uwd0q z2M-zIfAqiV{iMdW4W9L%?V|yR@A{0Ur@*$T4;?eK{rLrU_!Ojx!b63j1AldUsLL~) z2crJg|B?OwJ$e{|!~mWiKwX7&8UV~ChEFK`Go1a$=NiMOj6^@{&h6UZzxDs^V*mh? z%G$z}O%%hIB|M4PKCkHdJM{mYR~-5D_uK;U??C=N>tOi%SLFxHUd({;+S_mNzYDmG i|C@yW*Cb?bF>M}vA-4ufwBb22&^c>xCjYc;`2PXm1|m-Y literal 0 HcmV?d00001 diff --git a/apps/cobranza/public/manifest.webmanifest b/apps/cobranza/public/manifest.webmanifest new file mode 100644 index 0000000..b6579dc --- /dev/null +++ b/apps/cobranza/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Frasa · Cobranza", + "short_name": "Cobranza", + "start_url": "/pay/", + "scope": "/pay/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1B4332", + "icons": [ + { "src": "/pay/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/pay/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/cobranza/src/components/AuthGate.tsx b/apps/cobranza/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/cobranza/src/components/AuthGate.tsx +++ b/apps/cobranza/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' 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". + * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) + * y botón "Salir". */ export default function AuthGate({ children }: { children: ReactNode }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/cobranza/src/components/CambiarPasswordModal.tsx b/apps/cobranza/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/cobranza/src/components/CambiarPasswordModal.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' +import { validarConfirmacion, validarPasswordNueva } from '../lib/password' +import { cambiarPassword } from '../services/auth' + +const CLASE_INPUT = + 'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none' +const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente + * con la misma regla del API y muestra el mensaje del API si la actual no es + * correcta. Al éxito la sesión sigue intacta (el JWT no cambia). + */ +export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) { + const [actual, setActual] = useState('') + const [nueva, setNueva] = useState('') + const [confirmar, setConfirmar] = useState('') + const [error, setError] = useState(null) + const [exito, setExito] = useState(false) + const [cargando, setCargando] = useState(false) + + async function enviar(e: FormEvent) { + e.preventDefault() + const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar) + if (errorCliente) { + setError(errorCliente) + return + } + setError(null) + setCargando(true) + try { + await cambiarPassword(actual, nueva) + setExito(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña') + } finally { + setCargando(false) + } + } + + return ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/cobranza/src/lib/password.test.ts b/apps/cobranza/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/cobranza/src/lib/password.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validarConfirmacion, validarPasswordNueva } from './password' + +describe('validarPasswordNueva', () => { + it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta contraseñas que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('validarConfirmacion', () => { + it('null si coinciden; mensaje si difieren', () => { + expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull() + expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide') + }) +}) diff --git a/apps/cobranza/src/lib/password.ts b/apps/cobranza/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/cobranza/src/lib/password.ts @@ -0,0 +1,16 @@ +// Validación cliente de la contraseña nueva: misma regla que el API +// (api-gateway/src/services/auth.service.ts). El API vuelve a validar; +// esto solo evita un viaje de red con errores obvios. + +/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** null si la confirmación coincide con la nueva; mensaje si difiere */ +export function validarConfirmacion(nueva: string, confirmar: string): string | null { + return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña' +} diff --git a/apps/cobranza/src/services/auth.test.ts b/apps/cobranza/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/cobranza/src/services/auth.test.ts +++ b/apps/cobranza/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { expect(getSesion()).not.toBeNull() }) }) + +describe('cambiarPassword', () => { + it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionOk)) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + + await cambiarPassword('Frasa2026', 'NuevaPass2026') + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/auth/cambiar-password') + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' }) + expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' }) + expect(getSesion()).not.toBeNull() + }) + + it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }), + ), + ) + await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow( + 'La contraseña actual no es correcta', + ) + }) + + it('sin red: Error de conexión', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))) + await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar') + }) +}) diff --git a/apps/cobranza/src/services/auth.ts b/apps/cobranza/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/cobranza/src/services/auth.ts +++ b/apps/cobranza/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() if (typeof window !== 'undefined') window.location.reload() } + +/** + * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando + * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. + */ +export async function cambiarPassword(actual: string, nueva: string): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/auth/cambiar-password`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actual, nueva }), + }), + ) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} diff --git a/apps/cotizador/index.html b/apps/cotizador/index.html index a9995db..26e8784 100644 --- a/apps/cotizador/index.html +++ b/apps/cotizador/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Cotizador diff --git a/apps/cotizador/public/icons/icon-192.png b/apps/cotizador/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..bfe09ebecc27dc4bf00f242a9a4153c78d4f02a4 GIT binary patch literal 2620 zcmd5;dpHy98#a{Tn;}XpBSH!ZNv6$*=6uL8q7XT?$uPoEHN@?Q z`#)g{KR4QD`n|n>F5YhDlXKcFCjV{WC>1AVQy|&Aka`yFilEIN*95M z#^bpG0N(%DMnN!xBR7kXg}t-3j?z6!jLe@@L99U+3%9-`71)V5>sQ)|I5{~f^^bV^ zVj>>?w4(o5`tV{CDtSC36Q*!(K&h{~q%4qOaT~1I^;I_|@71wHAn&PTtZQ-3r!yTCl-Ebn_!Yc__Ioo6Q zzQn%UDsCI>^YXq|-z^O>_zVczSbr%5_a2Dxrh(et4hs32F?r0Pvp$!cvL@GF7&Xr$r@LEy%eIuZ!aQb z>7VaPT4KJU=|OaW@w<`oR}^8J>z_`Qkgi<@D_jj`v<5kaH-~6H5JYVph+IPLk~3_5 zsRIKkk}-EDu5yBw;XvkB009uYvqE%d9z_z3N)#YhW1E^9QHSG>V0+Dm@#D|3JjLoR zVrm~L8b4B`EJ>Iq&&!ol6@BziSS?SlO3p4wIT@H+uJGvYy1A=@}T5o25rfd*_;CT8Z_HpgR|l<>c^?dr4(bk zn)tlBeIK<1IVa@6ynSwNca)`{7*rEmEN17*rDtG8LO8SKw)aGuP~Q#~h;xaJ4F+ec z-M?}_olUqNbbq~p&MDNw!oXf{PIZ+-VD)YkJ;hD)0LH0A&>`$rSyYzd<|*A;M|K>N z8GoFpt0LC;&r7Hs4&6~5pZ0I)>e-26-~cDEZ>%_d?)jRSIvzyK+s0X+1;|^tZWYN1 zLve?#EbCzz02q)cZsyi`ha#YfL8HH=Q@kJ^)6)4?dTy|Zi8Cr_U$lv1TTvx3dD@WD zp@-m=Az5ZZiT)!Y@GR;DW0`&Yun)6wTIQ^FcuSZ>xvPlbHUN|;mcO;0(Q>dv&)E*8 z4V)X}QNLUO_4svYjdU<8Fs%i92}jwJbqP%X;sKsoe$+1){si5g;cjKW=L3?_%26=^NPx* zVG+79y|S>fQq;XJ(g~YQfrNl11CJolE+Y|>NkEfV{nOEr7fUD23mi5D49lNLrZtUq z`U`hI#Ue70QYIL+|KY62teKeHm=>;!$6B8#`}ohpxc)iG_#wYCu|2wv>!JzRa6y^n zq(7uP3D8hCf-^6-ld`5|lg_N+6*Sd1wzh+=_w19_{+MM+oUg1%Yq6uROqfkQo4!DK zJ%Q)e1+I}hA=JC_fZ7_fF zOJQzK9srHpli4z2G^Yrk}78vHpGM(Hp@ zym=-{`fJcC*iu_Na(e)|9d1x$SDqy|5Fsr$fI_}IV$RkFSJr^iM;>Vv3tC>#+3hAR z&%So);!Ba)>e$3b3al3V0G@yk;NQX)(dAJy_#>=*9UReY|vIymZL51KGH+0DO5{)Rl>z!7vsb{$mBJ{auYs z4CSd0t&7BGN|lxDQj3{5Dhuq)?)@400N&%*;c?S1VS&xG%Nw-!f7zdZ9ljx%m%PPZ zAor=7JMNKM3OJGAPWeMFVeVIr1?OEiRtDP+`9`_j%!Swwd%MBG?%sidsyFXk;DcSX zG4En2L&AQ>C2|gW>$SSNaa#qCUTEwuwxXouMaOhu3nD?#+|f-fe&J(93F66mN68mF z->|hP;%D>(!B$j!$l4|E!^w^GdDG{f?|TD>Uo3%hbsDzF9k6WpdZ1+>!lKls>}$zl z%oF!dAgECjgQaFON`2Xsft&5jX5Z}S=;(JJh}1>Eg~rbL_0@bWx%z~w^Yj7HDC#+Io6)X57x~lxGf99&8RydM(i_Jk3oj1-=QTt;Gz8^ASv7x^9E)GEs0KhKn zrSrxBAmLXeKyQ8c3u@&8Al!&Of7ZnJ=4+gp(0}2H?mA05qllBl4fT{9hKsLk6gpzn0sn!u!nJg{SC_mN7gtUH%e7y&%+%Op%HgZD-m~|AtmwLa#KjXaL8Qclr``$@7@1M4 zWj5Lc1q~$NE40Y{8WmUm7j}lCRP0RUYT--1mV1?EWa@cLejpG$ga_mb+4I@66ZGclyYtDi`ce#!OWj!Q<`fH*QrXgeIMzDZ<}VG;ZSj60Db3U zTH5T4>aHoZH0u%^Mo=*!b<~r{A?G%**#9(WMoF86`chpO0uqCm9)n|avwl}Z0W7azvu8SyGf z!@cS2`hidqVKrDMyMoCgs(QCV&@(wq-7e1@)?ljDt0Ca04F(^!w#f%@1 zdU1*S*a;%FcrAI!P4;C4>uw@r?-vOWOMF4WH-yFbxOExfABkHBmy9j3d3xWRP7pz2 zQQ+B#ogL}rnWg1dC8)kCe6ua1HtsgTp^r=Oc+SI0*&uLfq^}?+{x=3WW3jqgcSj3f+0n(sXAF-_p8)#Oh!1!+Jgl2 zM^O>DjRn@*lP+>|lRWSi{)L!uYwh>8z~0PaqKCK;G$UHX#-^BKrjdayO2lF;6kP_& z%{_E}j(-%>|CMC~Bbob>vSK}07S7-hJt`|_Q4+Y=kYcG!?{Sw~52b%He9+`IxzTfF zqAid5=}$QM21KktovGH`UN4ufS7uJcFLUU@K(8UVaS0t6dXu730}@p=QW3hkS6wVh z>}q>4{@xoCD|E%2^qhPCYqK-73QQyh)`9m|jK9ycw^odLf3MdsEx6fG!*16S^8AEA zvrm-xj7^(rMk1-SFOQ-1oECoEQ9f04zsRvPs#lo*lCZ~h4gcI;%1@i4 zaQQ-?mcdEaSh1=a=RVh!7Z_`D(J$84;Tae?d)8<;(>i#b)@9w!m8)24Hpt5i-#OhQ z)s0RZU^^iO=I(s)V!DdrBN-g|=0*0kP4pIZ-{}HI?0e}d9h*u5gJIcJi&#@~< z|60y2z1Vp_Bqc++I{!h($6z&|+qI>>w1c<;Wtma=B3y4=nKk~A&%N7mDt?UB;V$l+ zWrO(W6mM2%RYhe52jV^-IFq!wE`8W{xlLYnm8zN_SUP^Cgo>%EclH*(H0UM9EbA_< z(0)?2m`Hss@g`m1V0F@rqMEN+#a@h5l!$!Qh(#{bdz9esYWWww?0G7aHG0lW0z2dQ(#5cDR$7v?e|CRIHq>sq+`mwvq9r)W9pmlXvJPc*Id zDNfSNPv{Mvu~J16vN?CL``J;#C^GNen9G=2(Y5QH^AZ#-RJ>}_1co(NuBu&mYNbVI z;4@N%7g+VHy^%W9o-JSX`o%bgb(iupW=}#^(5y_A@p3~=S>UW-+3&?Mxe{GG*9I@5 z+O+-gqn9eS&lnL-_}c@P25m3QhexE_>^@v8lpoUzUi$NjyYB-gMRcW39_p@-clX)x zyXA{RbI=xerkky%G`|@fT<&IalUJMF`qK7r@PZBFo4$3|B^q?{v!eGGS^KSwKA^~K zbTwM%O|Gy1pa+&G$(&n7Agbl{L=@#}L=IQI{)S6oakXuV@b491jztMHp7Ed$Uun!J z(G)>cw{<&rq@KZtG9r#zuKp=Bn;>JV#BHXVyH$T}vggYuU75Y7;}&wrl`Vwy{8Yf@ z%O=7!e$ygV&%rDCyJX&ENHYx5vna0juti<<;{hBrF~j#Rzo zaA-|#(TfRjhMmEOYqI@RubpX@k_&< zg9fq>ZTtG%3K$XJED9#$nBhg~X23xc*wDH%98(@$qVXpf&&raD@8&T$st-YNt-L8I zwO56EgFh_Xft?FK0m}ofx&9uo#GlJJmC|Y+IgZT-&0*kHSKfuD?e6mwnWOe!iKG;j z;H!8x8xfRd#aXp{VydBZYea-{=^D2$KjQoBLy^FADU)wqgD$82W}iSyCTN0_ZSl-I zX_s^}lW!yPS9J_P^Fxr;S?sc-)*%iz+>;U7WbLwk5$aSzX%?rxOUZx=7M7e>zX()M z!PRQ2_nty4x}6@(9dqi)G_zZmE;S}1=8&4O%poeldjU343@2)(eF3j*RZUSF*<4-? zp&$iC;fdYS_BO>bLkf&T$Bl8bGQgjM#SVg7h6DoV!fsxZ8d@jsQLR5 zbtQcmi@8+vB?E9d8m{I+oFWDqK{FmG%{K&e^@F}U(EG2vAZp~sl#apNbO4tWs4hVh zE*cHEdd1ou-L23jh-fx>j5}5sqs=|99nfA_4G#l$|Ux%0wRk67<8gbc3mO^8 z82SWsxjsV@I+ex$jUCLv@QltHy@Gn-^s!YUTY6>XoEIQOMv9v2Jwq z;~a_q$q77z0sW``&99um-b)5^IqC=y0Jrhpym21f^N}0uV8{~YjPrC4H0f+uk_yB9 z1$_|k*d`9i%6zN4%ylYoZ8n*R=krxxZX~ z82?*=Z!!(GyftDzjGB_Vln*XPf+Jq5zuYqigB8;2?P%0unCj}kpqa7O1udy)o&32R zQ5f1o@Vl749tLBX>i>|8d892p#24U9D`wpVD6!*bUf;;}hR48D9N=eQOoRv7nuInU zP_-=D%JHZxBz#Fk?B8=qODJT}Pr!oieCww}9j`nHwoZ2MM@Bz|@8}rpv9~Jv5y<+8 z2_!P^>+Dy&n`FBWsxE>Z<4ia4>aYh)h^*34ykU&`zmNG7_c)?zxWg=D6F*q7AuFT6 ztrFMuy)^cO;{}aN0qQ%4u0zPm{}?Et_R4r?vdiRsEL?fzAn45yRf$a@%2d5JZrCOK z)G+@OOz{~s!P~WDJ$nY>fmYbbg&+t()cw>~j>Vs|k0QI@41*oVKZvO`-ECoL0&&|o zG|C1FmOvCeFcxi^+zHV?=W%W0T}O1a48+^Lk@h`n9WcJ8f*uF`b=}y6aJjp=t8$_B zb|3CgE7rHhNB)F_7ZeA|Z+D6efuQ|2{>zm(_pB+x%YEoEmRspphs{tg57@Z%`Mp7( zYY7BTqtjIH~*7Q%sv@-!`Q$p$uGEmH;vtNaT<;| z&eXGbHl8dr2r6`?j{g*Bg{~rN6$buHHLrG`q8-%K6bGx1n`ZFzpD_@&^{g&Ws|E*3 z!-h=+i4s8)NPld`8vi_CgI3<^CbtW0&fRB30KVM^jt5fW^Q<&~Mu8zm*ocnHtsCMm zBr5O zGaxT3orp8CznZ<&vqO}%H8_N?rd-8h5-S&4g@S_)${oIn=iETU-sac!*1zw=h`&dU zRFX>n-ntu9*xO8YKK7gck@?Bj-zcB!I-E1F88?-HUCW3b&!7(K;wK=*SiPg(XR4eT${>4Qlv+xLW!+O6W76t6;I; z=E6j#`scAA-}7KD+sd^oj4ufj?V4P3m9ekI9B!@efNNoI2jrJq};Lj ze*Px&M}+c#kXLPilg9eCGxvsiUSNb0gZ}imgt;Y(j0o4R?cCQ}!J2HD8Z({c*8P?tfh$cVlF@s&2~vYQ=my(H~3 zD3*D0;CYDuv%G&q`VN#)XIE#*lM8p8rC^cW?|CB|_yZv>%`1=yD4@0Iqv)vz9Im3)l?*fE#wCxzta7wb8lBD*-<0 z6Lxw*G*}M3QYDk^<=0ERQ}7wy(GibwLgaNo{HP)@`J7EB_h;xik@WjvS?Zj>wwfjM zFsj@(sow51eyuKV+d7;lr`7I>O?C6qaT109Ops+*3niC<_vVp_dY1c?k_E`9 zAzmq9qIcOOrK3pTmVfqB>Wn%OPQPDArP@-|iGlN0tq(ZMP7@23*pKOF z{-oV2>sLy~bUW-*vb=TamhZJ|H?R4Ys^#dPH>pbME=}sj|L_v(%pOcXd(JLkTktAP zSRZ`*s)6FYwjeh#s60Av&pM%e^3Zv_Zp#OYT*zF(A=y$^ExiQbZqx`chMZwb#@gr| z+#1)fqCm%o)yRaRy)AJ##Lx>KVDeLAqIvCCb{qUVOC3p=^q(GcB==NZhts^1IDC}( z(~tFpw$AMc*$+cu6zEDXNcXMbyK+1x2vQ_l1cFtzE*r5?kCmS@s+|r=2|*-~|5EWX zc>S*(Tt9$V8V~T*o*mOI(b%-!ukEV_(ECsT7Z*qJp}tvhcSeXALV+u`*!eoC%)Eh0 z93BnY(4ex}Xx4YAMS>5|S~uno@<8)mBvpAZAJ-!JvxYY%}Hx9k)@h0|}0x$FS?r`KBY9eEP=ZAn7Os zrvc=U6bdNb{IjO0C7KlLt)K-N8Bxd(y^Nb^eS-I;H2W0^k8weZXyBAs^m?S2jm}jT zToe7Iied?1r;j?I?zPdWJsXJcY#yQPreO#;6gUo1p>j+V7rgRY(pkf^qMza!@1c@H z;S>A%50QBb5?teD;Ue_jk6{d+3rf?g%Wy{98d}s)&(L6_{tH4Yv!AJ1RCk!7)!aPh zo!hgqG{reScQ&;E(ro;YX8RqYthP|$pM3b_vjICL*!Pc3nYCG*qC>`oVmTxal|B!M1vc}&37+ca_tt7oLo!Yt1%i%#5j>ghLtb}H>3$O^MZ<(R z8H{got6P@CVZTtu3<54p$7)4;eCJBP$~aY07QCpnk z#S4SbT&b-DJREXcoMitjeK`|jS%Uww)^S0y@-s4(7jm3j7;3brlHGaML8JfXI8(~p zAje5^*_b@%`thH6op5vCMS66<>#XXnTlCFiMqcR?UoL6fvw?CD8vMpb{Aif1sV}5u z@1FJ|EuwAJ`pQ(VZmmChfs<2-zUexemn-CRM=d$l$|LPFA{nr%&wQu}^2COkY8$?tcEiIB*8zYtsWZ z#l?;gmKY$w4h1s!Nncdlk9}COvm3RW?2L@wV(|Bih}d$+pgB29!Y@Duf7%vuiVs^H z_C2&MVx#U%ZW;HefR!9xJA6c*Y&?56UN!I3foKoyo&*^`wm+wd3myydfAjbgV^-zy z$s}T+v@&S|zq(`VrPO?g!hhaX{pSVU|Bn}!Obx|XS0!~&SP}SU7ho^wpU*pI6Z&7% Cyz6-Y literal 0 HcmV?d00001 diff --git a/apps/cotizador/public/manifest.webmanifest b/apps/cotizador/public/manifest.webmanifest new file mode 100644 index 0000000..9d10068 --- /dev/null +++ b/apps/cotizador/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Frasa · Cotizador", + "short_name": "Cotizador", + "start_url": "/quote/", + "scope": "/quote/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1B4332", + "icons": [ + { "src": "/quote/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/quote/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/cotizador/src/components/AuthGate.tsx b/apps/cotizador/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/cotizador/src/components/AuthGate.tsx +++ b/apps/cotizador/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' 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". + * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) + * y botón "Salir". */ export default function AuthGate({ children }: { children: ReactNode }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/cotizador/src/components/CambiarPasswordModal.tsx b/apps/cotizador/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/cotizador/src/components/CambiarPasswordModal.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' +import { validarConfirmacion, validarPasswordNueva } from '../lib/password' +import { cambiarPassword } from '../services/auth' + +const CLASE_INPUT = + 'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none' +const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente + * con la misma regla del API y muestra el mensaje del API si la actual no es + * correcta. Al éxito la sesión sigue intacta (el JWT no cambia). + */ +export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) { + const [actual, setActual] = useState('') + const [nueva, setNueva] = useState('') + const [confirmar, setConfirmar] = useState('') + const [error, setError] = useState(null) + const [exito, setExito] = useState(false) + const [cargando, setCargando] = useState(false) + + async function enviar(e: FormEvent) { + e.preventDefault() + const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar) + if (errorCliente) { + setError(errorCliente) + return + } + setError(null) + setCargando(true) + try { + await cambiarPassword(actual, nueva) + setExito(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña') + } finally { + setCargando(false) + } + } + + return ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/cotizador/src/lib/password.test.ts b/apps/cotizador/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/cotizador/src/lib/password.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validarConfirmacion, validarPasswordNueva } from './password' + +describe('validarPasswordNueva', () => { + it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta contraseñas que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('validarConfirmacion', () => { + it('null si coinciden; mensaje si difieren', () => { + expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull() + expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide') + }) +}) diff --git a/apps/cotizador/src/lib/password.ts b/apps/cotizador/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/cotizador/src/lib/password.ts @@ -0,0 +1,16 @@ +// Validación cliente de la contraseña nueva: misma regla que el API +// (api-gateway/src/services/auth.service.ts). El API vuelve a validar; +// esto solo evita un viaje de red con errores obvios. + +/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** null si la confirmación coincide con la nueva; mensaje si difiere */ +export function validarConfirmacion(nueva: string, confirmar: string): string | null { + return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña' +} diff --git a/apps/cotizador/src/services/auth.test.ts b/apps/cotizador/src/services/auth.test.ts index 8884d13..e54961d 100644 --- a/apps/cotizador/src/services/auth.test.ts +++ b/apps/cotizador/src/services/auth.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ErrorLogin, + cambiarPassword, conAuth, getSesion, getToken, @@ -122,3 +123,37 @@ describe('manejar401', () => { expect(getSesion()).not.toBeNull() }) }) + +describe('cambiarPassword', () => { + it('204: resuelve sin tocar la sesión y manda Bearer + ambas contraseñas', async () => { + localStorage.setItem('frasa_auth', JSON.stringify(sesionOk)) + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + + await cambiarPassword('Frasa2026', 'NuevaPass2026') + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/auth/cambiar-password') + expect(init.method).toBe('POST') + expect(init.headers).toMatchObject({ Authorization: 'Bearer jwt-de-prueba' }) + expect(JSON.parse(init.body as string)).toEqual({ actual: 'Frasa2026', nueva: 'NuevaPass2026' }) + expect(getSesion()).not.toBeNull() + }) + + it('400 del API: lanza Error con el mensaje del API (p. ej. actual incorrecta)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('{"error":"La contraseña actual no es correcta"}', { status: 400 }), + ), + ) + await expect(cambiarPassword('mala', 'NuevaPass2026')).rejects.toThrow( + 'La contraseña actual no es correcta', + ) + }) + + it('sin red: Error de conexión', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))) + await expect(cambiarPassword('a', 'b12345678')).rejects.toThrow('No se pudo conectar') + }) +}) diff --git a/apps/cotizador/src/services/auth.ts b/apps/cotizador/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/cotizador/src/services/auth.ts +++ b/apps/cotizador/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() if (typeof window !== 'undefined') window.location.reload() } + +/** + * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando + * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. + */ +export async function cambiarPassword(actual: string, nueva: string): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/auth/cambiar-password`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actual, nueva }), + }), + ) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} diff --git a/apps/dashboards/index.html b/apps/dashboards/index.html index b6978a8..401b807 100644 --- a/apps/dashboards/index.html +++ b/apps/dashboards/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Tableros diff --git a/apps/dashboards/public/icons/icon-192.png b/apps/dashboards/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..271bffcdc99abc12d069f2f924522f492c6c9f43 GIT binary patch literal 2187 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 literal 0 HcmV?d00001 diff --git a/apps/dashboards/public/icons/icon-512.png b/apps/dashboards/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..367b5f3d41bbdabef88c3f5505ab7d4304aabffb GIT binary patch literal 6368 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 => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/dashboards/src/components/CambiarPasswordModal.tsx b/apps/dashboards/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/dashboards/src/components/CambiarPasswordModal.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' +import { validarConfirmacion, validarPasswordNueva } from '../lib/password' +import { cambiarPassword } from '../services/auth' + +const CLASE_INPUT = + 'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none' +const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente + * con la misma regla del API y muestra el mensaje del API si la actual no es + * correcta. Al éxito la sesión sigue intacta (el JWT no cambia). + */ +export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) { + const [actual, setActual] = useState('') + const [nueva, setNueva] = useState('') + const [confirmar, setConfirmar] = useState('') + const [error, setError] = useState(null) + const [exito, setExito] = useState(false) + const [cargando, setCargando] = useState(false) + + async function enviar(e: FormEvent) { + e.preventDefault() + const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar) + if (errorCliente) { + setError(errorCliente) + return + } + setError(null) + setCargando(true) + try { + await cambiarPassword(actual, nueva) + setExito(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña') + } finally { + setCargando(false) + } + } + + return ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/dashboards/src/components/MiTablero.tsx b/apps/dashboards/src/components/MiTablero.tsx new file mode 100644 index 0000000..3c2dd83 --- /dev/null +++ b/apps/dashboards/src/components/MiTablero.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from 'react' +import { Cell, Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from 'recharts' +import KpiCard from './KpiCard' +import { EstadoVacio, TituloSeccion } from './comunes' +import { funnelMisLeads, type ResumenMiDia } from '../lib/mi-tablero' +import { fetchResumenMiDia } from '../services/mi-dia' + +// Misma paleta del funnel del tablero general +const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332'] + +/** + * "Mi tablero": KPIs personales del vendedor logueado (mis leads, actividades, + * apartados por vencer) y un funnel simple de sus leads por estado. + * Fuente: GET /api/mi-dia/resumen (filtra por su mondayUserId en el API). + */ +export default function MiTablero() { + const [resumen, setResumen] = useState(null) + const [error, setError] = useState(null) + const [cargando, setCargando] = useState(true) + + const cargar = useCallback(async () => { + setCargando(true) + try { + setResumen(await fetchResumenMiDia()) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') + } finally { + setCargando(false) + } + }, []) + + useEffect(() => { + void cargar() + }, [cargar]) + + const funnel = resumen ? funnelMisLeads(resumen.misLeads) : [] + + return ( +
+
+
+

FRASA Desarrollos

+

Mi tablero

+ {resumen && ( +

+ Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} +

+ )} +
+ +
+ + {error && ( +

+ Sin datos del API — {error} +

+ )} + + {resumen?.sinMapeoMonday && ( +

+ Tu usuario aún no está ligado a Monday: las listas personales aparecen vacías +

+ )} + + {!resumen && !error && cargando && } + {!resumen && !error && !cargando && } + + {resumen && ( + <> +
+ + + +
+ +
+ Mis leads por estado + {funnel.length === 0 ? ( + + ) : ( +
+ + + + + {funnel.map((etapa, i) => ( + + ))} + + + + + +
+ )} +
+ + )} +
+ ) +} diff --git a/apps/dashboards/src/components/SeccionEquipo.tsx b/apps/dashboards/src/components/SeccionEquipo.tsx new file mode 100644 index 0000000..b796bff --- /dev/null +++ b/apps/dashboards/src/components/SeccionEquipo.tsx @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from 'react' +import { EstadoVacio, TituloSeccion } from './comunes' +import type { ResumenMiDia } from '../lib/mi-tablero' +import { fetchResumenMiDia } from '../services/mi-dia' + +const CLASE_CELDA = 'px-4 py-3 text-sm text-neutral-900' +const CLASE_ENCABEZADO = + 'px-4 py-2 text-left text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Sección "Equipo" (solo coordinador/corporativo/admin): tabla vendedor → + * leads asignados y nuevos esta semana, más los leads nuevos sin asignar. + * Fuente: `equipo` de GET /api/mi-dia/resumen (el API solo la incluye para + * esos roles). + */ +export default function SeccionEquipo() { + const [equipo, setEquipo] = useState(null) + const [error, setError] = useState(null) + + const cargar = useCallback(async () => { + try { + setEquipo((await fetchResumenMiDia()).equipo ?? null) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cargar el equipo') + } + }, []) + + useEffect(() => { + void cargar() + }, [cargar]) + + return ( +
+ Equipo: leads por vendedor + {error && ( +

+ Sin datos del API — {error} +

+ )} + {!error && !equipo && } + {!error && equipo && ( + <> +

+ Leads nuevos sin asignar:{' '} + {equipo.leadsNuevosSinAsignar} +

+ {equipo.porVendedor.length === 0 ? ( + + ) : ( + + + + + + + + + + {equipo.porVendedor.map((v) => ( + + + + + + ))} + +
VendedorLeads asignadosNuevos esta semana
{v.nombre ?? `Monday #${v.mondayUserId}`}{v.leadsAsignados}{v.leadsNuevosSemana}
+ )} + + )} +
+ ) +} diff --git a/apps/dashboards/src/components/TableroGeneral.tsx b/apps/dashboards/src/components/TableroGeneral.tsx new file mode 100644 index 0000000..6c7155b --- /dev/null +++ b/apps/dashboards/src/components/TableroGeneral.tsx @@ -0,0 +1,252 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Funnel, + FunnelChart, + LabelList, + Legend, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import KpiCard from './KpiCard' +import SeccionEquipo from './SeccionEquipo' +import { EstadoVacio, TituloSeccion } from './comunes' +import { + datosCobranza, + datosFunnel, + datosPipeline, + formatoMoneda, + formatoPorcentaje, + unidadesApiladas, + unidadesEnEtapa, + type ResumenReportes, +} from '../lib/reportes' +import { fetchResumen } from '../services/reportes' + +// Paleta StudioBlank: acento verde + neutros (sin gradientes ni sombras) +const COLORES_STATUS: Record = { + DISPONIBLE: '#1B4332', + APARTADO: '#b45309', + VENDIDO: '#525252', + BLOQUEADO: '#b91c1c', + OTROS: '#d4d4d4', +} +const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332'] +const COLORES_COBRANZA = ['#1B4332', '#b45309'] + +/** + * Vista "General": el tablero comercial de todo el ecosistema (KPIs, inventario, + * pipeline, funnel y cobranza). Con `mostrarEquipo` (coordinador/corporativo/ + * admin) agrega la sección Equipo con leads por vendedor. + */ +export default function TableroGeneral({ mostrarEquipo }: { mostrarEquipo: boolean }) { + const [resumen, setResumen] = useState(null) + const [error, setError] = useState(null) + const [cargando, setCargando] = useState(true) + + const cargar = useCallback(async () => { + setCargando(true) + try { + setResumen(await fetchResumen()) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') + } finally { + setCargando(false) + } + }, []) + + useEffect(() => { + void cargar() + }, [cargar]) + + const unidades = resumen ? unidadesApiladas(resumen) : [] + const funnel = resumen ? datosFunnel(resumen) : [] + const cobranza = resumen ? datosCobranza(resumen) : [] + const pipeline = resumen ? datosPipeline(resumen) : [] + const sinPipeline = pipeline.every((e) => e.valor === 0) + const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0 + + return ( +
+
+
+

FRASA Desarrollos

+

Tablero comercial

+ {resumen && ( +

+ Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} +

+ )} +
+ +
+ + {error && ( +

+ Sin datos del API — {error} +

+ )} + + {!resumen && !error && cargando && } + {!resumen && !error && !cargando && } + + {resumen && ( + <> +
+ + + + + + + + + +
+ +
+ Unidades por desarrollo y status + {unidades.length === 0 ? ( + + ) : ( +
+ + + + + + + + {Object.keys(COLORES_STATUS).map((status) => ( + + ))} + + +
+ )} +
+ +
+ Pipeline de ventas por etapa + {sinPipeline ? ( + + ) : ( +
+ + + + + + + + + +
+ )} +
+ +
+
+ Embudo: leads → oportunidades → ganado + {funnel.every((e) => e.valor === 0) ? ( + + ) : ( +
+ + + + + {funnel.map((etapa, i) => ( + + ))} + + + + + +
+ )} +
+ +
+ Cobranza: al corriente vs vencidos + {sinCobranza ? ( + + ) : ( +
+ + + `${name}: ${value}`} + fontSize={11} + > + {cobranza.map((rebanada, i) => ( + + ))} + + + + +
+ )} +
+
+ + {mostrarEquipo && } + + )} +
+ ) +} diff --git a/apps/dashboards/src/components/comunes.tsx b/apps/dashboards/src/components/comunes.tsx new file mode 100644 index 0000000..0590c3c --- /dev/null +++ b/apps/dashboards/src/components/comunes.tsx @@ -0,0 +1,13 @@ +// Piezas visuales compartidas por las vistas del tablero (StudioBlank) + +export function EstadoVacio({ mensaje }: { mensaje: string }) { + return ( +
+

{mensaje}

+
+ ) +} + +export function TituloSeccion({ children }: { children: string }) { + return

{children}

+} diff --git a/apps/dashboards/src/lib/mi-tablero.test.ts b/apps/dashboards/src/lib/mi-tablero.test.ts new file mode 100644 index 0000000..5639632 --- /dev/null +++ b/apps/dashboards/src/lib/mi-tablero.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { esRolConEquipo, funnelMisLeads, type LeadMiDia } from './mi-tablero' + +function lead(id: string, estado: string): LeadMiDia { + return { id, nombre: `Lead ${id}`, telefono: '', email: '', estado, origen: 'Web', updatedAt: '' } +} + +describe('esRolConEquipo', () => { + it('coordinador, corporativo y admin ven equipo; vendedor no', () => { + expect(esRolConEquipo('coordinador')).toBe(true) + expect(esRolConEquipo('corporativo')).toBe(true) + expect(esRolConEquipo('admin')).toBe(true) + expect(esRolConEquipo('vendedor')).toBe(false) + }) +}) + +describe('funnelMisLeads', () => { + it('agrupa por estado normalizado y ordena de mayor a menor', () => { + const funnel = funnelMisLeads([ + lead('1', 'NUEVO'), + lead('2', 'nuevo'), + lead('3', ' Contactado '), + lead('4', 'CITA'), + ]) + expect(funnel).toEqual([ + { etapa: 'NUEVO', valor: 2 }, + { etapa: 'CITA', valor: 1 }, + { etapa: 'CONTACTADO', valor: 1 }, + ]) + }) + + it('lista vacía → funnel vacío; estado vacío → SIN ESTADO', () => { + expect(funnelMisLeads([])).toEqual([]) + expect(funnelMisLeads([lead('1', ' ')])).toEqual([{ etapa: 'SIN ESTADO', valor: 1 }]) + }) +}) diff --git a/apps/dashboards/src/lib/mi-tablero.ts b/apps/dashboards/src/lib/mi-tablero.ts new file mode 100644 index 0000000..7fd5621 --- /dev/null +++ b/apps/dashboards/src/lib/mi-tablero.ts @@ -0,0 +1,81 @@ +// Tipos del endpoint GET /api/mi-dia/resumen (espejo del contrato del API) y +// transformaciones puras para la vista "Mi tablero" y la sección "Equipo". + +export interface LeadMiDia { + id: string + nombre: string + telefono: string + email: string + estado: string + origen: string + updatedAt: string +} + +export interface ActividadMiDia { + id: string + nombre: string + tipo: string + fecha: string // 'YYYY-MM-DD' + estado: string +} + +export interface ApartadoPorVencer { + id: string + nombre: string + unidadCodigo: string + fechaLimite: string // 'YYYY-MM-DD' + estado: string + diasRestantes: number +} + +export interface VendedorEquipo { + mondayUserId: number + nombre: string | null + leadsAsignados: number + leadsNuevosSemana: number +} + +export interface ResumenMiDia { + generadoEn: string + sinMapeoMonday: boolean + misLeads: LeadMiDia[] + misActividadesHoy: ActividadMiDia[] + apartadosPorVencer: ApartadoPorVencer[] + misOportunidades: { porEtapa: Record } | null + notaOportunidades: string | null + conteos: { + leadsNuevosSemana: number + actividadesAbiertas: number + } + equipo?: { + leadsNuevosSinAsignar: number + porVendedor: VendedorEquipo[] + } +} + +const ROLES_CON_EQUIPO = ['coordinador', 'corporativo', 'admin'] + +/** ¿El rol ve el tablero general por defecto y la sección de equipo? (misma regla que el API) */ +export function esRolConEquipo(rol: string): boolean { + return ROLES_CON_EQUIPO.includes(rol) +} + +export interface EtapaFunnelPersonal { + etapa: string + valor: number +} + +/** + * Funnel personal: conteo de mis leads por estado (etiqueta normalizada en + * mayúsculas), ordenado de mayor a menor para que la gráfica baje. + */ +export function funnelMisLeads(misLeads: LeadMiDia[]): EtapaFunnelPersonal[] { + const conteo = new Map() + for (const lead of misLeads) { + const estado = lead.estado.trim().toUpperCase() || 'SIN ESTADO' + conteo.set(estado, (conteo.get(estado) ?? 0) + 1) + } + return [...conteo.entries()] + .map(([etapa, valor]) => ({ etapa, valor })) + .sort((a, b) => b.valor - a.valor || a.etapa.localeCompare(b.etapa)) +} diff --git a/apps/dashboards/src/lib/password.test.ts b/apps/dashboards/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/dashboards/src/lib/password.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validarConfirmacion, validarPasswordNueva } from './password' + +describe('validarPasswordNueva', () => { + it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta contraseñas que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('validarConfirmacion', () => { + it('null si coinciden; mensaje si difieren', () => { + expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull() + expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide') + }) +}) diff --git a/apps/dashboards/src/lib/password.ts b/apps/dashboards/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/dashboards/src/lib/password.ts @@ -0,0 +1,16 @@ +// Validación cliente de la contraseña nueva: misma regla que el API +// (api-gateway/src/services/auth.service.ts). El API vuelve a validar; +// esto solo evita un viaje de red con errores obvios. + +/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** null si la confirmación coincide con la nueva; mensaje si difiere */ +export function validarConfirmacion(nueva: string, confirmar: string): string | null { + return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña' +} diff --git a/apps/dashboards/src/pages/DashboardPage.tsx b/apps/dashboards/src/pages/DashboardPage.tsx index 1ba3a37..68c148a 100644 --- a/apps/dashboards/src/pages/DashboardPage.tsx +++ b/apps/dashboards/src/pages/DashboardPage.tsx @@ -1,255 +1,49 @@ -import { useCallback, useEffect, useState } from 'react' -import { - Bar, - BarChart, - CartesianGrid, - Cell, - Funnel, - FunnelChart, - LabelList, - Legend, - Pie, - PieChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts' -import KpiCard from '../components/KpiCard' -import { - datosCobranza, - datosFunnel, - datosPipeline, - formatoMoneda, - formatoPorcentaje, - unidadesApiladas, - unidadesEnEtapa, - type ResumenReportes, -} from '../lib/reportes' -import { fetchResumen } from '../services/reportes' +import { useState } from 'react' +import MiTablero from '../components/MiTablero' +import TableroGeneral from '../components/TableroGeneral' +import { esRolConEquipo } from '../lib/mi-tablero' +import { getSesion } from '../services/auth' -// Paleta StudioBlank: acento verde + neutros (sin gradientes ni sombras) -const COLORES_STATUS: Record = { - DISPONIBLE: '#1B4332', - APARTADO: '#b45309', - VENDIDO: '#525252', - BLOQUEADO: '#b91c1c', - OTROS: '#d4d4d4', -} -const COLORES_FUNNEL = ['#a3a3a3', '#737373', '#52796f', '#1B4332'] -const COLORES_COBRANZA = ['#1B4332', '#b45309'] +type Vista = 'mia' | 'general' + +const VISTAS: { id: Vista; etiqueta: string }[] = [ + { id: 'mia', etiqueta: 'Mi tablero' }, + { id: 'general', etiqueta: 'General' }, +] + +/** + * Tableros con vista por rol: el vendedor entra a "Mi tablero" (sus KPIs y el + * funnel de sus leads); coordinador/corporativo/admin entran a "General" con + * la sección Equipo. El toggle permite cambiar de vista en cualquier momento. + */ +export default function DashboardPage() { + const rol = getSesion()?.usuario.rol ?? 'vendedor' + const conEquipo = esRolConEquipo(rol) + const [vista, setVista] = useState(conEquipo ? 'general' : 'mia') -function EstadoVacio({ mensaje }: { mensaje: string }) { return ( -
-

{mensaje}

+
+
+
+ {VISTAS.map((v) => ( + + ))} +
+
+ {vista === 'mia' ? : }
) } - -function TituloSeccion({ children }: { children: string }) { - return

{children}

-} - -export default function DashboardPage() { - const [resumen, setResumen] = useState(null) - const [error, setError] = useState(null) - const [cargando, setCargando] = useState(true) - - const cargar = useCallback(async () => { - setCargando(true) - try { - setResumen(await fetchResumen()) - setError(null) - } catch (err) { - setError(err instanceof Error ? err.message : 'No se pudo cargar el tablero') - } finally { - setCargando(false) - } - }, []) - - useEffect(() => { - void cargar() - }, [cargar]) - - const unidades = resumen ? unidadesApiladas(resumen) : [] - const funnel = resumen ? datosFunnel(resumen) : [] - const cobranza = resumen ? datosCobranza(resumen) : [] - const pipeline = resumen ? datosPipeline(resumen) : [] - const sinPipeline = pipeline.every((e) => e.valor === 0) - const sinCobranza = resumen !== null && resumen.cobranza.planesAlCorriente + resumen.cobranza.planesConVencidos === 0 - - return ( -
-
-
-

FRASA Desarrollos

-

Tablero comercial

- {resumen && ( -

- Actualizado: {new Date(resumen.generadoEn).toLocaleString('es-MX')} -

- )} -
- -
- - {error && ( -

- Sin datos del API — {error} -

- )} - - {!resumen && !error && cargando && } - {!resumen && !error && !cargando && } - - {resumen && ( - <> -
- - - - - - - - - -
- -
- Unidades por desarrollo y status - {unidades.length === 0 ? ( - - ) : ( -
- - - - - - - - {Object.keys(COLORES_STATUS).map((status) => ( - - ))} - - -
- )} -
- -
- Pipeline de ventas por etapa - {sinPipeline ? ( - - ) : ( -
- - - - - - - - - -
- )} -
- -
-
- Embudo: leads → oportunidades → ganado - {funnel.every((e) => e.valor === 0) ? ( - - ) : ( -
- - - - - {funnel.map((etapa, i) => ( - - ))} - - - - - -
- )} -
- -
- Cobranza: al corriente vs vencidos - {sinCobranza ? ( - - ) : ( -
- - - `${name}: ${value}`} - fontSize={11} - > - {cobranza.map((rebanada, i) => ( - - ))} - - - - -
- )} -
-
- - )} -
- ) -} diff --git a/apps/dashboards/src/services/auth.ts b/apps/dashboards/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/dashboards/src/services/auth.ts +++ b/apps/dashboards/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() if (typeof window !== 'undefined') window.location.reload() } + +/** + * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando + * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. + */ +export async function cambiarPassword(actual: string, nueva: string): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/auth/cambiar-password`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actual, nueva }), + }), + ) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} diff --git a/apps/dashboards/src/services/mi-dia.ts b/apps/dashboards/src/services/mi-dia.ts new file mode 100644 index 0000000..cd28f9a --- /dev/null +++ b/apps/dashboards/src/services/mi-dia.ts @@ -0,0 +1,18 @@ +// Cliente del endpoint "Mi día" del API Gateway (para Mi tablero y la sección Equipo) +import { conAuth, manejar401 } from './auth' +import type { ResumenMiDia } from '../lib/mi-tablero' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' + +/** GET /mi-dia/resumen; lanza Error con mensaje legible si el API falla */ +export async function fetchResumenMiDia(): Promise { + let res: Response + try { + res = await fetch(`${API_URL}/mi-dia/resumen`, conAuth()) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) throw new Error(`El API respondió ${res.status}`) + return (await res.json()) as ResumenMiDia +} diff --git a/apps/landing/index.html b/apps/landing/index.html index 5bb6aaa..ff1e395 100644 --- a/apps/landing/index.html +++ b/apps/landing/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Ecosistema comercial diff --git a/apps/landing/public/icons/icon-192.png b/apps/landing/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..4172d74222bc3fdd14303fb9a193471eaaff41a0 GIT binary patch literal 638 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfyvd=#WAE}&f7bNUe1mp4uQ(= zlvW!=cbI9ink%vB*hg?_#<<-IiJZJ3dcy{f)XCD4br$K2hqd+A&rB3M{q+^wxl-#Y zhG)JGDojd(0-X&WEG|k4LW~nRI$Y>1bf;JD!J&C_X7cOv7cc*D`T6(je?LEdp{aM0 zjgZ4)ZWs4GdRlu+o`3Q38IscYU4G}-{5RV!=UM-~{P6nswTJWxC}(kbWO=;vyy&?E tX6l8>q@zW9rEHmv)2L`BC2{tLVb5-{w9XS+JAvtn!PC{xWt~$(69A7pv2XwY literal 0 HcmV?d00001 diff --git a/apps/landing/public/icons/icon-512.png b/apps/landing/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7eb1009ed27f51b9a66634d2cb5c16e4cfc26400 GIT binary patch literal 1977 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_t&9PZ!6KiaBquZ}fFaWM~aE z%;#RCtD{rE!Xv7iJ;R`3p()P;rfDi+Eln14v?C_`*;n)2r(m~!`No*_?-*X}l(%JQ zFl1p+@M2(CG)j#I0XP+8N!d2stf;HmRa4g+cX0lm+CBCEW8Bt1*#CL3q>^P9nID+> zUH|;ALSiq|vm?w59YPEZ8jJ%*&2r;!xP2pc+wQYvU%C?I;^N)>6Y6XR)Jcc{S&{jL zVMp)fuY2s}+}1Z(ejF^3I26j8EMPh5md}vDz&~0>4u$xlcRlt>TqRay-Qfx0mw{C> NgQu&X%Q~loCIC;OXte+U literal 0 HcmV?d00001 diff --git a/apps/landing/public/manifest.webmanifest b/apps/landing/public/manifest.webmanifest new file mode 100644 index 0000000..a2fa0f6 --- /dev/null +++ b/apps/landing/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Frasa Desarrollos", + "short_name": "Frasa", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1B4332", + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/mi-dia/index.html b/apps/mi-dia/index.html index 4f735ed..c02b879 100644 --- a/apps/mi-dia/index.html +++ b/apps/mi-dia/index.html @@ -3,6 +3,9 @@ + + + FRASA Desarrollos — Mi día diff --git a/apps/mi-dia/public/icons/icon-192.png b/apps/mi-dia/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..1b55ac0b943c1c343bd3c59fe12b5d03537a6177 GIT binary patch literal 643 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfyv9$#WAE}&fB|&UWXYZ+8&1N zam~M|edXjv_7_Sov>Ka|4+g$?U>GxZPt)XP_D_73-pOAoza22&^C#FN?DgBUhL!7{ z$sO3l+2P`#!lWc9(AnU@;-aJ=#5j?IzCx2;?_~GcvU_K3ZPmPQZ+;Zj{I0NnKQB?@ z*x$oD_Rs(J=H174pMSqzQ>=G{m11XZy3(&Lvi$JJ5{q+tAN3bB67ur@kEMO_Ki2m= wEh4Ct_%J+~wfF9sy^XBJR5X*4*sEi>S1-Q0FF^McFmW+>y85}Sb4q9e0PiZnCIA2c literal 0 HcmV?d00001 diff --git a/apps/mi-dia/public/icons/icon-512.png b/apps/mi-dia/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d44ad1901f7d3154d64306362bfc1d4f77b3a5d0 GIT binary patch literal 1982 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&t&wwUqN(1_t(4PZ!6KiaBquEBY}dFtjBW z-f7A+kl4{A-PzT9sw1K7aL--t8z!&rJSh>=nrgr2d~wtIy}#8qAOFeAV7q-WJA*+R z1A_}EgTs_jYBUJIsleBr|3Q`9yLa{X?cLVzc=7tnW_vjxqwVeGU#Xj=_dZ(meEaqD z``=&G-oeJtpy0*8u!v=p0tP{rRTaaWwPL5QCRtXowp1`M1TrxQs17)F`6Ba!H|UYb zWW&&)#mLYh#L%EIN)ZZzS!d*)FE(c|XkZ@<5jrSR)uc9KPE2>2iD6B Mp00i_>zopr05(WOH~;_u literal 0 HcmV?d00001 diff --git a/apps/mi-dia/public/manifest.webmanifest b/apps/mi-dia/public/manifest.webmanifest new file mode 100644 index 0000000..fabe7c2 --- /dev/null +++ b/apps/mi-dia/public/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "Frasa · Mi día", + "short_name": "Mi día", + "start_url": "/hoy/", + "scope": "/hoy/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#1B4332", + "icons": [ + { "src": "/hoy/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/hoy/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/apps/mi-dia/src/components/AuthGate.tsx b/apps/mi-dia/src/components/AuthGate.tsx index cbe78f9..baba256 100644 --- a/apps/mi-dia/src/components/AuthGate.tsx +++ b/apps/mi-dia/src/components/AuthGate.tsx @@ -1,13 +1,16 @@ import { useState, type ReactNode } from 'react' import { getSesion, logout, type Sesion } from '../services/auth' +import CambiarPasswordModal from './CambiarPasswordModal' 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". + * app con una barra discreta "nombre · rol", "Contraseña" (modal de cambio) + * y botón "Salir". */ export default function AuthGate({ children }: { children: ReactNode }) { const [sesion, setSesion] = useState(() => getSesion()) + const [modalPassword, setModalPassword] = useState(false) if (!sesion) { return setSesion(getSesion())} /> @@ -20,19 +23,29 @@ export default function AuthGate({ children }: { children: ReactNode }) { {sesion.usuario.nombre} · {sesion.usuario.rol} - +
+ + +
{children} + {modalPassword && setModalPassword(false)} />} ) } diff --git a/apps/mi-dia/src/components/CambiarPasswordModal.tsx b/apps/mi-dia/src/components/CambiarPasswordModal.tsx new file mode 100644 index 0000000..1be9e05 --- /dev/null +++ b/apps/mi-dia/src/components/CambiarPasswordModal.tsx @@ -0,0 +1,140 @@ +import { useState, type FormEvent } from 'react' +import { validarConfirmacion, validarPasswordNueva } from '../lib/password' +import { cambiarPassword } from '../services/auth' + +const CLASE_INPUT = + 'w-full border border-neutral-300 px-4 py-3 text-sm focus:border-accent focus:outline-none' +const CLASE_LABEL = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500' + +/** + * Modal "Cambiar contraseña": actual + nueva + confirmar. Valida en cliente + * con la misma regla del API y muestra el mensaje del API si la actual no es + * correcta. Al éxito la sesión sigue intacta (el JWT no cambia). + */ +export default function CambiarPasswordModal({ onCerrar }: { onCerrar: () => void }) { + const [actual, setActual] = useState('') + const [nueva, setNueva] = useState('') + const [confirmar, setConfirmar] = useState('') + const [error, setError] = useState(null) + const [exito, setExito] = useState(false) + const [cargando, setCargando] = useState(false) + + async function enviar(e: FormEvent) { + e.preventDefault() + const errorCliente = validarPasswordNueva(nueva) ?? validarConfirmacion(nueva, confirmar) + if (errorCliente) { + setError(errorCliente) + return + } + setError(null) + setCargando(true) + try { + await cambiarPassword(actual, nueva) + setExito(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo cambiar la contraseña') + } finally { + setCargando(false) + } + } + + return ( +
+
+

+ Cambiar contraseña +

+ {exito ? ( + <> +

+ Contraseña actualizada +

+ + + ) : ( +
+
+ + setActual(e.target.value)} + autoComplete="current-password" + autoFocus + required + className={CLASE_INPUT} + /> +
+
+ + setNueva(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +

Mínimo 8 caracteres y un número

+
+
+ + setConfirmar(e.target.value)} + autoComplete="new-password" + required + className={CLASE_INPUT} + /> +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/mi-dia/src/lib/mi-dia.ts b/apps/mi-dia/src/lib/mi-dia.ts index 7419ced..08e842b 100644 --- a/apps/mi-dia/src/lib/mi-dia.ts +++ b/apps/mi-dia/src/lib/mi-dia.ts @@ -41,7 +41,12 @@ export interface ResumenMiDia { } equipo?: { leadsNuevosSinAsignar: number - porVendedor: { mondayUserId: number; nombre: string | null; leadsAsignados: number }[] + porVendedor: { + mondayUserId: number + nombre: string | null + leadsAsignados: number + leadsNuevosSemana: number + }[] } } diff --git a/apps/mi-dia/src/lib/password.test.ts b/apps/mi-dia/src/lib/password.test.ts new file mode 100644 index 0000000..e5d540f --- /dev/null +++ b/apps/mi-dia/src/lib/password.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { validarConfirmacion, validarPasswordNueva } from './password' + +describe('validarPasswordNueva', () => { + it('exige ≥8 caracteres y al menos un número (misma regla que el API)', () => { + expect(validarPasswordNueva('corta1')).toContain('8 caracteres') + expect(validarPasswordNueva('sinnumeros')).toContain('número') + expect(validarPasswordNueva('')).toBeTruthy() + }) + + it('acepta contraseñas que cumplen la regla', () => { + expect(validarPasswordNueva('Frasa2026')).toBeNull() + expect(validarPasswordNueva('12345678')).toBeNull() + }) +}) + +describe('validarConfirmacion', () => { + it('null si coinciden; mensaje si difieren', () => { + expect(validarConfirmacion('abc12345', 'abc12345')).toBeNull() + expect(validarConfirmacion('abc12345', 'abc12346')).toContain('no coincide') + }) +}) diff --git a/apps/mi-dia/src/lib/password.ts b/apps/mi-dia/src/lib/password.ts new file mode 100644 index 0000000..a045f28 --- /dev/null +++ b/apps/mi-dia/src/lib/password.ts @@ -0,0 +1,16 @@ +// Validación cliente de la contraseña nueva: misma regla que el API +// (api-gateway/src/services/auth.service.ts). El API vuelve a validar; +// esto solo evita un viaje de red con errores obvios. + +/** null si cumple; mensaje si no (≥8 caracteres y al menos 1 número) */ +export function validarPasswordNueva(nueva: string): string | null { + if (nueva.length < 8 || !/\d/.test(nueva)) { + return 'La nueva contraseña debe tener al menos 8 caracteres y un número' + } + return null +} + +/** null si la confirmación coincide con la nueva; mensaje si difiere */ +export function validarConfirmacion(nueva: string, confirmar: string): string | null { + return nueva === confirmar ? null : 'La confirmación no coincide con la nueva contraseña' +} diff --git a/apps/mi-dia/src/services/auth.ts b/apps/mi-dia/src/services/auth.ts index a6919e1..70cd037 100644 --- a/apps/mi-dia/src/services/auth.ts +++ b/apps/mi-dia/src/services/auth.ts @@ -75,3 +75,28 @@ export function manejar401(res: Response): void { logout() if (typeof window !== 'undefined') window.location.reload() } + +/** + * POST /auth/cambiar-password. Lanza Error con el mensaje del API cuando + * falla (p. ej. "La contraseña actual no es correcta"); no toca la sesión. + */ +export async function cambiarPassword(actual: string, nueva: string): Promise { + let res: Response + try { + res = await fetch( + `${API_URL}/auth/cambiar-password`, + conAuth({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ actual, nueva }), + }), + ) + } catch { + throw new Error('No se pudo conectar con el servidor') + } + manejar401(res) + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null + throw new Error(body?.error ?? `Error ${res.status}`) + } +} diff --git a/scripts/generar-iconos-pwa.py b/scripts/generar-iconos-pwa.py new file mode 100644 index 0000000..32c8980 --- /dev/null +++ b/scripts/generar-iconos-pwa.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Genera los íconos PWA de las 7 apps del ecosistema FRASA. + +Ícono: cuadrado #1B4332 (verde marca) con la inicial del módulo en blanco, +en 192×192 y 512×512 PNG, en apps//public/icons/. + +Uso: /root/.venv-tools/bin/python scripts/generar-iconos-pwa.py +""" +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +VERDE = (0x1B, 0x43, 0x32) # #1B4332, --color-accent StudioBlank +BLANCO = (0xFF, 0xFF, 0xFF) +TAMANOS = (192, 512) +FUENTE = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + +# app → letra del ícono +APPS = { + "landing": "F", + "cotizador": "C", + "alta-clientes": "A", + "cobranza": "$", + "agente-ia": "I", + "dashboards": "D", + "mi-dia": "H", +} + +RAIZ = Path(__file__).resolve().parent.parent + + +def generar(letra: str, tamano: int, destino: Path) -> None: + imagen = Image.new("RGB", (tamano, tamano), VERDE) + dibujo = ImageDraw.Draw(imagen) + fuente = ImageFont.truetype(FUENTE, size=int(tamano * 0.55)) + caja = dibujo.textbbox((0, 0), letra, font=fuente) + ancho, alto = caja[2] - caja[0], caja[3] - caja[1] + x = (tamano - ancho) / 2 - caja[0] + y = (tamano - alto) / 2 - caja[1] + dibujo.text((x, y), letra, font=fuente, fill=BLANCO) + imagen.save(destino, "PNG") + + +def main() -> None: + for app, letra in APPS.items(): + directorio = RAIZ / "apps" / app / "public" / "icons" + directorio.mkdir(parents=True, exist_ok=True) + for tamano in TAMANOS: + destino = directorio / f"icon-{tamano}.png" + generar(letra, tamano, destino) + print(f"{app}: {destino.relative_to(RAIZ)}") + + +if __name__ == "__main__": + main()