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:
Lizandro Guarnizo
2026-06-01 09:42:38 -05:00
parent 93db76897d
commit 4872053fd1
57 changed files with 4330 additions and 192 deletions
+61
View File
@@ -0,0 +1,61 @@
import {
Controller, Post, Get, Body, Req, UseGuards, HttpCode, HttpStatus,
} from "@nestjs/common";
import { AuthService } from "./auth.service";
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto } from "./dto/auth.dto";
import { JwtAuthGuard } from "./guards/auth.guard";
import { CurrentUser } from "./decorators/current-user.decorator";
@Controller("auth")
export class AuthController {
constructor(private auth: AuthService) {}
/** POST /api/auth/register — Registro público (doc §09 paso 1) */
@Post("register")
register(@Body() dto: RegisterDto) {
return this.auth.register(dto);
}
/** POST /api/auth/login — Login con JWT + MFA opcional */
@Post("login")
@HttpCode(HttpStatus.OK)
login(@Body() dto: LoginDto, @Req() req: any) {
return this.auth.login(dto, req.ip);
}
/** POST /api/auth/refresh — Rotar refresh token */
@Post("refresh")
@HttpCode(HttpStatus.OK)
refresh(@Body() dto: RefreshDto) {
return this.auth.refresh(dto.refreshToken);
}
/** POST /api/auth/logout */
@Post("logout")
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
logout(@Body() dto: RefreshDto, @CurrentUser() user: any) {
return this.auth.logout(dto.refreshToken, user.id);
}
/** GET /api/auth/me — Perfil del usuario autenticado */
@Get("me")
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: any) {
return this.auth.getProfile(user.id);
}
/** POST /api/auth/mfa/setup — Genera QR para TOTP */
@Post("mfa/setup")
@UseGuards(JwtAuthGuard)
setupMfa(@CurrentUser() user: any) {
return this.auth.setupMfa(user.id);
}
/** POST /api/auth/mfa/verify — Activa MFA con primer código TOTP */
@Post("mfa/verify")
@UseGuards(JwtAuthGuard)
verifyMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
return this.auth.verifyMfa(user.id, dto.totpCode);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET", "change-me"),
signOptions: { expiresIn: config.get("JWT_EXPIRES_IN", "15m") },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+209
View File
@@ -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 },
});
}
}
@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user;
},
);
+44
View File
@@ -0,0 +1,44 @@
import { IsEmail, IsString, MinLength, IsOptional, Matches } from "class-validator";
export class RegisterDto {
@IsEmail({}, { message: "Email inválido" })
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsOptional()
@IsString()
phone?: string;
}
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
password!: string;
@IsOptional()
@IsString()
totpCode?: string;
}
export class RefreshDto {
@IsString()
refreshToken!: string;
}
export class SetupMfaDto {
@IsString()
totpCode!: string;
}
+31
View File
@@ -0,0 +1,31 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}
export const ROLES_KEY = "roles";
export function Roles(...roles: string[]) {
return (target: any, key?: string, descriptor?: any) => {
Reflect.defineMetadata(ROLES_KEY, roles, descriptor?.value ?? target);
return descriptor ?? target;
};
}
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
ctx.getHandler(), ctx.getClass(),
]);
if (!required || required.length === 0) return true;
const { user } = ctx.switchToHttp().getRequest();
if (!user || !required.includes(user.role)) {
throw new ForbiddenException("No tienes permisos para esta acción.");
}
return true;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>("JWT_SECRET", "change-me"),
});
}
async validate(payload: { sub: string; email: string; role: string; tenantId: string }): Promise<any> {
const user = await this.prisma.client.user.findUnique({ where: { id: payload.sub } });
if (!user || !user.isActive) throw new UnauthorizedException();
return { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId };
}
}