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

Gestión de Usuarios

+

Administra los usuarios de tu organización

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

de {stats.max_users_allowed} permitidos

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

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

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

Mínimo 8 caracteres

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

+ Puedes desactivar el usuario (recomendado) o eliminarlo permanentemente. +
+
+ + Cancelar + + handleDelete(false)} class="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + + Eliminar Permanente + + +
+
diff --git a/scripts/init_first_time.sh b/scripts/init_first_time.sh index c27ad145..31085dcf 100755 --- a/scripts/init_first_time.sh +++ b/scripts/init_first_time.sh @@ -11,6 +11,7 @@ # 5. Tenant y Company en PostgreSQL # 6. Relación usuario-tenant en tabla user_tenants # 7. Actualización del tenant_id del usuario con el valor real +# 8. Licencia Enterprise para el tenant (ilimitada, 1 año de vigencia) # # Requisitos: # - Keycloak corriendo en http://localhost:8080 @@ -22,7 +23,10 @@ # se actualiza con el ID real del tenant creado en PostgreSQL. ############################################################################### -# set -e # Comentado para permitir que el script continúe aunque algunos comandos fallen (ej: mapper ya existe) +set -euo pipefail # Modo strict: exit on error, undefined vars, pipe failures + +# Trap para cleanup en caso de error +trap 'echo -e "\n${RED}✗ Error en línea $LINENO. Script abortado.${NC}" >&2' ERR # Colores para output RED='\033[0;31m' @@ -30,6 +34,84 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color +############################################################################### +# Validar dependencias requeridas +############################################################################### +check_dependencies() { + local missing_deps=() + + command -v jq >/dev/null 2>&1 || missing_deps+=("jq") + command -v curl >/dev/null 2>&1 || missing_deps+=("curl") + command -v docker >/dev/null 2>&1 || missing_deps+=("docker") + + if [ ${#missing_deps[@]} -ne 0 ]; then + echo -e "${RED}✗ Error: Dependencias faltantes: ${missing_deps[*]}${NC}" >&2 + echo -e "${YELLOW}Instala las dependencias faltantes antes de continuar.${NC}" >&2 + exit 1 + fi +} + +check_dependencies + +############################################################################### +# Funciones auxiliares +############################################################################### + +# Ejecutar SQL en PostgreSQL via Docker +exec_pg_sql() { + local sql="$1" + docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 \ + psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" \ + -t -c "${sql}" 2>&1 +} + +# Crear mapper de tenant_id para un cliente +create_tenant_mapper() { + local client_id="$1" + local client_name="$2" + + echo "Configurando mapper para ${client_name}..." + + # Verificar si el mapper ya existe + local mappers + mappers=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_id}/protocol-mappers/models" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json") + + if echo "$mappers" | jq -e '.[] | select(.name == "tenant-id-mapper")' >/dev/null 2>&1; then + echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para ${client_name}${NC}" + return 0 + fi + + # Crear mapper + local response + response=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${client_id}/protocol-mappers/models" \ + -H "Authorization: Bearer ${ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "tenant-id-mapper", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "config": { + "user.attribute": "tenant_id", + "claim.name": "tenant_id", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }') + + local http_code + http_code=$(echo "$response" | tail -n1) + + if [ "$http_code" = "201" ]; then + echo -e "${GREEN}✓ Mapper tenant_id creado para ${client_name}${NC}" + else + echo -e "${YELLOW}⚠ No se pudo crear mapper para ${client_name} (HTTP ${http_code})${NC}" + fi +} + # Variables de configuración KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080/kcauth}" KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" @@ -71,7 +153,9 @@ while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do break fi RETRY_COUNT=$((RETRY_COUNT + 1)) - echo "Intento $RETRY_COUNT/$MAX_RETRIES..." + if [ $((RETRY_COUNT % 5)) -eq 0 ]; then + echo "Esperando Keycloak... ($RETRY_COUNT/$MAX_RETRIES)" + fi sleep 2 done @@ -92,7 +176,7 @@ TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid- -d "grant_type=password" \ -d "client_id=admin-cli") -ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*' | sed 's/"access_token":"//') +ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token // empty') if [ -z "$ACCESS_TOKEN" ]; then echo -e "${RED}✗ Error: No se pudo obtener el token de acceso${NC}" @@ -112,8 +196,8 @@ USER_PROFILE=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/us -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -# Verificar si tenant_id existe -TENANT_ID_EXISTS=$(echo "$USER_PROFILE" | grep -q "\"name\":\"tenant_id\"" && echo "true" || echo "false") +# Verificar si tenant_id existe usando jq +TENANT_ID_EXISTS=$(echo "$USER_PROFILE" | jq -e '.attributes[]? | select(.name == "tenant_id")' >/dev/null 2>&1 && echo "true" || echo "false") if [ "$TENANT_ID_EXISTS" = "false" ]; then echo "Agregando atributo tenant_id al User Profile..." @@ -160,9 +244,9 @@ BACKEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -if echo "$BACKEND_CLIENT_EXISTS" | grep -q "\"clientId\":\"anexo76-backend\""; then +if echo "$BACKEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-backend")' >/dev/null 2>&1; then echo -e "${YELLOW}⚠ Cliente Backend ya existe${NC}" - BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | jq -r '.[0].id') else CREATE_BACKEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ @@ -198,7 +282,7 @@ else BACKEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients?clientId=anexo76-backend" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") - BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + BACKEND_CLIENT_ID=$(echo "$BACKEND_CLIENT_EXISTS" | jq -r '.[0].id') else echo -e "${RED}✗ Error al crear cliente Backend (HTTP ${HTTP_CODE})${NC}" fi @@ -208,7 +292,7 @@ fi if [ -n "$BACKEND_CLIENT_ID" ]; then BACKEND_SECRET=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/client-secret" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" | grep -o '"value":"[^"]*' | sed 's/"value":"//') + -H "Content-Type: application/json" | jq -r '.value // empty') echo -e "${GREEN}✓ Backend Client ID: anexo76-backend${NC}" echo -e "${GREEN}✓ Backend Client Secret: ${BACKEND_SECRET}${NC}" @@ -252,9 +336,9 @@ FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -if echo "$FRONTEND_CLIENT_EXISTS" | grep -q "\"clientId\":\"anexo76-frontend\""; then +if echo "$FRONTEND_CLIENT_EXISTS" | jq -e '.[] | select(.clientId == "anexo76-frontend")' >/dev/null 2>&1; then echo -e "${YELLOW}⚠ Cliente Frontend ya existe${NC}" - FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id') else CREATE_FRONTEND=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ @@ -293,7 +377,7 @@ else FRONTEND_CLIENT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients?clientId=anexo76-frontend" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") - FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + FRONTEND_CLIENT_ID=$(echo "$FRONTEND_CLIENT_EXISTS" | jq -r '.[0].id') else echo -e "${RED}✗ Error al crear cliente Frontend (HTTP ${HTTP_CODE})${NC}" echo "Respuesta del servidor: $RESPONSE_BODY" @@ -307,83 +391,12 @@ echo -e "\n${YELLOW}[5/8] Configurando mappers para tenant_id...${NC}" # 4.1 Configurar mapper para Backend if [ -n "$BACKEND_CLIENT_ID" ]; then - echo "Configurando mapper para Backend..." - - # Verificar si el mapper tenant_id ya existe en el cliente - MAPPER_TENANT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" | grep -o "\"name\":\"tenant-id-mapper\"") - - if [ -z "$MAPPER_TENANT_EXISTS" ]; then - # Crear mapper para tenant_id directamente en el cliente - CREATE_MAPPER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${BACKEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "tenant-id-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "config": { - "user.attribute": "tenant_id", - "claim.name": "tenant_id", - "jsonType.label": "String", - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }') - - HTTP_CODE=$(echo "$CREATE_MAPPER_RESPONSE" | tail -n1) - if [ "$HTTP_CODE" = "201" ]; then - echo -e "${GREEN}✓ Mapper tenant_id creado para Backend${NC}" - else - echo -e "${YELLOW}⚠ Error al crear mapper para Backend (HTTP ${HTTP_CODE})${NC}" - echo "Respuesta: $(echo "$CREATE_MAPPER_RESPONSE" | head -n -1)" - fi - else - echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para Backend${NC}" - fi + create_tenant_mapper "$BACKEND_CLIENT_ID" "Backend" fi - # 4.2 Configurar mapper para Frontend if [ -n "$FRONTEND_CLIENT_ID" ]; then - echo "Configurando mapper para Frontend..." - - # Verificar si el mapper tenant_id ya existe en el cliente - MAPPER_TENANT_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" | grep -o "\"name\":\"tenant-id-mapper\"") - - if [ -z "$MAPPER_TENANT_EXISTS" ]; then - # Crear mapper para tenant_id directamente en el cliente - CREATE_MAPPER_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/clients/${FRONTEND_CLIENT_ID}/protocol-mappers/models" \ - -H "Authorization: Bearer ${ACCESS_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "tenant-id-mapper", - "protocol": "openid-connect", - "protocolMapper": "oidc-usermodel-attribute-mapper", - "config": { - "user.attribute": "tenant_id", - "claim.name": "tenant_id", - "jsonType.label": "String", - "id.token.claim": "true", - "access.token.claim": "true", - "userinfo.token.claim": "true" - } - }') - - HTTP_CODE=$(echo "$CREATE_MAPPER_RESPONSE" | tail -n1) - if [ "$HTTP_CODE" = "201" ]; then - echo -e "${GREEN}✓ Mapper tenant_id creado para Frontend${NC}" - else - echo -e "${YELLOW}⚠ Error al crear mapper para Frontend (HTTP ${HTTP_CODE})${NC}" - echo "Respuesta: $(echo "$CREATE_MAPPER_RESPONSE" | head -n -1)" - fi - else - echo -e "${YELLOW}⚠ Mapper tenant_id ya existe para Frontend${NC}" - fi + create_tenant_mapper "$FRONTEND_CLIENT_ID" "Frontend" fi @@ -397,9 +410,9 @@ USER_EXISTS=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/use -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") -if echo "$USER_EXISTS" | grep -q "\"username\":\"${DEMO_USERNAME}\""; then +if echo "$USER_EXISTS" | jq -e '.[] | select(.username == "'"${DEMO_USERNAME}"'")' >/dev/null 2>&1; then echo -e "${YELLOW}⚠ Usuario demo ya existe, actualizando...${NC}" - USER_ID=$(echo "$USER_EXISTS" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + USER_ID=$(echo "$USER_EXISTS" | jq -r '.[0].id') # Actualizar usuario existente curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users/${USER_ID}" \ @@ -455,7 +468,7 @@ else USER_RESPONSE=$(curl -s -X GET "${KEYCLOAK_URL}/admin/realms/${KEYCLOAK_REALM}/users?username=${DEMO_USERNAME}" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json") - USER_ID=$(echo "$USER_RESPONSE" | grep -o '"id":"[^"]*' | head -1 | sed 's/"id":"//') + USER_ID=$(echo "$USER_RESPONSE" | jq -r '.[0].id') else echo -e "${RED}✗ Error al crear usuario (HTTP ${HTTP_CODE})${NC}" echo "Respuesta: $CREATE_RESPONSE" @@ -470,14 +483,14 @@ echo -e "${GREEN}✓ Keycloak User ID: ${USER_ID}${NC}" ############################################################################### # 7. Agregar atributo tenant_id al usuario ############################################################################### -echo -e "\n${YELLOW}[7/8] Configurando atributo tenant_id para usuario demo...${NC}" +echo -e "\n${YELLOW}[7/9] Configurando atributo tenant_id para usuario demo...${NC}" # Nota: El tenant_id se agregará después de crear el tenant en PostgreSQL ############################################################################### # 8. Crear tenant y company en PostgreSQL ############################################################################### -echo -e "\n${YELLOW}[8/8] Creando tenant y company en PostgreSQL...${NC}" +echo -e "\n${YELLOW}[8/9] Creando tenant y company en PostgreSQL...${NC}" # Esperar a que PostgreSQL esté listo echo "Esperando a que PostgreSQL esté disponible..." @@ -491,7 +504,9 @@ while [ $PG_RETRY_COUNT -lt $MAX_PG_RETRIES ]; do break fi PG_RETRY_COUNT=$((PG_RETRY_COUNT + 1)) - echo "Intento $PG_RETRY_COUNT/$MAX_PG_RETRIES..." + if [ $((PG_RETRY_COUNT % 10)) -eq 0 ]; then + echo "Esperando PostgreSQL... ($PG_RETRY_COUNT/$MAX_PG_RETRIES)" + fi sleep 1 done @@ -502,13 +517,16 @@ fi # Insertar o actualizar tenant echo "Insertando tenant en PostgreSQL..." -docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" 2>&1 +exec_pg_sql "INSERT INTO core.tenants (name, slug, type, keycloak_realm, contact_email, is_active, created_at, updated_at) VALUES ('${TENANT_NAME}', '${TENANT_SLUG}', 'SHARED'::tenanttype, '${KEYCLOAK_REALM}', '${DEMO_EMAIL}', true, now(), now()) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, contact_email = EXCLUDED.contact_email, updated_at = CURRENT_TIMESTAMP;" >/dev/null # Obtener el ID del tenant con mejor manejo de errores echo "Obteniendo ID del tenant..." -TENANT_ID_RESULT=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT id FROM core.tenants WHERE slug = '${TENANT_SLUG}';" 2>&1) +set +e +TENANT_ID_RESULT=$(exec_pg_sql "SELECT id FROM core.tenants WHERE slug = '${TENANT_SLUG}';") +TENANT_QUERY_STATUS=$? +set -e -if [ $? -ne 0 ]; then +if [ $TENANT_QUERY_STATUS -ne 0 ]; then echo -e "${RED}✗ Error al consultar el tenant:${NC}" echo "$TENANT_ID_RESULT" echo -e "${YELLOW}Verificando si la tabla existe...${NC}" @@ -529,18 +547,18 @@ fi echo -e "${GREEN}✓ Tenant ID: ${TENANT_ID}${NC}" # Insertar company si no existe -COMPANY_EXISTS=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};") +COMPANY_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM a76.company WHERE tenant_id = ${TENANT_ID};") COMPANY_EXISTS=$(echo "$COMPANY_EXISTS" | xargs) if [ "$COMPANY_EXISTS" = "0" ]; then - docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" 2>&1 + exec_pg_sql "INSERT INTO a76.company (tenant_id, name, rfc, is_service_company, created_at, updated_at) VALUES (${TENANT_ID}, '${COMPANY_NAME}', '${COMPANY_RFC}', false, now(), now());" >/dev/null echo -e "${GREEN}✓ Company creada${NC}" else echo -e "${YELLOW}⚠ Company ya existe para este tenant${NC}" fi # Obtener información de la company -COMPANY_INFO=$(docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -t -c "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;") +COMPANY_INFO=$(exec_pg_sql "SELECT id, name FROM a76.company WHERE tenant_id = ${TENANT_ID} LIMIT 1;") echo -e "${GREEN}✓ Company: ${COMPANY_INFO}${NC}" @@ -561,10 +579,62 @@ echo -e "${GREEN}✓ Atributo tenant_id asignado al usuario${NC}" # Agregar relación usuario-tenant en la base de datos echo -e "\n${YELLOW}Creando relación usuario-tenant en la base de datos...${NC}" -docker exec -e PGPASSWORD="${POSTGRES_PASSWORD}" anexo76-postgres-a76 psql -h localhost -p 5432 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -c "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" 2>&1 +exec_pg_sql "INSERT INTO core.user_tenants (keycloak_user_id, tenant_id, company_id, role, is_active, created_at, updated_at) VALUES ('${USER_ID}', ${TENANT_ID}, 1, 'admin', true, now(), now()) ON CONFLICT (keycloak_user_id, tenant_id, company_id) DO UPDATE SET is_active = true, updated_at = CURRENT_TIMESTAMP;" >/dev/null echo -e "${GREEN}✓ Relación usuario-tenant creada en la base de datos${NC}" +############################################################################### +# 9. Crear licencia Enterprise para el tenant +############################################################################### +echo -e "\n${YELLOW}[9/9] Creando licencia Enterprise para el tenant...${NC}" + +# Calcular fechas de inicio y expiración (1 año desde hoy) +LICENSE_START_DATE=$(date -u +"%Y-%m-%d %H:%M:%S") +LICENSE_EXPIRE_DATE=$(date -u -d "+1 year" +"%Y-%m-%d %H:%M:%S") + +# Verificar si ya existe una licencia para este tenant +LICENSE_EXISTS=$(exec_pg_sql "SELECT COUNT(*) FROM core.licenses WHERE tenant_id = ${TENANT_ID};") +LICENSE_EXISTS=$(echo "$LICENSE_EXISTS" | xargs) + +if [ "$LICENSE_EXISTS" = "0" ]; then + # Crear licencia Enterprise con límites ilimitados (valores muy altos) + set +e # Desactivar exit on error temporalmente + LICENSE_CREATE_OUTPUT=$(exec_pg_sql "INSERT INTO core.licenses (tenant_id, plan, status, max_users, max_storage_gb, max_monthly_operations, feature_api_access, feature_advanced_reports, feature_integrations, feature_dedicated_support, starts_at, expires_at, created_at, updated_at) VALUES (${TENANT_ID}, 'ENTERPRISE', 'ACTIVE', 999999, 999999, 999999, true, true, true, true, '${LICENSE_START_DATE}'::timestamp, '${LICENSE_EXPIRE_DATE}'::timestamp, now(), now());" 2>&1) + LICENSE_CREATE_STATUS=$? + set -e # Reactivar exit on error + + if [ $LICENSE_CREATE_STATUS -eq 0 ]; then + echo -e "${GREEN}✓ Licencia Enterprise creada exitosamente${NC}" + echo -e "${GREEN} Plan: Enterprise${NC}" + echo -e "${GREEN} Usuarios: Ilimitados${NC}" + echo -e "${GREEN} Almacenamiento: Ilimitado${NC}" + echo -e "${GREEN} Operaciones mensuales: Ilimitadas${NC}" + echo -e "${GREEN} Inicio: ${LICENSE_START_DATE}${NC}" + echo -e "${GREEN} Expiración: ${LICENSE_EXPIRE_DATE}${NC}" + echo -e "${GREEN} Features: API, Reportes Avanzados, Integraciones, Soporte Dedicado${NC}" + else + echo -e "${RED}✗ Error al crear la licencia${NC}" + echo -e "${YELLOW}Detalle del error:${NC}" + echo "$LICENSE_CREATE_OUTPUT" + exit 1 + fi +else + echo -e "${YELLOW}⚠ Ya existe una licencia para este tenant, actualizando a Enterprise...${NC}" + + # Actualizar licencia existente a Enterprise + set +e # Desactivar exit on error temporalmente + exec_pg_sql "UPDATE core.licenses SET plan = 'ENTERPRISE', status = 'ACTIVE', max_users = 999999, max_storage_gb = 999999, max_monthly_operations = 999999, feature_api_access = true, feature_advanced_reports = true, feature_integrations = true, feature_dedicated_support = true, starts_at = '${LICENSE_START_DATE}'::timestamp, expires_at = '${LICENSE_EXPIRE_DATE}'::timestamp, updated_at = now() WHERE tenant_id = ${TENANT_ID};" >/dev/null 2>&1 + LICENSE_UPDATE_STATUS=$? + set -e # Reactivar exit on error + + if [ $LICENSE_UPDATE_STATUS -eq 0 ]; then + echo -e "${GREEN}✓ Licencia actualizada a Enterprise${NC}" + else + echo -e "${RED}✗ Error al actualizar la licencia${NC}" + exit 1 + fi +fi + ############################################################################### # Resumen final ############################################################################### @@ -598,6 +668,11 @@ echo -e " ${GREEN}✓${NC} tenant_id mapper para Frontend" echo -e "\n${YELLOW}Configuración del usuario demo:${NC}" echo -e " ${GREEN}✓${NC} tenant_id actualizado al ID real: ${TENANT_ID}" echo -e " ${GREEN}✓${NC} Relación usuario-tenant creada (rol: admin)" +echo -e "\n${YELLOW}Licencia Enterprise:${NC}" +echo -e " ${GREEN}✓${NC} Plan: Enterprise (ilimitado)" +echo -e " ${GREEN}✓${NC} Status: Activa" +echo -e " ${GREEN}✓${NC} Features: API, Reportes Avanzados, Integraciones, Soporte Dedicado" +echo -e " ${GREEN}✓${NC} Vigencia: 1 año" echo -e "\n${YELLOW}Puedes acceder al sistema en:${NC}" echo -e " ${GREEN}http://localhost:5173${NC}" echo -e "\n${GREEN}════════════════════════════════════════════════════════${NC}\n" From dc6325a0f2031b443d67ff9d3c6c00c7805f33e5 Mon Sep 17 00:00:00 2001 From: acazares Date: Tue, 13 Jan 2026 09:58:57 -0600 Subject: [PATCH 2/4] fix(sidebar): update group label from 'Projects' to 'Gestion' --- frontend/src/lib/components/sidebar/nav-projects.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index 75c5085f..e231955d 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -23,7 +23,7 @@ - Projects + Gestion {#each projects as item (item.name)} From c00107d921315d30cebd7614745c242646b24c74 Mon Sep 17 00:00:00 2001 From: acazares Date: Tue, 13 Jan 2026 12:37:11 -0600 Subject: [PATCH 3/4] feat: Implement user profile management with avatar upload and additional fields - Added avatar_url, phone, bio, and preferences fields to UserTenant model. - Updated UserService to handle user profile updates and retrieval. - Created endpoints for getting and updating the current user's profile. - Implemented avatar upload functionality with validation in routes. - Enhanced frontend to manage user profile, including avatar preview and form handling. - Added server-side logic for loading and updating user profile data. - Updated .gitignore to include uploads directory. --- .gitignore | 3 +- .../api/v1/modules/core/user_tenant/models.py | 23 +- backend/api/v1/modules/core/users/dto.py | 16 + backend/api/v1/modules/core/users/routes.py | 129 +++++++- backend/api/v1/modules/core/users/service.py | 136 +++++++- backend/core/middleware.py | 8 +- backend/main.py | 7 + .../dashboard/invoices/edit/save-invoice.ts | 3 +- .../lib/components/sidebar/app-sidebar.svelte | 2 +- .../lib/components/sidebar/nav-user.svelte | 27 +- frontend/src/lib/server/api.ts | 43 ++- frontend/src/lib/utils.ts | 23 ++ .../routes/dashboard/account/+page.server.ts | 130 ++++++++ .../src/routes/dashboard/account/+page.svelte | 306 ++++++++++++++++++ 14 files changed, 824 insertions(+), 32 deletions(-) create mode 100644 frontend/src/routes/dashboard/account/+page.server.ts create mode 100644 frontend/src/routes/dashboard/account/+page.svelte diff --git a/.gitignore b/.gitignore index 17d85cdc..911dde10 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ node_modules/ # Docker *.dockerignore -postgres-data/ \ No newline at end of file +postgres-data/ +backend/uploads/avatars \ No newline at end of file diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py index 879f3dd1..9fe82c4d 100644 --- a/backend/api/v1/modules/core/user_tenant/models.py +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -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") diff --git a/backend/api/v1/modules/core/users/dto.py b/backend/api/v1/modules/core/users/dto.py index 5e707531..d472a423 100644 --- a/backend/api/v1/modules/core/users/dto.py +++ b/backend/api/v1/modules/core/users/dto.py @@ -33,6 +33,14 @@ class UpdateUserRequestDTO(BaseModel): 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]: @@ -55,6 +63,14 @@ class UserResponseDTO(BaseModel): 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 diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 4c5b83b9..773773f9 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -3,10 +3,13 @@ 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, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from sqlalchemy.orm import Session from ..user_tenant.models import UserTenant @@ -139,6 +142,7 @@ def update_user( - 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, @@ -148,6 +152,10 @@ def update_user( 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 @@ -185,3 +193,122 @@ def change_user_password( """ 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} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 5325a0a3..6d2906f0 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -20,14 +20,16 @@ logger = logging.getLogger(__name__) def _normalize_keycloak_user( - user_data: Dict[str, Any], role: Optional[str] = None + 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 """ - return { + normalized = { "id": user_data.get("id"), "username": user_data.get("username", ""), "email": user_data.get("email", ""), @@ -39,6 +41,19 @@ def _normalize_keycloak_user( "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""" @@ -197,7 +212,7 @@ class UserService: # Obtener información completa del usuario user_info = self.keycloak_admin.get_user(user_id) - return _normalize_keycloak_user(user_info, role) + return _normalize_keycloak_user(user_info, role, user_tenant) except KeycloakError as e: logger.error(f"Keycloak error creating user: {str(e)}") @@ -257,7 +272,7 @@ class UserService: 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) + normalized_user = _normalize_keycloak_user(user_info, ut.role, ut) # Filtrar por búsqueda si se proporciona if search: @@ -316,7 +331,7 @@ class UserService: try: user_info = self.keycloak_admin.get_user(user_id) - return _normalize_keycloak_user(user_info, user_tenant.role) + 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") @@ -330,6 +345,10 @@ class UserService: 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 @@ -365,15 +384,25 @@ class UserService: if update_data: self.keycloak_admin.update_user(user_id, update_data) - # Actualizar rol en UserTenant si se proporciona + # Actualizar campos en UserTenant if role is not None: user_tenant.role = role - self.db.commit() + 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) + 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)}") @@ -495,3 +524,94 @@ class UserService: "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)}" + ) diff --git a/backend/core/middleware.py b/backend/core/middleware.py index f5d0df8a..0ed55a15 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -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) diff --git a/backend/main.py b/backend/main.py index c567450d..e5c1b936 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 9660f5b3..c1a4bd98 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -88,8 +88,7 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise - + AS
@@ -118,8 +126,8 @@
- - CN + + AS
{user.name} @@ -129,14 +137,7 @@ - - - Upgrade to Pro - - - - - + Account diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index d40b5594..ccbfdfdb 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -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); + // 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 || {}) } + : createAuthHeaders(accessToken, options.headers as Record); + 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); + // Si el body es FormData, no incluir Content-Type + const newHeaders = isFormData + ? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record || {}) } + : createAuthHeaders(newToken, options.headers as Record); + 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) { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 55b3a918..b273ec3e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 diff --git a/frontend/src/routes/dashboard/account/+page.server.ts b/frontend/src/routes/dashboard/account/+page.server.ts new file mode 100644 index 00000000..fd8b8e13 --- /dev/null +++ b/frontend/src/routes/dashboard/account/+page.server.ts @@ -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 = {}; + + 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 + }); + } + } +}; diff --git a/frontend/src/routes/dashboard/account/+page.svelte b/frontend/src/routes/dashboard/account/+page.svelte new file mode 100644 index 00000000..f3eaa5bf --- /dev/null +++ b/frontend/src/routes/dashboard/account/+page.svelte @@ -0,0 +1,306 @@ + + +
+
+

Configuración de Cuenta

+

+ Gestiona tu información personal y preferencias +

+
+ + {#if !profile && serverError} + + +
+

{serverError}

+ +
+
+
+ {:else if profile} +
{ + + // Agregar archivo si existe + if (avatarFile) { + formData.append('avatar', avatarFile); + } + + saving = true; + error = ''; + success = ''; + return async ({ update }) => { + await update(); + saving = false; + }; + }} + > +
+ + + + Foto de Perfil + Actualiza tu imagen de perfil + + +
+
+ +
+ +
+
+ +

+ Haz clic en la imagen o selecciona un archivo. JPG, PNG o GIF. Máximo 2MB. +

+
+
+
+
+
+ + + + + Información Personal + Actualiza tus datos personales + + +
+
+ + +
+ +
+ + +
+
+ +
+ + +

+ Cambiar el email puede requerir verificación +

+
+ +
+ + +
+ +
+ +