feat: multi-tenant user delete scope + conditional keycloak wait

- Add GET /users/{user_id}/tenant-count endpoint
- Add scope query param (current|all) to DELETE /users/{user_id}
- UserService.delete_user now supports scope='all' to deactivate/remove
  user from all tenants
- Frontend users.ts: getTenantCount(), delete() accepts scope param
- Frontend +page.svelte: async openDeleteDialog fetches tenant count,
  shows 4-button dialog when user belongs to >1 tenant
- docker-entrypoint.sh: Keycloak wait now conditional on WAIT_FOR_KEYCLOAK=1
This commit is contained in:
2026-05-04 14:17:12 -05:00
parent f241e88e21
commit 7ea8a4ba8d
5 changed files with 178 additions and 26 deletions

View File

@@ -218,6 +218,24 @@ async def update_user_detail(
return user
@router.get("/{user_id}/tenant-count")
async def get_user_tenant_count(
user_id: str,
company_id: int = Query(..., description="Company ID"),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Retorna en cuántos tenants está registrado el usuario.
"""
tenant_id = validate_access_to_resource(
db, company_id, current_user, required_permissions=["user.view"]
)
service = UserService(db, tenant_id, company_id)
count = service.get_user_tenant_count(user_id)
return {"tenant_count": count}
@router.delete("/{user_id}")
async def delete_user_route(
user_id: str,
@@ -226,15 +244,21 @@ async def delete_user_route(
True,
description="Si es True, solo desactiva. Si es False, elimina permanentemente",
),
scope: str = Query(
"current",
description="'current' para borrar solo del tenant activo, 'all' para borrar de todos los tenants",
),
db: Session = Depends(get_core_db),
current_user: dict = Depends(get_current_user),
):
"""
Elimina un usuario del tenant
Elimina un usuario del tenant.
scope='current' (default): solo del tenant activo.
scope='all': de todos los tenants en los que aparece el usuario.
"""
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.delete"])
service = UserService(db, tenant_id, company_id)
await service.delete_user(user_id, soft_delete=soft_delete)
await service.delete_user(user_id, soft_delete=soft_delete, scope=scope)
return {"message": "User deleted successfully"}

View File

@@ -394,8 +394,43 @@ class UserService:
self.db.refresh(user_tenant)
return _normalize_user({"id": user_id}, user_tenant.role, user_tenant)
async def delete_user(self, user_id: str, soft_delete: bool = True) -> None:
"""Elimina/Desactiva usuario"""
def get_user_tenant_count(self, user_id: str) -> int:
"""Cuenta en cuántos tenants está registrado el usuario."""
return (
self.db.query(func.count(UserTenant.id))
.filter(UserTenant.keycloak_user_id == user_id)
.scalar()
or 0
)
async def delete_user(
self, user_id: str, soft_delete: bool = True, scope: str = "current"
) -> None:
"""
Elimina/Desactiva usuario.
scope='current': solo del tenant activo.
scope='all': de todos los tenants (útil cuando el usuario pertenece a múltiples tenants).
"""
if scope == "all":
rows = (
self.db.query(UserTenant)
.filter(UserTenant.keycloak_user_id == user_id)
.all()
)
if not rows:
raise HTTPException(status_code=404, detail="User not found")
if soft_delete:
for row in rows:
row.is_active = False
self.db.commit()
else:
# TODO: Call Hub to delete from Keycloak
for row in rows:
self.db.delete(row)
self.db.commit()
return
# scope == "current" (default)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id)
).first()