- Tablas ConfiguracionSistema (7 configs) + RolConfiguracion (6 roles) - Endpoints: config/sistema GET/PATCH, config/roles GET/PATCH, config/usuarios GET/PATCH, config/reset-roles POST, config/acciones GET - modulosDeRol() async con fallback a MATRIZ estatica - auth/me devuelve acciones[] segun rol del usuario - saludMonday.estaDisponible() lee modo_local_forzado de DB (cache 30s) - Landing: vista Configuraciones con 4 tabs (Modo, Roles, Usuarios, Sistema) - Mi-dia: boton Descartar se deshabilita si !descartar_lead - 630 API tests + 24 landing tests + 20 mi-dia tests verdes Seed: npm run seed:config
145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
import bcrypt from 'bcryptjs'
|
||
import jwt from 'jsonwebtoken'
|
||
import type { Usuario } from '@prisma/client'
|
||
import { prisma } from './prisma.js'
|
||
import { config } from '../config/index.js'
|
||
import { modulosDeRol, type Modulo } from '../config/roles.js'
|
||
import { AppError } from '../utils/errors.js'
|
||
import * as configService from './config.service.js'
|
||
|
||
export interface UsuarioPublico {
|
||
nombre: string
|
||
rol: string
|
||
asignacion: string | null
|
||
// User ID de Monday vinculado (null = sin vincular; ver PATCH /usuarios/me/monday)
|
||
mondayUserId: number | null
|
||
// Módulos del ecosistema visibles para el rol (matriz de config/roles.ts)
|
||
modulos: Modulo[]
|
||
// Acciones granulares del rol (catálogo de config/acciones.ts)
|
||
acciones: string[]
|
||
}
|
||
|
||
export interface LoginResult {
|
||
token: string
|
||
usuario: UsuarioPublico
|
||
}
|
||
|
||
/**
|
||
* Normaliza un username: minúsculas, sin acentos, espacios simples.
|
||
* El login es case-insensitive y tolerante a espacios extra.
|
||
*/
|
||
export function normalizarUsername(nombre: string): string {
|
||
return nombre
|
||
.normalize('NFD')
|
||
.replace(/[̀-ͯ]/g, '') // diacríticos U+0300–U+036F
|
||
.toLowerCase()
|
||
.trim()
|
||
.replace(/\s+/g, ' ')
|
||
}
|
||
|
||
export async function aPublico(usuario: Usuario): Promise<UsuarioPublico> {
|
||
let acciones: string[] = []
|
||
try {
|
||
const acc = await configService.obtenerAcciones(usuario.rol)
|
||
acciones = Object.entries(acc)
|
||
.filter(([, v]) => v)
|
||
.map(([k]) => k)
|
||
} catch {
|
||
// Si falla la lectura de config, devuelve acciones vacías (no rompe)
|
||
}
|
||
return {
|
||
nombre: usuario.nombre,
|
||
rol: usuario.rol,
|
||
asignacion: usuario.asignacion,
|
||
mondayUserId: usuario.mondayUserId,
|
||
modulos: await modulosDeRol(usuario.rol),
|
||
acciones,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verifica credenciales y emite un JWT. Errores genéricos ("Credenciales
|
||
* inválidas") para no filtrar si el usuario existe o está inactivo.
|
||
*/
|
||
export async function login(username: string, password: string): Promise<LoginResult> {
|
||
const invalidas = () => new AppError(401, 'Credenciales inválidas')
|
||
|
||
const usuario = await prisma.usuario.findUnique({
|
||
where: { username: normalizarUsername(username) },
|
||
})
|
||
if (!usuario || !usuario.activo) throw invalidas()
|
||
|
||
const ok = await bcrypt.compare(password, usuario.passwordHash)
|
||
if (!ok) throw invalidas()
|
||
|
||
const token = jwt.sign(
|
||
{ sub: usuario.id, username: usuario.username, rol: usuario.rol },
|
||
config.auth.jwtSecret,
|
||
{ expiresIn: config.auth.jwtExpiresIn } as jwt.SignOptions,
|
||
)
|
||
return { token, usuario: await aPublico(usuario) }
|
||
}
|
||
|
||
/** Payload del JWT emitido por login() */
|
||
export interface TokenPayload {
|
||
sub: string
|
||
username: string
|
||
rol: string
|
||
}
|
||
|
||
/** Verifica un Bearer token y devuelve su payload; lanza 401 si no es válido */
|
||
export function verificarToken(token: string): TokenPayload {
|
||
try {
|
||
const payload = jwt.verify(token, config.auth.jwtSecret) as Partial<TokenPayload>
|
||
// Los tokens con otro scope (p. ej. la liga de firma remota) no son sesión
|
||
if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('sin sub')
|
||
return payload as TokenPayload
|
||
} catch {
|
||
throw new AppError(401, 'Token inválido o expirado')
|
||
}
|
||
}
|
||
|
||
/** Devuelve el usuario público de un token válido (GET /auth/me) */
|
||
export async function me(payload: TokenPayload): Promise<UsuarioPublico> {
|
||
const usuario = await prisma.usuario.findUnique({ where: { id: payload.sub } })
|
||
if (!usuario || !usuario.activo) throw new AppError(401, 'Credenciales inválidas')
|
||
return aPublico(usuario)
|
||
}
|
||
|
||
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<void> {
|
||
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 } })
|
||
}
|