From 7ea8a4ba8d6d1099e2307b02f1279228fe1b3583 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 4 May 2026 14:17:12 -0500 Subject: [PATCH 1/2] 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 --- backend/api/v1/modules/core/users/routes.py | 28 +++++- backend/api/v1/modules/core/users/service.py | 39 +++++++- backend/docker-entrypoint.sh | 18 +++- frontend/src/lib/api/dashboard/users.ts | 23 ++++- .../src/routes/dashboard/users/+page.svelte | 96 +++++++++++++++---- 5 files changed, 178 insertions(+), 26 deletions(-) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index adc0e862..3d4a4e6c 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -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"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 3d39839c..c847ba3c 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -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() diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 69805452..bcd9d7c4 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -29,8 +29,22 @@ wait_for_tcp() { return 0 } -wait_for_tcp "${CORE_DB_HOST:-postgres-a76}" "${CORE_DB_PORT:-5432}" "PostgreSQL" -wait_for_tcp "keycloak" "8080" "Keycloak" +DB_HOST_PRIMARY="${CORE_DB_HOST:-postgres-a76}" +DB_PORT="${CORE_DB_PORT:-5432}" + +if ! wait_for_tcp "$DB_HOST_PRIMARY" "$DB_PORT" "PostgreSQL"; then + # Fallback para entornos donde Docker solo registra el nombre del contenedor. + if [[ "$DB_HOST_PRIMARY" == "postgres-a76" ]]; then + wait_for_tcp "anexo76-postgres-a76" "$DB_PORT" "PostgreSQL" || true + else + echo " Continuando de todas formas..." + fi +fi + +# Keycloak solo se espera si se habilita explícitamente (ej. entorno Hub completo). +if [[ "${WAIT_FOR_KEYCLOAK:-0}" == "1" ]]; then + wait_for_tcp "${KEYCLOAK_SERVICE_HOST:-keycloak}" "${KEYCLOAK_SERVICE_PORT:-8080}" "Keycloak" || true +fi echo "Iniciando proceso: $*" exec "$@" diff --git a/frontend/src/lib/api/dashboard/users.ts b/frontend/src/lib/api/dashboard/users.ts index f8992b0a..755db998 100644 --- a/frontend/src/lib/api/dashboard/users.ts +++ b/frontend/src/lib/api/dashboard/users.ts @@ -125,14 +125,33 @@ export const usersAPI = { return response.data!; }, + /** + * Retorna en cuántos tenants está registrado el usuario. + */ + async getTenantCount(userId: string, companyId: number): Promise { + const response = await api.get<{ tenant_count: number }>( + `/v1/core/users/${userId}/tenant-count?company_id=${companyId}` + ); + if (response.error) { + throw new Error(response.error); + } + return response.data!.tenant_count; + }, + /** * Elimina un usuario */ - async delete(userId: string, companyId: number, softDelete: boolean = true): Promise { + async delete( + userId: string, + companyId: number, + softDelete: boolean = true, + scope: 'current' | 'all' = 'current' + ): Promise { const queryParams = new URLSearchParams(); queryParams.set('company_id', companyId.toString()); queryParams.set('soft_delete', softDelete.toString()); - + queryParams.set('scope', scope); + const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`); if (response.error) { throw new Error(response.error); diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 6efdc635..5d92a654 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -257,6 +257,10 @@ let showPasswordDialog = $state(false); let showRolesDialog = $state(false); + // Estado de borrado multi-tenant + let deleteUserTenantCount = $state(0); + let deleteLoadingCount = $state(false); + // Estados para gestión de roles let availableRoles = $state([]); let userRoles = $state([]); @@ -631,13 +635,28 @@ } // Abrir diálogo de eliminación - function openDeleteDialog(user: User) { + async function openDeleteDialog(user: User) { selectedUser = user; + deleteUserTenantCount = 0; + deleteLoadingCount = true; showDeleteDialog = true; + try { + const companyId = companyStore.activeCompany?.id; + if (companyId) { + deleteUserTenantCount = await usersAPI.getTenantCount(user.id, companyId); + } + } catch { + deleteUserTenantCount = 1; + } finally { + deleteLoadingCount = false; + } } // Eliminar usuario - async function handleDelete(softDelete: boolean = true) { + async function handleDelete( + softDelete: boolean = true, + scope: 'current' | 'all' = 'current' + ) { if (!selectedUser) return; const companyId = companyStore.activeCompany?.id; @@ -647,9 +666,12 @@ } try { - await usersAPI.delete(selectedUser.id, companyId, softDelete); + await usersAPI.delete(selectedUser.id, companyId, softDelete, scope); + const scopeMsg = scope === 'all' ? ' de todos los tenants' : ''; toast.success( - softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente' + softDelete + ? `Usuario desactivado exitosamente${scopeMsg}` + : `Usuario eliminado permanentemente${scopeMsg}` ); showDeleteDialog = false; selectedUser = null; @@ -2530,24 +2552,62 @@ ¿Eliminar usuario? - ¿Estás seguro de que deseas eliminar a {selectedUser?.username}? + ¿Estás seguro de que deseas eliminar a + {selectedUser?.username}?

- Puedes desactivar el usuario (recomendado) o eliminarlo permanentemente. + {#if deleteLoadingCount} + Verificando membresías... + {:else if deleteUserTenantCount > 1} + + ⚠️ Este usuario pertenece a + {deleteUserTenantCount} tenants. Puedes eliminarlo solo del + tenant actual o de todos. + + {/if}
- + Cancelar - - handleDelete(false)} - class="text-destructive-foreground bg-destructive hover:bg-destructive/90" - > - - Eliminar Permanente - + + {#if !deleteLoadingCount && deleteUserTenantCount > 1} + + + handleDelete(false, 'current')} + class="text-destructive-foreground bg-destructive hover:bg-destructive/90" + > + + Eliminar (este tenant) + + + + handleDelete(false, 'all')} + class="text-destructive-foreground bg-destructive hover:bg-destructive/90" + > + + Eliminar (todos) + + {:else} + + + handleDelete(false, 'current')} + class="text-destructive-foreground bg-destructive hover:bg-destructive/90" + > + + Eliminar Permanente + + {/if} From 5c4161e5d8b65e3f156a6d7ad8a60c6fd3a6aeaf Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 6 May 2026 08:20:39 -0500 Subject: [PATCH 2/2] feat: enhance user deletion process with access token and hub tenant ID propagation --- backend/api/v1/modules/core/users/routes.py | 18 +++- backend/api/v1/modules/core/users/service.py | 98 +++++++++++++++---- .../components/sidebar/team-switcher.svelte | 3 + frontend/src/routes/auth/sso/+page.server.ts | 8 ++ .../src/routes/dashboard/users/+page.svelte | 8 +- 5 files changed, 114 insertions(+), 21 deletions(-) diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 3d4a4e6c..2e9d2289 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -238,6 +238,7 @@ async def get_user_tenant_count( @router.delete("/{user_id}") async def delete_user_route( + request: Request, user_id: str, company_id: int = Query(..., description="Company ID"), soft_delete: bool = Query( @@ -257,8 +258,23 @@ async def delete_user_route( 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"]) + auth_header = request.headers.get("Authorization") or "" + token = ( + auth_header[7:].strip() + if auth_header.lower().startswith("bearer ") + else auth_header.strip() + ) + hub_tid = resolve_hub_tenant_id_for_api( + tenant_id, request.headers.get("X-Tenant-Override") + ) service = UserService(db, tenant_id, company_id) - await service.delete_user(user_id, soft_delete=soft_delete, scope=scope) + await service.delete_user( + user_id, + soft_delete=soft_delete, + scope=scope, + access_token=token or None, + hub_tenant_id=hub_tid, + ) return {"message": "User deleted successfully"} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index c847ba3c..a4ca98bf 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -286,6 +286,10 @@ class UserService: kc = u.get("keycloak_user_id") if not kc: continue + # Filtrar usuarios soft-deleted localmente (is_active=False en user_tenants local) + local_ut_check = local_by_kc.get(kc) + if local_ut_check is not None and not local_ut_check.is_active: + continue if needle: blob = " ".join( [ @@ -395,16 +399,24 @@ class UserService: return _normalize_user({"id": user_id}, user_tenant.role, user_tenant) def get_user_tenant_count(self, user_id: str) -> int: - """Cuenta en cuántos tenants está registrado el usuario.""" + """Cuenta en cuántos tenants activos está registrado el usuario.""" return ( self.db.query(func.count(UserTenant.id)) - .filter(UserTenant.keycloak_user_id == user_id) + .filter( + UserTenant.keycloak_user_id == user_id, + UserTenant.is_active == True, + ) .scalar() or 0 ) async def delete_user( - self, user_id: str, soft_delete: bool = True, scope: str = "current" + self, + user_id: str, + soft_delete: bool = True, + scope: str = "current", + access_token: Optional[str] = None, + hub_tenant_id: Optional[int] = None, ) -> None: """ Elimina/Desactiva usuario. @@ -419,33 +431,81 @@ class UserService: ) 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() + now = datetime.utcnow() + # Collect unique hub_tenant_ids to notify Hub for each tenant + hub_tenant_ids = {row.tenant_id for row in rows} + for row in rows: + row.is_active = False + if not soft_delete: + row.deleted_at = now + self.db.commit() + # Propagate to Hub for every tenant the user belonged to + if access_token: + for tid in hub_tenant_ids: + await self._hub_remove_user(user_id, tid, soft_delete, access_token) return # scope == "current" (default) user_tenant = self.db.query(UserTenant).filter( - and_(UserTenant.keycloak_user_id == user_id, UserTenant.tenant_id == self.tenant_id) + and_( + UserTenant.keycloak_user_id == user_id, + UserTenant.tenant_id == self.tenant_id, + UserTenant.company_id == self.company_id, + ) ).first() if not user_tenant: - raise HTTPException(status_code=404, detail="User not found") - - if soft_delete: - user_tenant.is_active = False + # No local record — user exists in Hub but not synced locally yet. + # Create tombstone so user is filtered from future listings. + user_tenant = UserTenant( + keycloak_user_id=user_id, + tenant_id=self.tenant_id, + company_id=self.company_id, + is_active=False, + deleted_at=None if soft_delete else datetime.utcnow(), + ) + self.db.add(user_tenant) self.db.commit() else: - # TODO: Call Hub to delete from Keycloak - self.db.delete(user_tenant) + user_tenant.is_active = False + if not soft_delete: + user_tenant.deleted_at = datetime.utcnow() self.db.commit() + # Propagate to Hub + if access_token and hub_tenant_id: + await self._hub_remove_user(user_id, hub_tenant_id, soft_delete, access_token) + + async def _hub_remove_user( + self, + user_id: str, + hub_tenant_id: int, + soft_delete: bool, + access_token: str, + ) -> None: + """Calls Hub POST /api/v1/hub/user-tenants/remove to sync the deletion.""" + base = (settings.HUB_URL or "").rstrip("/") + url = f"{base}/api/v1/hub/user-tenants/remove" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + url, + json={ + "keycloak_user_id": user_id, + "tenant_id": hub_tenant_id, + "soft_delete": soft_delete, + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if resp.status_code >= 400: + logger.warning( + "Hub remove user-tenant returned %s for user %s tenant %s: %s", + resp.status_code, user_id, hub_tenant_id, resp.text[:200], + ) + except Exception as exc: + logger.error("Error calling Hub remove user-tenant: %s", exc) + # Do not raise — local deletion already committed; Hub sync is best-effort. + async def change_password(self, user_id: str, password: str, temporary: bool = True) -> None: """Cambia contraseña vía Hub""" try: diff --git a/frontend/src/lib/components/sidebar/team-switcher.svelte b/frontend/src/lib/components/sidebar/team-switcher.svelte index c18b3458..35dca939 100644 --- a/frontend/src/lib/components/sidebar/team-switcher.svelte +++ b/frontend/src/lib/components/sidebar/team-switcher.svelte @@ -62,6 +62,9 @@ if (res.ok) { companyStore.clear(); await invalidateAll(); + // Reinicializar el store con las compañías del nuevo tenant + // (loadCompanies sin args hace fetch al backend que ya tiene la nueva cookie de tenant) + await companyStore.loadCompanies(); } else { const err = await res.json().catch(() => ({})); console.error('[team-switcher] switch-tenant error:', err); diff --git a/frontend/src/routes/auth/sso/+page.server.ts b/frontend/src/routes/auth/sso/+page.server.ts index 83df5cc7..13175323 100644 --- a/frontend/src/routes/auth/sso/+page.server.ts +++ b/frontend/src/routes/auth/sso/+page.server.ts @@ -16,6 +16,14 @@ export const load: PageServerLoad = async ({ url, cookies }) => { throw redirect(303, '/login?error=sso_missing_token'); } + // If there is already a valid session, skip the exchange to avoid + // re-using a one-time relay token (e.g. browser tab reload or prefetch). + const existingToken = cookies.get('access_token') || cookies.get('access_token_0'); + if (existingToken) { + console.log('[SSO] sesión existente detectada, redirigiendo sin exchange'); + throw redirect(303, '/dashboard'); + } + // SSO exchange must call Hub backend, not Anexo76 backend. // Use INTERNAL_HUB_URL for server-to-server communication. let hubUrl = process.env.INTERNAL_HUB_URL; diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 5d92a654..c309cf2d 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -2569,7 +2569,13 @@ Cancelar - {#if !deleteLoadingCount && deleteUserTenantCount > 1} + {#if deleteLoadingCount} + + + {:else if deleteUserTenantCount > 1}