From 5dd65df3e8f17f5dc568a0a8d316165260a51b1c Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 6 May 2026 12:09:04 -0500 Subject: [PATCH] feat: add first_name and last_name fields to UserTenant model and sync with Keycloak Co-authored-by: Copilot --- ...3d4e5f6_add_name_fields_to_user_tenants.py | 35 ++ .../api/v1/modules/core/user_tenant/models.py | 8 + backend/api/v1/modules/core/users/routes.py | 318 +++++++++--------- backend/api/v1/modules/core/users/service.py | 142 +++++++- backend/core/config.py | 3 + docker-compose.yml | 2 + .../lib/components/sidebar/app-sidebar.svelte | 22 +- frontend/src/lib/server/api.ts | 26 +- frontend/src/routes/auth/sso/+page.server.ts | 43 ++- frontend/src/routes/dashboard/+layout.svelte | 5 +- .../routes/dashboard/account/+page.server.ts | 49 +-- .../src/routes/dashboard/account/+page.svelte | 43 +-- 12 files changed, 432 insertions(+), 264 deletions(-) create mode 100644 backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py b/backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py new file mode 100644 index 00000000..fbf471a8 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_name_fields_to_user_tenants.py @@ -0,0 +1,35 @@ +"""add first_name and last_name to user_tenants + +Revision ID: a1b2c3d4e5f6 +Revises: 8c9bad3da37f +Create Date: 2026-05-06 00:00:00.000000 + +""" +from typing import Sequence, Union +import sqlalchemy as sa +from alembic import op + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "8c9bad3da37f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "user_tenants", + sa.Column("first_name", sa.String(100), nullable=True, + comment="Nombre (caché local de Keycloak)"), + schema="core", + ) + op.add_column( + "user_tenants", + sa.Column("last_name", sa.String(100), nullable=True, + comment="Apellido (caché local de Keycloak)"), + schema="core", + ) + + +def downgrade() -> None: + op.drop_column("user_tenants", "last_name", schema="core") + op.drop_column("user_tenants", "first_name", schema="core") diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py index 2674b292..33138d6e 100644 --- a/backend/api/v1/modules/core/user_tenant/models.py +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -56,6 +56,14 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin): avatar_url: Mapped[Optional[str]] = mapped_column( String(500), nullable=True, comment="URL de la imagen de perfil" ) + # Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub; + # se sincroniza al editar perfil desde Anexo76) + first_name: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Nombre (caché local de Keycloak)" + ) + last_name: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Apellido (caché local de Keycloak)" + ) phone: Mapped[Optional[str]] = mapped_column( String(20), nullable=True, comment="Teléfono del usuario" ) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 2e9d2289..b6dfbe7a 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -149,6 +149,169 @@ def get_user_avatar_image( return Response(content=data, media_type=media) +# === Endpoints de Perfil del Usuario Actual === + + +@router.get("/me/profile", response_model=UserResponseDTO) +async def get_my_profile( + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Obtiene el perfil completo 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") + + # 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 await service.get_current_user_profile(keycloak_user_id, current_user=current_user) + + +@router.put("/me/profile", response_model=UserResponseDTO) +async def update_my_profile( + request: Request, + data: UpdateUserRequestDTO, + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Actualiza el perfil del usuario actual. + Campos editables: first_name, last_name, phone. + Email, username y otros campos de identidad solo se cambian desde el Hub. + """ + 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" + ) + + auth_header = request.headers.get("Authorization") or "" + access_token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() or None + ) + + service = UserService(db, user_tenant.tenant_id, user_tenant.company_id) + return await service.update_current_user_profile( + keycloak_user_id=keycloak_user_id, + current_user=current_user, + access_token=access_token, + first_name=data.first_name, + last_name=data.last_name, + phone=data.phone, + ) + + +@router.post("/me/avatar") +async def upload_my_avatar( + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), + db: Session = Depends(get_core_db), +): + """ + Sube un avatar para el usuario actual. + Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant. + Retorna URL pública para (GET /users/avatar/...). + """ + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="El archivo debe ser una imagen") + + keycloak_user_id = current_user.get("sub") + if not keycloak_user_id: + raise HTTPException(status_code=400, detail="User ID not found in token") + + 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" + ) + + ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg" + if ext not in _AVATAR_EXT: + raise HTTPException( + status_code=400, + detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}", + ) + + contents = await file.read() + if len(contents) > 2 * 1024 * 1024: + raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB") + + tenant_id = user_tenant.tenant_id + + try: + if settings.use_s3_object_storage: + if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith( + "tenants/" + ): + delete_object_if_exists(str(user_tenant.avatar_url)) + key = user_avatar_key(tenant_id, keycloak_user_id, ext) + ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg" + put_object_bytes(key, contents, content_type=ct) + user_tenant.avatar_url = key + logger.info( + "User avatar stored in S3 key=%s bytes=%s", key, len(contents) + ) + else: + upload_dir = Path("uploads/avatars") + upload_dir.mkdir(parents=True, exist_ok=True) + filename = f"{keycloak_user_id}{ext}" + file_path = upload_dir / filename + with open(file_path, "wb") as f: + f.write(contents) + user_tenant.avatar_url = f"/uploads/avatars/{filename}" + + db.add(user_tenant) + db.commit() + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, detail=f"Error al guardar el avatar: {str(e)}" + ) from e + + public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id) + return {"avatar_url": public_url} + + @router.get("/{user_id}", response_model=UserResponseDTO) async def get_user_detail( user_id: str, @@ -293,158 +456,3 @@ async def change_user_password( service = UserService(db, tenant_id, company_id) await 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) -async def get_my_profile( - current_user: dict = Depends(get_current_user), - db: Session = Depends(get_core_db), -): - """ - Obtiene el perfil completo 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") - - # 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 await service.get_current_user_profile(keycloak_user_id) - - -@router.put("/me/profile", response_model=UserResponseDTO) -async 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 - """ - 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 await 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. - Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant. - Retorna URL pública para (GET /users/avatar/...). - """ - if not file.content_type or not file.content_type.startswith("image/"): - raise HTTPException(status_code=400, detail="El archivo debe ser una imagen") - - keycloak_user_id = current_user.get("sub") - if not keycloak_user_id: - raise HTTPException(status_code=400, detail="User ID not found in token") - - 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" - ) - - ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg" - if ext not in _AVATAR_EXT: - raise HTTPException( - status_code=400, - detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}", - ) - - contents = await file.read() - if len(contents) > 2 * 1024 * 1024: - raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB") - - tenant_id = user_tenant.tenant_id - - try: - if settings.use_s3_object_storage: - if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith( - "tenants/" - ): - delete_object_if_exists(str(user_tenant.avatar_url)) - key = user_avatar_key(tenant_id, keycloak_user_id, ext) - ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg" - put_object_bytes(key, contents, content_type=ct) - user_tenant.avatar_url = key - logger.info( - "User avatar stored in S3 key=%s bytes=%s", key, len(contents) - ) - else: - upload_dir = Path("uploads/avatars") - upload_dir.mkdir(parents=True, exist_ok=True) - filename = f"{keycloak_user_id}{ext}" - file_path = upload_dir / filename - with open(file_path, "wb") as f: - f.write(contents) - user_tenant.avatar_url = f"/uploads/avatars/{filename}" - - db.add(user_tenant) - db.commit() - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) from e - except Exception as e: - db.rollback() - raise HTTPException( - status_code=500, detail=f"Error al guardar el avatar: {str(e)}" - ) from e - - public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id) - return {"avatar_url": public_url} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index a4ca98bf..961e4bfa 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -21,14 +21,20 @@ def _normalize_user( user_tenant: Optional[Any] = None, ) -> Dict[str, Any]: """ - Normaliza los datos de usuario al formato esperado por el DTO + Normaliza los datos de usuario al formato esperado por el DTO. + Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'. """ + name_parts = (user_data.get("name") or "").split(" ", 1) + # Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76 + local_first = getattr(user_tenant, "first_name", None) if user_tenant else None + local_last = getattr(user_tenant, "last_name", None) if user_tenant else None + normalized = { "id": user_data.get("id") or user_data.get("sub"), "username": user_data.get("username") or user_data.get("preferred_username", ""), "email": user_data.get("email", ""), - "first_name": user_data.get("firstName") or user_data.get("name", "").split(" ")[0], - "last_name": user_data.get("lastName") or (" ".join(user_data.get("name", "").split(" ")[1:]) if " " in user_data.get("name", "") else ""), + "first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""), + "last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""), "enabled": user_data.get("enabled", True), "email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False), "created_timestamp": user_data.get("createdTimestamp"), @@ -38,9 +44,11 @@ def _normalize_user( # Agregar campos de perfil si user_tenant está disponible if user_tenant: avatar_out = user_tenant.avatar_url - if avatar_out and str(avatar_out).startswith("tenants/"): + if avatar_out: from core.s3_keys import public_user_avatar_api_path + # Siempre devolver la URL pública del endpoint de servicio de imágenes, + # independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...). avatar_out = public_user_avatar_api_path( user_tenant.tenant_id, user_tenant.keycloak_user_id ) @@ -621,19 +629,127 @@ class UserService: "usage_percentage": round(usage_percentage, 2), } - async def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]: + async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]: """Obtiene el perfil completo del usuario actual""" - # Reutilizamos verify_token para obtener info del Hub - from core.security import verify_token - user_info = await verify_token(keycloak_user_id) # keycloak_user_id es el token en este contexto, o el ID - # Nota: en routes.py se pasa el ID. Si necesitamos info real, pedimos al Hub. - + # Use the already-verified JWT claims dict — do NOT call verify_token(uuid) + user_info = current_user or {"id": keycloak_user_id} + user_tenant = self.db.query(UserTenant).filter( and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True) ).first() return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant) - async def update_current_user_profile(self, keycloak_user_id: str, **kwargs) -> Dict[str, Any]: - """Actualiza el perfil del usuario actual""" - return await self.update_user(keycloak_user_id, **kwargs) + async def update_current_user_profile( + self, + keycloak_user_id: str, + current_user: Dict[str, Any] = None, + access_token: Optional[str] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + Actualiza el perfil del usuario actual. + - first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub. + - phone: persiste solo localmente. + """ + user_tenant = self.db.query(UserTenant).filter( + and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id) + ).first() + + if not user_tenant: + raise HTTPException(status_code=404, detail="User not found") + + # Campos locales — incluye first_name/last_name como caché + for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]: + if field in kwargs and kwargs[field] is not None: + setattr(user_tenant, field, kwargs[field]) + + self.db.commit() + self.db.refresh(user_tenant) + + # Sincronizar nombre/apellido con Keycloak vía Hub (best-effort) + first_name = kwargs.get("first_name") + last_name = kwargs.get("last_name") + if access_token and (first_name or last_name): + await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token) + + user_info = current_user or {"id": keycloak_user_id} + return _normalize_user(user_info, user_tenant.role, user_tenant) + + async def _hub_get_service_token(self) -> Optional[str]: + """Obtiene un token de la cuenta de servicio Hub para operaciones admin.""" + if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD: + return None + base = (settings.HUB_URL or "").rstrip("/") + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{base}/api/v1/auth/login", + json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD}, + ) + if resp.status_code == 200: + data = resp.json() + return data.get("access_token") + logger.warning("Hub service account login failed status=%s", resp.status_code) + except Exception as exc: + logger.warning("Hub service account login error: %s", exc) + return None + + async def _hub_update_user_profile( + self, + user_id: str, + first_name: Optional[str], + last_name: Optional[str], + access_token: str, + ) -> None: + """Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea). + + Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no + tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en + HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD. + """ + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/admins/{user_id}" + payload: Dict[str, Any] = {} + if first_name: + payload["first_name"] = first_name + if last_name: + payload["last_name"] = last_name + if not payload: + return + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.patch( + url, + json=payload, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if resp.status_code == 403: + # El usuario no es Hub admin — reintentar con cuenta de servicio + logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id) + service_token = await self._hub_get_service_token() + if service_token: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.patch( + url, + json=payload, + headers={"Authorization": f"Bearer {service_token}"}, + ) + else: + logger.warning( + "Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)" + ) + return + + if resp.status_code not in (200, 204): + logger.warning( + "Hub profile sync failed status=%s body=%s", + resp.status_code, + resp.text[:300], + ) + else: + logger.info("Hub profile sync OK user_id=%s", user_id) + except Exception as exc: + logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc) + diff --git a/backend/core/config.py b/backend/core/config.py index ff90deee..8d84749d 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -41,6 +41,9 @@ class Settings(BaseSettings): # Hub de Aduanasoft — requerido siempre (SaaS y self-hosted) HUB_URL: str = "http://localhost:8001" + # Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak) + HUB_ADMIN_EMAIL: str = "" + HUB_ADMIN_PASSWORD: str = "" @field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before") @classmethod diff --git a/docker-compose.yml b/docker-compose.yml index a6c91756..c433cd5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,6 +69,8 @@ services: - SPOKE_URLS=${SPOKE_URLS:-""} # Hub — URL interna para validación de licencias - HUB_URL=${HUB_URL:-http://host.docker.internal:8001} + - HUB_ADMIN_EMAIL=${HUB_ADMIN_EMAIL:-} + - HUB_ADMIN_PASSWORD=${HUB_ADMIN_PASSWORD:-} - CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio} - S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000} - S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}} diff --git a/frontend/src/lib/components/sidebar/app-sidebar.svelte b/frontend/src/lib/components/sidebar/app-sidebar.svelte index 7ee5bd77..b9774567 100644 --- a/frontend/src/lib/components/sidebar/app-sidebar.svelte +++ b/frontend/src/lib/components/sidebar/app-sidebar.svelte @@ -1,5 +1,6 @@