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.
This commit is contained in:
@@ -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"])
|
||||
|
||||
3
backend/api/v1/modules/core/users/__init__.py
Normal file
3
backend/api/v1/modules/core/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de gestión de usuarios (Keycloak)
|
||||
"""
|
||||
89
backend/api/v1/modules/core/users/dto.py
Normal file
89
backend/api/v1/modules/core/users/dto.py
Normal file
@@ -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
|
||||
187
backend/api/v1/modules/core/users/routes.py
Normal file
187
backend/api/v1/modules/core/users/routes.py
Normal file
@@ -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"}
|
||||
497
backend/api/v1/modules/core/users/service.py
Normal file
497
backend/api/v1/modules/core/users/service.py
Normal file
@@ -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),
|
||||
}
|
||||
149
frontend/src/lib/api/dashboard/users.ts
Normal file
149
frontend/src/lib/api/dashboard/users.ts
Normal file
@@ -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<UserStats> {
|
||||
const response = await api.get<UserStats>('/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<UserListResponse> {
|
||||
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<UserListResponse>(endpoint);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene un usuario específico
|
||||
*/
|
||||
async get(userId: string): Promise<User> {
|
||||
const response = await api.get<User>(`/v1/core/users/${userId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crea un nuevo usuario
|
||||
*/
|
||||
async create(data: CreateUserRequest): Promise<User> {
|
||||
const response = await api.post<User>('/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<User> {
|
||||
const response = await api.put<User>(`/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<void> {
|
||||
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<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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"](),
|
||||
|
||||
6
frontend/src/routes/dashboard/users/+page.server.ts
Normal file
6
frontend/src/routes/dashboard/users/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Los datos se cargarán desde el cliente
|
||||
return {};
|
||||
};
|
||||
639
frontend/src/routes/dashboard/users/+page.svelte
Normal file
639
frontend/src/routes/dashboard/users/+page.svelte
Normal file
@@ -0,0 +1,639 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { usersAPI, type User, type UserStats } from '$lib/api/dashboard/users';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Users,
|
||||
AlertCircle
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Estados principales
|
||||
let users = $state<User[]>([]);
|
||||
let stats = $state<UserStats | null>(null);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(20);
|
||||
let totalPages = $state(1);
|
||||
|
||||
// Diálogos
|
||||
let showCreateDialog = $state(false);
|
||||
let showEditDialog = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let showPasswordDialog = $state(false);
|
||||
|
||||
// Usuario seleccionado para editar/eliminar
|
||||
let selectedUser = $state<User | null>(null);
|
||||
|
||||
// Formulario de creación
|
||||
let createForm = $state({
|
||||
email: '',
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
role: '',
|
||||
enabled: true,
|
||||
email_verified: false
|
||||
});
|
||||
|
||||
// Formulario de edición
|
||||
let editForm = $state({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
enabled: true,
|
||||
email_verified: false,
|
||||
role: ''
|
||||
});
|
||||
|
||||
// Formulario de cambio de contraseña
|
||||
let passwordForm = $state({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
temporary: true
|
||||
});
|
||||
|
||||
// Cargar estadísticas
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await usersAPI.getStats();
|
||||
} catch (error: any) {
|
||||
console.error('Error loading stats:', error);
|
||||
toast.error('Error al cargar estadísticas', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar usuarios
|
||||
async function loadUsers() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await usersAPI.list({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
search: searchTerm || undefined
|
||||
});
|
||||
|
||||
users = response.users;
|
||||
totalPages = response.total_pages;
|
||||
} catch (error: any) {
|
||||
console.error('Error loading users:', error);
|
||||
toast.error('Error al cargar usuarios', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar usuarios
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadUsers();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Crear usuario
|
||||
async function handleCreate() {
|
||||
if (!createForm.email || !createForm.username || !createForm.first_name ||
|
||||
!createForm.last_name || !createForm.password) {
|
||||
toast.error('Por favor complete todos los campos requeridos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (createForm.password.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// El tenant_id se obtiene automáticamente del token en el backend
|
||||
await usersAPI.create(createForm);
|
||||
|
||||
toast.success('Usuario creado exitosamente');
|
||||
showCreateDialog = false;
|
||||
resetCreateForm();
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating user:', error);
|
||||
toast.error('Error al crear usuario', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir diálogo de edición
|
||||
function openEditDialog(user: User) {
|
||||
selectedUser = user;
|
||||
editForm = {
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
enabled: user.enabled,
|
||||
email_verified: user.email_verified,
|
||||
role: user.role || ''
|
||||
};
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
// Actualizar usuario
|
||||
async function handleUpdate() {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
// Filtrar campos vacíos antes de enviar
|
||||
const updateData: any = {};
|
||||
if (editForm.first_name && editForm.first_name.trim()) {
|
||||
updateData.first_name = editForm.first_name.trim();
|
||||
}
|
||||
if (editForm.last_name && editForm.last_name.trim()) {
|
||||
updateData.last_name = editForm.last_name.trim();
|
||||
}
|
||||
if (editForm.email && editForm.email.trim()) {
|
||||
updateData.email = editForm.email.trim();
|
||||
}
|
||||
if (editForm.role !== undefined && editForm.role !== null) {
|
||||
updateData.role = editForm.role;
|
||||
}
|
||||
// Solo incluir enabled y email_verified si son diferentes del original
|
||||
if (editForm.enabled !== selectedUser.enabled) {
|
||||
updateData.enabled = editForm.enabled;
|
||||
}
|
||||
if (editForm.email_verified !== selectedUser.email_verified) {
|
||||
updateData.email_verified = editForm.email_verified;
|
||||
}
|
||||
|
||||
await usersAPI.update(selectedUser.id, updateData);
|
||||
toast.success('Usuario actualizado exitosamente');
|
||||
showEditDialog = false;
|
||||
selectedUser = null;
|
||||
await loadUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Error updating user:', error);
|
||||
toast.error('Error al actualizar usuario', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir diálogo de eliminación
|
||||
function openDeleteDialog(user: User) {
|
||||
selectedUser = user;
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
|
||||
// Eliminar usuario
|
||||
async function handleDelete(softDelete: boolean = true) {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
await usersAPI.delete(selectedUser.id, softDelete);
|
||||
toast.success(softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente');
|
||||
showDeleteDialog = false;
|
||||
selectedUser = null;
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting user:', error);
|
||||
toast.error('Error al eliminar usuario', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir diálogo de cambio de contraseña
|
||||
function openPasswordDialog(user: User) {
|
||||
selectedUser = user;
|
||||
passwordForm = {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
temporary: true
|
||||
};
|
||||
showPasswordDialog = true;
|
||||
}
|
||||
|
||||
// Cambiar contraseña
|
||||
async function handleChangePassword() {
|
||||
if (!selectedUser) return;
|
||||
|
||||
if (passwordForm.password !== passwordForm.confirmPassword) {
|
||||
toast.error('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.password.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await usersAPI.changePassword(selectedUser.id, {
|
||||
password: passwordForm.password,
|
||||
temporary: passwordForm.temporary
|
||||
});
|
||||
toast.success('Contraseña actualizada exitosamente');
|
||||
showPasswordDialog = false;
|
||||
selectedUser = null;
|
||||
passwordForm = { password: '', confirmPassword: '', temporary: true };
|
||||
} catch (error: any) {
|
||||
console.error('Error changing password:', error);
|
||||
toast.error('Error al cambiar contraseña', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resetear formularios
|
||||
function resetCreateForm() {
|
||||
createForm = {
|
||||
email: '',
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
role: '',
|
||||
enabled: true,
|
||||
email_verified: false
|
||||
};
|
||||
}
|
||||
|
||||
// Cambiar página
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar datos al montar
|
||||
onMount(() => {
|
||||
Promise.all([loadUsers(), loadStats()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Gestión de Usuarios</h1>
|
||||
<p class="text-muted-foreground">Administra los usuarios de tu organización</p>
|
||||
</div>
|
||||
<Button onclick={() => showCreateDialog = true}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nuevo Usuario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Estadísticas -->
|
||||
{#if stats}
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Total Usuarios</Card.Title>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">{stats.total_users}</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Activos</Card.Title>
|
||||
<UserCheck class="h-4 w-4 text-green-600" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold text-green-600">{stats.active_users}</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Disponibles</Card.Title>
|
||||
<AlertCircle class="h-4 w-4 text-blue-600" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold text-blue-600">{stats.users_available}</div>
|
||||
<p class="text-xs text-muted-foreground">de {stats.max_users_allowed} permitidos</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Uso de Licencia</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">{stats.usage_percentage.toFixed(1)}%</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
class:bg-green-600={stats.usage_percentage < 70}
|
||||
class:bg-yellow-600={stats.usage_percentage >= 70 && stats.usage_percentage < 90}
|
||||
class:bg-red-600={stats.usage_percentage >= 90}
|
||||
style="width: {stats.usage_percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabla de usuarios -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex justify-between items-center">
|
||||
<Card.Title>Usuarios</Card.Title>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar usuarios..."
|
||||
bind:value={searchTerm}
|
||||
oninput={handleSearch}
|
||||
class="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onclick={() => loadUsers()}>
|
||||
<RefreshCw class={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Usuario</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>Rol</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if loading && users.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center py-8">
|
||||
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if users.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center py-8 text-muted-foreground">
|
||||
No se encontraron usuarios
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each users as user}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{user.username}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-2">
|
||||
{user.email}
|
||||
{#if user.email_verified}
|
||||
<Badge variant="outline" class="text-xs">Verificado</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{user.first_name} {user.last_name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if user.role}
|
||||
<Badge>{user.role}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if user.enabled}
|
||||
<Badge variant="default" class="bg-green-600">Activo</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive">Inactivo</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => openEditDialog(user)}
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => openPasswordDialog(user)}
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => openDeleteDialog(user)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<!-- Paginación -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Página {currentPage} de {totalPages}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Diálogo Crear Usuario -->
|
||||
<Dialog.Root bind:open={showCreateDialog}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Crear Nuevo Usuario</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Ingresa la información del nuevo usuario. Se enviará un correo para configurar su contraseña.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="first_name">Nombre *</Label>
|
||||
<Input id="first_name" bind:value={createForm.first_name} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="last_name">Apellido *</Label>
|
||||
<Input id="last_name" bind:value={createForm.last_name} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="username">Usuario *</Label>
|
||||
<Input id="username" bind:value={createForm.username} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email *</Label>
|
||||
<Input id="email" type="email" bind:value={createForm.email} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Contraseña Temporal *</Label>
|
||||
<Input id="password" type="password" bind:value={createForm.password} required />
|
||||
<p class="text-xs text-muted-foreground">Mínimo 8 caracteres</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="role">Rol</Label>
|
||||
<Input id="role" bind:value={createForm.role} placeholder="Opcional" />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showCreateDialog = false}>Cancelar</Button>
|
||||
<Button onclick={handleCreate}>Crear Usuario</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo Editar Usuario -->
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Editar Usuario</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Modifica la información del usuario {selectedUser?.username}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_first_name">Nombre</Label>
|
||||
<Input id="edit_first_name" bind:value={editForm.first_name} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_last_name">Apellido</Label>
|
||||
<Input id="edit_last_name" bind:value={editForm.last_name} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_email">Email</Label>
|
||||
<Input id="edit_email" type="email" bind:value={editForm.email} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_role">Rol</Label>
|
||||
<Input id="edit_role" bind:value={editForm.role} />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="edit_enabled" bind:checked={editForm.enabled} class="h-4 w-4" />
|
||||
<Label for="edit_enabled">Usuario activo</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="edit_email_verified" bind:checked={editForm.email_verified} class="h-4 w-4" />
|
||||
<Label for="edit_email_verified">Email verificado</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showEditDialog = false}>Cancelar</Button>
|
||||
<Button onclick={handleUpdate}>Guardar Cambios</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo Cambiar Contraseña -->
|
||||
<Dialog.Root bind:open={showPasswordDialog}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Cambiar Contraseña</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Establece una nueva contraseña para {selectedUser?.username}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="new_password">Nueva Contraseña</Label>
|
||||
<Input id="new_password" type="password" bind:value={passwordForm.password} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm_password">Confirmar Contraseña</Label>
|
||||
<Input id="confirm_password" type="password" bind:value={passwordForm.confirmPassword} />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="temporary" bind:checked={passwordForm.temporary} class="h-4 w-4" />
|
||||
<Label for="temporary">Contraseña temporal (debe cambiarla al iniciar sesión)</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showPasswordDialog = false}>Cancelar</Button>
|
||||
<Button onclick={handleChangePassword}>Cambiar Contraseña</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo Eliminar Usuario -->
|
||||
<AlertDialog.Root bind:open={showDeleteDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>¿Eliminar usuario?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
¿Estás seguro de que deseas eliminar a {selectedUser?.username}?
|
||||
<br /><br />
|
||||
Puedes desactivar el usuario (recomendado) o eliminarlo permanentemente.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
|
||||
<Button variant="outline" onclick={() => handleDelete(true)}>
|
||||
<UserX class="w-4 h-4 mr-2" />
|
||||
Desactivar
|
||||
</Button>
|
||||
<AlertDialog.Action onclick={() => handleDelete(false)} class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Eliminar Permanente
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user