Initial commit - Horux Despachos NL
This commit is contained in:
653
apps/api/src/services/auth.service.ts
Normal file
653
apps/api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,653 @@
|
||||
import { prisma, tenantDb } from '../config/database.js';
|
||||
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||
import { generateAccessToken, generateRefreshToken, verifyToken } from '../auth/tokens.js';
|
||||
import { AppError } from '../middlewares/error.middleware.js';
|
||||
import { auditLog } from '../utils/audit.js';
|
||||
import { getPlatformRoles } from '../utils/platform-admin.js';
|
||||
import { getUserTenants, verifyMembership } from '../utils/memberships.js';
|
||||
import { emailService } from './email/email.service.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { invalidateTokenVersionCache } from '../middlewares/auth.middleware.js';
|
||||
import type { LoginRequest, RegisterRequest, LoginResponse, Role } from '@horux/shared';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export async function register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: data.usuario.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new AppError(400, 'El email ya está registrado');
|
||||
}
|
||||
|
||||
const existingTenant = await prisma.tenant.findUnique({
|
||||
where: { rfc: data.empresa.rfc },
|
||||
});
|
||||
|
||||
if (existingTenant) {
|
||||
throw new AppError(400, 'El RFC ya está registrado');
|
||||
}
|
||||
|
||||
// Provision a dedicated database for this tenant
|
||||
const databaseName = await tenantDb.provisionDatabase(data.empresa.rfc);
|
||||
|
||||
const tenant = await prisma.tenant.create({
|
||||
data: {
|
||||
nombre: data.empresa.nombre,
|
||||
rfc: data.empresa.rfc.toUpperCase(),
|
||||
plan: 'trial',
|
||||
databaseName,
|
||||
},
|
||||
});
|
||||
|
||||
const passwordHash = await hashPassword(data.usuario.password);
|
||||
const adminRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||
if (!adminRol) throw new AppError(500, 'Rol admin no encontrado en catálogo');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.usuario.email.toLowerCase(),
|
||||
passwordHash,
|
||||
nombre: data.usuario.nombre,
|
||||
lastTenantId: tenant.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Crea membership owner del caller en el tenant recién creado (fase 4 multi-tenant)
|
||||
await prisma.tenantMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: tenant.id,
|
||||
rolId: adminRol.id,
|
||||
isOwner: true,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const ownerRole: Role = 'owner';
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: ownerRole,
|
||||
tenantId: tenant.id,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: ownerRole,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.nombre,
|
||||
tenantRfc: tenant.rfc,
|
||||
plan: tenant.plan,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(data: LoginRequest): Promise<LoginResponse> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: data.email.toLowerCase() },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Credenciales inválidas');
|
||||
}
|
||||
|
||||
if (!user.active) {
|
||||
throw new AppError(401, 'Usuario desactivado');
|
||||
}
|
||||
|
||||
const isValidPassword = await verifyPassword(data.password, user.passwordHash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new AppError(401, 'Credenciales inválidas');
|
||||
}
|
||||
|
||||
// Resuelve el tenant activo desde memberships. Prefiere `lastTenantId` si
|
||||
// existe Y el user tiene membership activa ahí; sino cae al primer membership
|
||||
// por joinedAt ASC.
|
||||
const allMemberships = await prisma.tenantMembership.findMany({
|
||||
where: { userId: user.id, active: true, tenant: { active: true } },
|
||||
include: { tenant: true, rol: true },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
|
||||
let activeTenant;
|
||||
let activeRole: Role;
|
||||
|
||||
if (allMemberships.length === 0) {
|
||||
// Edge case: user sin membership activa. Si tiene platformRoles (admin
|
||||
// global), permite login con cualquier tenant activo como nominal — su
|
||||
// trabajo real es vía impersonación desde /clientes. Sin esto, no podría
|
||||
// ni entrar a la plataforma para crear el primer cliente.
|
||||
const earlyPlatformRoles = await getPlatformRoles(user.id);
|
||||
if (earlyPlatformRoles.length === 0) {
|
||||
throw new AppError(401, 'No tienes acceso a ninguna empresa activa');
|
||||
}
|
||||
const fallbackTenant = await prisma.tenant.findFirst({
|
||||
where: { active: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (!fallbackTenant) {
|
||||
throw new AppError(503, 'No hay tenants activos en el sistema. Ejecuta `pnpm db:seed` para bootstrap.');
|
||||
}
|
||||
activeTenant = fallbackTenant;
|
||||
activeRole = 'visor' as Role; // mínimo — la autorización real viene de platformRoles
|
||||
} else {
|
||||
const preferred = user.lastTenantId
|
||||
? allMemberships.find(m => m.tenantId === user.lastTenantId)
|
||||
: null;
|
||||
const activeMembership = preferred ?? allMemberships[0];
|
||||
activeTenant = activeMembership.tenant;
|
||||
activeRole = activeMembership.rol.nombre as Role;
|
||||
}
|
||||
|
||||
// `loginCount` se incrementa SOLO en login (NO en refresh) — es la métrica
|
||||
// que dispara el auto-dismiss del onboarding tras N sesiones.
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
lastLogin: new Date(),
|
||||
lastTenantId: activeTenant.id,
|
||||
loginCount: { increment: 1 },
|
||||
},
|
||||
select: { loginCount: true, onboardingDismissedAt: true },
|
||||
});
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: activeTenant.id,
|
||||
action: 'user.login',
|
||||
metadata: { email: user.email, tenantRfc: activeTenant.rfc },
|
||||
});
|
||||
|
||||
const [platformRoles, tenants] = await Promise.all([
|
||||
getPlatformRoles(user.id),
|
||||
getUserTenants(user.id),
|
||||
]);
|
||||
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: activeRole,
|
||||
tenantId: activeTenant.id,
|
||||
platformRoles,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: activeRole,
|
||||
tenantId: activeTenant.id,
|
||||
tenantName: activeTenant.nombre,
|
||||
tenantRfc: activeTenant.rfc,
|
||||
plan: activeTenant.plan,
|
||||
platformRoles,
|
||||
tenants,
|
||||
loginCount: updatedUser.loginCount,
|
||||
onboardingDismissedAt: updatedUser.onboardingDismissedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshTokens(token: string): Promise<{ accessToken: string; refreshToken: string }> {
|
||||
// Use a transaction to prevent race conditions
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const storedToken = await tx.refreshToken.findUnique({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (!storedToken) {
|
||||
throw new AppError(401, 'Token inválido');
|
||||
}
|
||||
|
||||
if (storedToken.expiresAt < new Date()) {
|
||||
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||
throw new AppError(401, 'Token expirado');
|
||||
}
|
||||
|
||||
const payload = verifyToken(token);
|
||||
|
||||
const user = await tx.user.findUnique({
|
||||
where: { id: payload.userId },
|
||||
});
|
||||
|
||||
if (!user || !user.active) {
|
||||
throw new AppError(401, 'Usuario no encontrado o desactivado');
|
||||
}
|
||||
|
||||
// Re-valida que el user sigue teniendo membership activa en el tenant del
|
||||
// JWT. Si lo removieron de ahí, cae al primer membership disponible.
|
||||
const currentMembership = await tx.tenantMembership.findFirst({
|
||||
where: { userId: user.id, tenantId: payload.tenantId, active: true, tenant: { active: true } },
|
||||
include: { tenant: true, rol: true },
|
||||
});
|
||||
let activeMembership = currentMembership;
|
||||
if (!activeMembership) {
|
||||
activeMembership = await tx.tenantMembership.findFirst({
|
||||
where: { userId: user.id, active: true, tenant: { active: true } },
|
||||
include: { tenant: true, rol: true },
|
||||
orderBy: { joinedAt: 'asc' },
|
||||
});
|
||||
}
|
||||
if (!activeMembership) {
|
||||
throw new AppError(401, 'No tienes acceso a ninguna empresa activa');
|
||||
}
|
||||
|
||||
// Use deleteMany to avoid error if already deleted (race condition)
|
||||
await tx.refreshToken.deleteMany({ where: { id: storedToken.id } });
|
||||
|
||||
const platformRoles = await getPlatformRoles(user.id);
|
||||
|
||||
const newTokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: activeMembership.rol.nombre as Role,
|
||||
tenantId: activeMembership.tenantId,
|
||||
platformRoles,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(newTokenPayload);
|
||||
const refreshToken = generateRefreshToken(newTokenPayload);
|
||||
|
||||
await tx.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(token: string): Promise<void> {
|
||||
// Busca el refreshToken antes de borrarlo para capturar el userId en auditoría
|
||||
const rt = await prisma.refreshToken.findFirst({
|
||||
where: { token },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
await prisma.refreshToken.deleteMany({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
if (rt) {
|
||||
const tenantId = (await prisma.user.findUnique({
|
||||
where: { id: rt.userId },
|
||||
select: { lastTenantId: true },
|
||||
}))?.lastTenantId ?? undefined;
|
||||
|
||||
auditLog({
|
||||
userId: rt.userId,
|
||||
tenantId,
|
||||
action: 'user.logout',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password reset
|
||||
// ============================================================================
|
||||
|
||||
const PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000; // 1 hora
|
||||
|
||||
/**
|
||||
* Solicita recuperación de contraseña. No revela si el email existe (anti-enumeration).
|
||||
*
|
||||
* Si el email es válido y user activo:
|
||||
* - Invalida cualquier token previo no usado del mismo user
|
||||
* - Genera token criptográficamente seguro (32 bytes hex)
|
||||
* - Envía email con link de reset (expira en 1h)
|
||||
*
|
||||
* Rate limit: aplicar en la capa de rutas (3/hora por IP).
|
||||
*/
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: normalizedEmail },
|
||||
select: { id: true, email: true, nombre: true, active: true, lastTenantId: true },
|
||||
});
|
||||
|
||||
// Respuesta idéntica para email existente/no-existente (anti-enumeration)
|
||||
if (!user || !user.active) {
|
||||
console.log(`[PasswordReset] Request para email inexistente o inactivo: ${normalizedEmail}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Invalida tokens previos no usados (marca como usados)
|
||||
await prisma.passwordResetToken.updateMany({
|
||||
where: { userId: user.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + PASSWORD_RESET_EXPIRY_MS);
|
||||
|
||||
await prisma.passwordResetToken.create({
|
||||
data: { userId: user.id, token, expiresAt },
|
||||
});
|
||||
|
||||
const resetUrl = `${env.FRONTEND_URL}/reset-password?token=${token}`;
|
||||
emailService.sendPasswordReset(user.email, { nombre: user.nombre, resetUrl })
|
||||
.catch(err => console.error('[EMAIL] Password reset notification failed:', err));
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: user.lastTenantId ?? undefined,
|
||||
action: 'user.password_reset_requested',
|
||||
metadata: { email: user.email },
|
||||
});
|
||||
|
||||
console.log(`[PasswordReset] Token emitido para ${normalizedEmail}, expira ${expiresAt.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirma recuperación de contraseña con token válido + nueva contraseña.
|
||||
*
|
||||
* Validaciones:
|
||||
* - Password mínimo 8 caracteres
|
||||
* - Token existe, no usado, no expirado
|
||||
*
|
||||
* Al éxito:
|
||||
* - Actualiza password hash
|
||||
* - Marca token como usado (single-use)
|
||||
* - Borra todos los refresh tokens del user (invalida sesiones activas → re-login forzado)
|
||||
*/
|
||||
export async function confirmPasswordReset(token: string, newPassword: string): Promise<void> {
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
|
||||
const record = await prisma.passwordResetToken.findUnique({
|
||||
where: { token },
|
||||
select: { id: true, userId: true, usedAt: true, expiresAt: true },
|
||||
});
|
||||
|
||||
if (!record) throw new AppError(400, 'Token inválido');
|
||||
if (record.usedAt) throw new AppError(400, 'Este enlace ya fue usado. Solicita uno nuevo.');
|
||||
if (record.expiresAt < new Date()) throw new AppError(400, 'El enlace expiró. Solicita uno nuevo.');
|
||||
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
await prisma.$transaction([
|
||||
// Actualiza hash + incrementa tokenVersion (invalida access tokens vivos)
|
||||
prisma.user.update({
|
||||
where: { id: record.userId },
|
||||
data: { passwordHash, tokenVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.passwordResetToken.update({
|
||||
where: { id: record.id },
|
||||
data: { usedAt: new Date() },
|
||||
}),
|
||||
// Invalida todos los refresh tokens activos del user
|
||||
prisma.refreshToken.deleteMany({
|
||||
where: { userId: record.userId },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Propaga el incremento al cache del middleware (en todos los PM2 workers)
|
||||
invalidateTokenVersionCache(record.userId);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: record.userId },
|
||||
select: { lastTenantId: true, email: true },
|
||||
});
|
||||
|
||||
auditLog({
|
||||
userId: record.userId,
|
||||
tenantId: user?.lastTenantId ?? undefined,
|
||||
action: 'user.password_reset_completed',
|
||||
metadata: { email: user?.email },
|
||||
});
|
||||
|
||||
console.log(`[PasswordReset] Completado para user ${record.userId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Change password (authenticated) + Logout all
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cambia la contraseña de un user autenticado. Requiere password actual para
|
||||
* prevenir cambios por alguien con acceso temporal a la sesión (ej: laptop
|
||||
* compartida dejada abierta). Incrementa tokenVersion — invalida TODAS las
|
||||
* sesiones activas del user (forza re-login en otros dispositivos).
|
||||
*/
|
||||
export async function changePassword(params: {
|
||||
userId: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}): Promise<void> {
|
||||
if (params.newPassword.length < 8) {
|
||||
throw new AppError(400, 'La contraseña debe tener al menos 8 caracteres');
|
||||
}
|
||||
if (params.currentPassword === params.newPassword) {
|
||||
throw new AppError(400, 'La nueva contraseña debe ser distinta a la actual');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
select: { id: true, passwordHash: true, email: true, lastTenantId: true, active: true },
|
||||
});
|
||||
if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado');
|
||||
|
||||
const validCurrent = await verifyPassword(params.currentPassword, user.passwordHash);
|
||||
if (!validCurrent) throw new AppError(401, 'Contraseña actual incorrecta');
|
||||
|
||||
const newHash = await hashPassword(params.newPassword);
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash: newHash, tokenVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.refreshToken.deleteMany({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
invalidateTokenVersionCache(user.id);
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: user.lastTenantId ?? undefined,
|
||||
action: 'user.password_changed',
|
||||
metadata: { email: user.email },
|
||||
});
|
||||
|
||||
console.log(`[ChangePassword] Completado para user ${user.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cierra todas las sesiones activas del user. Usado por el botón
|
||||
* "Cerrar todas las sesiones" en /configuracion/seguridad. Incrementa
|
||||
* tokenVersion + borra refresh tokens. El user se queda sin acceso y debe
|
||||
* re-loguearse (incluyendo la sesión actual, por diseño — es lo que el
|
||||
* usuario pidió explícitamente).
|
||||
*/
|
||||
export async function logoutAllSessions(userId: string): Promise<void> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { lastTenantId: true, email: true },
|
||||
});
|
||||
if (!user) throw new AppError(404, 'Usuario no encontrado');
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
}),
|
||||
prisma.refreshToken.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
|
||||
invalidateTokenVersionCache(userId);
|
||||
|
||||
auditLog({
|
||||
userId,
|
||||
tenantId: user.lastTenantId ?? undefined,
|
||||
action: 'user.sessions_invalidated',
|
||||
metadata: { email: user.email, reason: 'logout_all' },
|
||||
});
|
||||
|
||||
console.log(`[LogoutAll] Sesiones invalidadas para user ${userId}`);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Onboarding dismiss
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Marca el onboarding como dismissed. Idempotente — si ya estaba seteado, no
|
||||
* sobrescribe el timestamp original (preserva la fecha del primer dismiss).
|
||||
*/
|
||||
export async function dismissOnboarding(userId: string): Promise<{ onboardingDismissedAt: Date }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { onboardingDismissedAt: true },
|
||||
});
|
||||
if (!user) throw new AppError(404, 'Usuario no encontrado');
|
||||
|
||||
if (user.onboardingDismissedAt) {
|
||||
return { onboardingDismissedAt: user.onboardingDismissedAt };
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { onboardingDismissedAt: new Date() },
|
||||
select: { onboardingDismissedAt: true },
|
||||
});
|
||||
return { onboardingDismissedAt: updated.onboardingDismissedAt! };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Switch tenant (multi-membership)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Cambia el tenant activo del user. Valida que tenga membership activa en el
|
||||
* tenant destino, luego emite un nuevo par de tokens apuntando a ese tenantId
|
||||
* (con el rol que tiene en ese tenant específico). El refresh token actual se
|
||||
* invalida — el user opera con el par nuevo desde este momento.
|
||||
*
|
||||
* Casos de uso:
|
||||
* - Owner con varias empresas cambia entre ellas
|
||||
* - Contador que atiende múltiples clientes cambia de empresa activa
|
||||
*
|
||||
* Un user con 1 sola membership no debería llamarlo (no cambia nada), pero si
|
||||
* lo hace funciona igual: le da tokens nuevos apuntando al mismo tenant.
|
||||
*/
|
||||
export async function switchTenant(params: {
|
||||
userId: string;
|
||||
currentRefreshToken: string;
|
||||
targetTenantId: string;
|
||||
}): Promise<LoginResponse> {
|
||||
const membership = await verifyMembership(params.userId, params.targetTenantId);
|
||||
if (!membership) {
|
||||
throw new AppError(403, 'No tienes acceso a esa empresa');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
});
|
||||
if (!user || !user.active) throw new AppError(401, 'Usuario no encontrado');
|
||||
|
||||
const targetTenant = await prisma.tenant.findUnique({
|
||||
where: { id: params.targetTenantId },
|
||||
});
|
||||
if (!targetTenant || !targetTenant.active) {
|
||||
throw new AppError(404, 'Empresa no encontrada o desactivada');
|
||||
}
|
||||
|
||||
// Persiste el target como "último tenant activo" — al re-loguear caerá aquí
|
||||
// sin tener que volver a hacer switch.
|
||||
const previousTenantId = user.lastTenantId;
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastTenantId: targetTenant.id },
|
||||
});
|
||||
|
||||
// Invalida el refresh token actual (puede no existir si el caller pasó el
|
||||
// access token por error — deleteMany es idempotente).
|
||||
await prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } });
|
||||
|
||||
const [platformRoles, tenants] = await Promise.all([
|
||||
getPlatformRoles(user.id),
|
||||
getUserTenants(user.id),
|
||||
]);
|
||||
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: membership.rolNombre,
|
||||
tenantId: targetTenant.id,
|
||||
platformRoles,
|
||||
tokenVersion: user.tokenVersion,
|
||||
};
|
||||
|
||||
const accessToken = generateAccessToken(tokenPayload);
|
||||
const refreshToken = generateRefreshToken(tokenPayload);
|
||||
|
||||
await prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
token: refreshToken,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
auditLog({
|
||||
userId: user.id,
|
||||
tenantId: targetTenant.id,
|
||||
action: 'user.tenant_switched',
|
||||
metadata: { from: previousTenantId ?? null, to: targetTenant.id, targetRfc: targetTenant.rfc },
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
nombre: user.nombre,
|
||||
role: membership.rolNombre,
|
||||
tenantId: targetTenant.id,
|
||||
tenantName: targetTenant.nombre,
|
||||
tenantRfc: targetTenant.rfc,
|
||||
plan: targetTenant.plan,
|
||||
platformRoles,
|
||||
tenants,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user