diff --git a/.gitignore b/.gitignore index 17d85cdc..9beb6fa8 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/ \ No newline at end of file diff --git a/backend/api/v1/modules/a76/general_catalogs/company/models.py b/backend/api/v1/modules/a76/general_catalogs/company/models.py index b291dbb5..18bdd840 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/models.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/models.py @@ -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) diff --git a/backend/api/v1/modules/a76/general_catalogs/company/routes.py b/backend/api/v1/modules/a76/general_catalogs/company/routes.py index 294ff490..d62fc99a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/company/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/company/routes.py @@ -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 \ No newline at end of file + 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, + } 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/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/__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..d472a423 --- /dev/null +++ b/backend/api/v1/modules/core/users/dto.py @@ -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 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..773773f9 --- /dev/null +++ b/backend/api/v1/modules/core/users/routes.py @@ -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} 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..6d2906f0 --- /dev/null +++ b/backend/api/v1/modules/core/users/service.py @@ -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)}" + ) 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/api/dashboard/a76/general_catalogs/company.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts index d31e6aba..fff914d5 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/company.ts @@ -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> { return await api.delete(`/v1/a76/company/${id}`); } + +export async function uploadCompanyLogo(id: number, file: File): Promise> { + 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 + }; +} 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/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 - Projects + Gestion {#each projects as item (item.name)} diff --git a/frontend/src/lib/components/sidebar/nav-user.svelte b/frontend/src/lib/components/sidebar/nav-user.svelte index f3f472e2..144f6387 100644 --- a/frontend/src/lib/components/sidebar/nav-user.svelte +++ b/frontend/src/lib/components/sidebar/nav-user.svelte @@ -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} > - + AS
@@ -118,8 +126,8 @@
- - CN + + AS
{user.name} @@ -129,14 +137,7 @@ - - - Upgrade to Pro - - - - - + Account diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index c57880c1..341a0142 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -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 + ); @@ -22,19 +30,20 @@ class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" >
- {#if companyStore.activeCompany?.logo} + {#if activeCompanyLogoUrl} {companyStore.activeCompany.name} { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> {:else} {/if}
-
+
{companyStore.activeCompany?.name || 'Seleccionar compañía'} @@ -72,10 +81,10 @@ onSelect={() => companyStore.setActiveCompany(company)} class="gap-2 p-2 cursor-pointer" > -
+
{#if company.logo} {company.name} @@ -83,10 +92,10 @@ {/if}
-
- {company.name} +
+ {company.name} {#if company.rfc} - {company.rfc} + {company.rfc} {/if}
{#if companyStore.activeCompany?.id === company.id} 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 +

+
+ +
+ + +
+ +
+ +