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 @@