Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
38 KiB
38 KiB
ET-ADM-001: Implementación de RBAC Multi-Tenancy
ID: ET-ADM-001 Módulo: MAI-013 - Administración & Seguridad Tipo: Especificación Técnica Prioridad: P0 (Crítica) Fecha de creación: 2025-11-20 Versión: 1.0 Relacionado con: RF-ADM-001, RF-ADM-002
📋 Descripción
Especificación técnica completa para la implementación del sistema RBAC (Role-Based Access Control) con soporte multi-tenancy que permite:
- Gestión de múltiples empresas constructoras en un solo sistema
- 7 roles especializados con permisos granulares
- Row Level Security (RLS) en PostgreSQL
- Guards y decoradores en NestJS
- Componentes React para gestión de usuarios
🗄️ Base de Datos (PostgreSQL)
Schemas
-- Schema para autenticación y gestión de usuarios
CREATE SCHEMA IF NOT EXISTS auth_management;
-- Schema para multi-tenancy (empresas constructoras)
CREATE SCHEMA IF NOT EXISTS constructoras;
-- Schema para auditoría
CREATE SCHEMA IF NOT EXISTS audit_logging;
ENUMs
-- auth_management.construction_role
CREATE TYPE auth_management.construction_role AS ENUM (
'director', -- Director General
'engineer', -- Ingeniero/Planeación
'resident', -- Residente de Obra
'purchases', -- Compras/Almacén
'finance', -- Administración/Finanzas
'hr', -- RRHH/Nómina
'post_sales' -- Postventa
);
-- auth_management.account_status
CREATE TYPE auth_management.account_status AS ENUM (
'active', -- Activo
'inactive', -- Inactivo
'suspended', -- Suspendido temporalmente
'locked' -- Bloqueado por intentos fallidos
);
-- auth_management.permission_action
CREATE TYPE auth_management.permission_action AS ENUM (
'create',
'read',
'update',
'delete',
'approve'
);
Tabla: constructoras
CREATE TABLE constructoras.constructoras (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(20) UNIQUE NOT NULL, -- CONST-001, CONST-002
-- Información básica
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL, -- Razón social
rfc VARCHAR(13) UNIQUE NOT NULL,
tax_regime VARCHAR(100),
-- Domicilio fiscal
address TEXT,
city VARCHAR(100),
state VARCHAR(100),
zip_code VARCHAR(10),
country VARCHAR(2) DEFAULT 'MX',
-- Contacto
phone VARCHAR(20),
email VARCHAR(100),
website VARCHAR(200),
-- Branding
logo_url TEXT,
primary_color VARCHAR(7), -- Hex color: #FF5733
-- Estado
status VARCHAR(20) DEFAULT 'active',
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID,
CONSTRAINT constructoras_status_check CHECK (status IN ('active', 'inactive', 'suspended'))
);
-- Índices
CREATE INDEX idx_constructoras_code ON constructoras.constructoras(code);
CREATE INDEX idx_constructoras_rfc ON constructoras.constructoras(rfc);
CREATE INDEX idx_constructoras_status ON constructoras.constructoras(status);
-- Trigger para updated_at
CREATE TRIGGER update_constructoras_updated_at
BEFORE UPDATE ON constructoras.constructoras
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Tabla: users
CREATE TABLE auth_management.users (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL, -- bcrypt hash
-- Datos personales
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
full_name VARCHAR(200) GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED,
phone VARCHAR(20),
mobile_phone VARCHAR(20),
-- Avatar
avatar_url TEXT,
-- Estado de cuenta
status auth_management.account_status DEFAULT 'active',
email_verified BOOLEAN DEFAULT FALSE,
email_verified_at TIMESTAMPTZ,
-- Seguridad
last_login_at TIMESTAMPTZ,
last_login_ip INET,
failed_login_attempts INT DEFAULT 0,
locked_until TIMESTAMPTZ,
password_changed_at TIMESTAMPTZ DEFAULT NOW(),
must_change_password BOOLEAN DEFAULT TRUE, -- Primer login
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID,
deleted_at TIMESTAMPTZ, -- Soft delete
CONSTRAINT users_email_check CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$')
);
-- Índices
CREATE UNIQUE INDEX idx_users_email ON auth_management.users(email) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_status ON auth_management.users(status);
CREATE INDEX idx_users_last_login ON auth_management.users(last_login_at DESC);
-- Trigger para updated_at
CREATE TRIGGER update_users_updated_at
BEFORE UPDATE ON auth_management.users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Tabla: user_constructoras (Relación Many-to-Many)
CREATE TABLE auth_management.user_constructoras (
-- Relación
user_id UUID NOT NULL REFERENCES auth_management.users(id) ON DELETE CASCADE,
constructora_id UUID NOT NULL REFERENCES constructoras.constructoras(id) ON DELETE CASCADE,
-- Rol en esta constructora
role auth_management.construction_role NOT NULL,
-- Estado
status VARCHAR(20) DEFAULT 'active',
-- Permisos personalizados (JSONB)
custom_permissions JSONB DEFAULT '[]'::jsonb,
-- Metadata
joined_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
granted_by UUID REFERENCES auth_management.users(id),
PRIMARY KEY (user_id, constructora_id),
CONSTRAINT user_constructoras_status_check CHECK (status IN ('active', 'inactive'))
);
-- Índices
CREATE INDEX idx_user_constructoras_user ON auth_management.user_constructoras(user_id);
CREATE INDEX idx_user_constructoras_constructora ON auth_management.user_constructoras(constructora_id);
CREATE INDEX idx_user_constructoras_role ON auth_management.user_constructoras(role);
CREATE INDEX idx_user_constructoras_custom_permissions ON auth_management.user_constructoras USING GIN (custom_permissions);
-- Trigger para updated_at
CREATE TRIGGER update_user_constructoras_updated_at
BEFORE UPDATE ON auth_management.user_constructoras
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Tabla: invitations
CREATE TABLE auth_management.invitations (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token VARCHAR(64) UNIQUE NOT NULL, -- Random token
-- Usuario a invitar
email VARCHAR(255) NOT NULL,
first_name VARCHAR(100),
last_name VARCHAR(100),
-- Constructora y rol asignado
constructora_id UUID NOT NULL REFERENCES constructoras.constructoras(id),
role auth_management.construction_role NOT NULL,
-- Mensaje personalizado
custom_message TEXT,
-- Estado
status VARCHAR(20) DEFAULT 'pending',
accepted_at TIMESTAMPTZ,
-- Expiración
expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '7 days'),
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID NOT NULL REFERENCES auth_management.users(id),
CONSTRAINT invitations_status_check CHECK (status IN ('pending', 'accepted', 'expired', 'cancelled'))
);
-- Índices
CREATE UNIQUE INDEX idx_invitations_token ON auth_management.invitations(token) WHERE status = 'pending';
CREATE INDEX idx_invitations_email ON auth_management.invitations(email);
CREATE INDEX idx_invitations_status ON auth_management.invitations(status);
CREATE INDEX idx_invitations_expires_at ON auth_management.invitations(expires_at);
-- Trigger para expirar invitaciones automáticamente
CREATE OR REPLACE FUNCTION expire_old_invitations()
RETURNS void AS $$
BEGIN
UPDATE auth_management.invitations
SET status = 'expired'
WHERE status = 'pending'
AND expires_at < NOW();
END;
$$ LANGUAGE plpgsql;
-- Cron job (pg_cron o external)
-- SELECT cron.schedule('expire-invitations', '0 */6 * * *', 'SELECT expire_old_invitations();');
Tabla: role_permissions (Matriz de Permisos)
CREATE TABLE auth_management.role_permissions (
-- Identificación
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Rol y módulo
role auth_management.construction_role NOT NULL,
module VARCHAR(50) NOT NULL, -- 'projects', 'budgets', 'purchases', etc.
-- Permisos
permissions auth_management.permission_action[] NOT NULL,
-- Metadata
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(role, module)
);
-- Índices
CREATE INDEX idx_role_permissions_role ON auth_management.role_permissions(role);
CREATE INDEX idx_role_permissions_module ON auth_management.role_permissions(module);
-- Seed data: Matriz de permisos por defecto
INSERT INTO auth_management.role_permissions (role, module, permissions) VALUES
-- Director: Acceso completo a todo
('director', 'projects', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('director', 'budgets', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('director', 'purchases', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('director', 'estimations', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('director', 'admin', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
-- Engineer: CRUD en proyectos, presupuestos, control obra
('engineer', 'projects', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('engineer', 'budgets', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('engineer', 'construction', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('engineer', 'estimations', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
-- Resident: CRUD en obra, compras, inventarios
('resident', 'projects', ARRAY['read']::auth_management.permission_action[]),
('resident', 'construction', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('resident', 'purchases', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('resident', 'inventory', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
-- Purchases: CRUD+Approve en compras e inventarios
('purchases', 'purchases', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('purchases', 'inventory', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('purchases', 'projects', ARRAY['read']::auth_management.permission_action[]),
-- Finance: CRUD+Approve en estimaciones y finanzas
('finance', 'estimations', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('finance', 'reports', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('finance', 'projects', ARRAY['read']::auth_management.permission_action[]),
-- HR: CRUD+Approve en RRHH
('hr', 'hr', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]),
('hr', 'projects', ARRAY['read']::auth_management.permission_action[]),
-- Post Sales: CRUD en postventa y CRM
('post_sales', 'quality', ARRAY['create','read','update','delete']::auth_management.permission_action[]),
('post_sales', 'crm', ARRAY['create','read','update','delete','approve']::auth_management.permission_action[]);
Row Level Security (RLS)
-- Habilitar RLS en tablas críticas
ALTER TABLE constructoras.constructoras ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth_management.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE auth_management.user_constructoras ENABLE ROW LEVEL SECURITY;
-- Política: Solo ver usuarios de tu constructora
CREATE POLICY user_constructoras_isolation_policy
ON auth_management.user_constructoras
FOR ALL
USING (
constructora_id = current_setting('app.current_constructora_id', true)::uuid
);
-- Política: Solo ver tu constructora
CREATE POLICY constructoras_isolation_policy
ON constructoras.constructoras
FOR ALL
USING (
id = current_setting('app.current_constructora_id', true)::uuid
);
-- Función: Configurar contexto de sesión
CREATE OR REPLACE FUNCTION set_session_context(
p_user_id UUID,
p_constructora_id UUID,
p_user_role auth_management.construction_role
)
RETURNS void AS $$
BEGIN
PERFORM set_config('app.current_user_id', p_user_id::text, false);
PERFORM set_config('app.current_constructora_id', p_constructora_id::text, false);
PERFORM set_config('app.current_user_role', p_user_role::text, false);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
🔧 Backend (NestJS + TypeScript)
Entities (TypeORM)
user.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm';
import { UserConstructora } from './user-constructora.entity';
import { AccountStatus } from '../enums/account-status.enum';
@Entity({ schema: 'auth_management', name: 'users' })
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column({ name: 'password_hash', select: false })
passwordHash: string;
@Column({ name: 'first_name' })
firstName: string;
@Column({ name: 'last_name' })
lastName: string;
@Column({ name: 'full_name', insert: false, update: false })
fullName: string;
@Column({ nullable: true })
phone?: string;
@Column({ name: 'mobile_phone', nullable: true })
mobilePhone?: string;
@Column({ name: 'avatar_url', nullable: true })
avatarUrl?: string;
@Column({ type: 'enum', enum: AccountStatus, default: AccountStatus.ACTIVE })
status: AccountStatus;
@Column({ name: 'email_verified', default: false })
emailVerified: boolean;
@Column({ name: 'email_verified_at', nullable: true })
emailVerifiedAt?: Date;
@Column({ name: 'last_login_at', nullable: true })
lastLoginAt?: Date;
@Column({ name: 'last_login_ip', type: 'inet', nullable: true })
lastLoginIp?: string;
@Column({ name: 'failed_login_attempts', default: 0 })
failedLoginAttempts: number;
@Column({ name: 'locked_until', nullable: true })
lockedUntil?: Date;
@Column({ name: 'password_changed_at' })
passwordChangedAt: Date;
@Column({ name: 'must_change_password', default: true })
mustChangePassword: boolean;
@OneToMany(() => UserConstructora, uc => uc.user)
constructoras: UserConstructora[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'created_by', nullable: true })
createdBy?: string;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date;
}
constructora.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { UserConstructora } from './user-constructora.entity';
@Entity({ schema: 'constructoras', name: 'constructoras' })
export class Constructora {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, length: 20 })
code: string;
@Column({ length: 200 })
name: string;
@Column({ name: 'legal_name', length: 300 })
legalName: string;
@Column({ unique: true, length: 13 })
rfc: string;
@Column({ name: 'tax_regime', length: 100, nullable: true })
taxRegime?: string;
@Column({ type: 'text', nullable: true })
address?: string;
@Column({ length: 100, nullable: true })
city?: string;
@Column({ length: 100, nullable: true })
state?: string;
@Column({ name: 'zip_code', length: 10, nullable: true })
zipCode?: string;
@Column({ length: 2, default: 'MX' })
country: string;
@Column({ length: 20, nullable: true })
phone?: string;
@Column({ length: 100, nullable: true })
email?: string;
@Column({ length: 200, nullable: true })
website?: string;
@Column({ name: 'logo_url', type: 'text', nullable: true })
logoUrl?: string;
@Column({ name: 'primary_color', length: 7, nullable: true })
primaryColor?: string;
@Column({ length: 20, default: 'active' })
status: string;
@OneToMany(() => UserConstructora, uc => uc.constructora)
users: UserConstructora[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'created_by', nullable: true })
createdBy?: string;
}
user-constructora.entity.ts
import { Entity, Column, ManyToOne, JoinColumn, PrimaryColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { User } from './user.entity';
import { Constructora } from './constructora.entity';
import { ConstructionRole } from '../enums/construction-role.enum';
@Entity({ schema: 'auth_management', name: 'user_constructoras' })
export class UserConstructora {
@PrimaryColumn({ name: 'user_id' })
userId: string;
@PrimaryColumn({ name: 'constructora_id' })
constructoraId: string;
@ManyToOne(() => User, user => user.constructoras)
@JoinColumn({ name: 'user_id' })
user: User;
@ManyToOne(() => Constructora, constructora => constructora.users)
@JoinColumn({ name: 'constructora_id' })
constructora: Constructora;
@Column({ type: 'enum', enum: ConstructionRole })
role: ConstructionRole;
@Column({ length: 20, default: 'active' })
status: string;
@Column({ name: 'custom_permissions', type: 'jsonb', default: [] })
customPermissions: any[];
@CreateDateColumn({ name: 'joined_at' })
joinedAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
@Column({ name: 'granted_by', nullable: true })
grantedBy?: string;
}
ENUMs
// enums/construction-role.enum.ts
export enum ConstructionRole {
DIRECTOR = 'director',
ENGINEER = 'engineer',
RESIDENT = 'resident',
PURCHASES = 'purchases',
FINANCE = 'finance',
HR = 'hr',
POST_SALES = 'post_sales'
}
// enums/account-status.enum.ts
export enum AccountStatus {
ACTIVE = 'active',
INACTIVE = 'inactive',
SUSPENDED = 'suspended',
LOCKED = 'locked'
}
// enums/permission-action.enum.ts
export enum PermissionAction {
CREATE = 'create',
READ = 'read',
UPDATE = 'update',
DELETE = 'delete',
APPROVE = 'approve'
}
Services
users.service.ts
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from './entities/user.entity';
import { UserConstructora } from './entities/user-constructora.entity';
import { CreateUserDto, UpdateUserDto } from './dto';
import { AccountStatus } from './enums/account-status.enum';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private usersRepo: Repository<User>,
@InjectRepository(UserConstructora)
private userConstructorasRepo: Repository<UserConstructora>,
) {}
async create(dto: CreateUserDto, createdBy: string): Promise<User> {
// Validar email único
const existing = await this.usersRepo.findOne({
where: { email: dto.email }
});
if (existing) {
throw new BadRequestException('Email already exists');
}
// Hash password
const passwordHash = await bcrypt.hash(dto.password, 10);
// Crear usuario
const user = this.usersRepo.create({
email: dto.email,
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
phone: dto.phone,
mobilePhone: dto.mobilePhone,
createdBy
});
return this.usersRepo.save(user);
}
async findAll(constructoraId: string, filters?: any): Promise<User[]> {
const qb = this.usersRepo.createQueryBuilder('u')
.innerJoin('u.constructoras', 'uc')
.where('uc.constructora_id = :constructoraId', { constructoraId });
if (filters.status) {
qb.andWhere('u.status = :status', { status: filters.status });
}
if (filters.role) {
qb.andWhere('uc.role = :role', { role: filters.role });
}
if (filters.search) {
qb.andWhere(
'(u.full_name ILIKE :search OR u.email ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
return qb.getMany();
}
async findOne(id: string): Promise<User> {
const user = await this.usersRepo.findOne({
where: { id },
relations: ['constructoras', 'constructoras.constructora']
});
if (!user) {
throw new NotFoundException('User not found');
}
return user;
}
async update(id: string, dto: UpdateUserDto): Promise<User> {
const user = await this.findOne(id);
Object.assign(user, dto);
return this.usersRepo.save(user);
}
async changeStatus(
id: string,
status: AccountStatus,
reason?: string
): Promise<User> {
const user = await this.findOne(id);
user.status = status;
if (status === AccountStatus.LOCKED) {
user.lockedUntil = new Date(Date.now() + 30 * 60 * 1000); // 30 min
}
// TODO: Audit log
// TODO: Send notification
return this.usersRepo.save(user);
}
async changeRole(
userId: string,
constructoraId: string,
newRole: ConstructionRole,
changedBy: string
): Promise<UserConstructora> {
const uc = await this.userConstructorasRepo.findOne({
where: { userId, constructoraId }
});
if (!uc) {
throw new NotFoundException('User not assigned to this constructora');
}
const oldRole = uc.role;
uc.role = newRole;
// TODO: Audit log (role_change)
return this.userConstructorasRepo.save(uc);
}
async validateCredentials(
email: string,
password: string
): Promise<User | null> {
const user = await this.usersRepo.findOne({
where: { email },
select: ['id', 'email', 'passwordHash', 'status', 'failedLoginAttempts', 'lockedUntil']
});
if (!user) {
return null;
}
// Check si está bloqueado
if (user.status === AccountStatus.LOCKED) {
if (user.lockedUntil && user.lockedUntil > new Date()) {
throw new UnauthorizedException('Account is locked. Try again later.');
} else {
// Desbloquear automáticamente
user.status = AccountStatus.ACTIVE;
user.failedLoginAttempts = 0;
await this.usersRepo.save(user);
}
}
// Validar contraseña
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
// Incrementar intentos fallidos
user.failedLoginAttempts++;
if (user.failedLoginAttempts >= 5) {
user.status = AccountStatus.LOCKED;
user.lockedUntil = new Date(Date.now() + 30 * 60 * 1000);
// TODO: Send alert email
}
await this.usersRepo.save(user);
return null;
}
// Reset intentos fallidos
if (user.failedLoginAttempts > 0) {
user.failedLoginAttempts = 0;
await this.usersRepo.save(user);
}
return user;
}
}
Guards y Decoradores
permissions.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { PermissionsService } from '../services/permissions.service';
import { PermissionAction } from '../enums/permission-action.enum';
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private reflector: Reflector,
private permissionsService: PermissionsService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredModule = this.reflector.get<string>(
'module',
context.getHandler()
);
const requiredActions = this.reflector.get<PermissionAction[]>(
'actions',
context.getHandler()
);
if (!requiredModule || !requiredActions) {
return true; // No requiere permisos
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return false;
}
// Validar permisos
return this.permissionsService.hasPermissions(
user.role,
requiredModule,
requiredActions
);
}
}
decorators/require-permissions.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { PermissionAction } from '../enums/permission-action.enum';
export const RequirePermissions = (
module: string,
...actions: PermissionAction[]
) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
SetMetadata('module', module)(target, propertyKey, descriptor);
SetMetadata('actions', actions)(target, propertyKey, descriptor);
};
};
Controllers
users.controller.ts
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './guards/permissions.guard';
import { RequirePermissions } from './decorators/require-permissions.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { PermissionAction } from './enums/permission-action.enum';
import { CreateUserDto, UpdateUserDto, ChangeStatusDto, ChangeRoleDto } from './dto';
@Controller('admin/users')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class UsersController {
constructor(private usersService: UsersService) {}
@Get()
@RequirePermissions('admin', PermissionAction.READ)
async findAll(
@CurrentUser() user: any,
@Query() filters: any
) {
return this.usersService.findAll(user.constructoraId, filters);
}
@Get(':id')
@RequirePermissions('admin', PermissionAction.READ)
async findOne(@Param('id') id: string) {
return this.usersService.findOne(id);
}
@Patch(':id')
@RequirePermissions('admin', PermissionAction.UPDATE)
async update(
@Param('id') id: string,
@Body() dto: UpdateUserDto
) {
return this.usersService.update(id, dto);
}
@Patch(':id/status')
@RequirePermissions('admin', PermissionAction.UPDATE)
async changeStatus(
@Param('id') id: string,
@Body() dto: ChangeStatusDto
) {
return this.usersService.changeStatus(id, dto.status, dto.reason);
}
@Patch(':id/role')
@RequirePermissions('admin', PermissionAction.UPDATE)
async changeRole(
@Param('id') id: string,
@Body() dto: ChangeRoleDto,
@CurrentUser() user: any
) {
return this.usersService.changeRole(
id,
user.constructoraId,
dto.newRole,
user.id
);
}
@Delete(':id')
@RequirePermissions('admin', PermissionAction.DELETE)
async remove(@Param('id') id: string) {
// Soft delete
return this.usersService.softDelete(id);
}
}
🎨 Frontend (React + TypeScript)
Hooks
useAuth.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { User, Constructora } from '../types';
interface AuthState {
user: User | null;
currentConstructora: Constructora | null;
accessToken: string | null;
setAuth: (user: User, constructora: Constructora, token: string) => void;
switchConstructora: (constructora: Constructora) => void;
logout: () => void;
hasPermission: (module: string, action: string) => boolean;
}
export const useAuth = create<AuthState>()(
persist(
(set, get) => ({
user: null,
currentConstructora: null,
accessToken: null,
setAuth: (user, constructora, token) => {
set({ user, currentConstructora: constructora, accessToken: token });
},
switchConstructora: async (constructora) => {
const { user } = get();
// API call to switch constructora
const response = await fetch('/api/auth/switch-constructora', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${get().accessToken}`
},
body: JSON.stringify({ constructoraId: constructora.id })
});
const { accessToken } = await response.json();
set({ currentConstructora: constructora, accessToken });
},
logout: () => {
set({ user: null, currentConstructora: null, accessToken: null });
},
hasPermission: (module, action) => {
const { user } = get();
if (!user) return false;
// TODO: Check permissions from user.role and user.customPermissions
return true;
}
}),
{ name: 'auth-storage' }
)
);
Components
ConstructoraSelector.tsx
import React, { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
import { Constructora } from '../types';
export const ConstructoraSelector: React.FC = () => {
const { user, currentConstructora, switchConstructora } = useAuth();
const [isOpen, setIsOpen] = useState(false);
if (!user || !user.constructoras || user.constructoras.length <= 1) {
return null;
}
const handleSwitch = async (constructora: Constructora) => {
await switchConstructora(constructora);
setIsOpen(false);
};
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-4 py-2 bg-white border rounded-lg hover:bg-gray-50"
>
<Building className="w-5 h-5" />
<span className="font-medium">{currentConstructora?.name}</span>
<ChevronDown className="w-4 h-4" />
</button>
{isOpen && (
<div className="absolute top-full mt-2 w-64 bg-white border rounded-lg shadow-lg">
{user.constructoras.map((uc) => (
<button
key={uc.constructora.id}
onClick={() => handleSwitch(uc.constructora)}
className={`w-full text-left px-4 py-3 hover:bg-gray-50 flex items-center justify-between ${
uc.constructora.id === currentConstructora?.id ? 'bg-blue-50' : ''
}`}
>
<div>
<div className="font-medium">{uc.constructora.name}</div>
<div className="text-sm text-gray-500">{uc.role}</div>
</div>
{uc.constructora.id === currentConstructora?.id && (
<Check className="w-5 h-5 text-blue-600" />
)}
</button>
))}
</div>
)}
</div>
);
};
UsersList.tsx
import React, { useEffect, useState } from 'react';
import { useAuth } from '../hooks/useAuth';
import { User } from '../types';
import { api } from '../services/api';
export const UsersList: React.FC = () => {
const { hasPermission, currentConstructora } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState({
status: 'active',
role: '',
search: ''
});
useEffect(() => {
fetchUsers();
}, [filters]);
const fetchUsers = async () => {
setLoading(true);
try {
const response = await api.get('/admin/users', { params: filters });
setUsers(response.data);
} catch (error) {
console.error('Error fetching users:', error);
} finally {
setLoading(false);
}
};
const handleChangeStatus = async (userId: string, status: string) => {
try {
await api.patch(`/admin/users/${userId}/status`, { status });
fetchUsers();
} catch (error) {
console.error('Error changing status:', error);
}
};
if (!hasPermission('admin', 'read')) {
return <div>No tienes permisos para ver esta página</div>;
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Usuarios</h1>
{hasPermission('admin', 'create') && (
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg">
Invitar Usuario
</button>
)}
</div>
{/* Filtros */}
<div className="mb-4 flex gap-4">
<input
type="text"
placeholder="Buscar por nombre o email..."
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
className="px-4 py-2 border rounded-lg"
/>
<select
value={filters.status}
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
className="px-4 py-2 border rounded-lg"
>
<option value="">Todos los estados</option>
<option value="active">Activos</option>
<option value="inactive">Inactivos</option>
<option value="suspended">Suspendidos</option>
</select>
<select
value={filters.role}
onChange={(e) => setFilters({ ...filters, role: e.target.value })}
className="px-4 py-2 border rounded-lg"
>
<option value="">Todos los roles</option>
<option value="director">Director</option>
<option value="engineer">Ingeniero</option>
<option value="resident">Residente</option>
{/* ... */}
</select>
</div>
{/* Tabla */}
<div className="bg-white border rounded-lg">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left">Usuario</th>
<th className="px-6 py-3 text-left">Rol</th>
<th className="px-6 py-3 text-left">Estado</th>
<th className="px-6 py-3 text-left">Último acceso</th>
<th className="px-6 py-3 text-left">Acciones</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<img
src={user.avatarUrl || '/default-avatar.png'}
className="w-10 h-10 rounded-full"
/>
<div>
<div className="font-medium">{user.fullName}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-sm">
{user.role}
</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded text-sm ${
user.status === 'active' ? 'bg-green-100 text-green-800' :
user.status === 'suspended' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{user.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : 'Nunca'}
</td>
<td className="px-6 py-4">
{hasPermission('admin', 'update') && (
<div className="flex gap-2">
<button className="text-blue-600 hover:underline">Editar</button>
{user.status === 'active' && (
<button
onClick={() => handleChangeStatus(user.id, 'suspended')}
className="text-red-600 hover:underline"
>
Suspender
</button>
)}
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
🧪 Tests
Unit Tests
users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
import { UserConstructora } from './entities/user-constructora.entity';
import { AccountStatus } from './enums/account-status.enum';
describe('UsersService', () => {
let service: UsersService;
let usersRepo: Repository<User>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
create: jest.fn(),
save: jest.fn(),
},
},
{
provide: getRepositoryToken(UserConstructora),
useValue: {},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
usersRepo = module.get(getRepositoryToken(User));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('validateCredentials', () => {
it('should return user when credentials are valid', async () => {
const mockUser = {
id: 'uuid-123',
email: 'test@empresa.com',
passwordHash: await bcrypt.hash('password123', 10),
status: AccountStatus.ACTIVE,
failedLoginAttempts: 0
};
jest.spyOn(usersRepo, 'findOne').mockResolvedValue(mockUser as User);
const result = await service.validateCredentials('test@empresa.com', 'password123');
expect(result).toBeDefined();
expect(result.email).toBe('test@empresa.com');
});
it('should lock account after 5 failed attempts', async () => {
const mockUser = {
id: 'uuid-123',
email: 'test@empresa.com',
passwordHash: await bcrypt.hash('password123', 10),
status: AccountStatus.ACTIVE,
failedLoginAttempts: 4 // Ya tiene 4 intentos
};
jest.spyOn(usersRepo, 'findOne').mockResolvedValue(mockUser as User);
jest.spyOn(usersRepo, 'save').mockResolvedValue(mockUser as User);
const result = await service.validateCredentials('test@empresa.com', 'wrong-password');
expect(result).toBeNull();
expect(mockUser.status).toBe(AccountStatus.LOCKED);
expect(mockUser.lockedUntil).toBeDefined();
});
});
});
🔗 Referencias
- Requerimiento funcional: RF-ADM-001, RF-ADM-002
- Historias de usuario: US-ADM-001, US-ADM-002
- Módulo: README.md
Generado: 2025-11-20 Versión: 1.0 Autor: Sistema de Documentación Técnica Estado: ✅ Completo