Merge pull request 'feature/user-management-ui' (#54) from feature/user-management-ui into development

Reviewed-on: ADUANASOFT/anexo76#54
This commit is contained in:
2026-01-13 19:07:29 +00:00
27 changed files with 2891 additions and 172 deletions

3
.gitignore vendored
View File

@@ -59,4 +59,5 @@ node_modules/
# Docker
*.dockerignore
postgres-data/
postgres-data/
backend/uploads/

View File

@@ -57,7 +57,7 @@ class Company(Base, TimestampMixin):
position: Mapped[Optional[str]] = mapped_column(String(30))
# Configuración
logo: Mapped[Optional[str]] = mapped_column(String(255))
logo: Mapped[Optional[str]] = mapped_column(String(500))
has_express_line: Mapped[Optional[bool]] = mapped_column(Boolean)
order_format_type: Mapped[Optional[str]] = mapped_column(String(19))
previous_code: Mapped[Optional[int]] = mapped_column(SmallInteger)

View File

@@ -2,9 +2,12 @@
Rutas para gestión de empresa
"""
import os
import shutil
from typing import List, Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from core.database import get_core_db
@@ -14,9 +17,15 @@ from .dto import CompanyCreateDTO, CompanyResponseDTO, CompanyUpdateDTO
from .models import Company
from .service import CompanyService
# Configuración de directorios
UPLOAD_DIR = "uploads/companies"
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
# Main router that includes base CRUD
router = APIRouter(prefix="/company")
@router.post(
"", # Se suma al prefix, queda POST /api/v1/a76/company
response_model=CompanyResponseDTO,
@@ -28,7 +37,7 @@ async def create_company(
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
@@ -70,12 +79,12 @@ async def list_companies(
service = CompanyService(db)
items, total = service.get_all(
db,
tenant_id,
db,
tenant_id,
company_id=0, # Not used for companies
skip=skip,
skip=skip,
limit=page_size,
filters=filters if filters else None
filters=filters if filters else None,
)
total_pages = (total + page_size - 1) // page_size
@@ -286,9 +295,7 @@ async def update_company(
detail="Tenant ID not found in user data",
)
updated_company = CompanyService.update(
db, company_id, tenant_id, 0, data
)
updated_company = CompanyService.update(db, company_id, tenant_id, 0, data)
if not updated_company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -323,4 +330,86 @@ async def delete_company(
detail="Company not found",
)
return None
return None
@router.post(
"/{company_id}/upload-logo",
response_model=dict,
summary="Upload company logo",
)
async def upload_company_logo(
company_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""Upload a logo for a company"""
tenant_id = current_user.get("tenant_id")
if not tenant_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant ID not found in user data",
)
# Validar que la empresa existe
company = CompanyService.get_by_id(db, company_id, tenant_id, 0)
if not company:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found",
)
# Validar extensión
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}",
)
# Validar tamaño
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File too large. Maximum size: {MAX_FILE_SIZE / 1024 / 1024}MB",
)
# Crear directorio si no existe
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Eliminar logo anterior si existe
if company.logo:
old_logo_path = company.logo
if os.path.exists(old_logo_path):
try:
os.remove(old_logo_path)
except Exception:
pass # No es crítico si falla
# Generar nombre único
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"company_{company_id}_{timestamp}{file_ext}"
file_path = os.path.join(UPLOAD_DIR, filename)
# Guardar archivo
try:
await file.seek(0)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error saving file: {str(e)}",
)
# Actualizar la empresa con la ruta del logo
update_data = CompanyUpdateDTO(logo=file_path)
updated_company = CompanyService.update(db, company_id, tenant_id, 0, update_data)
return {
"message": "Logo uploaded successfully",
"logo_path": file_path,
"company_id": company_id,
}

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

@@ -6,7 +6,14 @@ from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Boolean, ForeignKeyConstraint, String, UniqueConstraint
from sqlalchemy import (
Boolean,
ForeignKeyConstraint,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
if TYPE_CHECKING:
@@ -43,5 +50,19 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
# Información adicional - Rol del usuario en este tenant (opcional)
role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Campos de perfil de usuario
avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="URL de la imagen de perfil"
)
phone: Mapped[Optional[str]] = mapped_column(
String(20), nullable=True, comment="Teléfono del usuario"
)
bio: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, comment="Biografía del usuario"
)
preferences: Mapped[Optional[dict]] = mapped_column(
JSON, nullable=True, comment="Preferencias del usuario (tema, idioma, etc.)"
)
# Relación con Tenant
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations")

View File

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

View File

@@ -0,0 +1,105 @@
"""
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")
# Campos de perfil
avatar_url: Optional[str] = Field(
None, max_length=500, description="URL del avatar"
)
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
bio: Optional[str] = Field(None, description="Biografía")
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
@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")
# Campos de perfil
avatar_url: Optional[str] = Field(None, description="URL del avatar")
phone: Optional[str] = Field(None, description="Teléfono")
bio: Optional[str] = Field(None, description="Biografía")
preferences: Optional[dict] = Field(
default_factory=dict, description="Preferencias"
)
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,314 @@
"""
Rutas para gestión de usuarios de Keycloak
"""
from typing import Optional
import os
import uuid
from pathlib import Path
from core.database import get_core_db
from core.security import get_current_user, get_tenant_from_token
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
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
- Perfil (avatar, teléfono, bio, preferencias)
"""
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,
avatar_url=data.avatar_url,
phone=data.phone,
bio=data.bio,
preferences=data.preferences,
)
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"}
# === Endpoints de Perfil del Usuario Actual ===
@router.get("/me/profile", response_model=UserResponseDTO)
def get_my_profile(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Obtiene el perfil completo del usuario actual
Incluye datos de Keycloak y datos de perfil (avatar, bio, etc.)
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Obtener user_tenant para crear servicio
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"
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return service.get_current_user_profile(keycloak_user_id)
@router.put("/me/profile", response_model=UserResponseDTO)
def update_my_profile(
data: UpdateUserRequestDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Actualiza el perfil del usuario actual
Puede actualizar:
- Datos de Keycloak: nombre, apellido, email
- Datos de perfil: avatar, teléfono, biografía, preferencias
"""
keycloak_user_id = current_user.get("sub")
if not keycloak_user_id:
raise HTTPException(status_code=400, detail="User ID not found in token")
# Obtener user_tenant
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"
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return service.update_current_user_profile(
keycloak_user_id=keycloak_user_id,
first_name=data.first_name,
last_name=data.last_name,
email=data.email,
avatar_url=data.avatar_url,
phone=data.phone,
bio=data.bio,
preferences=data.preferences,
)
@router.post("/me/avatar")
async def upload_avatar(
file: UploadFile = File(...),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Sube un avatar para el usuario actual
Retorna la URL del avatar subido
"""
# Validar tipo de archivo
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
# Validar tamaño (max 2MB)
contents = await file.read()
if len(contents) > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
# Crear directorio si no existe
upload_dir = Path("/app/uploads/avatars")
upload_dir.mkdir(parents=True, exist_ok=True)
# Generar nombre con keycloak_user_id (sobrescribe si existe)
keycloak_user_id = current_user.get("sub")
ext = Path(file.filename or "image.jpg").suffix
filename = f"{keycloak_user_id}{ext}"
file_path = upload_dir / filename
# Guardar archivo
with open(file_path, "wb") as f:
f.write(contents)
# Retornar URL relativa
avatar_url = f"/uploads/avatars/{filename}"
return {"avatar_url": avatar_url}

View File

@@ -0,0 +1,617 @@
"""
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,
user_tenant: Optional[Any] = 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
"""
normalized = {
"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,
}
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
normalized.update(
{
"avatar_url": user_tenant.avatar_url,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
}
)
return normalized
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, user_tenant)
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, ut)
# 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, user_tenant)
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,
avatar_url: Optional[str] = None,
phone: Optional[str] = None,
bio: Optional[str] = None,
preferences: Optional[dict] = 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 campos en UserTenant
if role is not None:
user_tenant.role = role
if avatar_url is not None:
user_tenant.avatar_url = avatar_url
if phone is not None:
user_tenant.phone = phone
if bio is not None:
user_tenant.bio = bio
if preferences is not None:
user_tenant.preferences = preferences
self.db.commit()
self.db.refresh(user_tenant)
# Obtener información actualizada
user_info = self.keycloak_admin.get_user(user_id)
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
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),
}
def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
"""
Obtiene el perfil completo del usuario actual
Combina datos de Keycloak con datos de UserTenant
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
)
.first()
)
if not user_tenant:
raise HTTPException(status_code=404, detail="User profile not found")
try:
user_info = self.keycloak_admin.get_user(keycloak_user_id)
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
except KeycloakError as e:
logger.error(f"Error getting user from Keycloak: {str(e)}")
raise HTTPException(status_code=404, detail="User not found")
def update_current_user_profile(
self,
keycloak_user_id: str,
first_name: Optional[str] = None,
last_name: Optional[str] = None,
email: Optional[str] = None,
avatar_url: Optional[str] = None,
phone: Optional[str] = None,
bio: Optional[str] = None,
preferences: Optional[dict] = None,
) -> Dict[str, Any]:
"""
Actualiza el perfil del usuario actual
"""
user_tenant = (
self.db.query(UserTenant)
.filter(
and_(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
)
.first()
)
if not user_tenant:
raise HTTPException(status_code=404, detail="User profile not found")
try:
# Actualizar 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 update_data:
self.keycloak_admin.update_user(keycloak_user_id, update_data)
# Actualizar campos de perfil en UserTenant
if avatar_url is not None:
user_tenant.avatar_url = avatar_url
if phone is not None:
user_tenant.phone = phone
if bio is not None:
user_tenant.bio = bio
if preferences is not None:
user_tenant.preferences = preferences
self.db.commit()
self.db.refresh(user_tenant)
# Retornar perfil actualizado
user_info = self.keycloak_admin.get_user(keycloak_user_id)
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
except KeycloakError as e:
logger.error(f"Error updating user profile: {str(e)}")
self.db.rollback()
raise HTTPException(
status_code=500, detail=f"Error updating profile: {str(e)}"
)

View File

@@ -28,7 +28,13 @@ class TenantMiddleware(BaseHTTPMiddleware):
# Rutas públicas que no requieren tenant
# Permitir acceso sin autenticación a rutas de documentación y salud
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
public_prefixes = ["/api/v1/auth", "/api/v1/status", "/api/health", "/api/"]
public_prefixes = [
"/api/v1/auth",
"/api/v1/status",
"/api/health",
"/api/",
"/uploads",
]
path = request.url.path
# Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect)

View File

@@ -18,6 +18,8 @@ from fastapi import FastAPI, Request, status, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pathlib import Path
# Importar modelos para registrar con SQLAlchemy
from api.v1.modules.a76.items.models import Item
@@ -98,6 +100,11 @@ if settings.DEBUG:
app.add_middleware(LicenseValidationMiddleware)
app.add_middleware(TenantMiddleware)
# Crear directorio de uploads si no existe y montar archivos estáticos
uploads_dir = Path("/app/uploads")
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# Registrar routers
app.include_router(api_v1_router, prefix="/api/v1")

View File

@@ -20,6 +20,7 @@ export interface Company {
responsible_mother_last_name: string | null;
responsible_rfc?: string | null;
position?: string | null;
logo?: string | null;
has_express_line?: boolean;
is_service_company?: boolean;
order_format_type?: string | null;
@@ -113,3 +114,36 @@ export async function updateCompany(id: number, data: CompanyUpdate): Promise<Ap
export async function deleteCompany(id: number): Promise<ApiResponse<void>> {
return await api.delete(`/v1/a76/company/${id}`);
}
export async function uploadCompanyLogo(id: number, file: File): Promise<ApiResponse<{ message: string; logo_path: string; company_id: number }>> {
const formData = new FormData();
formData.append('file', file);
// Para FormData, usamos fetch directamente ya que necesitamos omitir Content-Type
// para que el navegador establezca el boundary automáticamente
const token = localStorage.getItem('access_token');
const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
const response = await fetch(`${API_BASE_URL}/v1/a76/company/${id}/upload-logo`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
credentials: 'include'
});
const data = await response.json();
if (!response.ok) {
return {
error: data.detail || data.message || 'Error al subir el logo',
status: response.status
};
}
return {
data,
status: response.status
};
}

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

@@ -88,8 +88,7 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<SaveInvo
} else {
// Actualizar factura existente con todos sus sub-recursos
const updatePayload = { ...payload, id: invoiceId } as UpdateInvoiceData;
const response = await invoicesApi.update(invoiceId!, companyId, updatePayload);
console.log('Update response:', response);
const response = await invoicesApi.update(invoiceId!, companyId, updatePayload);
if (response.error) {
const error: any = new Error(response.error);
error.validationErrors = response.validationErrors;

View File

@@ -27,7 +27,7 @@
? {
name: userData.name || userData.preferred_username || "",
email: userData.email || "",
// avatar: "/avatars/default.jpg", // Puedes agregar avatar desde Keycloak si está disponible
avatar: userData.avatar_url || "/avatars/default.jpg",
}
: sidebarData.user,
});

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

@@ -23,7 +23,7 @@
</script>
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
<Sidebar.GroupLabel>Projects</Sidebar.GroupLabel>
<Sidebar.GroupLabel>Gestion</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each projects as item (item.name)}
<Sidebar.MenuItem>

View File

@@ -7,18 +7,22 @@
import BellIcon from "@lucide/svelte/icons/bell";
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import SparklesIcon from "@lucide/svelte/icons/sparkles";
import LogOutIcon from "@lucide/svelte/icons/log-out";
import LanguagesIcon from "@lucide/svelte/icons/languages";
import MoonIcon from "@lucide/svelte/icons/moon";
import SunIcon from "@lucide/svelte/icons/sun";
import { logout } from "$lib/auth";
import { cookieName } from "$lib/paraglide/runtime";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import { browser } from "$app/environment";
import { getBackendAssetUrl } from "$lib/utils";
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
const sidebar = useSidebar();
// URL completa del avatar
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
// Estado reactivo del idioma actual
let currentLocale = $derived(page.data.locale || 'en');
@@ -51,6 +55,10 @@
await logout();
}
function navigateToAccount() {
goto('/dashboard/account');
}
function toggleLanguage() {
if (!browser) return;
@@ -98,7 +106,7 @@
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
@@ -118,8 +126,8 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
@@ -129,14 +137,7 @@
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<SparklesIcon />
Upgrade to Pro
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<DropdownMenu.Item onclick={navigateToAccount}>
<BadgeCheckIcon />
Account
</DropdownMenu.Item>

View File

@@ -6,8 +6,16 @@
import BuildingIcon from "@lucide/svelte/icons/building";
import CheckIcon from "@lucide/svelte/icons/check";
import { companyStore } from "$lib/stores/company.svelte";
import { getBackendAssetUrl } from "$lib/utils";
const sidebar = useSidebar();
// Derivar la URL del logo
let activeCompanyLogoUrl = $derived(
companyStore.activeCompany?.logo
? getBackendAssetUrl(companyStore.activeCompany.logo)
: null
);
</script>
@@ -22,19 +30,20 @@
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg overflow-hidden"
>
{#if companyStore.activeCompany?.logo}
{#if activeCompanyLogoUrl}
<img
src={companyStore.activeCompany.logo}
alt={companyStore.activeCompany.name}
class="size-full rounded-lg object-cover"
src={activeCompanyLogoUrl}
alt={companyStore.activeCompany?.name || 'Company'}
class="size-full object-cover"
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
{:else}
<BuildingIcon class="size-4 text-white" />
{/if}
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<div class="grid flex-1 text-left text-sm leading-tight min-w-0">
<span class="truncate font-medium">
{companyStore.activeCompany?.name || 'Seleccionar compañía'}
</span>
@@ -72,10 +81,10 @@
onSelect={() => companyStore.setActiveCompany(company)}
class="gap-2 p-2 cursor-pointer"
>
<div class="flex size-6 items-center justify-center rounded-md border">
<div class="flex size-6 items-center justify-center rounded-md border overflow-hidden">
{#if company.logo}
<img
src={company.logo}
src={getBackendAssetUrl(company.logo)}
alt={company.name}
class="size-full rounded object-cover"
/>
@@ -83,10 +92,10 @@
<BuildingIcon class="size-3.5 shrink-0" />
{/if}
</div>
<div class="flex flex-1 flex-col">
<span class="font-medium">{company.name}</span>
<div class="flex flex-1 flex-col min-w-0">
<span class="font-medium truncate">{company.name}</span>
{#if company.rfc}
<span class="text-xs text-muted-foreground">{company.rfc}</span>
<span class="text-xs text-muted-foreground truncate">{company.rfc}</span>
{/if}
</div>
{#if companyStore.activeCompany?.id === company.id}

View File

@@ -156,7 +156,7 @@ export async function authenticatedFetch(
}
// Construir URL completa
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
// Crear AbortController para timeout
const controller = new AbortController();
@@ -166,7 +166,12 @@ export async function authenticatedFetch(
}, timeout);
// Realizar la petición inicial
const headers = createAuthHeaders(accessToken, options.headers as Record<string, string>);
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
const isFormData = options.body instanceof FormData;
const headers = isFormData
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
let response = await fetch(url, {
...options,
headers,
@@ -187,7 +192,11 @@ export async function authenticatedFetch(
newController.abort();
}, timeout);
const newHeaders = createAuthHeaders(newToken, options.headers as Record<string, string>);
// Si el body es FormData, no incluir Content-Type
const newHeaders = isFormData
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
: createAuthHeaders(newToken, options.headers as Record<string, string>);
response = await fetch(url, {
...options,
headers: newHeaders,
@@ -247,7 +256,33 @@ export async function validateAuth(
return null;
}
return await response.json();
const keycloakData = await response.json();
// Obtener perfil adicional del usuario (avatar, bio, etc.)
try {
const profileResponse = await authenticatedFetch(
'v1/core/users/me/profile',
{},
cookies,
fetch
);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
// Combinar datos de Keycloak con datos del perfil
return {
...keycloakData,
avatar_url: profileData.avatar_url,
phone: profileData.phone,
bio: profileData.bio,
preferences: profileData.preferences
};
}
} catch (profileError) {
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
}
return keycloakData;
} catch (error) {
// Si es un redirect, re-lanzarlo
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {

View File

@@ -3,6 +3,29 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
};
/**
* Convierte una ruta relativa del backend en una URL completa
* @param path Ruta relativa (ej: "/uploads/avatars/file.png")
* @returns URL completa del backend (ej: "http://localhost:8000/uploads/avatars/file.png")
*/
export function getBackendAssetUrl(path: string | null | undefined): string {
if (!path) return '';
// Si ya es una URL completa, retornarla tal cual
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// Eliminar la / inicial si existe para evitar //
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// Obtener la base URL del API sin el sufijo /api
let baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
baseUrl = baseUrl.replace(/\/api\/?$/, ''); // Eliminar /api o /api/ del final
return `${baseUrl}/${cleanPath}`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -0,0 +1,130 @@
/**
* Server-side load y actions para gestión de perfil de usuario
*/
import { fail, redirect } from '@sveltejs/kit';
import { authenticatedFetch } from '$lib/server/api';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
try {
const response = await authenticatedFetch(
'v1/core/users/me/profile',
{
method: 'GET',
},
cookies,
fetch,
'/login' // Redirigir a login si no está autenticado
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
profile: null,
error: errorData.detail || 'Error al cargar el perfil'
};
}
const profile = await response.json();
return {
profile,
error: null
};
} catch (err: any) {
console.error('Error loading profile:', err);
return {
profile: null,
error: 'Error al cargar el perfil'
};
}
};
export const actions: Actions = {
updateProfile: async ({ request, cookies, fetch }) => {
const formData = await request.formData();
// Manejar subida de avatar si existe
const avatarFile = formData.get('avatar') as File | null;
let avatarUrl: string | null = null;
if (avatarFile && avatarFile instanceof File && avatarFile.size > 0) {
try {
const uploadFormData = new FormData();
uploadFormData.append('file', avatarFile);
const uploadResponse = await authenticatedFetch(
'v1/core/users/me/avatar',
{
method: 'POST',
body: uploadFormData
},
cookies,
fetch,
'/login'
);
if (uploadResponse.ok) {
const result = await uploadResponse.json();
avatarUrl = result.avatar_url;
} else {
const errorText = await uploadResponse.text();
console.error('Upload failed:', errorText);
}
} catch (err) {
console.error('Error uploading avatar:', err);
}
}
// Construir objeto de actualización desde FormData
const updateData: Record<string, any> = {};
for (const [key, value] of formData.entries()) {
if (key === 'avatar') continue; // Skip avatar file
if (value && value !== '') {
updateData[key] = value;
}
}
// Agregar avatar_url si se subió exitosamente
if (avatarUrl) {
updateData.avatar_url = avatarUrl;
}
try {
const response = await authenticatedFetch(
'v1/core/users/me/profile',
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
},
cookies,
fetch,
'/login'
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return fail(response.status, {
error: errorData.detail || 'Error al actualizar el perfil',
values: updateData
});
}
const updatedProfile = await response.json();
return {
success: true,
profile: updatedProfile
};
} catch (err: any) {
console.error('Error updating profile:', err);
return fail(500, {
error: 'Error al actualizar el perfil',
values: updateData
});
}
}
};

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar';
import { getBackendAssetUrl } from '$lib/utils';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
// State derivado de los datos del servidor
let profile = $derived(data.profile);
let serverError = $derived(data.error);
// State local
let saving = $state(false);
let success = $state('');
let error = $state('');
let avatarFile = $state<File | null>(null);
let avatarPreview = $state('');
// Effect para manejar errores del servidor
$effect(() => {
if (serverError) {
error = serverError;
}
});
// Effect para manejar respuesta del form action
$effect(() => {
if (form?.success) {
success = 'Perfil actualizado exitosamente';
avatarPreview = '';
avatarFile = null;
setTimeout(() => {
success = '';
}, 3000);
}
if (form?.error) {
error = form.error;
}
});
async function handleAvatarChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
error = 'Por favor selecciona una imagen válida';
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
error = 'La imagen debe ser menor a 2MB';
return;
}
avatarFile = file;
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
avatarPreview = e.target?.result as string;
};
reader.readAsDataURL(file);
}
}
function getInitials(profile: typeof data.profile): string {
if (!profile) return '??';
const first = profile.first_name?.[0] || '';
const last = profile.last_name?.[0] || '';
return (first + last).toUpperCase() || profile.username?.[0]?.toUpperCase() || '?';
}
let currentAvatarUrl = $derived(
avatarPreview || getBackendAssetUrl(profile?.avatar_url) || ''
);
</script>
<div class="container mx-auto py-8 px-4 max-w-5xl">
<div class="mb-8 space-y-1">
<h1 class="text-4xl font-bold tracking-tight">Configuración de Cuenta</h1>
<p class="text-muted-foreground text-lg">
Gestiona tu información personal y preferencias
</p>
</div>
{#if !profile && serverError}
<Card>
<CardContent class="py-12">
<div class="text-center text-destructive">
<p class="font-medium">{serverError}</p>
<Button class="mt-4" onclick={() => window.location.reload()}>
Reintentar
</Button>
</div>
</CardContent>
</Card>
{:else if profile}
<form
method="POST"
action="?/updateProfile"
enctype="multipart/form-data"
use:enhance={({ formData }) => {
// Agregar archivo si existe
if (avatarFile) {
formData.append('avatar', avatarFile);
}
saving = true;
error = '';
success = '';
return async ({ update }) => {
await update();
saving = false;
};
}}
>
<div class="space-y-6 pb-24">
<!-- Profile Picture -->
<Card class="transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle class="text-xl">Foto de Perfil</CardTitle>
<CardDescription>Actualiza tu imagen de perfil</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex flex-col sm:flex-row items-center gap-8">
<div class="relative group">
<label for="avatar" class="cursor-pointer">
<Avatar class="h-28 w-28 ring-4 ring-background shadow-lg transition-all group-hover:scale-105 group-hover:ring-primary/50">
<AvatarImage src={currentAvatarUrl} alt={profile.username} />
<AvatarFallback class="text-3xl font-semibold">{getInitials(profile)}</AvatarFallback>
</Avatar>
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</label>
</div>
<div class="flex-1 w-full">
<div class="flex flex-col gap-3">
<Input
id="avatar"
type="file"
accept="image/*"
onchange={handleAvatarChange}
class="cursor-pointer transition-colors"
/>
<p class="text-sm text-muted-foreground">
Haz clic en la imagen o selecciona un archivo. JPG, PNG o GIF. Máximo 2MB.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Personal Information -->
<Card class="transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle class="text-xl">Información Personal</CardTitle>
<CardDescription>Actualiza tus datos personales</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2.5">
<Label for="first_name" class="text-sm font-medium">Nombre</Label>
<Input
id="first_name"
name="first_name"
value={profile.first_name || ''}
placeholder="Tu nombre"
class="transition-colors"
required
/>
</div>
<div class="space-y-2.5">
<Label for="last_name" class="text-sm font-medium">Apellido</Label>
<Input
id="last_name"
name="last_name"
value={profile.last_name || ''}
placeholder="Tu apellido"
class="transition-colors"
required
/>
</div>
</div>
<div class="space-y-2.5">
<Label for="email" class="text-sm font-medium">Email</Label>
<Input
id="email"
name="email"
type="email"
value={profile.email || ''}
placeholder="tu@email.com"
class="transition-colors"
required
/>
<p class="text-xs text-muted-foreground">
Cambiar el email puede requerir verificación
</p>
</div>
<div class="space-y-2.5">
<Label for="phone" class="text-sm font-medium">Teléfono</Label>
<Input
id="phone"
name="phone"
type="tel"
value={profile.phone || ''}
placeholder="+52 123 456 7890"
class="transition-colors"
/>
</div>
<div class="space-y-2.5">
<Label for="bio" class="text-sm font-medium">Biografía</Label>
<Textarea
id="bio"
name="bio"
value={profile.bio || ''}
placeholder="Cuéntanos algo sobre ti..."
rows={4}
class="resize-none transition-colors"
/>
<p class="text-xs text-muted-foreground">
Máximo 500 caracteres
</p>
</div>
</CardContent>
</Card>
<!-- Account Information (Read-only) -->
<Card class="transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle class="text-xl">Información de Cuenta</CardTitle>
<CardDescription>Datos de tu cuenta en el sistema</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2.5">
<Label class="text-sm font-medium">Nombre de Usuario</Label>
<Input value={profile.username} disabled class="bg-muted/50" />
</div>
<div class="space-y-2.5">
<Label class="text-sm font-medium">ID de Usuario</Label>
<Input value={profile.id} disabled class="font-mono text-xs bg-muted/50" />
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Messages -->
{#if error}
<div class="bg-destructive/15 text-destructive px-5 py-4 rounded-lg border border-destructive/20 shadow-sm">
<p class="text-sm font-medium">{error}</p>
</div>
{/if}
{#if success}
<div class="bg-green-50 text-green-800 px-5 py-4 rounded-lg border border-green-200 shadow-sm">
<p class="text-sm font-medium">{success}</p>
</div>
{/if}
<!-- Actions - Sticky Footer -->
<div class="fixed bottom-0 left-0 right-0 md:left-64 bg-background/95 backdrop-blur-sm border-t p-4 z-50">
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-3">
<Button
type="button"
variant="outline"
onclick={() => window.location.reload()}
disabled={saving}
class="w-full sm:w-auto transition-all"
>
Cancelar
</Button>
<Button
type="submit"
disabled={saving}
class="w-full sm:w-auto transition-all shadow-lg shadow-primary/20"
>
{saving ? 'Guardando...' : 'Guardar Cambios'}
</Button>
</div>
</div>
</form>
{/if}
</div>

View File

@@ -10,10 +10,12 @@
import {
createCompany,
updateCompany,
getCompany, // Asumiendo que esta función existe en tu API
getCompany,
uploadCompanyLogo,
type Company
} from '$lib/api/dashboard/a76/general_catalogs/company';
import { ArrowLeft, LoaderCircle, Save } from 'lucide-svelte';
import { getBackendAssetUrl } from '$lib/utils';
import { ArrowLeft, LoaderCircle, Save, Upload, X, Building2, FileText, User, Settings } from 'lucide-svelte';
// 1. Lógica de Navegación y Modo
const id = $derived($page.params.id);
@@ -22,6 +24,16 @@
let loading = $state(false);
let error = $state<string | null>(null);
let logoFile = $state<File | null>(null);
let logoPreview = $state<string | null>(null);
let currentLogo = $state<string | null>(null);
let uploadingLogo = $state(false);
let activeTab = $state('general');
// URL completa del logo derivada
let currentLogoUrl = $derived(
logoPreview || getBackendAssetUrl(currentLogo) || ''
);
// 2. Estado Inicial (Reset)
const initialData = {
@@ -51,7 +63,7 @@
// 3. Efecto para "Heredar" datos o Limpiar
$effect(() => {
if (isEdit) {
fetchData(id);
fetchData(String(id));
} else {
formData = { ...initialData };
error = null;
@@ -86,6 +98,10 @@
ctpat_svi: item.ctpat_svi || '',
trusted_exporter_number: item.trusted_exporter_number || ''
};
// Guardar la URL del logo actual si existe
if (item.logo) {
currentLogo = item.logo;
}
}
} catch (e) {
error = "Error al cargar los datos de la empresa";
@@ -94,6 +110,66 @@
}
}
function handleLogoChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Validar tipo
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
error = 'Tipo de archivo no permitido. Solo JPG, PNG, GIF o WEBP';
return;
}
// Validar tamaño (5MB)
if (file.size > 5 * 1024 * 1024) {
error = 'El archivo es demasiado grande. Máximo 5MB';
return;
}
logoFile = file;
// Crear preview
const reader = new FileReader();
reader.onload = (e) => {
logoPreview = e.target?.result as string;
};
reader.readAsDataURL(file);
error = null;
}
}
function removeLogo() {
logoFile = null;
logoPreview = null;
// Resetear el input
const input = document.getElementById('logo-input') as HTMLInputElement;
if (input) input.value = '';
}
async function uploadLogo(companyId: number) {
if (!logoFile) return;
uploadingLogo = true;
try {
const response = await uploadCompanyLogo(companyId, logoFile);
if (response.error) {
throw new Error(response.error);
}
// Actualizar la ruta del logo actual
if (response.data?.logo_path) {
currentLogo = response.data.logo_path;
logoFile = null;
logoPreview = null;
}
} catch (e: any) {
error = `Error al subir el logo: ${e.message}`;
} finally {
uploadingLogo = false;
}
}
const clean = (value: any) => (typeof value === 'string' ? value.trim() || null : value);
async function handleSubmit() {
@@ -131,6 +207,11 @@
if (response.error) throw new Error(response.error);
// Si hay un logo para subir y ya tenemos la empresa creada/actualizada
if (logoFile && response.data?.id) {
await uploadLogo(response.data.id);
}
goto('/dashboard/general_catalogs/company_information');
} catch (e: any) {
error = e.message || 'Error al guardar';
@@ -157,12 +238,53 @@
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6">
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-6 pb-48">
<Card.Root>
<Card.Content class="p-6">
<Tabs.Root value="general" class="w-full">
<Tabs.Root bind:value={activeTab} class="w-full">
<div class="min-h-[400px]">
<Tabs.Content value="general" class="space-y-4 pt-4">
<!-- Logo Upload Section -->
<div class="grid gap-4 p-4 border rounded-lg bg-muted/30">
<Label>Logo de la Empresa</Label>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
{#if logoPreview || currentLogoUrl}
<div class="relative w-32 h-32 border-2 border-dashed rounded-lg overflow-hidden">
<img
src={logoPreview || currentLogoUrl}
alt="Logo preview"
class="w-full h-full object-contain"
/>
{#if logoPreview}
<button
type="button"
onclick={removeLogo}
class="absolute top-1 right-1 p-1 bg-destructive text-destructive-foreground rounded-full hover:bg-destructive/90"
>
<X class="h-3 w-3" />
</button>
{/if}
</div>
{:else}
<div class="w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center bg-muted">
<Upload class="h-8 w-8 text-muted-foreground" />
</div>
{/if}
<div class="flex-1 space-y-2">
<Input
id="logo-input"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
onchange={handleLogoChange}
class="cursor-pointer"
/>
<p class="text-xs text-muted-foreground">
Formatos permitidos: JPG, PNG, GIF, WEBP. Tamaño máximo: 5MB
</p>
</div>
</div>
</div>
<div class="grid gap-2">
<Label for="name">Razón Social <span class="text-destructive">*</span></Label>
<Input id="name" bind:value={formData.name} placeholder="Nombre oficial" />
@@ -264,31 +386,53 @@
</div>
</Tabs.Content>
</div>
<Tabs.List class="grid grid-cols-4 fixed bottom-24 left-1/2 -translate-x-1/2 w-[95%] max-w-2xl z-40 shadow-2xl bg-background border p-1 rounded-xl">
<Tabs.Trigger value="general">General</Tabs.Trigger>
<Tabs.Trigger value="programas">Programas</Tabs.Trigger>
<Tabs.Trigger value="responsable">Responsable</Tabs.Trigger>
<Tabs.Trigger value="config">Config</Tabs.Trigger>
</Tabs.List>
</Tabs.Root>
</Card.Content>
</Card.Root>
</form>
<div class="fixed bottom-0 right-0 left-0 md:left-64 p-4 border-t bg-background/95 backdrop-blur z-50">
<div class="max-w-6xl mx-auto flex justify-end gap-4">
<Button type="button" variant="ghost" onclick={() => goto('/dashboard/general_catalogs/company_information')} disabled={loading}>
<!-- Footer fijo en la parte inferior -->
<div class="fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-50 group-has-data-[state=collapsed]/sidebar-wrapper:ml-[calc(var(--sidebar-width-icon))] ml-[calc(var(--sidebar-width))]">
<div class="px-4 py-4 space-y-4 max-w-6xl mx-auto">
<!-- Tabs Navigation -->
<Tabs.Root bind:value={activeTab} class="w-full">
<div class="w-full overflow-x-auto pb-2">
<Tabs.List class="inline-flex md:grid md:w-full md:grid-cols-4">
<Tabs.Trigger value="general" class="whitespace-nowrap">
<Building2 class="mr-2 h-4 w-4" />
General
</Tabs.Trigger>
<Tabs.Trigger value="programas" class="whitespace-nowrap">
<FileText class="mr-2 h-4 w-4" />
Programas
</Tabs.Trigger>
<Tabs.Trigger value="responsable" class="whitespace-nowrap">
<User class="mr-2 h-4 w-4" />
Responsable
</Tabs.Trigger>
<Tabs.Trigger value="config" class="whitespace-nowrap">
<Settings class="mr-2 h-4 w-4" />
Config
</Tabs.Trigger>
</Tabs.List>
</div>
</Tabs.Root>
<!-- Botones de acción -->
<div class="flex justify-end gap-3">
<Button type="button" variant="outline" onclick={() => goto('/dashboard/general_catalogs/company_information')} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading} class="min-w-[140px]">
<Button type="submit" disabled={loading} onclick={handleSubmit}>
{#if loading}
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{isEdit ? 'Actualizando...' : 'Guardando...'}
{:else}
<Save class="mr-2 h-4 w-4" />
{isEdit ? 'Actualizar' : 'Guardar'}
{/if}
{isEdit ? 'Actualizar' : 'Guardar'}
</Button>
</div>
</div>
</form>
</div>
</div>

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"