- Configure workspace Git repository with comprehensive .gitignore - Add Odoo as submodule for ERP reference code - Include documentation: SETUP.md, GIT-STRUCTURE.md - Add gitignore templates for projects (backend, frontend, database) - Structure supports independent repos per project/subproject level Workspace includes: - core/ - Reusable patterns, modules, orchestration system - projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.) - knowledge-base/ - Reference code and patterns (includes Odoo submodule) - devtools/ - Development tools and templates - customers/ - Client implementations template 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
21 KiB
21 KiB
ET-AUTH-002: Gestión de Estados de Cuenta
📋 Metadata
| Campo | Valor |
|---|---|
| ID | ET-AUTH-002 |
| Módulo | Autenticación y Autorización |
| Tipo | Especificación Técnica |
| Estado | ✅ Implementado |
| Versión | 1.0 |
| Fecha creación | 2025-11-07 |
🔗 Referencias
Requerimiento Funcional
📄 RF-AUTH-002: Estados de Cuenta de Usuario
Implementación DDL
🗄️ Archivos relacionados:
ENUM Principal:
-- apps/database/ddl/00-prerequisites.sql:34-36
DO $$ BEGIN
CREATE TYPE auth_management.user_status AS ENUM (
'active', -- Usuario activo, puede acceder
'inactive', -- Inactivo temporalmente
'suspended', -- Suspendido por admin (reversible)
'banned', -- Baneado permanentemente
'pending' -- Registro pendiente de verificación
);
EXCEPTION WHEN duplicate_object THEN null; END $$;
COMMENT ON TYPE auth_management.user_status IS 'Estados de cuenta de usuario';
Tabla Principal:
-- apps/database/ddl/schemas/auth_management/tables/03-profiles.sql:17
CREATE TABLE auth_management.profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
-- ESTADO DE LA CUENTA
status auth_management.user_status NOT NULL DEFAULT 'pending',
-- Metadata de estado
status_changed_at TIMESTAMPTZ,
status_changed_by UUID REFERENCES auth_management.profiles(user_id),
status_reason TEXT, -- Razón de suspensión/baneo
-- Otros campos...
role auth_management.gamilit_role NOT NULL DEFAULT 'student',
display_name TEXT,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT gamilit.now_mexico(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT gamilit.now_mexico()
);
Funciones:
auth_management.verify_user_status(user_id):130- Valida accesoauth_management.suspend_user(user_id, reason):145- Suspende usuarioauth_management.ban_user(user_id, reason):160- Banea usuarioauth_management.reactivate_user(user_id):175- Reactiva usuario
Triggers:
trg_profiles_status_change:190- Audita cambios de estadotrg_verify_email_set_active:195- Cambia pending → active al verificar
Mapeo Completo
📊 Ver en: Mapeo Requerimientos → Implementación
🏗️ Arquitectura de Estados
Diagrama de Transiciones
┌──────────────────────────────────────────────────────────┐
│ CICLO DE VIDA DE CUENTA │
└──────────────────────────────────────────────────────────┘
[REGISTRO]
│
▼
┌──────────┐
│ pending │ ◄─── Estado inicial
└────┬─────┘
│
│ verify_email()
│
▼
┌──────────┐
│ active │ ◄─── Estado principal
└─┬──┬──┬──┘
│ │ │
│ │ └──────► [admin_suspends] ──► ┌────────────┐
│ │ │ suspended │
│ │ [admin_lifts] ◄──── └────┬───────┘
│ │ │
│ │ [admin_bans] ───┘
│ │ │
│ └────► [user_deactivates] ──┤
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ inactive │ │ banned │
└────┬─────┘ └──────────┘
│ │
│ reactivate() │
│ │
└─────────────────────────────►│
▼
[FINAL]
(irreversible)
LEYENDA:
────► Transición automática/usuario
- - -> Transición admin only
═════► Transición irreversible
🔧 Implementación Técnica
1. Funciones de Gestión de Estado
verify_user_status()
-- apps/database/ddl/schemas/auth_management/functions/01-verify_user_status.sql
CREATE OR REPLACE FUNCTION auth_management.verify_user_status(
p_user_id UUID
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_status auth_management.user_status;
BEGIN
-- Obtener estado actual
SELECT status INTO v_status
FROM auth_management.profiles
WHERE user_id = p_user_id;
-- Si no existe el usuario
IF v_status IS NULL THEN
RETURN FALSE;
END IF;
-- Solo 'active' puede acceder
RETURN v_status = 'active';
END;
$$;
COMMENT ON FUNCTION auth_management.verify_user_status(UUID) IS
'Verifica si usuario puede acceder al sistema (solo active)';
suspend_user()
-- apps/database/ddl/schemas/auth_management/functions/02-suspend_user.sql
CREATE OR REPLACE FUNCTION auth_management.suspend_user(
p_user_id UUID,
p_reason TEXT,
p_suspended_by UUID DEFAULT NULL
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_current_status auth_management.user_status;
BEGIN
-- Obtener estado actual
SELECT status INTO v_current_status
FROM auth_management.profiles
WHERE user_id = p_user_id;
-- Solo se puede suspender si está active
IF v_current_status != 'active' THEN
RAISE EXCEPTION 'User must be active to suspend. Current status: %', v_current_status;
END IF;
-- Validar razón
IF p_reason IS NULL OR LENGTH(TRIM(p_reason)) < 10 THEN
RAISE EXCEPTION 'Suspension reason must be at least 10 characters';
END IF;
-- Actualizar estado
UPDATE auth_management.profiles
SET
status = 'suspended',
status_changed_at = NOW(),
status_changed_by = p_suspended_by,
status_reason = p_reason,
updated_at = NOW()
WHERE user_id = p_user_id;
-- Cerrar todas las sesiones activas del usuario
-- (Supabase Auth maneja esto automáticamente)
-- Trigger automático auditará el cambio
RAISE NOTICE 'User % suspended. Reason: %', p_user_id, p_reason;
END;
$$;
COMMENT ON FUNCTION auth_management.suspend_user(UUID, TEXT, UUID) IS
'Suspende usuario (solo de active a suspended)';
ban_user()
-- apps/database/ddl/schemas/auth_management/functions/03-ban_user.sql
CREATE OR REPLACE FUNCTION auth_management.ban_user(
p_user_id UUID,
p_reason TEXT,
p_banned_by UUID DEFAULT NULL
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_current_status auth_management.user_status;
v_email TEXT;
BEGIN
-- Obtener estado y email
SELECT status, email INTO v_current_status, v_email
FROM auth_management.profiles
WHERE user_id = p_user_id;
-- Se puede banear desde active o suspended
IF v_current_status NOT IN ('active', 'suspended') THEN
RAISE EXCEPTION 'Can only ban active or suspended users. Current: %', v_current_status;
END IF;
-- Validar razón (debe ser grave)
IF p_reason IS NULL OR LENGTH(TRIM(p_reason)) < 20 THEN
RAISE EXCEPTION 'Ban reason must be at least 20 characters (permanent action)';
END IF;
-- Actualizar estado (IRREVERSIBLE)
UPDATE auth_management.profiles
SET
status = 'banned',
status_changed_at = NOW(),
status_changed_by = p_banned_by,
status_reason = p_reason,
updated_at = NOW()
WHERE user_id = p_user_id;
-- Bloquear email para futuros registros
INSERT INTO auth_management.blocked_emails (
email, reason, blocked_by, blocked_at
) VALUES (
v_email, p_reason, p_banned_by, NOW()
) ON CONFLICT (email) DO NOTHING;
-- Cerrar sesiones
-- Trigger auditará
RAISE NOTICE 'User % BANNED (PERMANENT). Reason: %', p_user_id, p_reason;
END;
$$;
COMMENT ON FUNCTION auth_management.ban_user(UUID, TEXT, UUID) IS
'Banea usuario PERMANENTEMENTE (irreversible)';
reactivate_user()
-- apps/database/ddl/schemas/auth_management/functions/04-reactivate_user.sql
CREATE OR REPLACE FUNCTION auth_management.reactivate_user(
p_user_id UUID
)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_current_status auth_management.user_status;
BEGIN
SELECT status INTO v_current_status
FROM auth_management.profiles
WHERE user_id = p_user_id;
-- Solo se puede reactivar desde inactive o suspended
IF v_current_status NOT IN ('inactive', 'suspended') THEN
RAISE EXCEPTION 'Can only reactivate inactive or suspended users. Current: %', v_current_status;
END IF;
-- NO se puede reactivar banned (irreversible)
IF v_current_status = 'banned' THEN
RAISE EXCEPTION 'Cannot reactivate banned users';
END IF;
-- Reactivar
UPDATE auth_management.profiles
SET
status = 'active',
status_changed_at = NOW(),
status_changed_by = p_user_id, -- Self-reactivation
status_reason = NULL, -- Clear reason
updated_at = NOW()
WHERE user_id = p_user_id;
RAISE NOTICE 'User % reactivated', p_user_id;
END;
$$;
COMMENT ON FUNCTION auth_management.reactivate_user(UUID) IS
'Reactiva usuario (de inactive o suspended a active)';
2. Triggers de Auditoría
trg_profiles_status_change
-- apps/database/ddl/schemas/auth_management/tables/03-profiles.sql:200
CREATE OR REPLACE FUNCTION audit_logging.log_status_change()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Solo auditar si el status cambió
IF OLD.status IS DISTINCT FROM NEW.status THEN
INSERT INTO audit_logging.audit_logs (
user_id,
action,
resource_type,
resource_id,
details,
ip_address,
user_agent
) VALUES (
NEW.status_changed_by, -- Quién hizo el cambio
'update',
'user_status',
NEW.user_id,
jsonb_build_object(
'old_status', OLD.status,
'new_status', NEW.status,
'reason', NEW.status_reason,
'changed_at', NEW.status_changed_at
),
current_setting('request.headers.x-forwarded-for', true),
current_setting('request.headers.user-agent', true)
);
-- Enviar notificación si cambio no fue iniciado por el usuario
IF NEW.status_changed_by IS DISTINCT FROM NEW.user_id THEN
INSERT INTO public.notifications (
user_id,
type,
priority,
title,
body,
data
) VALUES (
NEW.user_id,
'system_announcement',
CASE
WHEN NEW.status IN ('suspended', 'banned') THEN 'high'
ELSE 'medium'
END,
'Estado de cuenta actualizado',
CASE NEW.status
WHEN 'suspended' THEN 'Tu cuenta ha sido suspendida: ' || NEW.status_reason
WHEN 'banned' THEN 'Tu cuenta ha sido baneada permanentemente: ' || NEW.status_reason
WHEN 'active' THEN 'Tu cuenta ha sido reactivada'
ELSE 'El estado de tu cuenta cambió'
END,
jsonb_build_object(
'old_status', OLD.status,
'new_status', NEW.status
)
);
END IF;
END IF;
RETURN NEW;
END;
$$;
-- Crear trigger
CREATE TRIGGER trg_profiles_status_change
AFTER UPDATE OF status ON auth_management.profiles
FOR EACH ROW
EXECUTE FUNCTION audit_logging.log_status_change();
trg_verify_email_set_active
-- Trigger para cambiar de pending a active al verificar email
CREATE OR REPLACE FUNCTION auth_management.on_email_verified()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- Si email_confirmed_at se setea y status es pending
IF NEW.email_confirmed_at IS NOT NULL
AND OLD.email_confirmed_at IS NULL
AND NEW.status = 'pending'
THEN
-- Cambiar a active
NEW.status := 'active';
NEW.status_changed_at := NOW();
NEW.status_changed_by := NEW.user_id; -- Self-verification
RAISE NOTICE 'User % verified email, status changed to active', NEW.user_id;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_verify_email_set_active
BEFORE UPDATE OF email_confirmed_at ON auth_management.profiles
FOR EACH ROW
EXECUTE FUNCTION auth_management.on_email_verified();
3. Backend - Middleware y Guards
UserStatusMiddleware
// apps/backend/src/modules/auth/middleware/user-status.middleware.ts
import { Injectable, NestMiddleware, ForbiddenException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
/**
* Middleware que valida el estado del usuario en cada request
*
* Bloquea acceso si status !== 'active'
* Excepciones: endpoints de reactivación y status check
*/
@Injectable()
export class UserStatusMiddleware implements NestMiddleware {
// Paths que NO requieren status active
private readonly exemptPaths = [
'/auth/status',
'/auth/reactivate',
'/auth/logout',
'/health',
];
use(req: Request, res: Response, next: NextFunction) {
// Si no hay usuario autenticado, dejar pasar (otro guard lo manejará)
if (!req.user) {
return next();
}
// Verificar si path está exento
const isExempt = this.exemptPaths.some(path =>
req.path.startsWith(path)
);
if (isExempt) {
return next();
}
// Validar status
const userStatus = req.user.status;
if (userStatus !== 'active') {
// Mensajes específicos por estado
const messages = {
pending: 'Tu cuenta está pendiente de verificación. Por favor verifica tu email.',
inactive: 'Tu cuenta está desactivada. Puedes reactivarla en cualquier momento.',
suspended: `Tu cuenta ha sido suspendida. Razón: ${req.user.status_reason || 'No especificada'}. Contacta a soporte.`,
banned: `Tu cuenta ha sido baneada permanentemente. Razón: ${req.user.status_reason || 'Violación de términos'}. Esta acción es irreversible.`,
};
throw new ForbiddenException({
statusCode: 403,
message: messages[userStatus] || 'Acceso denegado',
error: 'AccountStatusError',
status: userStatus,
});
}
// Usuario active, permitir acceso
next();
}
}
UpdateUserStatusDto
// apps/backend/src/modules/auth/dto/update-user-status.dto.ts
import { IsEnum, IsString, MinLength, IsUUID, IsOptional } from 'class-validator';
import { UserStatus } from '../enums/user-status.enum';
export class UpdateUserStatusDto {
@IsEnum(UserStatus)
status: UserStatus;
@IsString()
@MinLength(10, { message: 'Reason must be at least 10 characters' })
@IsOptional()
reason?: string;
}
export class SuspendUserDto {
@IsUUID()
userId: string;
@IsString()
@MinLength(10)
reason: string;
}
export class BanUserDto {
@IsUUID()
userId: string;
@IsString()
@MinLength(20, { message: 'Ban reason must be at least 20 characters (permanent action)' })
reason: string;
}
UserManagementService
// apps/backend/src/modules/auth/services/user-management.service.ts
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Profile } from '../entities/profile.entity';
import { UserStatus } from '../enums/user-status.enum';
@Injectable()
export class UserManagementService {
constructor(
@InjectRepository(Profile)
private profileRepo: Repository<Profile>,
) {}
/**
* Suspende usuario (requiere rol super_admin)
*/
async suspendUser(
userId: string,
reason: string,
adminId: string,
): Promise<void> {
const profile = await this.profileRepo.findOne({ where: { userId } });
if (!profile) {
throw new BadRequestException('User not found');
}
if (profile.status !== UserStatus.ACTIVE) {
throw new BadRequestException(
`Cannot suspend user with status: ${profile.status}`,
);
}
// Ejecutar función SQL
await this.profileRepo.query(
`SELECT auth_management.suspend_user($1, $2, $3)`,
[userId, reason, adminId],
);
}
/**
* Banea usuario PERMANENTEMENTE (requiere super_admin)
*/
async banUser(
userId: string,
reason: string,
adminId: string,
): Promise<void> {
const profile = await this.profileRepo.findOne({ where: { userId } });
if (!profile) {
throw new BadRequestException('User not found');
}
if (!['active', 'suspended'].includes(profile.status)) {
throw new BadRequestException(
`Cannot ban user with status: ${profile.status}`,
);
}
// Confirmar que admin entiende que es permanente
await this.profileRepo.query(
`SELECT auth_management.ban_user($1, $2, $3)`,
[userId, reason, adminId],
);
}
/**
* Reactiva usuario (de inactive o suspended)
*/
async reactivateUser(userId: string): Promise<void> {
const profile = await this.profileRepo.findOne({ where: { userId } });
if (!profile) {
throw new BadRequestException('User not found');
}
if (!['inactive', 'suspended'].includes(profile.status)) {
throw new BadRequestException(
`Cannot reactivate user with status: ${profile.status}`,
);
}
if (profile.status === UserStatus.BANNED) {
throw new ForbiddenException('Cannot reactivate banned users');
}
await this.profileRepo.query(
`SELECT auth_management.reactivate_user($1)`,
[userId],
);
}
}
4. Frontend - Componentes
UserStatusBadge
// apps/frontend/src/components/ui/UserStatusBadge.tsx
import React from 'react';
import { UserStatus } from '@/types/auth.types';
interface UserStatusBadgeProps {
status: UserStatus;
showLabel?: boolean;
}
export const UserStatusBadge: React.FC<UserStatusBadgeProps> = ({
status,
showLabel = true
}) => {
const config = {
pending: {
color: 'bg-yellow-100 text-yellow-800 border-yellow-300',
icon: '⏳',
label: 'Pendiente',
},
active: {
color: 'bg-green-100 text-green-800 border-green-300',
icon: '✅',
label: 'Activo',
},
inactive: {
color: 'bg-gray-100 text-gray-800 border-gray-300',
icon: '⏸️',
label: 'Inactivo',
},
suspended: {
color: 'bg-orange-100 text-orange-800 border-orange-300',
icon: '⚠️',
label: 'Suspendido',
},
banned: {
color: 'bg-red-100 text-red-800 border-red-300',
icon: '🚫',
label: 'Baneado',
},
};
const { color, icon, label } = config[status] || config.active;
return (
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium border ${color}`}>
<span className="mr-1">{icon}</span>
{showLabel && <span>{label}</span>}
</span>
);
};
📊 Testing
Test Case 1: Suspender usuario activo
test('Admin can suspend active user with reason', async () => {
const admin = await createUser({ role: 'super_admin' });
const user = await createUser({ status: 'active' });
await loginAs(admin);
const response = await api.post(`/admin/users/${user.id}/suspend`, {
reason: 'Inappropriate behavior reported by multiple users',
});
expect(response.status).toBe(200);
// Verificar cambio de estado
const updatedUser = await getUser(user.id);
expect(updatedUser.status).toBe('suspended');
expect(updatedUser.status_reason).toContain('Inappropriate behavior');
// Verificar auditoría
const auditLog = await getLatestAuditLog(user.id, 'user_status');
expect(auditLog.details.old_status).toBe('active');
expect(auditLog.details.new_status).toBe('suspended');
});
📚 Referencias
Documentos Relacionados
Documento: docs/02-especificaciones-tecnicas/01-autenticacion-autorizacion/ET-AUTH-002-estados-cuenta.md
Ruta relativa desde docs/: 02-especificaciones-tecnicas/01-autenticacion-autorizacion/ET-AUTH-002-estados-cuenta.md