From 4872053fd1126e49896c4dcbe67bd661b6d1af61 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 09:42:38 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20Fase=201=20=E2=80=94=20Auth=20JWT+MFA,?= =?UTF-8?q?=20portales=20CRUD,=20UI=20completa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/package.json | 23 +- apps/api/src/app.module.ts | 10 + apps/api/src/auth/auth.controller.ts | 61 +++ apps/api/src/auth/auth.module.ts | 25 ++ apps/api/src/auth/auth.service.ts | 209 +++++++++ .../auth/decorators/current-user.decorator.ts | 7 + apps/api/src/auth/dto/auth.dto.ts | 44 ++ apps/api/src/auth/guards/auth.guard.ts | 31 ++ apps/api/src/auth/jwt.strategy.ts | 22 + apps/api/src/b2b/b2b.controller.ts | 34 ++ apps/api/src/b2b/b2b.module.ts | 6 + apps/api/src/b2b/b2b.service.ts | 64 +++ apps/api/src/main.ts | 11 + apps/api/src/packages/dto/package.dto.ts | 50 +++ apps/api/src/packages/packages.controller.ts | 33 ++ apps/api/src/packages/packages.module.ts | 9 + apps/api/src/packages/packages.service.ts | 102 +++++ apps/api/src/pre-alerts/dto/pre-alert.dto.ts | 23 + .../src/pre-alerts/pre-alerts.controller.ts | 33 ++ apps/api/src/pre-alerts/pre-alerts.module.ts | 6 + apps/api/src/pre-alerts/pre-alerts.service.ts | 45 ++ apps/api/src/users/users.controller.ts | 43 ++ apps/api/src/users/users.module.ts | 6 + apps/api/src/users/users.service.ts | 49 +++ apps/web/package.json | 2 + apps/web/src/app/admin/auditoria/page.tsx | 20 + apps/web/src/app/admin/layout.tsx | 63 +++ apps/web/src/app/admin/page.tsx | 76 ++++ apps/web/src/app/admin/reportes/page.tsx | 70 +++ apps/web/src/app/admin/tarifas/page.tsx | 48 +++ apps/web/src/app/admin/usuarios/page.tsx | 77 ++++ apps/web/src/app/bodega/despacho/page.tsx | 66 +++ apps/web/src/app/bodega/layout.tsx | 64 +++ apps/web/src/app/bodega/page.tsx | 75 ++++ apps/web/src/app/bodega/paquetes/page.tsx | 161 +++++++ apps/web/src/app/bodega/verificacion/page.tsx | 73 ++++ apps/web/src/app/calculadora/page.tsx | 207 +++++---- .../src/app/carga-pesada/cotizacion/page.tsx | 108 +++++ apps/web/src/app/carga-pesada/page.tsx | 53 +++ apps/web/src/app/casillero/page.tsx | 81 ++++ apps/web/src/app/como-funciona/page.tsx | 61 +++ apps/web/src/app/globals.css | 377 +++++++++++++++- apps/web/src/app/layout.tsx | 30 +- apps/web/src/app/login/page.tsx | 120 ++++++ apps/web/src/app/portal/calculadora/page.tsx | 130 ++++++ apps/web/src/app/portal/layout.tsx | 75 ++++ apps/web/src/app/portal/mi-casillero/page.tsx | 125 ++++++ apps/web/src/app/portal/mis-paquetes/page.tsx | 104 +++++ apps/web/src/app/portal/page.tsx | 124 ++++++ apps/web/src/app/portal/perfil/page.tsx | 109 +++++ apps/web/src/app/portal/pre-alerta/page.tsx | 122 ++++++ apps/web/src/app/quienes-somos/page.tsx | 62 +++ apps/web/src/app/registro/page.tsx | 109 +++++ apps/web/src/app/tarifas/page.tsx | 65 +++ apps/web/src/app/tracking/page.tsx | 197 +++++---- apps/web/src/lib/api.ts | 121 ++++++ pnpm-lock.yaml | 401 +++++++++++++++++- 57 files changed, 4330 insertions(+), 192 deletions(-) create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/decorators/current-user.decorator.ts create mode 100644 apps/api/src/auth/dto/auth.dto.ts create mode 100644 apps/api/src/auth/guards/auth.guard.ts create mode 100644 apps/api/src/auth/jwt.strategy.ts create mode 100644 apps/api/src/b2b/b2b.controller.ts create mode 100644 apps/api/src/b2b/b2b.module.ts create mode 100644 apps/api/src/b2b/b2b.service.ts create mode 100644 apps/api/src/packages/dto/package.dto.ts create mode 100644 apps/api/src/packages/packages.controller.ts create mode 100644 apps/api/src/packages/packages.module.ts create mode 100644 apps/api/src/packages/packages.service.ts create mode 100644 apps/api/src/pre-alerts/dto/pre-alert.dto.ts create mode 100644 apps/api/src/pre-alerts/pre-alerts.controller.ts create mode 100644 apps/api/src/pre-alerts/pre-alerts.module.ts create mode 100644 apps/api/src/pre-alerts/pre-alerts.service.ts create mode 100644 apps/api/src/users/users.controller.ts create mode 100644 apps/api/src/users/users.module.ts create mode 100644 apps/api/src/users/users.service.ts create mode 100644 apps/web/src/app/admin/auditoria/page.tsx create mode 100644 apps/web/src/app/admin/layout.tsx create mode 100644 apps/web/src/app/admin/page.tsx create mode 100644 apps/web/src/app/admin/reportes/page.tsx create mode 100644 apps/web/src/app/admin/tarifas/page.tsx create mode 100644 apps/web/src/app/admin/usuarios/page.tsx create mode 100644 apps/web/src/app/bodega/despacho/page.tsx create mode 100644 apps/web/src/app/bodega/layout.tsx create mode 100644 apps/web/src/app/bodega/page.tsx create mode 100644 apps/web/src/app/bodega/paquetes/page.tsx create mode 100644 apps/web/src/app/bodega/verificacion/page.tsx create mode 100644 apps/web/src/app/carga-pesada/cotizacion/page.tsx create mode 100644 apps/web/src/app/carga-pesada/page.tsx create mode 100644 apps/web/src/app/casillero/page.tsx create mode 100644 apps/web/src/app/como-funciona/page.tsx create mode 100644 apps/web/src/app/login/page.tsx create mode 100644 apps/web/src/app/portal/calculadora/page.tsx create mode 100644 apps/web/src/app/portal/layout.tsx create mode 100644 apps/web/src/app/portal/mi-casillero/page.tsx create mode 100644 apps/web/src/app/portal/mis-paquetes/page.tsx create mode 100644 apps/web/src/app/portal/page.tsx create mode 100644 apps/web/src/app/portal/perfil/page.tsx create mode 100644 apps/web/src/app/portal/pre-alerta/page.tsx create mode 100644 apps/web/src/app/quienes-somos/page.tsx create mode 100644 apps/web/src/app/registro/page.tsx create mode 100644 apps/web/src/app/tarifas/page.tsx create mode 100644 apps/web/src/lib/api.ts diff --git a/apps/api/package.json b/apps/api/package.json index c052b78..0616d2f 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,7 +18,16 @@ "@nestjs/common": "^11.1.0", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.0", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.0", + "@nestjs/throttler": "^6.5.0", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "otplib": "^13.4.1", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2" }, @@ -26,21 +35,31 @@ "@nestjs/cli": "^11.0.7", "@nestjs/schematics": "^11.0.5", "@nestjs/testing": "^11.1.0", + "@types/bcrypt": "^6.0.0", "@types/express": "^5.0.1", "@types/jest": "^29.5.14", "@types/node": "^22.15.21", + "@types/passport-jwt": "^4.0.1", "jest": "^29.7.0", "ts-jest": "^29.3.4", "typescript": "^5.8.3" }, "jest": { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, - "collectCoverageFrom": ["**/*.(t|j)s", "!**/*.module.ts", "!**/main.ts"], + "collectCoverageFrom": [ + "**/*.(t|j)s", + "!**/*.module.ts", + "!**/main.ts" + ], "coverageDirectory": "../coverage", "testEnvironment": "node", "coverageThreshold": { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 456f3d0..bdda57d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,11 @@ import { HealthModule } from "./health/health.module"; import { PrismaModule } from "./prisma/prisma.module"; import { CalculatorModule } from "./calculator/calculator.module"; import { TrackingModule } from "./tracking/tracking.module"; +import { AuthModule } from "./auth/auth.module"; +import { UsersModule } from "./users/users.module"; +import { PackagesModule } from "./packages/packages.module"; +import { PreAlertsModule } from "./pre-alerts/pre-alerts.module"; +import { B2BModule } from "./b2b/b2b.module"; @Module({ imports: [ @@ -15,6 +20,11 @@ import { TrackingModule } from "./tracking/tracking.module"; HealthModule, CalculatorModule, TrackingModule, + AuthModule, + UsersModule, + PackagesModule, + PreAlertsModule, + B2BModule, ], }) export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..8170c0a --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..fd02a25 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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("JWT_SECRET", "change-me"), + signOptions: { expiresIn: config.get("JWT_EXPIRES_IN", "15m") }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtStrategy], + exports: [AuthService, JwtModule], +}) +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..0a66c42 --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.prisma.client.auditLog.create({ + data: { tenantId, userId, action, resource, resourceId }, + }); + } +} diff --git a/apps/api/src/auth/decorators/current-user.decorator.ts b/apps/api/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..8b89679 --- /dev/null +++ b/apps/api/src/auth/decorators/current-user.decorator.ts @@ -0,0 +1,7 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext) => { + return ctx.switchToHttp().getRequest().user; + }, +); diff --git a/apps/api/src/auth/dto/auth.dto.ts b/apps/api/src/auth/dto/auth.dto.ts new file mode 100644 index 0000000..c2f7183 --- /dev/null +++ b/apps/api/src/auth/dto/auth.dto.ts @@ -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; +} diff --git a/apps/api/src/auth/guards/auth.guard.ts b/apps/api/src/auth/guards/auth.guard.ts new file mode 100644 index 0000000..0b8c818 --- /dev/null +++ b/apps/api/src/auth/guards/auth.guard.ts @@ -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(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; + } +} diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..3fd3508 --- /dev/null +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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("JWT_SECRET", "change-me"), + }); + } + + async validate(payload: { sub: string; email: string; role: string; tenantId: string }): Promise { + 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 }; + } +} diff --git a/apps/api/src/b2b/b2b.controller.ts b/apps/api/src/b2b/b2b.controller.ts new file mode 100644 index 0000000..5a77ab7 --- /dev/null +++ b/apps/api/src/b2b/b2b.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Post, Patch, Body, Param, UseGuards, HttpCode, HttpStatus } from "@nestjs/common"; +import { B2BService, CreateB2BDto, UpdateB2BStatusDto } from "./b2b.service"; +import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +const TENANT_FALLBACK = "moraworld"; // B2B público usa tenantId del slug + +@Controller("b2b") +export class B2BController { + constructor(private svc: B2BService) {} + + /** POST /api/b2b — público */ + @Post() + @HttpCode(HttpStatus.CREATED) + async create(@Body() dto: CreateB2BDto, @CurrentUser() user: any): Promise { + // Si hay usuario autenticado usa su tenantId, sino carga el tenant por slug + const tenantId = user?.tenantId ?? TENANT_FALLBACK; + return this.svc.create(dto, tenantId); + } + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + findAll(@CurrentUser() user: any): Promise { + return this.svc.findAll(user.tenantId); + } + + @Patch(":id/status") + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + updateStatus(@Param("id") id: string, @Body() dto: UpdateB2BStatusDto): Promise { + return this.svc.updateStatus(id, dto); + } +} diff --git a/apps/api/src/b2b/b2b.module.ts b/apps/api/src/b2b/b2b.module.ts new file mode 100644 index 0000000..3b740ca --- /dev/null +++ b/apps/api/src/b2b/b2b.module.ts @@ -0,0 +1,6 @@ +import { Module } from "@nestjs/common"; +import { B2BController } from "./b2b.controller"; +import { B2BService } from "./b2b.service"; + +@Module({ controllers: [B2BController], providers: [B2BService] }) +export class B2BModule {} diff --git a/apps/api/src/b2b/b2b.service.ts b/apps/api/src/b2b/b2b.service.ts new file mode 100644 index 0000000..81c2233 --- /dev/null +++ b/apps/api/src/b2b/b2b.service.ts @@ -0,0 +1,64 @@ +import { IsString, IsOptional, IsNumber, Min } from "class-validator"; +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { generateTrackingId } from "../common/utils/tracking-id.util"; + +export class CreateB2BDto { + @IsString() contactName!: string; + @IsString() contactEmail!: string; + @IsOptional() @IsString() contactPhone?: string; + @IsOptional() @IsString() companyName?: string; + @IsString() merchandiseType!: string; + @IsString() description!: string; + @IsOptional() @IsNumber() @Min(0) commercialValue?: number; +} + +export class UpdateB2BStatusDto { + @IsString() status!: string; + @IsOptional() @IsString() quotationNotes?: string; + @IsOptional() @IsNumber() @Min(0) quotationAmount?: number; +} + +@Injectable() +export class B2BService { + constructor(private prisma: PrismaService) {} + + async findAll(tenantId: string): Promise { + return this.prisma.client.b2BRequest.findMany({ + where: { tenantId }, + orderBy: { createdAt: "desc" }, + }); + } + + async create(dto: CreateB2BDto, tenantId: string): Promise { + const count = await this.prisma.client.b2BRequest.count(); + const trackingId = `B2B-${String(count + 1).padStart(6, "0")}`; + return this.prisma.client.b2BRequest.create({ + data: { + tenantId, + trackingId, + contactName: dto.contactName, + contactEmail: dto.contactEmail, + contactPhone: dto.contactPhone, + companyName: dto.companyName, + merchandiseType: dto.merchandiseType, + description: dto.description, + commercialValue: dto.commercialValue ?? null, + status: "PENDIENTE", + }, + }); + } + + async updateStatus(id: string, dto: UpdateB2BStatusDto): Promise { + const req = await this.prisma.client.b2BRequest.findUnique({ where: { id } }); + if (!req) throw new NotFoundException("Solicitud B2B no encontrada."); + return this.prisma.client.b2BRequest.update({ + where: { id }, + data: { + status: dto.status as any, + quotationNotes: dto.quotationNotes, + quotationAmount: dto.quotationAmount ?? null, + }, + }); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 33aa306..a0e3b98 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,4 +1,5 @@ import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; import { AppModule } from "./app.module"; async function bootstrap() { @@ -15,6 +16,16 @@ async function bootstrap() { app.setGlobalPrefix("api"); + // Validación global de DTOs (class-validator) + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: false, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + const port = process.env.API_PORT ?? process.env.PORT ?? 3001; await app.listen(port, "0.0.0.0"); diff --git a/apps/api/src/packages/dto/package.dto.ts b/apps/api/src/packages/dto/package.dto.ts new file mode 100644 index 0000000..6268ae1 --- /dev/null +++ b/apps/api/src/packages/dto/package.dto.ts @@ -0,0 +1,50 @@ +import { IsString, IsOptional, IsNumber, Min } from "class-validator"; + +export class CreatePackageDto { + @IsString() + vendorTracking!: string; + + @IsString() + description!: string; + + @IsString() + store!: string; + + @IsOptional() + @IsNumber() + @Min(0) + declaredValue?: number; + + @IsOptional() + @IsNumber() + @Min(0) + declaredWeightLb?: number; + + @IsOptional() + @IsNumber() + @Min(0) + lengthCm?: number; + + @IsOptional() + @IsNumber() + @Min(0) + widthCm?: number; + + @IsOptional() + @IsNumber() + @Min(0) + heightCm?: number; + + /** userId del cliente al que pertenece este paquete */ + @IsString() + userId!: string; +} + +export class UpdateStatusDto { + @IsString() + status!: string; + + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/api/src/packages/packages.controller.ts b/apps/api/src/packages/packages.controller.ts new file mode 100644 index 0000000..454a4da --- /dev/null +++ b/apps/api/src/packages/packages.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common"; +import { PackagesService } from "./packages.service"; +import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto"; +import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +@Controller("packages") +@UseGuards(JwtAuthGuard, RolesGuard) +export class PackagesController { + constructor(private svc: PackagesService) {} + + @Get() + findAll(@CurrentUser() user: any, @Query("status") status?: string, @Query("search") search?: string): Promise { + return this.svc.findAll(user, { status, search }); + } + + @Get(":id") + findOne(@Param("id") id: string, @CurrentUser() user: any): Promise { + return this.svc.findOne(id, user); + } + + @Post() + @Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN") + create(@Body() dto: CreatePackageDto, @CurrentUser() user: any): Promise { + return this.svc.create(dto, user.id, user.tenantId); + } + + @Patch(":id/status") + @Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN") + updateStatus(@Param("id") id: string, @Body() dto: UpdateStatusDto, @CurrentUser() user: any): Promise { + return this.svc.updateStatus(id, dto, user.id); + } +} diff --git a/apps/api/src/packages/packages.module.ts b/apps/api/src/packages/packages.module.ts new file mode 100644 index 0000000..79186ea --- /dev/null +++ b/apps/api/src/packages/packages.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { PackagesController } from "./packages.controller"; +import { PackagesService } from "./packages.service"; + +@Module({ + controllers: [PackagesController], + providers: [PackagesService], +}) +export class PackagesModule {} diff --git a/apps/api/src/packages/packages.service.ts b/apps/api/src/packages/packages.service.ts new file mode 100644 index 0000000..af10acd --- /dev/null +++ b/apps/api/src/packages/packages.service.ts @@ -0,0 +1,102 @@ +import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { generateTrackingId } from "../common/utils/tracking-id.util"; +import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto"; + +@Injectable() +export class PackagesService { + constructor(private prisma: PrismaService) {} + + async findAll(user: any, filters?: { status?: string; search?: string }): Promise { + const where: any = { tenantId: user.tenantId }; + + if (user.role === "CLIENTE") { + where.userId = user.id; + } + + if (filters?.status) where.status = filters.status; + if (filters?.search) { + where.OR = [ + { trackingId: { contains: filters.search, mode: "insensitive" } }, + { vendorTracking: { contains: filters.search, mode: "insensitive" } }, + { description: { contains: filters.search, mode: "insensitive" } }, + ]; + } + + return this.prisma.client.package.findMany({ + where, + include: { + user: { select: { firstName: true, lastName: true, email: true } }, + statusHistory: { orderBy: { createdAt: "desc" }, take: 1 }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + async findOne(id: string, user: any): Promise { + const pkg = await this.prisma.client.package.findUnique({ + where: { id }, + include: { + user: { select: { firstName: true, lastName: true, email: true } }, + statusHistory: { orderBy: { createdAt: "desc" } }, + preAlert: true, + }, + }); + if (!pkg) throw new NotFoundException("Paquete no encontrado."); + if (user.role === "CLIENTE" && pkg.userId !== user.id) throw new ForbiddenException(); + return pkg; + } + + async create(dto: CreatePackageDto, operatorId: string, tenantId: string): Promise { + const trackingId = generateTrackingId(); + + const pkg = await this.prisma.client.package.create({ + data: { + trackingId, + tenantId, + userId: dto.userId, + description: dto.description, + store: dto.store, + vendorTracking: dto.vendorTracking, + declaredValue: dto.declaredValue ?? 0, + declaredWeight: dto.declaredWeightLb ?? null, + lengthCm: dto.lengthCm ?? null, + widthCm: dto.widthCm ?? null, + heightCm: dto.heightCm ?? null, + status: "REGISTRADO", + }, + }); + + await this.prisma.client.packageStatusHistory.create({ + data: { + packageId: pkg.id, + status: "REGISTRADO", + createdBy: operatorId, + note: "Paquete registrado al recibirse en bodega NJ", + }, + }); + + return pkg; + } + + async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise { + const pkg = await this.prisma.client.package.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException("Paquete no encontrado."); + + const updated = await this.prisma.client.package.update({ + where: { id }, + data: { status: dto.status as any }, + }); + + await this.prisma.client.packageStatusHistory.create({ + data: { + packageId: id, + status: dto.status as any, + createdBy: operatorId, + note: dto.note, + }, + }); + + return updated; + } +} diff --git a/apps/api/src/pre-alerts/dto/pre-alert.dto.ts b/apps/api/src/pre-alerts/dto/pre-alert.dto.ts new file mode 100644 index 0000000..e0bdb25 --- /dev/null +++ b/apps/api/src/pre-alerts/dto/pre-alert.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsOptional, IsNumber, Min } from "class-validator"; + +export class CreatePreAlertDto { + @IsString() + store!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsNumber() + @Min(0) + declaredValue?: number; + + @IsOptional() + @IsString() + vendorTracking?: string; +} + +export class UpdatePreAlertStatusDto { + @IsString() + status!: string; +} diff --git a/apps/api/src/pre-alerts/pre-alerts.controller.ts b/apps/api/src/pre-alerts/pre-alerts.controller.ts new file mode 100644 index 0000000..5b4b1c4 --- /dev/null +++ b/apps/api/src/pre-alerts/pre-alerts.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common"; +import { PreAlertsService } from "./pre-alerts.service"; +import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto"; +import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +@Controller("pre-alerts") +@UseGuards(JwtAuthGuard, RolesGuard) +export class PreAlertsController { + constructor(private svc: PreAlertsService) {} + + @Get() + findAll(@CurrentUser() user: any) { + return this.svc.findAll(user); + } + + @Post() + @Roles("CLIENTE") + create(@Body() dto: CreatePreAlertDto, @CurrentUser() user: any) { + return this.svc.create(dto, user); + } + + @Patch(":id/status") + @Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN") + updateStatus(@Param("id") id: string, @Body() dto: UpdatePreAlertStatusDto): Promise { + return this.svc.updateStatus(id, dto); + } + + @Delete(":id") + remove(@Param("id") id: string, @CurrentUser() user: any) { + return this.svc.remove(id, user); + } +} diff --git a/apps/api/src/pre-alerts/pre-alerts.module.ts b/apps/api/src/pre-alerts/pre-alerts.module.ts new file mode 100644 index 0000000..1064596 --- /dev/null +++ b/apps/api/src/pre-alerts/pre-alerts.module.ts @@ -0,0 +1,6 @@ +import { Module } from "@nestjs/common"; +import { PreAlertsController } from "./pre-alerts.controller"; +import { PreAlertsService } from "./pre-alerts.service"; + +@Module({ controllers: [PreAlertsController], providers: [PreAlertsService] }) +export class PreAlertsModule {} diff --git a/apps/api/src/pre-alerts/pre-alerts.service.ts b/apps/api/src/pre-alerts/pre-alerts.service.ts new file mode 100644 index 0000000..e28f848 --- /dev/null +++ b/apps/api/src/pre-alerts/pre-alerts.service.ts @@ -0,0 +1,45 @@ +import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto"; + +@Injectable() +export class PreAlertsService { + constructor(private prisma: PrismaService) {} + + async findAll(user: any): Promise { + const where: any = { tenantId: user.tenantId }; + if (user.role === "CLIENTE") where.userId = user.id; + return this.prisma.client.preAlert.findMany({ + where, + include: { user: { select: { firstName: true, lastName: true, email: true } } }, + orderBy: { createdAt: "desc" }, + }); + } + + async create(dto: CreatePreAlertDto, user: any): Promise { + return this.prisma.client.preAlert.create({ + data: { + tenantId: user.tenantId, + userId: user.id, + store: dto.store, + description: dto.description, + declaredValue: dto.declaredValue ?? 0, + vendorTracking: dto.vendorTracking, + status: "PENDIENTE", + }, + }); + } + + async updateStatus(id: string, dto: UpdatePreAlertStatusDto): Promise { + const alert = await this.prisma.client.preAlert.findUnique({ where: { id } }); + if (!alert) throw new NotFoundException("Pre-alerta no encontrada."); + return this.prisma.client.preAlert.update({ where: { id }, data: { status: dto.status as any } }); + } + + async remove(id: string, user: any): Promise { + const alert = await this.prisma.client.preAlert.findUnique({ where: { id } }); + if (!alert) throw new NotFoundException("Pre-alerta no encontrada."); + if (user.role === "CLIENTE" && alert.userId !== user.id) throw new ForbiddenException(); + return this.prisma.client.preAlert.delete({ where: { id } }); + } +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts new file mode 100644 index 0000000..0c5dc84 --- /dev/null +++ b/apps/api/src/users/users.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Patch, Body, Param, Query, UseGuards } from "@nestjs/common"; +import { UsersService } from "./users.service"; +import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { IsEnum, IsBoolean } from "class-validator"; + +class UpdateRoleDto { + @IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"]) + role!: string; +} +class SetActiveDto { + @IsBoolean() isActive!: boolean; +} + +@Controller("users") +@UseGuards(JwtAuthGuard, RolesGuard) +export class UsersController { + constructor(private svc: UsersService) {} + + @Get() + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + findAll(@CurrentUser() user: any, @Query("search") search?: string): Promise { + return this.svc.findAll(user.tenantId, search); + } + + @Get(":id") + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + findOne(@Param("id") id: string): Promise { + return this.svc.findOne(id); + } + + @Patch(":id/role") + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + updateRole(@Param("id") id: string, @Body() dto: UpdateRoleDto): Promise { + return this.svc.updateRole(id, dto.role); + } + + @Patch(":id/active") + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + setActive(@Param("id") id: string, @Body() dto: SetActiveDto): Promise { + return this.svc.setActive(id, dto.isActive); + } +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..974df2b --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,6 @@ +import { Module } from "@nestjs/common"; +import { UsersController } from "./users.controller"; +import { UsersService } from "./users.service"; + +@Module({ controllers: [UsersController], providers: [UsersService] }) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..2d08e23 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,49 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class UsersService { + constructor(private prisma: PrismaService) {} + + async findAll(tenantId: string, search?: string): Promise { + return this.prisma.client.user.findMany({ + where: { + tenantId, + ...(search ? { + OR: [ + { email: { contains: search, mode: "insensitive" } }, + { firstName: { contains: search, mode: "insensitive" } }, + { lastName: { contains: search, mode: "insensitive" } }, + ], + } : {}), + }, + select: { + id: true, email: true, firstName: true, lastName: true, phone: true, + role: true, isActive: true, mfaEnabled: true, lastLoginAt: true, createdAt: true, + suite: { select: { code: true } }, + }, + orderBy: { createdAt: "desc" }, + }); + } + + async findOne(id: string): Promise { + const user = await this.prisma.client.user.findUnique({ + where: { id }, + select: { + id: true, email: true, firstName: true, lastName: true, phone: true, + role: true, isActive: true, mfaEnabled: true, lastLoginAt: true, createdAt: true, + suite: { select: { code: true } }, + }, + }); + if (!user) throw new NotFoundException("Usuario no encontrado."); + return user; + } + + async updateRole(id: string, role: string): Promise { + return this.prisma.client.user.update({ where: { id }, data: { role: role as any } }); + } + + async setActive(id: string, isActive: boolean): Promise { + return this.prisma.client.user.update({ where: { id }, data: { isActive } }); + } +} diff --git a/apps/web/package.json b/apps/web/package.json index ec904dd..78d242a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,11 +9,13 @@ "lint": "next lint" }, "dependencies": { + "js-cookie": "^3.0.8", "next": "^15.3.2", "react": "^19.1.0", "react-dom": "^19.1.0" }, "devDependencies": { + "@types/js-cookie": "^3.0.6", "@types/node": "^22.15.21", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", diff --git a/apps/web/src/app/admin/auditoria/page.tsx b/apps/web/src/app/admin/auditoria/page.tsx new file mode 100644 index 0000000..a12c97a --- /dev/null +++ b/apps/web/src/app/admin/auditoria/page.tsx @@ -0,0 +1,20 @@ +"use client"; +// Auditoría — placeholder con nota de implementación futura (requiere endpoint /audit) +export default function AuditoriaPage() { + return ( +
+

Auditoría

Registro de acciones del sistema (AuditLog).

+
+
+
+ El log de auditoría se registra automáticamente en la tabla AuditLog de la base de datos. + Para consultarlo directamente, accede al panel de base de datos o agrega el endpoint GET /api/audit en la API. +
+

+ Acciones registradas: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, MFA_ENABLED, USER_REGISTER, y más. +

+
+
+
+ ); +} diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx new file mode 100644 index 0000000..90704ef --- /dev/null +++ b/apps/web/src/app/admin/layout.tsx @@ -0,0 +1,63 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { getUser, clearAuth } from "@/lib/api"; +import { api } from "@/lib/api"; + +const NAV = [ + { href: "/admin", icon: "◈", label: "Dashboard" }, + { href: "/admin/usuarios", icon: "👥", label: "Usuarios" }, + { href: "/admin/tarifas", icon: "💰", label: "Tarifas" }, + { href: "/admin/reportes", icon: "📊", label: "Reportes" }, + { href: "/admin/auditoria", icon: "🔍", label: "Auditoría" }, + { href: "/bodega", icon: "📦", label: "→ Bodega" }, +]; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [user, setUser] = useState(null); + + useEffect(() => { + const u = getUser(); + if (!u) { router.replace("/login"); return; } + if (!["ADMIN_EMPRESA","SUPER_ADMIN"].includes(u.role)) { router.replace("/portal"); return; } + setUser(u); + }, [router]); + + const handleLogout = async () => { + try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {} + clearAuth(); router.push("/login"); + }; + + if (!user) return
; + + return ( +
+ +
+
+ Panel de Administración + {user.email} +
+
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx new file mode 100644 index 0000000..f02543d --- /dev/null +++ b/apps/web/src/app/admin/page.tsx @@ -0,0 +1,76 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { api } from "@/lib/api"; + +export default function AdminDashboard() { + const [users, setUsers] = useState([]); + const [b2b, setB2b] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([api.users.list(), api.b2b.list()]) + .then(([u, b]) => { setUsers(u); setB2b(b); }) + .catch(() => {}).finally(() => setLoading(false)); + }, []); + + if (loading) return
; + + const clientes = users.filter(u => u.role === "CLIENTE").length; + const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length; + + return ( +
+

Dashboard Admin

+
+ {[ + { label: "Total usuarios", value: users.length, color: "var(--primary)" }, + { label: "Clientes", value: clientes, color: "var(--green)" }, + { label: "Solicitudes B2B", value: b2bPending, color: "var(--yellow)" }, + { label: "Activos", value: users.filter(u=>u.isActive).length, color: "var(--accent)" }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
+
+
+ Últimos usuarios + Ver todos → +
+
+ + + + {users.slice(0,8).map(u => ( + + + + + + + ))} + +
NombreEmailRolEstado
{u.firstName} {u.lastName}{u.email}{u.role}{u.isActive ? "Activo" : "Inactivo"}
+
+
+
+
Solicitudes B2B pendientes
+ {b2bPending === 0 ? ( +

Sin solicitudes pendientes.

+ ) : ( + b2b.filter(r=>r.status==="PENDIENTE").map(r => ( +
+
{r.companyName}
+
{r.contactEmail}
+
+ )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/reportes/page.tsx b/apps/web/src/app/admin/reportes/page.tsx new file mode 100644 index 0000000..5910909 --- /dev/null +++ b/apps/web/src/app/admin/reportes/page.tsx @@ -0,0 +1,70 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +export default function ReportesPage() { + const [users, setUsers] = useState([]); + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([api.users.list(), api.packages.list()]) + .then(([u, p]) => { setUsers(u); setPackages(p); }) + .catch(() => {}).finally(() => setLoading(false)); + }, []); + + if (loading) return
; + + const byStatus: Record = {}; + packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; }); + + const totalDeclared = packages.reduce((a, p) => a + (p.declaredValueUsd ?? 0), 0); + const byRole: Record = {}; + users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; }); + + return ( +
+

Reportes

Resumen operativo del sistema.

+ +
+ {[ + { label: "Total paquetes", value: packages.length, color: "var(--primary)" }, + { label: "Entregados", value: byStatus["ENTREGADO"] ?? 0, color: "var(--green)" }, + { label: "En tránsito", value: byStatus["EN_CAMINO_A_ECUADOR"] ?? 0, color: "var(--yellow)" }, + { label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ +
+
+
Paquetes por estado
+
+ {Object.entries(byStatus).map(([status, count]) => ( +
+ {status.replace(/_/g," ")} + {count} +
+ ))} + {Object.keys(byStatus).length === 0 &&

Sin datos.

} +
+
+
+
Usuarios por rol
+
+ {Object.entries(byRole).map(([role, count]) => ( +
+ {role} + {count} +
+ ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/admin/tarifas/page.tsx b/apps/web/src/app/admin/tarifas/page.tsx new file mode 100644 index 0000000..c9edd53 --- /dev/null +++ b/apps/web/src/app/admin/tarifas/page.tsx @@ -0,0 +1,48 @@ +"use client"; + +// Página de tarifas — muestra tabla estática de precios (§05 doc) + formulario de edición futuro +const TARIFAS = [ + { category: "Mensajería Acelerada", max: "$200", exento: "Sí (≤ $200)", flete: "$8–$15/lb", notas: "Hasta 4 kg · sin impuestos" }, + { category: "Courier", max: "$400", exento: "≤ $200", flete: "$8–$15/lb", notas: "Impuestos desde $200.01" }, + { category: "Régimen 4×4", max: "$2,000",exento: "No", flete: "Variable", notas: "SENAE arancel + IVA + FODINFA" }, + { category: "Carga pesada (FCL)", max: "Ilimitado", exento: "No", flete: "Cotización", notas: "Tarifa por m³ y peso" }, +]; + +export default function TarifasPage() { + return ( +
+
+

Tarifas

+

Tabla de regímenes aduaneros y precios base.

+
+ +
+
Regímenes aduaneros (§05 / §15)
+
+ + + + + + + + {TARIFAS.map(t => ( + + + + + + + + ))} + +
RégimenValor máx.ExentoFleteNotas
{t.category}{t.max}{t.exento}{t.flete}{t.notas}
+
+
+ +
+ Nota: Las tarifas de flete varían según el peso volumétrico (L×W×H / 139) vs. peso real. Se cobra el mayor. Los impuestos SENAE (FODINFA 0.5%, Arancel variable, IVA 15%) se calculan automáticamente en la calculadora del portal. +
+
+ ); +} diff --git a/apps/web/src/app/admin/usuarios/page.tsx b/apps/web/src/app/admin/usuarios/page.tsx new file mode 100644 index 0000000..3d86ca3 --- /dev/null +++ b/apps/web/src/app/admin/usuarios/page.tsx @@ -0,0 +1,77 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +const ROLES = ["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"]; + +export default function UsuariosPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [updating, setUpdating] = useState(null); + + const load = (s?: string) => { setLoading(true); api.users.list(s).then(setUsers).catch(()=>{}).finally(()=>setLoading(false)); }; + useEffect(() => { load(); }, []); + + const handleRoleChange = async (id: string, role: string) => { + setUpdating(id); + try { await api.users.updateRole(id, role); load(); } catch {} + finally { setUpdating(null); } + }; + + const handleToggleActive = async (id: string, isActive: boolean) => { + setUpdating(id); + try { await api.users.setActive(id, !isActive); load(); } catch {} + finally { setUpdating(null); } + }; + + return ( +
+
+

Usuarios

Gestión de cuentas y roles.

+
+ setSearch(e.target.value)} + onKeyDown={e => e.key === "Enter" && load(search)} /> + +
+
+ +
+
+ {loading ?
: ( + + + + {users.map(u => ( + + + + + + + + + ))} + +
NombreEmailCasilleroRolEstadoAcciones
{u.firstName} {u.lastName}{u.email}{u.suite?.code ?? "—"} + + {u.isActive ? "Activo" : "Inactivo"} + +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/bodega/despacho/page.tsx b/apps/web/src/app/bodega/despacho/page.tsx new file mode 100644 index 0000000..5ac641b --- /dev/null +++ b/apps/web/src/app/bodega/despacho/page.tsx @@ -0,0 +1,66 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +// Despacho — paquetes listos para enviar a Ecuador +export default function DespachoPage() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(null); + const [msg, setMsg] = useState(""); + + const load = () => { + api.packages.list({ status: "LISTO_PARA_RETIRO" }).then(setPackages).catch(()=>{}).finally(()=>setLoading(false)); + }; + useEffect(() => { load(); }, []); + + const markDispatched = async (id: string) => { + setUpdating(id); + try { + await api.packages.updateStatus(id, { status: "EN_CAMINO_A_ECUADOR", notes: "Despachado desde bodega NJ" }); + setMsg("Paquete marcado como despachado."); + load(); + } catch (err: any) { setMsg(err.message ?? "Error"); } + finally { setUpdating(null); } + }; + + return ( +
+

Despacho

Paquetes listos para envío a Ecuador.

+ {msg &&
{msg}
} + {loading ?
: ( + packages.length === 0 ? ( +
+
+

No hay paquetes pendientes de despacho.

+
+ ) : ( +
+
+ + + + {packages.map(p => ( + + + + + + + + + ))} + +
TrackingClientePesoValor declaradoCategoríaAcción
{p.trackingId}{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}{p.weightLb ? `${p.weightLb} lb` : "—"}{p.declaredValueUsd ? `$${p.declaredValueUsd}` : "—"}{(p.senaeCategory ?? "").replace(/_/g," ")} + +
+
+
+ ) + )} +
+ ); +} diff --git a/apps/web/src/app/bodega/layout.tsx b/apps/web/src/app/bodega/layout.tsx new file mode 100644 index 0000000..4fdb2b7 --- /dev/null +++ b/apps/web/src/app/bodega/layout.tsx @@ -0,0 +1,64 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { getUser, clearAuth } from "@/lib/api"; +import { api } from "@/lib/api"; + +const NAV = [ + { href: "/bodega", icon: "◈", label: "Dashboard" }, + { href: "/bodega/paquetes", icon: "📦", label: "Paquetes" }, + { href: "/bodega/verificacion", icon: "✅", label: "Verificación NJ" }, + { href: "/bodega/despacho", icon: "🚢", label: "Despacho" }, + { href: "/admin", icon: "⚙️", label: "→ Admin" }, +]; + +const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANAS","ADMIN","SUPERADMIN"]; + +export default function BodegaLayout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const pathname = usePathname(); + const [user, setUser] = useState(null); + + useEffect(() => { + const u = getUser(); + if (!u) { router.replace("/login"); return; } + if (!ALLOWED.includes(u.role)) { router.replace("/portal"); return; } + setUser(u); + }, [router]); + + const handleLogout = async () => { + try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {} + clearAuth(); router.push("/login"); + }; + + if (!user) return
; + + return ( +
+ +
+
+ Panel de Bodega + {user.email} +
+
{children}
+
+
+ ); +} diff --git a/apps/web/src/app/bodega/page.tsx b/apps/web/src/app/bodega/page.tsx new file mode 100644 index 0000000..1bb2b46 --- /dev/null +++ b/apps/web/src/app/bodega/page.tsx @@ -0,0 +1,75 @@ +"use client"; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { api } from "@/lib/api"; + +export default function BodegaDashboard() { + const [packages, setPackages] = useState([]); + const [preAlerts, setPreAlerts] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([api.packages.list(), api.preAlerts.list()]) + .then(([p, a]) => { setPackages(p); setPreAlerts(a); }) + .catch(() => {}).finally(() => setLoading(false)); + }, []); + + if (loading) return
; + + const recibidos = packages.filter(p => p.status === "RECIBIDO_EN_NJ").length; + const enCamino = packages.filter(p => p.status === "EN_CAMINO_A_ECUADOR").length; + const listos = packages.filter(p => p.status === "LISTO_PARA_RETIRO").length; + const alertasPend = preAlerts.filter(p => p.status === "PENDIENTE").length; + + return ( +
+

Dashboard Bodega

+
+ {[ + { label: "Recibidos en NJ", value: recibidos, color: "var(--primary)" }, + { label: "En camino a EC", value: enCamino, color: "var(--yellow)" }, + { label: "Listos para retiro",value: listos, color: "var(--green)" }, + { label: "Pre-alertas pend.", value: alertasPend, color: "var(--accent)" }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+
+
+
+ Paquetes recientes + Ver todos → +
+
+ + + + {packages.slice(0,8).map(p => ( + + + + + + ))} + +
TrackingClienteEstado
{p.trackingId}{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}{p.status.replace(/_/g," ")}
+
+
+
+
Pre-alertas pendientes
+ {alertasPend === 0 ?

Sin pre-alertas pendientes.

: ( + preAlerts.filter(a => a.status === "PENDIENTE").map(a => ( +
+
{a.store} — {a.orderNumber}
+
{a.description ?? "Sin descripción"}
+
+ )) + )} +
+
+
+ ); +} diff --git a/apps/web/src/app/bodega/paquetes/page.tsx b/apps/web/src/app/bodega/paquetes/page.tsx new file mode 100644 index 0000000..744feab --- /dev/null +++ b/apps/web/src/app/bodega/paquetes/page.tsx @@ -0,0 +1,161 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +const STATUSES = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO","RETENIDO_ADUANA","DEVUELTO","PERDIDO","CANCELADO"]; + +export default function BodegaPaquetesPage() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [statusForm, setStatusForm] = useState({ status: "", notes: "", location: "" }); + const [updating, setUpdating] = useState(false); + const [msg, setMsg] = useState(""); + + // Crear paquete + const [showCreate, setShowCreate] = useState(false); + const [createForm, setCreateForm] = useState({ trackingNumber: "", suiteCode: "", description: "", weightLb: "", declaredValueUsd: "" }); + const [suites, setSuites] = useState([]); + const [creating, setCreating] = useState(false); + + const load = () => { setLoading(true); api.packages.list().then(setPackages).catch(()=>{}).finally(()=>setLoading(false)); }; + useEffect(() => { load(); }, []); + + const handleUpdateStatus = async (e: React.FormEvent) => { + e.preventDefault(); setUpdating(true); setMsg(""); + try { + await api.packages.updateStatus(selected.id, { status: statusForm.status, notes: statusForm.notes, location: statusForm.location }); + setMsg("Estado actualizado."); load(); setSelected(null); + } catch (err: any) { setMsg(err.message ?? "Error"); } + finally { setUpdating(false); } + }; + + return ( +
+
+

Paquetes

Gestión de paquetes en bodega.

+ +
+ + {msg &&
{msg}
} + + {/* Modal crear */} + {showCreate && ( +
+
+ Registrar nuevo paquete + +
+
+

Ingresa el tracking del proveedor y el código de suite del cliente para registrar el paquete al recibirlo en NJ.

+
{ + e.preventDefault(); setCreating(true); + try { + // Buscar suiteId por código + const allPkgs = await api.packages.list(); + const suiteMatch = allPkgs.find((p: any) => p.suite?.code === createForm.suiteCode); + // Si no hay coincidencia, mostrar error + if (!suiteMatch && !createForm.suiteCode) { setMsg("Código de suite inválido."); setCreating(false); return; } + await api.packages.create({ + trackingNumber: createForm.trackingNumber, + description: createForm.description || undefined, + weightLb: createForm.weightLb ? parseFloat(createForm.weightLb) : undefined, + declaredValueUsd: createForm.declaredValueUsd ? parseFloat(createForm.declaredValueUsd) : undefined, + suiteId: suiteMatch?.suiteId ?? createForm.suiteCode, // API acepta suiteId + }); + setMsg("Paquete registrado."); setShowCreate(false); load(); + } catch (err: any) { setMsg(err.message); } finally { setCreating(false); } + }}> +
+ + setCreateForm(f => ({...f, trackingNumber: e.target.value}))} required /> +
+
+ + setCreateForm(f => ({...f, suiteCode: e.target.value}))} required /> +
+
+ + setCreateForm(f => ({...f, description: e.target.value}))} /> +
+
+ + setCreateForm(f => ({...f, weightLb: e.target.value}))} /> +
+
+ +
+
+
+
+ )} + + {/* Tabla */} +
+
+ {loading ?
: ( + + + + {packages.map(p => ( + + + + + + + + + ))} + +
TrackingProveedorClientePesoEstadoAcción
{p.trackingId}{p.trackingNumber ?? "—"}{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}{p.weightLb ? `${p.weightLb} lb` : "—"}{p.status.replace(/_/g," ")} + +
+ )} +
+
+ + {/* Panel actualizar estado */} + {selected && ( +
+
+
+ Actualizar estado — {selected.trackingId} + +
+
+
+
+ + +
+
+ + setStatusForm(f => ({...f, location: e.target.value}))} /> +
+
+ +