655 lines
21 KiB
TypeScript
655 lines
21 KiB
TypeScript
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');
|
|
}
|
|
|
|
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);
|
|
|
|
// Persiste el target como "último tenant activo" y atomiza la rotacion del
|
|
// refresh token (delete + create) para evitar race conditions con requests
|
|
// concurrentes que intenten refrescar con el token anterior.
|
|
const previousTenantId = user.lastTenantId;
|
|
await prisma.$transaction([
|
|
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).
|
|
prisma.refreshToken.deleteMany({ where: { token: params.currentRefreshToken } }),
|
|
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,
|
|
},
|
|
};
|
|
}
|