feat: enhance user avatar handling and synchronization with Workspace

- Updated sidebar component to merge user data from Keycloak and Workspace, including avatar URLs.
- Refactored nav-user component to utilize new avatar resolution logic and display user information more effectively.
- Introduced a new utility function to resolve user avatar URLs, prioritizing Workspace avatars.
- Implemented backend changes to support synchronization of user profile data, including avatar URLs from Workspace.
- Added database migration to include new fields for Workspace profile synchronization in user_tenants.
- Created a new client for fetching user profiles from Workspace.
- Updated dashboard components to reflect changes in user data structure and avatar handling.
- Removed avatar upload functionality from the profile update process, relying on Workspace for avatar management.
- Added tests for avatar resolution logic to ensure correct prioritization of avatar sources.
This commit is contained in:
2026-05-08 17:03:29 -05:00
parent 9fe307bd74
commit 1dbc75107f
21 changed files with 1170 additions and 150 deletions

View File

@@ -63,6 +63,7 @@ class UserInfoResponseDTO(BaseModel):
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
avatar_url: Optional[str] = None
roles: list[str] = []
permissions: list[str] = []

View File

@@ -1,6 +1,7 @@
import logging
import httpx
from typing import Any, Dict
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
from fastapi import HTTPException
@@ -23,6 +24,96 @@ class AuthService:
def __init__(self, db: Session):
self.db = db
@staticmethod
def _clean_text(value: Any) -> Optional[str]:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return None
def _pick_text(self, *candidates: Any) -> Optional[str]:
for candidate in candidates:
value = self._clean_text(candidate)
if value:
return value
return None
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
try:
claims = jwt.get_unverified_claims(access_token)
return claims if isinstance(claims, dict) else {}
except JWTError:
return {}
except Exception:
return {}
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Fallback de datos de usuario consultando el Hub admin API.
Es opcional y no debe romper /me si falla.
"""
if not keycloak_user_id:
return None
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
return None
admin_token = login_resp.json().get("access_token")
if not admin_token:
return None
user_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
if user_resp.status_code == 200:
payload = user_resp.json()
return payload if isinstance(payload, dict) else None
except Exception as exc:
logger.debug("kc_admin_user_lookup_failed: %s", exc)
return None
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
for source in sources:
if not isinstance(source, dict):
continue
direct = self._pick_text(
source.get("avatar_url"),
source.get("avatarUrl"),
source.get("picture"),
source.get("photo"),
)
if direct:
return direct
attrs = source.get("attributes")
if isinstance(attrs, dict):
attr_candidate = attrs.get("avatar_url")
if isinstance(attr_candidate, list) and attr_candidate:
value = self._clean_text(attr_candidate[0])
if value:
return value
if isinstance(attr_candidate, str):
value = self._clean_text(attr_candidate)
if value:
return value
return None
async def login(
self,
login_data: LoginRequestDTO,
@@ -55,6 +146,38 @@ class AuthService:
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "login",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
@@ -102,7 +225,37 @@ class AuthService:
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
@@ -115,9 +268,78 @@ class AuthService:
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
from core.workspace_profile_sync import sync_workspace_profile_for_user
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
return UserInfoResponseDTO(**user_info)
kc_user = self._decode_kc_user_from_token(access_token)
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
needs_admin_fallback = any(
not self._clean_text(user_info.get(field))
for field in ("email", "preferred_username")
) or self._extract_avatar_url(user_info) is None
kc_admin_user = None
if needs_admin_fallback:
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
first_name = self._pick_text(
user_info.get("first_name"),
user_info.get("given_name"),
kc_user.get("given_name"),
kc_user.get("first_name"),
(kc_admin_user or {}).get("firstName"),
(kc_admin_user or {}).get("first_name"),
)
last_name = self._pick_text(
user_info.get("last_name"),
user_info.get("family_name"),
kc_user.get("family_name"),
kc_user.get("last_name"),
(kc_admin_user or {}).get("lastName"),
(kc_admin_user or {}).get("last_name"),
)
full_name = self._pick_text(
f"{first_name} {last_name}" if first_name and last_name else None,
first_name,
last_name,
)
enriched_user_info = dict(user_info)
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
enriched_user_info["email"] = self._pick_text(
user_info.get("email"),
(kc_admin_user or {}).get("email"),
kc_user.get("email"),
)
enriched_user_info["preferred_username"] = self._pick_text(
user_info.get("preferred_username"),
user_info.get("username"),
kc_user.get("preferred_username"),
kc_user.get("username"),
(kc_admin_user or {}).get("username"),
)
enriched_user_info["avatar_url"] = self._extract_avatar_url(
user_info,
kc_user,
kc_admin_user or {},
)
enriched_user_info["name"] = self._pick_text(
user_info.get("name"),
full_name,
kc_user.get("name"),
enriched_user_info.get("preferred_username"),
)
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=enriched_user_info.get("sub"),
tenant_id=enriched_user_info.get("tenant_id"),
workspace_profile=enriched_user_info,
)
return UserInfoResponseDTO(**enriched_user_info)
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""

View File

@@ -2,6 +2,7 @@
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -13,6 +14,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
DateTime,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -56,6 +58,17 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="URL de la imagen de perfil"
)
workspace_user_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
)
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
)
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
)
# 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(

View File

@@ -154,6 +154,7 @@ def get_user_avatar_image(
@router.get("/me/profile", response_model=UserResponseDTO)
async def get_my_profile(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
@@ -180,7 +181,17 @@ async def get_my_profile(
)
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)
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
)
return await service.get_current_user_profile(
keycloak_user_id,
current_user=current_user,
access_token=access_token,
)
@router.put("/me/profile", response_model=UserResponseDTO)
@@ -214,6 +225,14 @@ async def update_my_profile(
status_code=400, detail="User does not belong to any tenant"
)
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
# y no debe mutarse localmente en Anexo76.
if current_user.get("sub"):
raise HTTPException(
status_code=409,
detail="Avatar is managed by Workspace for this user",
)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()

View File

@@ -2,6 +2,7 @@ import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import and_, func
@@ -15,6 +16,30 @@ from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
if not user_tenant or not user_tenant.avatar_url:
return None
avatar_out = str(user_tenant.avatar_url)
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
return avatar_out if _is_valid_http_url(avatar_out) else None
from core.s3_keys import public_user_avatar_api_path
# Entregamos siempre el endpoint público del backend para assets locales/S3.
return public_user_avatar_api_path(
user_tenant.tenant_id,
user_tenant.keycloak_user_id,
)
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
@@ -43,18 +68,20 @@ def _normalize_user(
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
avatar_out = user_tenant.avatar_url
if avatar_out:
from core.s3_keys import public_user_avatar_api_path
workspace_avatar = (
user_tenant.workspace_avatar_url
if _is_valid_http_url(user_tenant.workspace_avatar_url)
else None
)
legacy_avatar = _legacy_avatar_public_url(user_tenant)
avatar_out = workspace_avatar or legacy_avatar
# 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
)
normalized.update(
{
"avatar_url": avatar_out,
"workspace_avatar_url": workspace_avatar,
"legacy_avatar_url": legacy_avatar,
"workspace_user_id": user_tenant.workspace_user_id,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
@@ -629,11 +656,27 @@ class UserService:
"usage_percentage": round(usage_percentage, 2),
}
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
async def get_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
user_info = current_user or {"id": keycloak_user_id}
# Perfil "me": sincronización con cache corto (5 min).
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=keycloak_user_id,
tenant_id=self.tenant_id,
)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()