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:
2026-01-13 09:56:53 -06:00
parent 34e71b3114
commit f812d71508
10 changed files with 1759 additions and 108 deletions

View File

@@ -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"])

View File

@@ -0,0 +1,3 @@
"""
Módulo de gestión de usuarios (Keycloak)
"""

View 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

View 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"}

View 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),
}

View 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);
}
}
};

View File

@@ -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"](),

View 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 {};
};

View 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>

View File

@@ -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"