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 { 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 { 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 // 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 { 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 { 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 } }) }