feat(config): alta de usuarios desde Configuraciones (POST /config/usuarios, solo admin)
This commit is contained in:
@@ -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> {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user