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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,4 +59,5 @@ node_modules/
|
||||
|
||||
# Docker
|
||||
*.dockerignore
|
||||
postgres-data/
|
||||
postgres-data/
|
||||
backend/uploads/
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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")
|
||||
|
||||
3
backend/api/v1/modules/core/users/__init__.py
Normal file
3
backend/api/v1/modules/core/users/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Módulo de gestión de usuarios (Keycloak)
|
||||
"""
|
||||
105
backend/api/v1/modules/core/users/dto.py
Normal file
105
backend/api/v1/modules/core/users/dto.py
Normal 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
|
||||
314
backend/api/v1/modules/core/users/routes.py
Normal file
314
backend/api/v1/modules/core/users/routes.py
Normal 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}
|
||||
617
backend/api/v1/modules/core/users/service.py
Normal file
617
backend/api/v1/modules/core/users/service.py
Normal 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)}"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
149
frontend/src/lib/api/dashboard/users.ts
Normal file
149
frontend/src/lib/api/dashboard/users.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Cliente API para gestión de usuarios
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
enabled: boolean;
|
||||
email_verified: boolean;
|
||||
created_timestamp?: number;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
inactive_users: number;
|
||||
max_users_allowed: number;
|
||||
users_available: number;
|
||||
usage_percentage: number;
|
||||
}
|
||||
|
||||
export interface UserListResponse {
|
||||
users: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
email: string;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
password: string;
|
||||
role?: string;
|
||||
enabled?: boolean;
|
||||
email_verified?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
email?: string;
|
||||
enabled?: boolean;
|
||||
email_verified?: boolean;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordRequest {
|
||||
password: string;
|
||||
temporary?: boolean;
|
||||
}
|
||||
|
||||
export const usersAPI = {
|
||||
/**
|
||||
* Obtiene estadísticas de usuarios del tenant
|
||||
*/
|
||||
async getStats(): Promise<UserStats> {
|
||||
const response = await api.get<UserStats>('/v1/core/users/stats');
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Lista usuarios del tenant con paginación
|
||||
*/
|
||||
async list(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}): Promise<UserListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
|
||||
const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`;
|
||||
const response = await api.get<UserListResponse>(endpoint);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtiene un usuario específico
|
||||
*/
|
||||
async get(userId: string): Promise<User> {
|
||||
const response = await api.get<User>(`/v1/core/users/${userId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crea un nuevo usuario
|
||||
*/
|
||||
async create(data: CreateUserRequest): Promise<User> {
|
||||
const response = await api.post<User>('/v1/core/users/', data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualiza un usuario existente
|
||||
*/
|
||||
async update(userId: string, data: UpdateUserRequest): Promise<User> {
|
||||
const response = await api.put<User>(`/v1/core/users/${userId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
return response.data!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Elimina un usuario
|
||||
*/
|
||||
async delete(userId: string, softDelete: boolean = true): Promise<void> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('soft_delete', softDelete.toString());
|
||||
|
||||
const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Cambia la contraseña de un usuario
|
||||
*/
|
||||
async changePassword(userId: string, data: ChangePasswordRequest): Promise<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"](),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
130
frontend/src/routes/dashboard/account/+page.server.ts
Normal file
130
frontend/src/routes/dashboard/account/+page.server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
306
frontend/src/routes/dashboard/account/+page.svelte
Normal file
306
frontend/src/routes/dashboard/account/+page.svelte
Normal 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>
|
||||
@@ -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>
|
||||
6
frontend/src/routes/dashboard/users/+page.server.ts
Normal file
6
frontend/src/routes/dashboard/users/+page.server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
// Los datos se cargarán desde el cliente
|
||||
return {};
|
||||
};
|
||||
639
frontend/src/routes/dashboard/users/+page.svelte
Normal file
639
frontend/src/routes/dashboard/users/+page.svelte
Normal file
@@ -0,0 +1,639 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { usersAPI, type User, type UserStats } from '$lib/api/dashboard/users';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Key,
|
||||
UserCheck,
|
||||
UserX,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Users,
|
||||
AlertCircle
|
||||
} from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Estados principales
|
||||
let users = $state<User[]>([]);
|
||||
let stats = $state<UserStats | null>(null);
|
||||
let loading = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(20);
|
||||
let totalPages = $state(1);
|
||||
|
||||
// Diálogos
|
||||
let showCreateDialog = $state(false);
|
||||
let showEditDialog = $state(false);
|
||||
let showDeleteDialog = $state(false);
|
||||
let showPasswordDialog = $state(false);
|
||||
|
||||
// Usuario seleccionado para editar/eliminar
|
||||
let selectedUser = $state<User | null>(null);
|
||||
|
||||
// Formulario de creación
|
||||
let createForm = $state({
|
||||
email: '',
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
role: '',
|
||||
enabled: true,
|
||||
email_verified: false
|
||||
});
|
||||
|
||||
// Formulario de edición
|
||||
let editForm = $state({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
enabled: true,
|
||||
email_verified: false,
|
||||
role: ''
|
||||
});
|
||||
|
||||
// Formulario de cambio de contraseña
|
||||
let passwordForm = $state({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
temporary: true
|
||||
});
|
||||
|
||||
// Cargar estadísticas
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await usersAPI.getStats();
|
||||
} catch (error: any) {
|
||||
console.error('Error loading stats:', error);
|
||||
toast.error('Error al cargar estadísticas', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar usuarios
|
||||
async function loadUsers() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await usersAPI.list({
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
search: searchTerm || undefined
|
||||
});
|
||||
|
||||
users = response.users;
|
||||
totalPages = response.total_pages;
|
||||
} catch (error: any) {
|
||||
console.error('Error loading users:', error);
|
||||
toast.error('Error al cargar usuarios', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar usuarios
|
||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadUsers();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Crear usuario
|
||||
async function handleCreate() {
|
||||
if (!createForm.email || !createForm.username || !createForm.first_name ||
|
||||
!createForm.last_name || !createForm.password) {
|
||||
toast.error('Por favor complete todos los campos requeridos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (createForm.password.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// El tenant_id se obtiene automáticamente del token en el backend
|
||||
await usersAPI.create(createForm);
|
||||
|
||||
toast.success('Usuario creado exitosamente');
|
||||
showCreateDialog = false;
|
||||
resetCreateForm();
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
} catch (error: any) {
|
||||
console.error('Error creating user:', error);
|
||||
toast.error('Error al crear usuario', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir diálogo de edición
|
||||
function openEditDialog(user: User) {
|
||||
selectedUser = user;
|
||||
editForm = {
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
enabled: user.enabled,
|
||||
email_verified: user.email_verified,
|
||||
role: user.role || ''
|
||||
};
|
||||
showEditDialog = true;
|
||||
}
|
||||
|
||||
// Actualizar usuario
|
||||
async function handleUpdate() {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
// Filtrar campos vacíos antes de enviar
|
||||
const updateData: any = {};
|
||||
if (editForm.first_name && editForm.first_name.trim()) {
|
||||
updateData.first_name = editForm.first_name.trim();
|
||||
}
|
||||
if (editForm.last_name && editForm.last_name.trim()) {
|
||||
updateData.last_name = editForm.last_name.trim();
|
||||
}
|
||||
if (editForm.email && editForm.email.trim()) {
|
||||
updateData.email = editForm.email.trim();
|
||||
}
|
||||
if (editForm.role !== undefined && editForm.role !== null) {
|
||||
updateData.role = editForm.role;
|
||||
}
|
||||
// Solo incluir enabled y email_verified si son diferentes del original
|
||||
if (editForm.enabled !== selectedUser.enabled) {
|
||||
updateData.enabled = editForm.enabled;
|
||||
}
|
||||
if (editForm.email_verified !== selectedUser.email_verified) {
|
||||
updateData.email_verified = editForm.email_verified;
|
||||
}
|
||||
|
||||
await usersAPI.update(selectedUser.id, updateData);
|
||||
toast.success('Usuario actualizado exitosamente');
|
||||
showEditDialog = false;
|
||||
selectedUser = null;
|
||||
await loadUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Error updating user:', error);
|
||||
toast.error('Error al actualizar usuario', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir diálogo de eliminación
|
||||
function openDeleteDialog(user: User) {
|
||||
selectedUser = user;
|
||||
showDeleteDialog = true;
|
||||
}
|
||||
|
||||
// Eliminar usuario
|
||||
async function handleDelete(softDelete: boolean = true) {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
await usersAPI.delete(selectedUser.id, softDelete);
|
||||
toast.success(softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente');
|
||||
showDeleteDialog = false;
|
||||
selectedUser = null;
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting user:', error);
|
||||
toast.error('Error al eliminar usuario', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Abrir diálogo de cambio de contraseña
|
||||
function openPasswordDialog(user: User) {
|
||||
selectedUser = user;
|
||||
passwordForm = {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
temporary: true
|
||||
};
|
||||
showPasswordDialog = true;
|
||||
}
|
||||
|
||||
// Cambiar contraseña
|
||||
async function handleChangePassword() {
|
||||
if (!selectedUser) return;
|
||||
|
||||
if (passwordForm.password !== passwordForm.confirmPassword) {
|
||||
toast.error('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.password.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await usersAPI.changePassword(selectedUser.id, {
|
||||
password: passwordForm.password,
|
||||
temporary: passwordForm.temporary
|
||||
});
|
||||
toast.success('Contraseña actualizada exitosamente');
|
||||
showPasswordDialog = false;
|
||||
selectedUser = null;
|
||||
passwordForm = { password: '', confirmPassword: '', temporary: true };
|
||||
} catch (error: any) {
|
||||
console.error('Error changing password:', error);
|
||||
toast.error('Error al cambiar contraseña', {
|
||||
description: error.response?.data?.detail || error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resetear formularios
|
||||
function resetCreateForm() {
|
||||
createForm = {
|
||||
email: '',
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
password: '',
|
||||
role: '',
|
||||
enabled: true,
|
||||
email_verified: false
|
||||
};
|
||||
}
|
||||
|
||||
// Cambiar página
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar datos al montar
|
||||
onMount(() => {
|
||||
Promise.all([loadUsers(), loadStats()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Gestión de Usuarios</h1>
|
||||
<p class="text-muted-foreground">Administra los usuarios de tu organización</p>
|
||||
</div>
|
||||
<Button onclick={() => showCreateDialog = true}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Nuevo Usuario
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Estadísticas -->
|
||||
{#if stats}
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Total Usuarios</Card.Title>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">{stats.total_users}</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Activos</Card.Title>
|
||||
<UserCheck class="h-4 w-4 text-green-600" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold text-green-600">{stats.active_users}</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Disponibles</Card.Title>
|
||||
<AlertCircle class="h-4 w-4 text-blue-600" />
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold text-blue-600">{stats.users_available}</div>
|
||||
<p class="text-xs text-muted-foreground">de {stats.max_users_allowed} permitidos</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card.Title class="text-sm font-medium">Uso de Licencia</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="text-2xl font-bold">{stats.usage_percentage.toFixed(1)}%</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-2">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all"
|
||||
class:bg-green-600={stats.usage_percentage < 70}
|
||||
class:bg-yellow-600={stats.usage_percentage >= 70 && stats.usage_percentage < 90}
|
||||
class:bg-red-600={stats.usage_percentage >= 90}
|
||||
style="width: {stats.usage_percentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tabla de usuarios -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex justify-between items-center">
|
||||
<Card.Title>Usuarios</Card.Title>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar usuarios..."
|
||||
bind:value={searchTerm}
|
||||
oninput={handleSearch}
|
||||
class="pl-10 w-64"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onclick={() => loadUsers()}>
|
||||
<RefreshCw class={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Usuario</Table.Head>
|
||||
<Table.Head>Email</Table.Head>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>Rol</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if loading && users.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center py-8">
|
||||
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if users.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={6} class="text-center py-8 text-muted-foreground">
|
||||
No se encontraron usuarios
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each users as user}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{user.username}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div class="flex items-center gap-2">
|
||||
{user.email}
|
||||
{#if user.email_verified}
|
||||
<Badge variant="outline" class="text-xs">Verificado</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{user.first_name} {user.last_name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if user.role}
|
||||
<Badge>{user.role}</Badge>
|
||||
{:else}
|
||||
<span class="text-muted-foreground">-</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if user.enabled}
|
||||
<Badge variant="default" class="bg-green-600">Activo</Badge>
|
||||
{:else}
|
||||
<Badge variant="destructive">Inactivo</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => openEditDialog(user)}
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => openPasswordDialog(user)}
|
||||
>
|
||||
<Key class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => openDeleteDialog(user)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
<!-- Paginación -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Página {currentPage} de {totalPages}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === totalPages}
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
<!-- Diálogo Crear Usuario -->
|
||||
<Dialog.Root bind:open={showCreateDialog}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Crear Nuevo Usuario</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Ingresa la información del nuevo usuario. Se enviará un correo para configurar su contraseña.
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="first_name">Nombre *</Label>
|
||||
<Input id="first_name" bind:value={createForm.first_name} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="last_name">Apellido *</Label>
|
||||
<Input id="last_name" bind:value={createForm.last_name} required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="username">Usuario *</Label>
|
||||
<Input id="username" bind:value={createForm.username} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="email">Email *</Label>
|
||||
<Input id="email" type="email" bind:value={createForm.email} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Contraseña Temporal *</Label>
|
||||
<Input id="password" type="password" bind:value={createForm.password} required />
|
||||
<p class="text-xs text-muted-foreground">Mínimo 8 caracteres</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="role">Rol</Label>
|
||||
<Input id="role" bind:value={createForm.role} placeholder="Opcional" />
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showCreateDialog = false}>Cancelar</Button>
|
||||
<Button onclick={handleCreate}>Crear Usuario</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo Editar Usuario -->
|
||||
<Dialog.Root bind:open={showEditDialog}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Editar Usuario</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Modifica la información del usuario {selectedUser?.username}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_first_name">Nombre</Label>
|
||||
<Input id="edit_first_name" bind:value={editForm.first_name} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_last_name">Apellido</Label>
|
||||
<Input id="edit_last_name" bind:value={editForm.last_name} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_email">Email</Label>
|
||||
<Input id="edit_email" type="email" bind:value={editForm.email} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="edit_role">Rol</Label>
|
||||
<Input id="edit_role" bind:value={editForm.role} />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="edit_enabled" bind:checked={editForm.enabled} class="h-4 w-4" />
|
||||
<Label for="edit_enabled">Usuario activo</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="edit_email_verified" bind:checked={editForm.email_verified} class="h-4 w-4" />
|
||||
<Label for="edit_email_verified">Email verificado</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showEditDialog = false}>Cancelar</Button>
|
||||
<Button onclick={handleUpdate}>Guardar Cambios</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo Cambiar Contraseña -->
|
||||
<Dialog.Root bind:open={showPasswordDialog}>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Cambiar Contraseña</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Establece una nueva contraseña para {selectedUser?.username}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="new_password">Nueva Contraseña</Label>
|
||||
<Input id="new_password" type="password" bind:value={passwordForm.password} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="confirm_password">Confirmar Contraseña</Label>
|
||||
<Input id="confirm_password" type="password" bind:value={passwordForm.confirmPassword} />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="checkbox" id="temporary" bind:checked={passwordForm.temporary} class="h-4 w-4" />
|
||||
<Label for="temporary">Contraseña temporal (debe cambiarla al iniciar sesión)</Label>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => showPasswordDialog = false}>Cancelar</Button>
|
||||
<Button onclick={handleChangePassword}>Cambiar Contraseña</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Diálogo Eliminar Usuario -->
|
||||
<AlertDialog.Root bind:open={showDeleteDialog}>
|
||||
<AlertDialog.Content>
|
||||
<AlertDialog.Header>
|
||||
<AlertDialog.Title>¿Eliminar usuario?</AlertDialog.Title>
|
||||
<AlertDialog.Description>
|
||||
¿Estás seguro de que deseas eliminar a {selectedUser?.username}?
|
||||
<br /><br />
|
||||
Puedes desactivar el usuario (recomendado) o eliminarlo permanentemente.
|
||||
</AlertDialog.Description>
|
||||
</AlertDialog.Header>
|
||||
<AlertDialog.Footer>
|
||||
<AlertDialog.Cancel>Cancelar</AlertDialog.Cancel>
|
||||
<Button variant="outline" onclick={() => handleDelete(true)}>
|
||||
<UserX class="w-4 h-4 mr-2" />
|
||||
Desactivar
|
||||
</Button>
|
||||
<AlertDialog.Action onclick={() => handleDelete(false)} class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
<Trash2 class="w-4 h-4 mr-2" />
|
||||
Eliminar Permanente
|
||||
</AlertDialog.Action>
|
||||
</AlertDialog.Footer>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
@@ -11,6 +11,7 @@
|
||||
# 5. Tenant y Company en PostgreSQL
|
||||
# 6. Relación usuario-tenant en tabla user_tenants
|
||||
# 7. Actualización del tenant_id del usuario con el valor real
|
||||
# 8. Licencia Enterprise para el tenant (ilimitada, 1 año de vigencia)
|
||||
#
|
||||
# Requisitos:
|
||||
# - Keycloak corriendo en http://localhost:8080
|
||||
@@ -22,7 +23,10 @@
|
||||
# se actualiza con el ID real del tenant creado en PostgreSQL.
|
||||
###############################################################################
|
||||
|
||||
# set -e # Comentado para permitir que el script continúe aunque algunos comandos fallen (ej: mapper ya existe)
|
||||
set -euo pipefail # Modo strict: exit on error, undefined vars, pipe failures
|
||||
|
||||
# Trap para cleanup en caso de error
|
||||
trap 'echo -e "\n${RED}✗ Error en línea $LINENO. Script abortado.${NC}" >&2' ERR
|
||||
|
||||
# Colores para output
|
||||
RED='\033[0;31m'
|
||||
@@ -30,6 +34,84 @@ GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
###############################################################################
|
||||
# Validar dependencias requeridas
|
||||
###############################################################################
|
||||
check_dependencies() {
|
||||
local missing_deps=()
|
||||
|
||||
command -v jq >/dev/null 2>&1 || missing_deps+=("jq")
|
||||
command -v curl >/dev/null 2>&1 || missing_deps+=("curl")
|
||||
command -v docker >/dev/null 2>&1 || missing_deps+=("docker")
|
||||
|
||||
if [ ${#missing_deps[@]} -ne 0 ]; then
|
||||
echo -e "${RED}✗ Error: Dependencias faltantes: ${missing_deps[*]}${NC}" >&2
|
||||
echo -e "${YELLOW}Instala las dependencias faltantes antes de continuar.${NC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_dependencies
|
||||
|
||||
###############################################################################
|
||||
# Funciones auxiliares
|
||||
###############################################################################
|
||||
|
||||
# Ejecutar SQL en PostgreSQL via Docker
|
||||
exec_pg_sql() {
|
||||
local sql="$1"
|
||||
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 \
|
||||
psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \
|
||||
-t -c "${sql}" 2>&1
|
||||
}
|
||||
|
||||
# Crear mapper de tenant_id para un cliente
|
||||
create_tenant_mapper() {
|
||||
local client_id="$1"
|
||||
local client_name="$2"
|
||||
|
||||
echo "Configurando mapper para ${client_name}..."
|
||||
|
||||
# Verificar si el mapper ya existe
|
||||
local mappers
|
||||
mappers=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_id}/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
if echo "$mappers" | jq -e '.[] | select(.name == "tenant-id-mapper")' >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para ${client_name}${NC}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Crear mapper
|
||||
local response
|
||||
response=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_id}/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "tenant-id-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"config": {
|
||||
"user.attribute": "tenant_id",
|
||||
"claim.name": "tenant_id",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}')
|
||||
|
||||
local http_code
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" = "201" ]; then
|
||||
echo -e "${GREEN}✓ Mapper tenant_id creado para ${client_name}${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ No se pudo crear mapper para ${client_name} (HTTP ${http_code})${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Variables de configuración
|
||||
KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080/kcauth}"
|
||||
KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}"
|
||||
@@ -71,7 +153,9 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
break
|
||||
fi
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Intento $RETRY_COUNT/$MAX_RETRIES..."
|
||||
if [ $((RETRY_COUNT % 5)) -eq 0 ]; then
|
||||
echo "Esperando Keycloak... ($RETRY_COUNT/$MAX_RETRIES)"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
@@ -92,7 +176,7 @@ TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-
|
||||
-d "grant_type=password" \
|
||||
-d "client_id=admin-cli")
|
||||
|
||||
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*' | sed 's/"access_token":"//')
|
||||
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token // empty')
|
||||
|
||||
if [ -z "$ACCESS_TOKEN" ]; then
|
||||
echo -e "${RED}✗ Error: No se pudo obtener el token de acceso${NC}"
|
||||
@@ -112,8 +196,8 @@ USER_PROFILE=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/us
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
# Verificar si tenant_id existe
|
||||
TENANT_ID_EXISTS=$(echo "$USER_PROFILE" | grep -q "\"name\":\"tenant_id\"" && echo "true" || echo "false")
|
||||
# Verificar si tenant_id existe usando jq
|
||||
TENANT_ID_EXISTS=$(echo "$USER_PROFILE" | jq -e '.attributes[]? | select(.name == "tenant_id")' >/dev/null 2>&1 && echo "true" || echo "false")
|
||||
|
||||
if [ "$TENANT_ID_EXISTS" = "false" ]; then
|
||||
echo "Agregando atributo tenant_id al User Profile..."
|
||||
@@ -160,9 +244,9 @@ BACKEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
if echo "$BACKEND_CLIENT_EXISTS" | grep -q "\"clientId\":\"anexo76-backend\""; then
|
||||
if echo "$BACKEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-backend")' >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Cliente Backend ya existe${NC}"
|
||||
BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//')
|
||||
BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | jq -r '.[0].id')
|
||||
else
|
||||
CREATE_BACKEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
@@ -198,7 +282,7 @@ else
|
||||
BACKEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients?clientId=anexo76-backend" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//')
|
||||
BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | jq -r '.[0].id')
|
||||
else
|
||||
echo -e "${RED}✗ Error al crear cliente Backend (HTTP ${HTTP_CODE})${NC}"
|
||||
fi
|
||||
@@ -208,7 +292,7 @@ fi
|
||||
if [ -n "$BACKEND_CLIENT_ID" ]; then
|
||||
BACKEND_SECRET=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/client-secret" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" | grep -o '"value":"[^"]*' | sed 's/"value":"//')
|
||||
-H "Content-Type: application/json" | jq -r '.value // empty')
|
||||
|
||||
echo -e "${GREEN}✓ Backend Client ID: anexo76-backend${NC}"
|
||||
echo -e "${GREEN}✓ Backend Client Secret: ${BACKEND_SECRET}${NC}"
|
||||
@@ -252,9 +336,9 @@ FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
if echo "$FRONTEND_CLIENT_EXISTS" | grep -q "\"clientId\":\"anexo76-frontend\""; then
|
||||
if echo "$FRONTEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-frontend")' >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Cliente Frontend ya existe${NC}"
|
||||
FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//')
|
||||
FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id')
|
||||
else
|
||||
CREATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
@@ -293,7 +377,7 @@ else
|
||||
FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients?clientId=anexo76-frontend" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//')
|
||||
FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id')
|
||||
else
|
||||
echo -e "${RED}✗ Error al crear cliente Frontend (HTTP ${HTTP_CODE})${NC}"
|
||||
echo "Respuesta del servidor: $RESPONSE_BODY"
|
||||
@@ -307,83 +391,12 @@ echo -e "\n${YELLOW}[5/8] Configurando mappers para tenant_id...${NC}"
|
||||
|
||||
# 4.1 Configurar mapper para Backend
|
||||
if [ -n "$BACKEND_CLIENT_ID" ]; then
|
||||
echo "Configurando mapper para Backend..."
|
||||
|
||||
# Verificar si el mapper tenant_id ya existe en el cliente
|
||||
MAPPER_TENANT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" | grep -o "\"name\":\"tenant-id-mapper\"")
|
||||
|
||||
if [ -z "$MAPPER_TENANT_EXISTS" ]; then
|
||||
# Crear mapper para tenant_id directamente en el cliente
|
||||
CREATE_MAPPER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "tenant-id-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"config": {
|
||||
"user.attribute": "tenant_id",
|
||||
"claim.name": "tenant_id",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}')
|
||||
|
||||
HTTP_CODE=$(echo "$CREATE_MAPPER_RESPONSE" | tail -n1)
|
||||
if [ "$HTTP_CODE" = "201" ]; then
|
||||
echo -e "${GREEN}✓ Mapper tenant_id creado para Backend${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Error al crear mapper para Backend (HTTP ${HTTP_CODE})${NC}"
|
||||
echo "Respuesta: $(echo "$CREATE_MAPPER_RESPONSE" | head -n -1)"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para Backend${NC}"
|
||||
fi
|
||||
create_tenant_mapper "$BACKEND_CLIENT_ID" "Backend"
|
||||
fi
|
||||
|
||||
|
||||
# 4.2 Configurar mapper para Frontend
|
||||
if [ -n "$FRONTEND_CLIENT_ID" ]; then
|
||||
echo "Configurando mapper para Frontend..."
|
||||
|
||||
# Verificar si el mapper tenant_id ya existe en el cliente
|
||||
MAPPER_TENANT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" | grep -o "\"name\":\"tenant-id-mapper\"")
|
||||
|
||||
if [ -z "$MAPPER_TENANT_EXISTS" ]; then
|
||||
# Crear mapper para tenant_id directamente en el cliente
|
||||
CREATE_MAPPER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}/protocol-mappers/models" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "tenant-id-mapper",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||
"config": {
|
||||
"user.attribute": "tenant_id",
|
||||
"claim.name": "tenant_id",
|
||||
"jsonType.label": "String",
|
||||
"id.token.claim": "true",
|
||||
"access.token.claim": "true",
|
||||
"userinfo.token.claim": "true"
|
||||
}
|
||||
}')
|
||||
|
||||
HTTP_CODE=$(echo "$CREATE_MAPPER_RESPONSE" | tail -n1)
|
||||
if [ "$HTTP_CODE" = "201" ]; then
|
||||
echo -e "${GREEN}✓ Mapper tenant_id creado para Frontend${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Error al crear mapper para Frontend (HTTP ${HTTP_CODE})${NC}"
|
||||
echo "Respuesta: $(echo "$CREATE_MAPPER_RESPONSE" | head -n -1)"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para Frontend${NC}"
|
||||
fi
|
||||
create_tenant_mapper "$FRONTEND_CLIENT_ID" "Frontend"
|
||||
fi
|
||||
|
||||
|
||||
@@ -397,9 +410,9 @@ USER_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/use
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
if echo "$USER_EXISTS" | grep -q "\"username\":\"${DEMO_USERNAME}\""; then
|
||||
if echo "$USER_EXISTS" | jq -e '.[] | select(.username == "'"${DEMO_USERNAME}"'")' >/dev/null 2>&1; then
|
||||
echo -e "${YELLOW}⚠ Usuario demo ya existe, actualizando...${NC}"
|
||||
USER_ID=$(echo "$USER_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//')
|
||||
USER_ID=$(echo "$USER_EXISTS" | jq -r '.[0].id')
|
||||
|
||||
# Actualizar usuario existente
|
||||
curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}" \
|
||||
@@ -455,7 +468,7 @@ else
|
||||
USER_RESPONSE=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users?username=${DEMO_USERNAME}" \
|
||||
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
|
||||
-H "Content-Type: application/json")
|
||||
USER_ID=$(echo "$USER_RESPONSE" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//')
|
||||
USER_ID=$(echo "$USER_RESPONSE" | jq -r '.[0].id')
|
||||
else
|
||||
echo -e "${RED}✗ Error al crear usuario (HTTP ${HTTP_CODE})${NC}"
|
||||
echo "Respuesta: $CREATE_RESPONSE"
|
||||
@@ -470,14 +483,14 @@ echo -e "${GREEN}✓ Keycloak User ID: ${USER_ID}${NC}"
|
||||
###############################################################################
|
||||
# 7. Agregar atributo tenant_id al usuario
|
||||
###############################################################################
|
||||
echo -e "\n${YELLOW}[7/8] Configurando atributo tenant_id para usuario demo...${NC}"
|
||||
echo -e "\n${YELLOW}[7/9] Configurando atributo tenant_id para usuario demo...${NC}"
|
||||
|
||||
# Nota: El tenant_id se agregará después de crear el tenant en PostgreSQL
|
||||
|
||||
###############################################################################
|
||||
# 8. Crear tenant y company en PostgreSQL
|
||||
###############################################################################
|
||||
echo -e "\n${YELLOW}[8/8] Creando tenant y company en PostgreSQL...${NC}"
|
||||
echo -e "\n${YELLOW}[8/9] Creando tenant y company en PostgreSQL...${NC}"
|
||||
|
||||
# Esperar a que PostgreSQL esté listo
|
||||
echo "Esperando a que PostgreSQL esté disponible..."
|
||||
@@ -491,7 +504,9 @@ while [ $PG_RETRY_COUNT -lt $MAX_PG_RETRIES ]; do
|
||||
break
|
||||
fi
|
||||
PG_RETRY_COUNT=$((PG_RETRY_COUNT + 1))
|
||||
echo "Intento $PG_RETRY_COUNT/$MAX_PG_RETRIES..."
|
||||
if [ $((PG_RETRY_COUNT % 10)) -eq 0 ]; then
|
||||
echo "Esperando PostgreSQL... ($PG_RETRY_COUNT/$MAX_PG_RETRIES)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
@@ -502,13 +517,16 @@ fi
|
||||
|
||||
# Insertar o actualizar tenant
|
||||
echo "Insertando tenant en PostgreSQL..."
|
||||
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" 2>&1
|
||||
exec_pg_sql "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
||||
|
||||
# Obtener el ID del tenant con mejor manejo de errores
|
||||
echo "Obteniendo ID del tenant..."
|
||||
TENANT_ID_RESULT=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT id FROM core.tenants WHERE slug = '${TENANT_SLUG}';" 2>&1)
|
||||
set +e
|
||||
TENANT_ID_RESULT=$(exec_pg_sql "SELECT id FROM core.tenants WHERE slug = '${TENANT_SLUG}';")
|
||||
TENANT_QUERY_STATUS=$?
|
||||
set -e
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
if [ $TENANT_QUERY_STATUS -ne 0 ]; then
|
||||
echo -e "${RED}✗ Error al consultar el tenant:${NC}"
|
||||
echo "$TENANT_ID_RESULT"
|
||||
echo -e "${YELLOW}Verificando si la tabla existe...${NC}"
|
||||
@@ -529,18 +547,18 @@ fi
|
||||
echo -e "${GREEN}✓ Tenant ID: ${TENANT_ID}${NC}"
|
||||
|
||||
# Insertar company si no existe
|
||||
COMPANY_EXISTS=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};")
|
||||
COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};")
|
||||
COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs)
|
||||
|
||||
if [ "$COMPANY_EXISTS" = "0" ]; then
|
||||
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" 2>&1
|
||||
exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null
|
||||
echo -e "${GREEN}✓ Company creada${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}"
|
||||
fi
|
||||
|
||||
# Obtener información de la company
|
||||
COMPANY_INFO=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;")
|
||||
COMPANY_INFO=$(exec_pg_sql "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;")
|
||||
|
||||
echo -e "${GREEN}✓ Company: ${COMPANY_INFO}${NC}"
|
||||
|
||||
@@ -561,10 +579,62 @@ echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}"
|
||||
# Agregar relación usuario-tenant en la base de datos
|
||||
echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}"
|
||||
|
||||
docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" 2>&1
|
||||
exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null
|
||||
|
||||
echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos${NC}"
|
||||
|
||||
###############################################################################
|
||||
# 9. Crear licencia Enterprise para el tenant
|
||||
###############################################################################
|
||||
echo -e "\n${YELLOW}[9/9] Creando licencia Enterprise para el tenant...${NC}"
|
||||
|
||||
# Calcular fechas de inicio y expiración (1 año desde hoy)
|
||||
LICENSE_START_DATE=$(date -u +"%Y-%m-%d %H:%M:%S")
|
||||
LICENSE_EXPIRE_DATE=$(date -u -d "+1 year" +"%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Verificar si ya existe una licencia para este tenant
|
||||
LICENSE_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM core.licenses WHERE tenant_id = ${TENANT_ID};")
|
||||
LICENSE_EXISTS=$(echo "$LICENSE_EXISTS" | xargs)
|
||||
|
||||
if [ "$LICENSE_EXISTS" = "0" ]; then
|
||||
# Crear licencia Enterprise con límites ilimitados (valores muy altos)
|
||||
set +e # Desactivar exit on error temporalmente
|
||||
LICENSE_CREATE_OUTPUT=$(exec_pg_sql "INSERT INTO core.licenses (tenant_id, plan, status, max_users, max_storage_gb, max_monthly_operations, feature_api_access, feature_advanced_reports, feature_integrations, feature_dedicated_support, starts_at, expires_at, created_at, updated_at) VALUES (${TENANT_ID}, 'ENTERPRISE', 'ACTIVE', 999999, 999999, 999999, true, true, true, true, '${LICENSE_START_DATE}'::timestamp, '${LICENSE_EXPIRE_DATE}'::timestamp, now(), now());" 2>&1)
|
||||
LICENSE_CREATE_STATUS=$?
|
||||
set -e # Reactivar exit on error
|
||||
|
||||
if [ $LICENSE_CREATE_STATUS -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente${NC}"
|
||||
echo -e "${GREEN} Plan: Enterprise${NC}"
|
||||
echo -e "${GREEN} Usuarios: Ilimitados${NC}"
|
||||
echo -e "${GREEN} Almacenamiento: Ilimitado${NC}"
|
||||
echo -e "${GREEN} Operaciones mensuales: Ilimitadas${NC}"
|
||||
echo -e "${GREEN} Inicio: ${LICENSE_START_DATE}${NC}"
|
||||
echo -e "${GREEN} Expiración: ${LICENSE_EXPIRE_DATE}${NC}"
|
||||
echo -e "${GREEN} Features: API, Reportes Avanzados, Integraciones, Soporte Dedicado${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Error al crear la licencia${NC}"
|
||||
echo -e "${YELLOW}Detalle del error:${NC}"
|
||||
echo "$LICENSE_CREATE_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠ Ya existe una licencia para este tenant, actualizando a Enterprise...${NC}"
|
||||
|
||||
# Actualizar licencia existente a Enterprise
|
||||
set +e # Desactivar exit on error temporalmente
|
||||
exec_pg_sql "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1
|
||||
LICENSE_UPDATE_STATUS=$?
|
||||
set -e # Reactivar exit on error
|
||||
|
||||
if [ $LICENSE_UPDATE_STATUS -eq 0 ]; then
|
||||
echo -e "${GREEN}✓ Licencia actualizada a Enterprise${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ Error al actualizar la licencia${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
# Resumen final
|
||||
###############################################################################
|
||||
@@ -598,6 +668,11 @@ echo -e " ${GREEN}✓${NC} tenant_id mapper para Frontend"
|
||||
echo -e "\n${YELLOW}Configuración del usuario demo:${NC}"
|
||||
echo -e " ${GREEN}✓${NC} tenant_id actualizado al ID real: ${TENANT_ID}"
|
||||
echo -e " ${GREEN}✓${NC} Relación usuario-tenant creada (rol: admin)"
|
||||
echo -e "\n${YELLOW}Licencia Enterprise:${NC}"
|
||||
echo -e " ${GREEN}✓${NC} Plan: Enterprise (ilimitado)"
|
||||
echo -e " ${GREEN}✓${NC} Status: Activa"
|
||||
echo -e " ${GREEN}✓${NC} Features: API, Reportes Avanzados, Integraciones, Soporte Dedicado"
|
||||
echo -e " ${GREEN}✓${NC} Vigencia: 1 año"
|
||||
echo -e "\n${YELLOW}Puedes acceder al sistema en:${NC}"
|
||||
echo -e " ${GREEN}http://localhost:5173${NC}"
|
||||
echo -e "\n${GREEN}════════════════════════════════════════════════════════${NC}\n"
|
||||
|
||||
Reference in New Issue
Block a user