feat(config): alta de usuarios desde Configuraciones (POST /config/usuarios, solo admin)

This commit is contained in:
2026-09-14 20:21:15 +00:00
parent 31379806be
commit b99707689f
5 changed files with 240 additions and 1 deletions

View File

@@ -7,6 +7,7 @@
import type { NextFunction, Request, Response } from 'express' import type { NextFunction, Request, Response } from 'express'
import { z } from 'zod' import { z } from 'zod'
import * as configService from '../services/config.service.js' import * as configService from '../services/config.service.js'
import { esRolValido } from '../config/roles.js'
import { AppError, BadRequestError } from '../utils/errors.js' import { AppError, BadRequestError } from '../utils/errors.js'
// ── Middleware admin ──────────────────────────────────────────────────── // ── Middleware admin ────────────────────────────────────────────────────
@@ -95,6 +96,27 @@ export async function actualizarUsuarioEndpoint(req: Request, res: Response): Pr
res.status(204).end() 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<void> {
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 ─────────────────────────────────────── // ── POST /api/config/reset-roles ───────────────────────────────────────
export async function resetRolesEndpoint(_req: Request, res: Response): Promise<void> { export async function resetRolesEndpoint(_req: Request, res: Response): Promise<void> {

View File

@@ -95,6 +95,7 @@ import {
actualizarRolConfigEndpoint, actualizarRolConfigEndpoint,
obtenerUsuariosEndpoint, obtenerUsuariosEndpoint,
actualizarUsuarioEndpoint, actualizarUsuarioEndpoint,
crearUsuarioEndpoint,
resetRolesEndpoint, resetRolesEndpoint,
obtenerAccionesEndpoint, obtenerAccionesEndpoint,
} from '../controllers/config.controller.js' } from '../controllers/config.controller.js'
@@ -276,6 +277,7 @@ apiRouter.get('/config/roles', requireAdmin, obtenerRolesConfigEndpoint)
apiRouter.patch('/config/roles/:rol', requireAdmin, actualizarRolConfigEndpoint) apiRouter.patch('/config/roles/:rol', requireAdmin, actualizarRolConfigEndpoint)
apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint) apiRouter.get('/config/usuarios', requireAdmin, obtenerUsuariosEndpoint)
apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint) apiRouter.patch('/config/usuarios/:id', requireAdmin, actualizarUsuarioEndpoint)
apiRouter.post('/config/usuarios', requireAdmin, crearUsuarioEndpoint)
apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint) apiRouter.post('/config/reset-roles', requireAdmin, resetRolesEndpoint)
apiRouter.get('/config/acciones', obtenerAccionesEndpoint) apiRouter.get('/config/acciones', obtenerAccionesEndpoint)

View File

@@ -7,6 +7,10 @@
*/ */
import { prisma } from './prisma.js' 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 { accionesPorRol, type AccionClave } from '../config/acciones.js'
import { MATRIZ } from '../config/roles.js' import { MATRIZ } from '../config/roles.js'
import type { Modulo } 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 if (data.activo !== undefined) clean.activo = data.activo
await prisma.usuario.update({ where: { id }, data: clean as never }) 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<UsuarioListado> {
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
}
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState, type FormEvent } from 'react'
import { import {
crearUsuarioConfig,
getUsuariosConfig, getUsuariosConfig,
patchUsuarioConfig, patchUsuarioConfig,
type UsuarioConfig, type UsuarioConfig,
@@ -49,6 +50,16 @@ export function Usuarios() {
// feedback por usuario: { [usuarioId]: 'ok' | 'error' | null } // feedback por usuario: { [usuarioId]: 'ok' | 'error' | null }
const [feedback, setFeedback] = useState<Record<string, string>>({}) const [feedback, setFeedback] = useState<Record<string, string>>({})
// ─── Alta de usuario ─────────────────────────────────────────────
const [formAbierto, setFormAbierto] = useState(false)
const [nombreNuevo, setNombreNuevo] = useState('')
const [rolNuevo, setRolNuevo] = useState<string>('vendedor')
const [asignacionNueva, setAsignacionNueva] = useState<string>('')
const [passwordNuevo, setPasswordNuevo] = useState('')
const [guardandoAlta, setGuardandoAlta] = useState(false)
const [errorAlta, setErrorAlta] = useState('')
const [exitoAlta, setExitoAlta] = useState('')
const cargar = useCallback(async () => { const cargar = useCallback(async () => {
setCargando(true) setCargando(true)
setError('') 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) { if (cargando) {
return ( return (
<div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-6 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
@@ -132,6 +176,109 @@ export function Usuarios() {
<p role="alert" className="text-sm text-red-600">{error}</p> <p role="alert" className="text-sm text-red-600">{error}</p>
)} )}
{/* Alta de usuario */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Dar de alta usuario
</h2>
<button
type="button"
onClick={() => {
setFormAbierto((v) => !v)
setErrorAlta('')
setExitoAlta('')
}}
className="rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
>
{formAbierto ? 'Cerrar' : '+ Nuevo usuario'}
</button>
</div>
{formAbierto && (
<form onSubmit={enviarAlta} className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="alta-nombre" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Nombre completo *
</label>
<input
id="alta-nombre"
type="text"
value={nombreNuevo}
onChange={(e) => 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]"
/>
<p className="mt-1 text-[11px] text-[#64748B] dark:text-[#94A3B8]">
El usuario de acceso se genera del nombre (minúsculas, sin acentos).
</p>
</div>
<div>
<label htmlFor="alta-password" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Contraseña *
</label>
<input
id="alta-password"
type="password"
value={passwordNuevo}
onChange={(e) => 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]"
/>
</div>
<div>
<label htmlFor="alta-rol" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Rol *
</label>
<select
id="alta-rol"
value={rolNuevo}
onChange={(e) => setRolNuevo(e.target.value)}
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]"
>
{ROLES.map((r) => (
<option key={r} value={r}>{ROL_LABELS[r]}</option>
))}
</select>
</div>
<div>
<label htmlFor="alta-asignacion" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Asignación
</label>
<select
id="alta-asignacion"
value={asignacionNueva}
onChange={(e) => setAsignacionNueva(e.target.value)}
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]"
>
{ASIGNACIONES.map((a) => (
<option key={a} value={a}>{ASIGNACION_LABELS[a]}</option>
))}
</select>
</div>
<div className="sm:col-span-2 flex items-center gap-3">
<button
type="submit"
disabled={guardandoAlta}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50"
>
{guardandoAlta ? 'Creando…' : 'Crear usuario'}
</button>
{exitoAlta && (
<p role="status" className="text-sm text-green-600">{exitoAlta}</p>
)}
{errorAlta && (
<p role="alert" className="text-sm text-red-600">{errorAlta}</p>
)}
</div>
</form>
)}
</div>
{/* Búsqueda */} {/* Búsqueda */}
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"> <div className="rounded-xl border border-[#E5E7EB] bg-white p-4 shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<label htmlFor="buscar-usuario" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]"> <label htmlFor="buscar-usuario" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">

View File

@@ -108,6 +108,29 @@ export async function patchUsuarioConfig(
if (!resp.ok) throw new Error(`HTTP ${resp.status}`) if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
} }
/** POST /config/usuarios — dar de alta un usuario (solo admin) */
export async function crearUsuarioConfig(data: {
nombre: string
rol: string
asignacion: string | null
password: string
}): Promise<UsuarioConfig> {
const resp = await fetch(
`${API_URL}/config/usuarios`,
conAuth({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}),
)
manejar401(resp)
if (!resp.ok) {
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
throw new Error(cuerpo?.error ?? `HTTP ${resp.status}`)
}
return (await resp.json()) as UsuarioConfig
}
// ─── Auditoría ───────────────────────────────────────────────── // ─── Auditoría ─────────────────────────────────────────────────
export interface AuditoriaItem { export interface AuditoriaItem {