From b99707689f9c67bf7078c7be7c52f2db807e5e47 Mon Sep 17 00:00:00 2001 From: Consult AS Technology Date: Mon, 14 Sep 2026 20:21:15 +0000 Subject: [PATCH] feat(config): alta de usuarios desde Configuraciones (POST /config/usuarios, solo admin) --- .../src/controllers/config.controller.ts | 22 +++ api-gateway/src/routes/index.ts | 2 + api-gateway/src/services/config.service.ts | 45 ++++++ .../src/components/config/Usuarios.tsx | 149 +++++++++++++++++- apps/landing/src/services/config.ts | 23 +++ 5 files changed, 240 insertions(+), 1 deletion(-) diff --git a/api-gateway/src/controllers/config.controller.ts b/api-gateway/src/controllers/config.controller.ts index a848751..a1f40fb 100644 --- a/api-gateway/src/controllers/config.controller.ts +++ b/api-gateway/src/controllers/config.controller.ts @@ -7,6 +7,7 @@ import type { NextFunction, Request, Response } from 'express' import { z } from 'zod' import * as configService from '../services/config.service.js' +import { esRolValido } from '../config/roles.js' import { AppError, BadRequestError } from '../utils/errors.js' // ── Middleware admin ──────────────────────────────────────────────────── @@ -95,6 +96,27 @@ export async function actualizarUsuarioEndpoint(req: Request, res: Response): Pr res.status(204).end() } +// ── POST /api/config/usuarios — dar de alta un usuario (solo admin) ──── + +const crearUsuarioSchema = z.object({ + nombre: z.string().trim().min(3, 'Nombre demasiado corto').max(80), + rol: z.string().min(1), + asignacion: z.string().nullable().optional(), + password: z.string().min(8, 'La contraseña necesita al menos 8 caracteres').max(72), +}) + +export async function crearUsuarioEndpoint(req: Request, res: Response): Promise { + const body = crearUsuarioSchema.parse(req.body) + if (!esRolValido(body.rol)) throw new BadRequestError(`Rol inválido: ${body.rol}`) + const usuario = await configService.crearUsuario({ + nombre: body.nombre, + rol: body.rol, + asignacion: body.asignacion ?? null, + password: body.password, + }) + res.status(201).json(usuario) +} + // ── POST /api/config/reset-roles ─────────────────────────────────────── export async function resetRolesEndpoint(_req: Request, res: Response): Promise { diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts index 5c76dac..6907889 100644 --- a/api-gateway/src/routes/index.ts +++ b/api-gateway/src/routes/index.ts @@ -95,6 +95,7 @@ import { actualizarRolConfigEndpoint, obtenerUsuariosEndpoint, actualizarUsuarioEndpoint, + crearUsuarioEndpoint, resetRolesEndpoint, obtenerAccionesEndpoint, } from '../controllers/config.controller.js' @@ -276,6 +277,7 @@ apiRouter.get('/config/roles', requireAdmin, obtenerRolesConfigEndpoint) apiRouter.patch('/config/roles/:rol', requireAdmin, actualizarRolConfigEndpoint) apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint) apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint) +apiRouter.post('/config/usuarios', requireAdmin, crearUsuarioEndpoint) apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint) apiRouter.get('/config/acciones', obtenerAccionesEndpoint) diff --git a/api-gateway/src/services/config.service.ts b/api-gateway/src/services/config.service.ts index ce31696..3bd4ad8 100644 --- a/api-gateway/src/services/config.service.ts +++ b/api-gateway/src/services/config.service.ts @@ -7,6 +7,10 @@ */ import { prisma } from './prisma.js' +import bcrypt from 'bcryptjs' +import { Prisma } from '@prisma/client' +import { normalizarUsername } from './auth.service.js' +import { BadRequestError } from '../utils/errors.js' import { accionesPorRol, type AccionClave } from '../config/acciones.js' import { MATRIZ } from '../config/roles.js' import type { Modulo } from '../config/roles.js' @@ -192,3 +196,44 @@ export async function actualizarUsuario( if (data.activo !== undefined) clean.activo = data.activo await prisma.usuario.update({ where: { id }, data: clean as never }) } + +/** Crea un usuario (solo admin): username derivado del nombre, bcrypt cost 12 */ +export async function crearUsuario(datos: { + nombre: string + rol: string + asignacion: string | null + password: string +}): Promise { + const username = normalizarUsername(datos.nombre) + const passwordHash = bcrypt.hashSync(datos.password, 12) + try { + const u = await prisma.usuario.create({ + data: { + username, + nombre: datos.nombre.trim().replace(/\s+/g, ' '), + passwordHash, + rol: datos.rol, + asignacion: datos.asignacion, + activo: true, + }, + select: { + id: true, + username: true, + nombre: true, + rol: true, + asignacion: true, + activo: true, + mondayUserId: true, + }, + }) + return u + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + throw new BadRequestError(`El usuario "${username}" ya existe`) + } + throw err + } +} diff --git a/apps/landing/src/components/config/Usuarios.tsx b/apps/landing/src/components/config/Usuarios.tsx index 0e94455..2d5192e 100644 --- a/apps/landing/src/components/config/Usuarios.tsx +++ b/apps/landing/src/components/config/Usuarios.tsx @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useState, type FormEvent } from 'react' import { + crearUsuarioConfig, getUsuariosConfig, patchUsuarioConfig, type UsuarioConfig, @@ -49,6 +50,16 @@ export function Usuarios() { // feedback por usuario: { [usuarioId]: 'ok' | 'error' | null } const [feedback, setFeedback] = useState>({}) + // ─── Alta de usuario ───────────────────────────────────────────── + const [formAbierto, setFormAbierto] = useState(false) + const [nombreNuevo, setNombreNuevo] = useState('') + const [rolNuevo, setRolNuevo] = useState('vendedor') + const [asignacionNueva, setAsignacionNueva] = useState('') + const [passwordNuevo, setPasswordNuevo] = useState('') + const [guardandoAlta, setGuardandoAlta] = useState(false) + const [errorAlta, setErrorAlta] = useState('') + const [exitoAlta, setExitoAlta] = useState('') + const cargar = useCallback(async () => { setCargando(true) setError('') @@ -118,6 +129,39 @@ export function Usuarios() { ) }) + const enviarAlta = async (e: FormEvent) => { + e.preventDefault() + setErrorAlta('') + setExitoAlta('') + if (nombreNuevo.trim().length < 3) { + setErrorAlta('Escribe el nombre completo del usuario.') + return + } + if (passwordNuevo.length < 8) { + setErrorAlta('La contraseña necesita al menos 8 caracteres.') + return + } + setGuardandoAlta(true) + try { + const creado = await crearUsuarioConfig({ + nombre: nombreNuevo.trim(), + rol: rolNuevo, + asignacion: asignacionNueva || null, + password: passwordNuevo, + }) + setExitoAlta(`Usuario "${creado.username}" creado con rol ${ROL_LABELS[creado.rol] ?? creado.rol}.`) + setNombreNuevo('') + setRolNuevo('vendedor') + setAsignacionNueva('') + setPasswordNuevo('') + await cargar() + } catch (err) { + setErrorAlta(err instanceof Error && err.message ? err.message : 'No se pudo crear el usuario.') + } finally { + setGuardandoAlta(false) + } + } + if (cargando) { return (
@@ -132,6 +176,109 @@ export function Usuarios() {

{error}

)} + {/* Alta de usuario */} +
+
+

+ Dar de alta usuario +

+ +
+ + {formAbierto && ( +
+
+ + setNombreNuevo(e.target.value)} + placeholder="P. ej. María López García" + className="mt-1.5 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]" + /> +

+ El usuario de acceso se genera del nombre (minúsculas, sin acentos). +

+
+ +
+ + setPasswordNuevo(e.target.value)} + placeholder="Mínimo 8 caracteres" + className="mt-1.5 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + {exitoAlta && ( +

{exitoAlta}

+ )} + {errorAlta && ( +

{errorAlta}

+ )} +
+
+ )} +
+ {/* Búsqueda */}