fix(api): evitar 500 al crear tenant con email existente + rate-limit trust proxy
- createTenant ahora reusa User si el email ya existe globalmente
(hace upsert de membership en vez de crear user duplicado)
- Arregla error de express-rate-limit con X-Forwarded-For:
app.set('trust proxy', 1) para que funcione detrás de Cloudflare
- Tipos de email templates actualizados para tempPassword nullable
This commit is contained in:
@@ -45,6 +45,10 @@ import metricasRoutes from './routes/metricas.routes.js';
|
|||||||
|
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
|
|
||||||
|
// Trust proxy — la app corre detrás de Cloudflare/nginx. Necesario para que
|
||||||
|
// express-rate-limit lea correctamente X-Forwarded-For sin lanzar warnings.
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
|
// Security. Helmet default incluye un CSP restrictivo que puede chocar con el
|
||||||
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
|
// frontend cuando éste embebe recursos propios (ej: /terminos embebe el PDF de
|
||||||
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
|
// /legal/). Dejamos CSP off en el API y centralizamos los headers de seguridad
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ async function sendEmail(to: string, subject: string, html: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const emailService = {
|
export const emailService = {
|
||||||
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string }) => {
|
sendWelcome: async (to: string, data: { nombre: string; email: string; tempPassword: string | null | undefined }) => {
|
||||||
const { welcomeEmail } = await import('./templates/welcome.js');
|
const { welcomeEmail } = await import('./templates/welcome.js');
|
||||||
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
|
await sendEmail(to, 'Bienvenido a Horux360', welcomeEmail(data));
|
||||||
},
|
},
|
||||||
@@ -60,7 +60,7 @@ export const emailService = {
|
|||||||
clienteRfc: string;
|
clienteRfc: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
tempPassword: string;
|
tempPassword: string | null | undefined;
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function newClientAdminEmail(data: {
|
|||||||
clienteRfc: string;
|
clienteRfc: string;
|
||||||
adminEmail: string;
|
adminEmail: string;
|
||||||
adminNombre: string;
|
adminNombre: string;
|
||||||
tempPassword: string;
|
tempPassword: string | null | undefined;
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
plan: string;
|
plan: string;
|
||||||
}): string {
|
}): string {
|
||||||
@@ -46,7 +46,7 @@ export function newClientAdminEmail(data: {
|
|||||||
${sectionHeader('Credenciales del usuario', C.secondary)}
|
${sectionHeader('Credenciales del usuario', C.secondary)}
|
||||||
${row('Nombre', escapeHtml(data.adminNombre))}
|
${row('Nombre', escapeHtml(data.adminNombre))}
|
||||||
${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)}
|
${row('Email', `<span style="font-family:monospace;">${escapeHtml(data.adminEmail)}</span>`)}
|
||||||
${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword)}</code>`, true)}
|
${row('Contraseña temporal', `<code style="background-color:${C.bgLight};padding:4px 10px;border-radius:6px;font-size:13px;color:#dc2626;border:1px solid ${C.border};">${escapeHtml(data.tempPassword || 'N/A - usuario ya existía')}</code>`, true)}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
|
<div style="background-color:#fef2f2;border-left:4px solid #ef4444;border-radius:8px;padding:12px 16px;margin:0 0 16px;">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
import { baseTemplate, heading, primaryButton, infoBox, BRAND_COLORS as C } from './base.js';
|
||||||
|
|
||||||
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string }): string {
|
export function welcomeEmail(data: { nombre: string; email: string; tempPassword: string | null | undefined }): string {
|
||||||
return baseTemplate(`
|
return baseTemplate(`
|
||||||
${heading('Bienvenido a Horux 360')}
|
${heading('Bienvenido a Horux 360')}
|
||||||
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
<p style="color:${C.textPrimary};margin:0 0 16px;">Hola <strong>${data.nombre}</strong>,</p>
|
||||||
|
|||||||
@@ -91,26 +91,41 @@ export async function createTenant(data: {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Create admin user with temp password
|
// 3. Create or reuse admin user
|
||||||
const tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
|
||||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
|
||||||
|
|
||||||
// Get owner role ID from roles table (rol que asignamos al dueño del tenant al crearlo)
|
|
||||||
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
const ownerRol = await prisma.rol.findUnique({ where: { nombre: 'owner' } });
|
||||||
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
|
if (!ownerRol) throw new Error('Rol owner no encontrado en la base de datos');
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
let user = await prisma.user.findUnique({ where: { email: data.adminEmail } });
|
||||||
data: {
|
let tempPassword: string | null = null;
|
||||||
email: data.adminEmail,
|
|
||||||
passwordHash: hashedPassword,
|
|
||||||
nombre: data.adminNombre,
|
|
||||||
lastTenantId: tenant.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Crea membership owner del nuevo user en su tenant (fase 4 multi-tenant)
|
if (!user) {
|
||||||
await prisma.tenantMembership.create({
|
tempPassword = randomBytes(4).toString('hex'); // 8-char random
|
||||||
data: {
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.adminEmail,
|
||||||
|
passwordHash: hashedPassword,
|
||||||
|
nombre: data.adminNombre,
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// User ya existe: actualizar lastTenantId y nombre si cambió
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
lastTenantId: tenant.id,
|
||||||
|
...(data.adminNombre && data.adminNombre !== user.nombre ? { nombre: data.adminNombre } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crea membership owner del user en su tenant (fase 4 multi-tenant).
|
||||||
|
// Si ya existía (re-invite a otro tenant), reactivar.
|
||||||
|
await prisma.tenantMembership.upsert({
|
||||||
|
where: { userId_tenantId: { userId: user.id, tenantId: tenant.id } },
|
||||||
|
update: { rolId: ownerRol.id, isOwner: true, active: true },
|
||||||
|
create: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
rolId: ownerRol.id,
|
rolId: ownerRol.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user