From f812d715081c199126bcb18e436bbd063d305657 Mon Sep 17 00:00:00 2001 From: acazares Date: Tue, 13 Jan 2026 09:56:53 -0600 Subject: [PATCH] feat: Implement user management service and frontend integration - Added UserService to manage Keycloak users with license validation in the backend. - Created API client for user management in the frontend. - Developed user management page with functionalities to create, update, delete, and list users. - Implemented user statistics retrieval and display. - Added dialogs for user creation, editing, password change, and deletion confirmation. --- backend/api/v1/modules/core/router.py | 2 + backend/api/v1/modules/core/users/__init__.py | 3 + backend/api/v1/modules/core/users/dto.py | 89 +++ backend/api/v1/modules/core/users/routes.py | 187 +++++ backend/api/v1/modules/core/users/service.py | 497 ++++++++++++++ frontend/src/lib/api/dashboard/users.ts | 149 ++++ .../src/lib/components/sidebar/modules.ts | 24 +- .../routes/dashboard/users/+page.server.ts | 6 + .../src/routes/dashboard/users/+page.svelte | 639 ++++++++++++++++++ scripts/init_first_time.sh | 271 +++++--- 10 files changed, 1759 insertions(+), 108 deletions(-) create mode 100644 backend/api/v1/modules/core/users/__init__.py create mode 100644 backend/api/v1/modules/core/users/dto.py create mode 100644 backend/api/v1/modules/core/users/routes.py create mode 100644 backend/api/v1/modules/core/users/service.py create mode 100644 frontend/src/lib/api/dashboard/users.ts create mode 100644 frontend/src/routes/dashboard/users/+page.server.ts create mode 100644 frontend/src/routes/dashboard/users/+page.svelte diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py index 53e412ef..3be9c11f 100644 --- a/backend/api/v1/modules/core/router.py +++ b/backend/api/v1/modules/core/router.py @@ -2,6 +2,7 @@ from .auth.routes import router as auth_router from .licenses.routes import router as licenses_router from .tenants.routes import router as tenants_router from .user_tenant.routes import router as user_tenant_router +from .users.routes import router as users_router from .dashboard.routes import router as dashboard_router from fastapi import APIRouter @@ -10,5 +11,6 @@ router = APIRouter() router.include_router(auth_router) router.include_router(tenants_router, prefix="/core", tags=["core / tenants"]) router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"]) +router.include_router(users_router, prefix="/core", tags=["core / users"]) router.include_router(licenses_router, prefix="/core", tags=["core / licenses"]) router.include_router(dashboard_router, prefix="/core", tags=["core / dashboard"]) diff --git a/backend/api/v1/modules/core/users/__init__.py b/backend/api/v1/modules/core/users/__init__.py new file mode 100644 index 00000000..572c6f5e --- /dev/null +++ b/backend/api/v1/modules/core/users/__init__.py @@ -0,0 +1,3 @@ +""" +Módulo de gestión de usuarios (Keycloak) +""" diff --git a/backend/api/v1/modules/core/users/dto.py b/backend/api/v1/modules/core/users/dto.py new file mode 100644 index 00000000..5e707531 --- /dev/null +++ b/backend/api/v1/modules/core/users/dto.py @@ -0,0 +1,89 @@ +""" +DTOs para gestión de usuarios de Keycloak +""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, EmailStr, Field, field_validator + + +class CreateUserRequestDTO(BaseModel): + """Request para crear un nuevo usuario en Keycloak""" + + email: EmailStr = Field(..., description="Email del usuario") + username: str = Field( + ..., min_length=3, max_length=50, description="Nombre de usuario" + ) + first_name: str = Field(..., min_length=1, max_length=100, description="Nombre") + last_name: str = Field(..., min_length=1, max_length=100, description="Apellido") + password: str = Field(..., min_length=8, description="Contraseña temporal") + role: Optional[str] = Field(None, description="Rol del usuario en el tenant") + enabled: bool = Field(True, description="Si el usuario está habilitado") + email_verified: bool = Field(False, description="Si el email está verificado") + + +class UpdateUserRequestDTO(BaseModel): + """Request para actualizar un usuario en Keycloak""" + + first_name: Optional[str] = Field(None, max_length=100) + last_name: Optional[str] = Field(None, max_length=100) + email: Optional[str] = Field(None, max_length=255) + enabled: Optional[bool] = None + email_verified: Optional[bool] = None + role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual") + + @field_validator("first_name", "last_name", "email") + @classmethod + def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]: + """Valida que si el string está presente, no esté vacío""" + if v is not None and v.strip() == "": + return None # Convertir strings vacíos a None + return v + + +class UserResponseDTO(BaseModel): + """Response con información de usuario de Keycloak""" + + id: str = Field(..., description="ID de Keycloak del usuario") + username: str + email: str = Field(default="", description="Email del usuario") + first_name: str = Field(default="", description="Nombre del usuario") + last_name: str = Field(default="", description="Apellido del usuario") + enabled: bool + email_verified: bool + created_timestamp: Optional[int] = None + role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual") + + class Config: + from_attributes = True + + +class UserListResponseDTO(BaseModel): + """Response con lista de usuarios""" + + users: List[UserResponseDTO] + total: int + page: int + page_size: int + total_pages: int + + +class ChangePasswordRequestDTO(BaseModel): + """Request para cambiar contraseña de un usuario""" + + password: str = Field(..., min_length=8, description="Nueva contraseña") + temporary: bool = Field( + True, description="Si es temporal (usuario debe cambiarla al login)" + ) + + +class UserStatsDTO(BaseModel): + """Estadísticas de usuarios del tenant""" + + total_users: int + active_users: int + inactive_users: int + max_users_allowed: int + users_available: int + usage_percentage: float diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py new file mode 100644 index 00000000..4c5b83b9 --- /dev/null +++ b/backend/api/v1/modules/core/users/routes.py @@ -0,0 +1,187 @@ +""" +Rutas para gestión de usuarios de Keycloak +""" + +from typing import Optional + +from core.database import get_core_db +from core.security import get_current_user, get_tenant_from_token +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from ..user_tenant.models import UserTenant +from .dto import ( + ChangePasswordRequestDTO, + CreateUserRequestDTO, + UpdateUserRequestDTO, + UserListResponseDTO, + UserResponseDTO, + UserStatsDTO, +) +from .service import UserService + +router = APIRouter(prefix="/users", tags=["Users"]) + + +def get_user_service( + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +) -> UserService: + """Dependency para obtener servicio de usuarios con el tenant y company del usuario actual""" + # Obtener keycloak_user_id del usuario actual + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + # Buscar el user_tenant activo del usuario + user_tenant = ( + db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.is_active == True, + ) + .first() + ) + + if not user_tenant: + raise HTTPException( + status_code=400, detail="User does not belong to any tenant" + ) + + return UserService(db, user_tenant.tenant_id, user_tenant.company_id) + + +@router.get("/stats", response_model=UserStatsDTO) +def get_user_statistics( + service: UserService = Depends(get_user_service), +): + """ + Obtiene estadísticas de usuarios del tenant actual + + Muestra: + - Total de usuarios + - Usuarios activos e inactivos + - Límite de licencia + - Usuarios disponibles + - Porcentaje de uso + """ + return service.get_user_stats() + + +@router.get("/", response_model=UserListResponseDTO) +def list_users( + page: int = Query(1, ge=1, description="Número de página"), + page_size: int = Query(20, ge=1, le=100, description="Tamaño de página"), + search: Optional[str] = Query(None, description="Término de búsqueda"), + service: UserService = Depends(get_user_service), +): + """ + Lista todos los usuarios del tenant con paginación + + Se puede filtrar por término de búsqueda (busca en username, email, nombre) + """ + result = service.get_tenant_users(page=page, page_size=page_size, search=search) + return result + + +@router.get("/{user_id}", response_model=UserResponseDTO) +def get_user( + user_id: str, + service: UserService = Depends(get_user_service), +): + """ + Obtiene información detallada de un usuario específico + + El usuario debe pertenecer al tenant actual + """ + return service.get_user(user_id) + + +@router.post("/", response_model=UserResponseDTO, status_code=201) +def create_user( + data: CreateUserRequestDTO, + service: UserService = Depends(get_user_service), +): + """ + Crea un nuevo usuario en Keycloak y lo asocia al tenant + + Validaciones: + - Verifica que no se exceda el límite de usuarios de la licencia + - Verifica que el email y username sean únicos + - Crea el usuario con contraseña temporal + + Nota: El tenant_id se obtiene automáticamente del servicio (del token del usuario actual) + """ + user = service.create_user( + email=data.email, + username=data.username, + first_name=data.first_name, + last_name=data.last_name, + password=data.password, + role=data.role, + enabled=data.enabled, + email_verified=data.email_verified, + ) + return user + + +@router.put("/{user_id}", response_model=UserResponseDTO) +def update_user( + user_id: str, + data: UpdateUserRequestDTO, + service: UserService = Depends(get_user_service), +): + """ + Actualiza información de un usuario + + Puede actualizar: + - Datos personales (nombre, apellido, email) + - Estado (habilitado/deshabilitado) + - Verificación de email + - Rol en el tenant + """ + user = service.update_user( + user_id=user_id, + first_name=data.first_name, + last_name=data.last_name, + email=data.email, + enabled=data.enabled, + email_verified=data.email_verified, + role=data.role, + ) + return user + + +@router.delete("/{user_id}") +def delete_user( + user_id: str, + soft_delete: bool = Query( + True, + description="Si es True, solo desactiva. Si es False, elimina permanentemente", + ), + service: UserService = Depends(get_user_service), +): + """ + Elimina un usuario del tenant + + - soft_delete=True: Solo desactiva la relación (recomendado) + - soft_delete=False: Elimina permanentemente de Keycloak + """ + service.delete_user(user_id, soft_delete=soft_delete) + return {"message": "User deleted successfully"} + + +@router.post("/{user_id}/change-password") +def change_user_password( + user_id: str, + data: ChangePasswordRequestDTO, + service: UserService = Depends(get_user_service), +): + """ + Cambia la contraseña de un usuario + + - temporary=True: Usuario debe cambiar la contraseña en el próximo login + - temporary=False: Contraseña permanente + """ + service.change_password(user_id, data.password, data.temporary) + return {"message": "Password changed successfully"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py new file mode 100644 index 00000000..5325a0a3 --- /dev/null +++ b/backend/api/v1/modules/core/users/service.py @@ -0,0 +1,497 @@ +""" +Servicio para gestionar usuarios de Keycloak con validación de licencias +""" + +import logging +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import HTTPException +from keycloak import KeycloakAdmin, KeycloakError +from sqlalchemy import and_, func +from sqlalchemy.orm import Session + +from core.config import settings + +from ..licenses.models import License, LicenseStatus +from ..user_tenant.models import UserTenant + +logger = logging.getLogger(__name__) + + +def _normalize_keycloak_user( + user_data: Dict[str, Any], role: Optional[str] = None +) -> Dict[str, Any]: + """ + Normaliza los datos de usuario de Keycloak al formato esperado por el DTO + + Keycloak usa camelCase, nuestro DTO usa snake_case + """ + return { + "id": user_data.get("id"), + "username": user_data.get("username", ""), + "email": user_data.get("email", ""), + "first_name": user_data.get("firstName", ""), + "last_name": user_data.get("lastName", ""), + "enabled": user_data.get("enabled", False), + "email_verified": user_data.get("emailVerified", False), + "created_timestamp": user_data.get("createdTimestamp"), + "role": role, + } + + +class UserService: + """Servicio para gestionar usuarios en Keycloak""" + + def __init__(self, db: Session, tenant_id: int, company_id: int = None): + self.db = db + self.tenant_id = tenant_id + self.company_id = company_id + + # Inicializar cliente admin de Keycloak + self.keycloak_admin = KeycloakAdmin( + server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth", + username=settings.KEYCLOAK_ADMIN_USERNAME, + password=settings.KEYCLOAK_ADMIN_PASSWORD, + realm_name=settings.KEYCLOAK_REALM, + verify=True, + ) + + def _get_license(self) -> License: + """Obtiene la licencia del tenant actual""" + license = ( + self.db.query(License).filter(License.tenant_id == self.tenant_id).first() + ) + + if not license: + raise HTTPException( + status_code=404, detail="License not found for this tenant" + ) + + if license.status != LicenseStatus.ACTIVE: + raise HTTPException( + status_code=403, + detail=f"License is not active. Current status: {license.status.value}", + ) + + # Verificar si la licencia está vigente + now = datetime.now(license.expires_at.tzinfo) + if license.expires_at < now: + raise HTTPException(status_code=403, detail="License has expired") + + return license + + def _check_user_limit(self) -> None: + """Verifica si se puede crear un nuevo usuario según la licencia""" + license = self._get_license() + + # Contar usuarios activos del tenant + active_users = ( + self.db.query(func.count(UserTenant.id)) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + .scalar() + ) + + if active_users >= license.max_users: + raise HTTPException( + status_code=403, + detail=f"User limit reached. Your license allows {license.max_users} users. " + f"Currently active: {active_users}. Please upgrade your license.", + ) + + def create_user( + self, + email: str, + username: str, + first_name: str, + last_name: str, + password: str, + role: Optional[str] = None, + enabled: bool = True, + email_verified: bool = False, + ) -> Dict[str, Any]: + """ + Crea un nuevo usuario en Keycloak y lo asocia al tenant + + Args: + email: Email del usuario + username: Nombre de usuario + first_name: Nombre + last_name: Apellido + password: Contraseña inicial + role: Rol en el tenant + enabled: Si el usuario está habilitado + email_verified: Si el email está verificado + + Returns: + Información del usuario creado + + Raises: + HTTPException: Si se alcanza el límite de usuarios o falla la creación + """ + # Verificar límite de usuarios + self._check_user_limit() + + try: + # Crear usuario en Keycloak + new_user = { + "email": email, + "username": username, + "enabled": enabled, + "emailVerified": email_verified, + "firstName": first_name, + "lastName": last_name, + "attributes": { + "tenant_id": [ + str(self.tenant_id) + ] # Atributo requerido por Keycloak + }, + "credentials": [ + { + "type": "password", + "value": password, + "temporary": True, # Usuario debe cambiar en primer login + } + ], + } + + user_id = self.keycloak_admin.create_user(new_user) + logger.info(f"User created in Keycloak: {user_id}") + + # Obtener company_id si no se proporcionó + if not self.company_id: + # Obtener la primera company del tenant + from api.v1.modules.a76.general_catalogs.company.models import Company + + company = ( + self.db.query(Company) + .filter(Company.tenant_id == self.tenant_id) + .first() + ) + + if not company: + raise HTTPException( + status_code=400, + detail="No company found for this tenant. Please create a company first.", + ) + company_id = company.id + else: + company_id = self.company_id + + # Crear relación con el tenant + user_tenant = UserTenant( + keycloak_user_id=user_id, + tenant_id=self.tenant_id, + company_id=company_id, + role=role, + is_active=True, + ) + self.db.add(user_tenant) + self.db.commit() + + # Obtener información completa del usuario + user_info = self.keycloak_admin.get_user(user_id) + + return _normalize_keycloak_user(user_info, role) + + except KeycloakError as e: + logger.error(f"Keycloak error creating user: {str(e)}") + self.db.rollback() + + # Manejar errores específicos + if "User exists with same email" in str(e): + raise HTTPException( + status_code=409, detail="A user with this email already exists" + ) + elif "User exists with same username" in str(e): + raise HTTPException( + status_code=409, detail="A user with this username already exists" + ) + + raise HTTPException( + status_code=500, detail=f"Error creating user in Keycloak: {str(e)}" + ) + except Exception as e: + logger.error(f"Unexpected error creating user: {str(e)}") + self.db.rollback() + raise HTTPException( + status_code=500, detail=f"Error creating user: {str(e)}" + ) + + def get_tenant_users( + self, page: int = 1, page_size: int = 20, search: Optional[str] = None + ) -> Dict[str, Any]: + """ + Obtiene todos los usuarios del tenant con paginación + + Args: + page: Número de página (1-indexed) + page_size: Tamaño de página + search: Término de búsqueda (busca en username, email, nombre) + + Returns: + Dict con usuarios y metadatos de paginación + """ + try: + # Obtener relaciones usuario-tenant + query = self.db.query(UserTenant).filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + + total = query.count() + + # Calcular offset + offset = (page - 1) * page_size + user_tenants = query.offset(offset).limit(page_size).all() + + # Obtener información de Keycloak para cada usuario + users = [] + for ut in user_tenants: + try: + user_info = self.keycloak_admin.get_user(ut.keycloak_user_id) + normalized_user = _normalize_keycloak_user(user_info, ut.role) + + # Filtrar por búsqueda si se proporciona + if search: + search_lower = search.lower() + if ( + search_lower in normalized_user.get("username", "").lower() + or search_lower in normalized_user.get("email", "").lower() + or search_lower + in normalized_user.get("first_name", "").lower() + or search_lower + in normalized_user.get("last_name", "").lower() + ): + users.append(normalized_user) + else: + users.append(normalized_user) + + except KeycloakError as e: + logger.warning( + f"Could not fetch user {ut.keycloak_user_id} from Keycloak: {str(e)}" + ) + continue + + total_pages = (total + page_size - 1) // page_size + + return { + "users": users, + "total": len(users) if search else total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } + + except Exception as e: + logger.error(f"Error getting tenant users: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Error getting users: {str(e)}" + ) + + def get_user(self, user_id: str) -> Dict[str, Any]: + """Obtiene un usuario específico del tenant""" + # Verificar que el usuario pertenece al tenant + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found in this tenant") + + try: + user_info = self.keycloak_admin.get_user(user_id) + return _normalize_keycloak_user(user_info, user_tenant.role) + except KeycloakError as e: + logger.error(f"Error getting user from Keycloak: {str(e)}") + raise HTTPException(status_code=404, detail="User not found in Keycloak") + + def update_user( + self, + user_id: str, + first_name: Optional[str] = None, + last_name: Optional[str] = None, + email: Optional[str] = None, + enabled: Optional[bool] = None, + email_verified: Optional[bool] = None, + role: Optional[str] = None, + ) -> Dict[str, Any]: + """Actualiza información de un usuario""" + # Verificar que el usuario pertenece al tenant + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found in this tenant") + + try: + # Preparar datos de actualización para Keycloak + update_data = {} + if first_name is not None: + update_data["firstName"] = first_name + if last_name is not None: + update_data["lastName"] = last_name + if email is not None: + update_data["email"] = email + if enabled is not None: + update_data["enabled"] = enabled + if email_verified is not None: + update_data["emailVerified"] = email_verified + + # Actualizar en Keycloak si hay cambios + if update_data: + self.keycloak_admin.update_user(user_id, update_data) + + # Actualizar rol en UserTenant si se proporciona + if role is not None: + user_tenant.role = role + self.db.commit() + + # Obtener información actualizada + user_info = self.keycloak_admin.get_user(user_id) + + return _normalize_keycloak_user(user_info, user_tenant.role) + + except KeycloakError as e: + logger.error(f"Error updating user in Keycloak: {str(e)}") + self.db.rollback() + raise HTTPException( + status_code=500, detail=f"Error updating user: {str(e)}" + ) + + def delete_user(self, user_id: str, soft_delete: bool = True) -> None: + """ + Elimina un usuario del tenant + + Args: + user_id: ID del usuario en Keycloak + soft_delete: Si es True, solo desactiva. Si es False, elimina de Keycloak + """ + # Verificar que el usuario pertenece al tenant + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found in this tenant") + + try: + if soft_delete: + # Solo desactivar la relación + user_tenant.is_active = False + self.db.commit() + else: + # Eliminar permanentemente de Keycloak + self.keycloak_admin.delete_user(user_id) + # Eliminar relación + self.db.delete(user_tenant) + self.db.commit() + + except KeycloakError as e: + logger.error(f"Error deleting user from Keycloak: {str(e)}") + self.db.rollback() + raise HTTPException( + status_code=500, detail=f"Error deleting user: {str(e)}" + ) + + def change_password( + self, user_id: str, password: str, temporary: bool = True + ) -> None: + """Cambia la contraseña de un usuario""" + # Verificar que el usuario pertenece al tenant + user_tenant = ( + self.db.query(UserTenant) + .filter( + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + .first() + ) + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found in this tenant") + + try: + self.keycloak_admin.set_user_password( + user_id, password, temporary=temporary + ) + except KeycloakError as e: + logger.error(f"Error changing user password: {str(e)}") + raise HTTPException( + status_code=500, detail=f"Error changing password: {str(e)}" + ) + + def get_user_stats(self) -> Dict[str, Any]: + """Obtiene estadísticas de usuarios del tenant""" + license = self._get_license() + + # Contar usuarios activos e inactivos + active_users = ( + self.db.query(func.count(UserTenant.id)) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == True, + ) + ) + .scalar() + ) + + inactive_users = ( + self.db.query(func.count(UserTenant.id)) + .filter( + and_( + UserTenant.tenant_id == self.tenant_id, + UserTenant.is_active == False, + ) + ) + .scalar() + ) + + total_users = active_users + inactive_users + users_available = max(0, license.max_users - active_users) + usage_percentage = ( + (active_users / license.max_users * 100) if license.max_users > 0 else 0 + ) + + return { + "total_users": total_users, + "active_users": active_users, + "inactive_users": inactive_users, + "max_users_allowed": license.max_users, + "users_available": users_available, + "usage_percentage": round(usage_percentage, 2), + } diff --git a/frontend/src/lib/api/dashboard/users.ts b/frontend/src/lib/api/dashboard/users.ts new file mode 100644 index 00000000..ce8badd9 --- /dev/null +++ b/frontend/src/lib/api/dashboard/users.ts @@ -0,0 +1,149 @@ +/** + * Cliente API para gestión de usuarios + */ + +import { api } from '$lib/api'; + +export interface User { + id: string; + username: string; + email: string; + first_name: string; + last_name: string; + enabled: boolean; + email_verified: boolean; + created_timestamp?: number; + role?: string; +} + +export interface UserStats { + total_users: number; + active_users: number; + inactive_users: number; + max_users_allowed: number; + users_available: number; + usage_percentage: number; +} + +export interface UserListResponse { + users: User[]; + total: number; + page: number; + page_size: number; + total_pages: number; +} + +export interface CreateUserRequest { + email: string; + username: string; + first_name: string; + last_name: string; + password: string; + role?: string; + enabled?: boolean; + email_verified?: boolean; +} + +export interface UpdateUserRequest { + first_name?: string; + last_name?: string; + email?: string; + enabled?: boolean; + email_verified?: boolean; + role?: string; +} + +export interface ChangePasswordRequest { + password: string; + temporary?: boolean; +} + +export const usersAPI = { + /** + * Obtiene estadísticas de usuarios del tenant + */ + async getStats(): Promise { + const response = await api.get('/v1/core/users/stats'); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Lista usuarios del tenant con paginación + */ + async list(params?: { + page?: number; + page_size?: number; + search?: string; + }): Promise { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.set('page', params.page.toString()); + if (params?.page_size) queryParams.set('page_size', params.page_size.toString()); + if (params?.search) queryParams.set('search', params.search); + + const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`; + const response = await api.get(endpoint); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Obtiene un usuario específico + */ + async get(userId: string): Promise { + const response = await api.get(`/v1/core/users/${userId}`); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Crea un nuevo usuario + */ + async create(data: CreateUserRequest): Promise { + const response = await api.post('/v1/core/users/', data); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Actualiza un usuario existente + */ + async update(userId: string, data: UpdateUserRequest): Promise { + const response = await api.put(`/v1/core/users/${userId}`, data); + if (response.error) { + throw new Error(response.error); + } + return response.data!; + }, + + /** + * Elimina un usuario + */ + async delete(userId: string, softDelete: boolean = true): Promise { + const queryParams = new URLSearchParams(); + queryParams.set('soft_delete', softDelete.toString()); + + const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`); + if (response.error) { + throw new Error(response.error); + } + }, + + /** + * Cambia la contraseña de un usuario + */ + async changePassword(userId: string, data: ChangePasswordRequest): Promise { + const response = await api.post(`/v1/core/users/${userId}/change-password`, data); + if (response.error) { + throw new Error(response.error); + } + } +}; diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 1276da7c..238e4693 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -388,17 +388,11 @@ export function getSidebarData(): SidebarData { url: "#", icon: Settings2, items: [ - { - title: m["sidebar.reference_data.general"](), - url: "#", - }, - { - title: m["sidebar.reference_data.licencia"](), - url: "#", - }, + + { title: m["sidebar.reference_data.usuarios"](), - url: "#", + url: "", }, ], }, @@ -406,8 +400,18 @@ export function getSidebarData(): SidebarData { projects: [ { name: m["sidebar.reference_data.usuarios"](), + url: "/dashboard/users", + icon: Users, + }, + { + name: m["sidebar.reference_data.licencia"](), url: "#", - icon: ChartPie, + icon: BadgeCheck, + }, + { + name: m["sidebar.reference_data.configuracion"](), + url: "#", + icon: Settings2, }, { name: m["sidebar.reference_data.ayuda"](), diff --git a/frontend/src/routes/dashboard/users/+page.server.ts b/frontend/src/routes/dashboard/users/+page.server.ts new file mode 100644 index 00000000..ab301f4d --- /dev/null +++ b/frontend/src/routes/dashboard/users/+page.server.ts @@ -0,0 +1,6 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + // Los datos se cargarán desde el cliente + return {}; +}; diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte new file mode 100644 index 00000000..3b4a7a3a --- /dev/null +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -0,0 +1,639 @@ + + +
+ +
+
+

Gestión de Usuarios

+

Administra los usuarios de tu organización

+
+ +
+ + + {#if stats} +
+ + + Total Usuarios + + + +
{stats.total_users}
+
+
+ + + + Activos + + + +
{stats.active_users}
+
+
+ + + + Disponibles + + + +
{stats.users_available}
+

de {stats.max_users_allowed} permitidos

+
+
+ + + + Uso de Licencia + + +
{stats.usage_percentage.toFixed(1)}%
+
+
= 70 && stats.usage_percentage < 90} + class:bg-red-600={stats.usage_percentage >= 90} + style="width: {stats.usage_percentage}%" + >
+
+
+
+
+ {/if} + + + + +
+ Usuarios +
+
+ + +
+ +
+
+
+ + + + + Usuario + Email + Nombre + Rol + Estado + Acciones + + + + {#if loading && users.length === 0} + + + + + + {:else if users.length === 0} + + + No se encontraron usuarios + + + {:else} + {#each users as user} + + {user.username} + +
+ {user.email} + {#if user.email_verified} + Verificado + {/if} +
+
+ {user.first_name} {user.last_name} + + {#if user.role} + {user.role} + {:else} + - + {/if} + + + {#if user.enabled} + Activo + {:else} + Inactivo + {/if} + + +
+ + + +
+
+
+ {/each} + {/if} +
+
+ + + {#if totalPages > 1} +
+

+ Página {currentPage} de {totalPages} +

+
+ + +
+
+ {/if} +
+
+
+ + + + + + Crear Nuevo Usuario + + Ingresa la información del nuevo usuario. Se enviará un correo para configurar su contraseña. + + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +

Mínimo 8 caracteres

+
+
+ + +
+
+ + + + +
+
+ + + + + + Editar Usuario + + Modifica la información del usuario {selectedUser?.username} + + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + + + + + Cambiar Contraseña + + Establece una nueva contraseña para {selectedUser?.username} + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+
+ + + + + + ¿Eliminar usuario? + + ¿Estás seguro de que deseas eliminar a {selectedUser?.username}? +

+ Puedes desactivar el usuario (recomendado) o eliminarlo permanentemente. +
+
+ + Cancelar + + handleDelete(false)} class="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + + Eliminar Permanente + + +
+
diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index c27ad145..31085dcf 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -11,6 +11,7 @@ # 5. Tenant y Company en PostgreSQL # 6. Relación usuario-tenant en tabla user_tenants # 7. Actualización del tenant_id del usuario con el valor real +# 8. Licencia Enterprise para el tenant (ilimitada, 1 año de vigencia) # # Requisitos: # - Keycloak corriendo en http://localhost:8080 @@ -22,7 +23,10 @@ # se actualiza con el ID real del tenant creado en PostgreSQL. ############################################################################### -# set -e # Comentado para permitir que el script continúe aunque algunos comandos fallen (ej: mapper ya existe) +set -euo pipefail # Modo strict: exit on error, undefined vars, pipe failures + +# Trap para cleanup en caso de error +trap 'echo -e "\n${RED}✗ Error en línea $LINENO. Script abortado.${NC}" >&2' ERR # Colores para output RED='\033[0;31m' @@ -30,6 +34,84 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color +############################################################################### +# Validar dependencias requeridas +############################################################################### +check_dependencies() { + local missing_deps=() + + command -v jq >/dev/null 2>&1 || missing_deps+=("jq") + command -v curl >/dev/null 2>&1 || missing_deps+=("curl") + command -v docker >/dev/null 2>&1 || missing_deps+=("docker") + + if [ ${#missing_deps[@]} -ne 0 ]; then + echo -e "${RED}✗ Error: Dependencias faltantes: ${missing_deps[*]}${NC}" >&2 + echo -e "${YELLOW}Instala las dependencias faltantes antes de continuar.${NC}" >&2 + exit 1 + fi +} + +check_dependencies + +############################################################################### +# Funciones auxiliares +############################################################################### + +# Ejecutar SQL en PostgreSQL via Docker +exec_pg_sql() { + local sql="$1" + docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 \ + psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ + -t -c "${sql}" 2>&1 +} + +# Crear mapper de tenant_id para un cliente +create_tenant_mapper() { + local client_id="$1" + local client_name="$2" + + echo "Configurando mapper para ${client_name}..." + + # Verificar si el mapper ya existe + local mappers + mappers=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_id}/protocol-mappers/models" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json") + + if echo "$mappers" | jq -e '.[] | select(.name == "tenant-id-mapper")' >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para ${client_name}${NC}" + return 0 + fi + + # Crear mapper + local response + response=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_id}/protocol-mappers/models" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "tenant-id-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "tenant_id", + "claim.name": "tenant_id", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }') + + local http_code + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "201" ]; then + echo -e "${GREEN}✓ Mapper tenant_id creado para ${client_name}${NC}" + else + echo -e "${YELLOW}⚠ No se pudo crear mapper para ${client_name} (HTTP ${http_code})${NC}" + fi +} + # Variables de configuración KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080/kcauth}" KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" @@ -71,7 +153,9 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do break fi RETRY_COUNT=$((RETRY_COUNT + 1)) - echo "Intento $RETRY_COUNT/$MAX_RETRIES..." + if [ $((RETRY_COUNT % 5)) -eq 0 ]; then + echo "Esperando Keycloak... ($RETRY_COUNT/$MAX_RETRIES)" + fi sleep 2 done @@ -92,7 +176,7 @@ TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid- -d "grant_type=password" \ -d "client_id=admin-cli") -ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*' | sed 's/"access_token":"//') +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token // empty') if [ -z "$ACCESS_TOKEN" ]; then echo -e "${RED}✗ Error: No se pudo obtener el token de acceso${NC}" @@ -112,8 +196,8 @@ USER_PROFILE=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/us -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -# Verificar si tenant_id existe -TENANT_ID_EXISTS=$(echo "$USER_PROFILE" | grep -q "\"name\":\"tenant_id\"" && echo "true" || echo "false") +# Verificar si tenant_id existe usando jq +TENANT_ID_EXISTS=$(echo "$USER_PROFILE" | jq -e '.attributes[]? | select(.name == "tenant_id")' >/dev/null 2>&1 && echo "true" || echo "false") if [ "$TENANT_ID_EXISTS" = "false" ]; then echo "Agregando atributo tenant_id al User Profile..." @@ -160,9 +244,9 @@ BACKEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -if echo "$BACKEND_CLIENT_EXISTS" | grep -q "\"clientId\":\"anexo76-backend\""; then +if echo "$BACKEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-backend")' >/dev/null 2>&1; then echo -e "${YELLOW}⚠ Cliente Backend ya existe${NC}" - BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | jq -r '.[0].id') else CREATE_BACKEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ @@ -198,7 +282,7 @@ else BACKEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients?clientId=anexo76-backend" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") - BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | jq -r '.[0].id') else echo -e "${RED}✗ Error al crear cliente Backend (HTTP ${HTTP_CODE})${NC}" fi @@ -208,7 +292,7 @@ fi if [ -n "$BACKEND_CLIENT_ID" ]; then BACKEND_SECRET=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/client-secret" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" | grep -o '"value":"[^"]*' | sed 's/"value":"//') + -H "Content-Type: application/json" | jq -r '.value // empty') echo -e "${GREEN}✓ Backend Client ID: anexo76-backend${NC}" echo -e "${GREEN}✓ Backend Client Secret: ${BACKEND_SECRET}${NC}" @@ -252,9 +336,9 @@ FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -if echo "$FRONTEND_CLIENT_EXISTS" | grep -q "\"clientId\":\"anexo76-frontend\""; then +if echo "$FRONTEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-frontend")' >/dev/null 2>&1; then echo -e "${YELLOW}⚠ Cliente Frontend ya existe${NC}" - FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id') else CREATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ @@ -293,7 +377,7 @@ else FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients?clientId=anexo76-frontend" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") - FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id') else echo -e "${RED}✗ Error al crear cliente Frontend (HTTP ${HTTP_CODE})${NC}" echo "Respuesta del servidor: $RESPONSE_BODY" @@ -307,83 +391,12 @@ echo -e "\n${YELLOW}[5/8] Configurando mappers para tenant_id...${NC}" # 4.1 Configurar mapper para Backend if [ -n "$BACKEND_CLIENT_ID" ]; then - echo "Configurando mapper para Backend..." - - # Verificar si el mapper tenant_id ya existe en el cliente - MAPPER_TENANT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" | grep -o "\"name\":\"tenant-id-mapper\"") - - if [ -z "$MAPPER_TENANT_EXISTS" ]; then - # Crear mapper para tenant_id directamente en el cliente - CREATE_MAPPER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "tenant-id-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "config": { - "user.attribute": "tenant_id", - "claim.name": "tenant_id", - "jsonType.label": "String", - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }') - - HTTP_CODE=$(echo "$CREATE_MAPPER_RESPONSE" | tail -n1) - if [ "$HTTP_CODE" = "201" ]; then - echo -e "${GREEN}✓ Mapper tenant_id creado para Backend${NC}" - else - echo -e "${YELLOW}⚠ Error al crear mapper para Backend (HTTP ${HTTP_CODE})${NC}" - echo "Respuesta: $(echo "$CREATE_MAPPER_RESPONSE" | head -n -1)" - fi - else - echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para Backend${NC}" - fi + create_tenant_mapper "$BACKEND_CLIENT_ID" "Backend" fi - # 4.2 Configurar mapper para Frontend if [ -n "$FRONTEND_CLIENT_ID" ]; then - echo "Configurando mapper para Frontend..." - - # Verificar si el mapper tenant_id ya existe en el cliente - MAPPER_TENANT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" | grep -o "\"name\":\"tenant-id-mapper\"") - - if [ -z "$MAPPER_TENANT_EXISTS" ]; then - # Crear mapper para tenant_id directamente en el cliente - CREATE_MAPPER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "tenant-id-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "config": { - "user.attribute": "tenant_id", - "claim.name": "tenant_id", - "jsonType.label": "String", - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }') - - HTTP_CODE=$(echo "$CREATE_MAPPER_RESPONSE" | tail -n1) - if [ "$HTTP_CODE" = "201" ]; then - echo -e "${GREEN}✓ Mapper tenant_id creado para Frontend${NC}" - else - echo -e "${YELLOW}⚠ Error al crear mapper para Frontend (HTTP ${HTTP_CODE})${NC}" - echo "Respuesta: $(echo "$CREATE_MAPPER_RESPONSE" | head -n -1)" - fi - else - echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para Frontend${NC}" - fi + create_tenant_mapper "$FRONTEND_CLIENT_ID" "Frontend" fi @@ -397,9 +410,9 @@ USER_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/use -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -if echo "$USER_EXISTS" | grep -q "\"username\":\"${DEMO_USERNAME}\""; then +if echo "$USER_EXISTS" | jq -e '.[] | select(.username == "'"${DEMO_USERNAME}"'")' >/dev/null 2>&1; then echo -e "${YELLOW}⚠ Usuario demo ya existe, actualizando...${NC}" - USER_ID=$(echo "$USER_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + USER_ID=$(echo "$USER_EXISTS" | jq -r '.[0].id') # Actualizar usuario existente curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}" \ @@ -455,7 +468,7 @@ else USER_RESPONSE=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users?username=${DEMO_USERNAME}" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") - USER_ID=$(echo "$USER_RESPONSE" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + USER_ID=$(echo "$USER_RESPONSE" | jq -r '.[0].id') else echo -e "${RED}✗ Error al crear usuario (HTTP ${HTTP_CODE})${NC}" echo "Respuesta: $CREATE_RESPONSE" @@ -470,14 +483,14 @@ echo -e "${GREEN}✓ Keycloak User ID: ${USER_ID}${NC}" ############################################################################### # 7. Agregar atributo tenant_id al usuario ############################################################################### -echo -e "\n${YELLOW}[7/8] Configurando atributo tenant_id para usuario demo...${NC}" +echo -e "\n${YELLOW}[7/9] Configurando atributo tenant_id para usuario demo...${NC}" # Nota: El tenant_id se agregará después de crear el tenant en PostgreSQL ############################################################################### # 8. Crear tenant y company en PostgreSQL ############################################################################### -echo -e "\n${YELLOW}[8/8] Creando tenant y company en PostgreSQL...${NC}" +echo -e "\n${YELLOW}[8/9] Creando tenant y company en PostgreSQL...${NC}" # Esperar a que PostgreSQL esté listo echo "Esperando a que PostgreSQL esté disponible..." @@ -491,7 +504,9 @@ while [ $PG_RETRY_COUNT -lt $MAX_PG_RETRIES ]; do break fi PG_RETRY_COUNT=$((PG_RETRY_COUNT + 1)) - echo "Intento $PG_RETRY_COUNT/$MAX_PG_RETRIES..." + if [ $((PG_RETRY_COUNT % 10)) -eq 0 ]; then + echo "Esperando PostgreSQL... ($PG_RETRY_COUNT/$MAX_PG_RETRIES)" + fi sleep 1 done @@ -502,13 +517,16 @@ fi # Insertar o actualizar tenant echo "Insertando tenant en PostgreSQL..." -docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" 2>&1 +exec_pg_sql "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null # Obtener el ID del tenant con mejor manejo de errores echo "Obteniendo ID del tenant..." -TENANT_ID_RESULT=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT id FROM core.tenants WHERE slug = '${TENANT_SLUG}';" 2>&1) +set +e +TENANT_ID_RESULT=$(exec_pg_sql "SELECT id FROM core.tenants WHERE slug = '${TENANT_SLUG}';") +TENANT_QUERY_STATUS=$? +set -e -if [ $? -ne 0 ]; then +if [ $TENANT_QUERY_STATUS -ne 0 ]; then echo -e "${RED}✗ Error al consultar el tenant:${NC}" echo "$TENANT_ID_RESULT" echo -e "${YELLOW}Verificando si la tabla existe...${NC}" @@ -529,18 +547,18 @@ fi echo -e "${GREEN}✓ Tenant ID: ${TENANT_ID}${NC}" # Insertar company si no existe -COMPANY_EXISTS=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};") +COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};") COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs) if [ "$COMPANY_EXISTS" = "0" ]; then - docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" 2>&1 + exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null echo -e "${GREEN}✓ Company creada${NC}" else echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}" fi # Obtener información de la company -COMPANY_INFO=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;") +COMPANY_INFO=$(exec_pg_sql "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;") echo -e "${GREEN}✓ Company: ${COMPANY_INFO}${NC}" @@ -561,10 +579,62 @@ echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}" # Agregar relación usuario-tenant en la base de datos echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}" -docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" 2>&1 +exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos${NC}" +############################################################################### +# 9. Crear licencia Enterprise para el tenant +############################################################################### +echo -e "\n${YELLOW}[9/9] Creando licencia Enterprise para el tenant...${NC}" + +# Calcular fechas de inicio y expiración (1 año desde hoy) +LICENSE_START_DATE=$(date -u +"%Y-%m-%d %H:%M:%S") +LICENSE_EXPIRE_DATE=$(date -u -d "+1 year" +"%Y-%m-%d %H:%M:%S") + +# Verificar si ya existe una licencia para este tenant +LICENSE_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM core.licenses WHERE tenant_id = ${TENANT_ID};") +LICENSE_EXISTS=$(echo "$LICENSE_EXISTS" | xargs) + +if [ "$LICENSE_EXISTS" = "0" ]; then + # Crear licencia Enterprise con límites ilimitados (valores muy altos) + set +e # Desactivar exit on error temporalmente + LICENSE_CREATE_OUTPUT=$(exec_pg_sql "INSERT INTO core.licenses (tenant_id, plan, status, max_users, max_storage_gb, max_monthly_operations, feature_api_access, feature_advanced_reports, feature_integrations, feature_dedicated_support, starts_at, expires_at, created_at, updated_at) VALUES (${TENANT_ID}, 'ENTERPRISE', 'ACTIVE', 999999, 999999, 999999, true, true, true, true, '${LICENSE_START_DATE}'::timestamp, '${LICENSE_EXPIRE_DATE}'::timestamp, now(), now());" 2>&1) + LICENSE_CREATE_STATUS=$? + set -e # Reactivar exit on error + + if [ $LICENSE_CREATE_STATUS -eq 0 ]; then + echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente${NC}" + echo -e "${GREEN} Plan: Enterprise${NC}" + echo -e "${GREEN} Usuarios: Ilimitados${NC}" + echo -e "${GREEN} Almacenamiento: Ilimitado${NC}" + echo -e "${GREEN} Operaciones mensuales: Ilimitadas${NC}" + echo -e "${GREEN} Inicio: ${LICENSE_START_DATE}${NC}" + echo -e "${GREEN} Expiración: ${LICENSE_EXPIRE_DATE}${NC}" + echo -e "${GREEN} Features: API, Reportes Avanzados, Integraciones, Soporte Dedicado${NC}" + else + echo -e "${RED}✗ Error al crear la licencia${NC}" + echo -e "${YELLOW}Detalle del error:${NC}" + echo "$LICENSE_CREATE_OUTPUT" + exit 1 + fi +else + echo -e "${YELLOW}⚠ Ya existe una licencia para este tenant, actualizando a Enterprise...${NC}" + + # Actualizar licencia existente a Enterprise + set +e # Desactivar exit on error temporalmente + exec_pg_sql "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1 + LICENSE_UPDATE_STATUS=$? + set -e # Reactivar exit on error + + if [ $LICENSE_UPDATE_STATUS -eq 0 ]; then + echo -e "${GREEN}✓ Licencia actualizada a Enterprise${NC}" + else + echo -e "${RED}✗ Error al actualizar la licencia${NC}" + exit 1 + fi +fi + ############################################################################### # Resumen final ############################################################################### @@ -598,6 +668,11 @@ echo -e " ${GREEN}✓${NC} tenant_id mapper para Frontend" echo -e "\n${YELLOW}Configuración del usuario demo:${NC}" echo -e " ${GREEN}✓${NC} tenant_id actualizado al ID real: ${TENANT_ID}" echo -e " ${GREEN}✓${NC} Relación usuario-tenant creada (rol: admin)" +echo -e "\n${YELLOW}Licencia Enterprise:${NC}" +echo -e " ${GREEN}✓${NC} Plan: Enterprise (ilimitado)" +echo -e " ${GREEN}✓${NC} Status: Activa" +echo -e " ${GREEN}✓${NC} Features: API, Reportes Avanzados, Integraciones, Soporte Dedicado" +echo -e " ${GREEN}✓${NC} Vigencia: 1 año" echo -e "\n${YELLOW}Puedes acceder al sistema en:${NC}" echo -e " ${GREEN}http://localhost:5173${NC}" echo -e "\n${GREEN}════════════════════════════════════════════════════════${NC}\n"