feat: Fase 1 — Auth JWT+MFA, portales CRUD, UI completa
API: - AuthModule: register, login, refresh, logout, MFA/TOTP setup+verify - JwtStrategy + JwtAuthGuard + RolesGuard + CurrentUser decorator - PackagesModule: CRUD paquetes + historial de estados - PreAlertsModule: pre-alertas por usuario - UsersModule: gestión de usuarios + roles + activación - B2BModule: solicitudes de carga pesada/cotización - ValidationPipe global + CORS configurado Web (Next.js 15): - globals.css completo (design system + utility classes) - Layout raíz con WhatsApp flotante - /login + /registro funcionales con JWT y redirección por rol - /portal: dashboard, mi-casillero, mis-paquetes, pre-alerta, calculadora, perfil - /admin: dashboard, usuarios (gestión roles/activación), tarifas, reportes, auditoría - /bodega: dashboard, paquetes (crear+actualizar estado), verificación, despacho - /tracking: tracking real con progreso visual + historial - /calculadora: calculadora interactiva real (API SENAE §15) - /como-funciona, /tarifas, /quienes-somos, /casillero - /carga-pesada + /carga-pesada/cotizacion (formulario B2B) - lib/api.ts: cliente HTTP con auto-refresh de token Roles sincronizados con schema: SUPER_ADMIN, ADMIN_EMPRESA, OPERADOR_BODEGA, AGENTE_ADUANERO, CLIENTE, SOPORTE
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
Injectable, UnauthorizedException, ConflictException, BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { generateSuiteCode } from "../common/utils/suite-code.util";
|
||||
import { RegisterDto, LoginDto } from "./dto/auth.dto";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import * as crypto from "crypto";
|
||||
import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib";
|
||||
|
||||
const TENANT_SLUG = "moraworld";
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
// ─── Register ────────────────────────────────────────────────
|
||||
async register(dto: RegisterDto): Promise<any> {
|
||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
|
||||
if (!tenant) throw new BadRequestException("Tenant no encontrado.");
|
||||
|
||||
const existing = await this.prisma.client.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
|
||||
});
|
||||
if (existing) throw new ConflictException("Ya existe una cuenta con ese email.");
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
|
||||
const user = await this.prisma.client.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: dto.email.toLowerCase(),
|
||||
passwordHash,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
phone: dto.phone ?? null,
|
||||
role: "CLIENTE",
|
||||
},
|
||||
});
|
||||
|
||||
// Asignar Suite automáticamente (doc §09)
|
||||
const suiteCount = await this.prisma.client.suite.count({ where: { tenantId: tenant.id } });
|
||||
const suiteCode = generateSuiteCode(suiteCount + 1);
|
||||
await this.prisma.client.suite.create({
|
||||
data: { tenantId: tenant.id, userId: user.id, code: suiteCode },
|
||||
});
|
||||
|
||||
await this.audit(tenant.id, user.id, "USER_REGISTER", "User", user.id);
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
suiteCode,
|
||||
suiteAddress: `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────────
|
||||
async login(dto: LoginDto, ip?: string): Promise<any> {
|
||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
|
||||
if (!tenant) throw new UnauthorizedException();
|
||||
|
||||
const user = await this.prisma.client.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
await this.audit(tenant.id, null, "LOGIN_FAILED", "User", dto.email);
|
||||
throw new UnauthorizedException("Credenciales inválidas.");
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
await this.audit(tenant.id, user.id, "LOGIN_FAILED", "User", user.id);
|
||||
throw new UnauthorizedException("Credenciales inválidas.");
|
||||
}
|
||||
|
||||
// MFA
|
||||
if (user.mfaEnabled) {
|
||||
if (!dto.totpCode) return { requiresMfa: true, userId: user.id };
|
||||
const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! });
|
||||
if (!ok) {
|
||||
await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id);
|
||||
throw new UnauthorizedException("Código MFA inválido.");
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.client.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
await this.audit(tenant.id, user.id, "LOGIN_SUCCESS", "User", user.id);
|
||||
|
||||
const suite = await this.prisma.client.suite.findUnique({ where: { userId: user.id } });
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
suite: suite ? {
|
||||
code: suite.code,
|
||||
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
|
||||
} : null,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Refresh ─────────────────────────────────────────────────
|
||||
async refresh(refreshToken: string): Promise<any> {
|
||||
const stored = await this.prisma.client.refreshToken.findUnique({ where: { token: refreshToken } });
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException("Refresh token inválido o expirado.");
|
||||
}
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: stored.userId } });
|
||||
if (!user || !user.isActive) throw new UnauthorizedException();
|
||||
|
||||
await this.prisma.client.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.generateTokens(user.id, user.email, user.role, user.tenantId);
|
||||
}
|
||||
|
||||
// ─── Logout ──────────────────────────────────────────────────
|
||||
async logout(refreshToken: string, userId: string): Promise<void> {
|
||||
await this.prisma.client.refreshToken.updateMany({
|
||||
where: { token: refreshToken, userId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.audit(null, userId, "LOGOUT", "User", userId);
|
||||
}
|
||||
|
||||
// ─── MFA Setup ───────────────────────────────────────────────
|
||||
async setupMfa(userId: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
const secret = generateSecret();
|
||||
const otpAuthUrl = generateURI({
|
||||
issuer: "Moraworld Imports",
|
||||
label: user.email,
|
||||
secret,
|
||||
});
|
||||
|
||||
await this.prisma.client.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaSecret: secret, mfaEnabled: false },
|
||||
});
|
||||
|
||||
return { secret, otpAuthUrl };
|
||||
}
|
||||
|
||||
async verifyMfa(userId: string, totpCode: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.mfaSecret) throw new BadRequestException("Primero genera el secreto MFA.");
|
||||
|
||||
const ok = totpVerify({ token: totpCode, secret: user.mfaSecret });
|
||||
if (!ok) throw new BadRequestException("Código TOTP inválido.");
|
||||
|
||||
await this.prisma.client.user.update({ where: { id: userId }, data: { mfaEnabled: true } });
|
||||
await this.audit(user.tenantId, userId, "MFA_ENABLED", "User", userId);
|
||||
return { mfaEnabled: true };
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────
|
||||
async getProfile(userId: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const suite = await this.prisma.client.suite.findUnique({ where: { userId } });
|
||||
return {
|
||||
...this.sanitizeUser(user),
|
||||
suite: suite ? {
|
||||
code: suite.code,
|
||||
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
private async generateTokens(userId: string, email: string, role: string, tenantId: string): Promise<any> {
|
||||
const payload = { sub: userId, email, role, tenantId };
|
||||
const accessToken = this.jwt.sign(payload, { expiresIn: this.config.get("JWT_EXPIRES_IN", "15m") });
|
||||
const refreshToken = crypto.randomBytes(64).toString("hex");
|
||||
const refreshExpires = new Date();
|
||||
refreshExpires.setDate(refreshExpires.getDate() + 7);
|
||||
await this.prisma.client.refreshToken.create({
|
||||
data: { userId, token: refreshToken, expiresAt: refreshExpires },
|
||||
});
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private sanitizeUser(user: any): any {
|
||||
const { passwordHash, mfaSecret, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
private async audit(tenantId: string | null, userId: string | null, action: string, resource?: string, resourceId?: string): Promise<void> {
|
||||
await this.prisma.client.auditLog.create({
|
||||
data: { tenantId, userId, action, resource, resourceId },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user