mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
feat(ui): Add user expulsion from tenants with JWT authentication fix (#10787)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
This commit is contained in:
+9
-1
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.26.0] (Prowler UNRELEASED)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.3] (Prowler v5.24.3)
|
||||
|
||||
### 🐞 Fixed
|
||||
@@ -20,7 +28,6 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
- `/finding-groups/latest/<check_id>/resources` now selects the latest completed scan per provider by `-completed_at` (then `-inserted_at`) instead of `-inserted_at`, matching the `/finding-groups/latest` summary path and the daily-summary upsert so overlapping scans no longer produce diverging `delta`/`new_count` between the two endpoints [(#10802)](https://github.com/prowler-cloud/prowler/pull/10802)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.1] (Prowler v5.24.1)
|
||||
|
||||
@@ -34,6 +41,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
- Attack Paths: Missing `tenant_id` filter while getting related findings after scan completes [(#10722)](https://github.com/prowler-cloud/prowler/pull/10722)
|
||||
- Finding group counters `pass_count`, `fail_count` and `manual_count` now exclude muted findings [(#10753)](https://github.com/prowler-cloud/prowler/pull/10753)
|
||||
- Silent data loss in `ResourceFindingMapping` bulk insert that left findings orphaned when `INSERT ... ON CONFLICT DO NOTHING` dropped rows without raising; added explicit `unique_fields` [(#10724)](https://github.com/prowler-cloud/prowler/pull/10724)
|
||||
- `DELETE /tenants/{tenant_pk}/memberships/{id}` now deletes the expelled user's account when the removed membership was their last one, and blacklists every outstanding refresh token for that user so their existing sessions can no longer mint new access tokens [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -330,6 +330,7 @@ class MembershipFilter(FilterSet):
|
||||
model = Membership
|
||||
fields = {
|
||||
"tenant": ["exact"],
|
||||
"user": ["exact"],
|
||||
"role": ["exact"],
|
||||
"date_joined": ["date", "gte", "lte"],
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
OutstandingToken,
|
||||
)
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from api.attack_paths import (
|
||||
AttackPathsQueryDefinition,
|
||||
@@ -47,6 +52,7 @@ from api.models import (
|
||||
Finding,
|
||||
Integration,
|
||||
Invitation,
|
||||
InvitationRoleRelationship,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
LighthouseTenantConfiguration,
|
||||
@@ -746,6 +752,39 @@ class TestTenantViewSet:
|
||||
# Test user + 2 extra users for tenant 2
|
||||
assert len(response.json()["data"]) == 3
|
||||
|
||||
def test_tenants_list_memberships_filter_by_user(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2, _ = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
|
||||
{"filter[user]": str(user3.id)},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
data = response.json()["data"]
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == str(membership3.id)
|
||||
|
||||
def test_tenants_list_memberships_filter_by_user_no_match(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2, _ = tenants_fixture
|
||||
unrelated_user = User.objects.create_user(
|
||||
name="unrelated",
|
||||
password=TEST_PASSWORD,
|
||||
email="unrelated@gmail.com",
|
||||
)
|
||||
|
||||
response = authenticated_client.get(
|
||||
reverse("tenant-membership-list", kwargs={"tenant_pk": tenant2.id}),
|
||||
{"filter[user]": str(unrelated_user.id)},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["data"] == []
|
||||
|
||||
def test_tenants_list_memberships_as_member(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
@@ -803,6 +842,7 @@ class TestTenantViewSet:
|
||||
):
|
||||
_, tenant2, _ = tenants_fixture
|
||||
user_membership = Membership.objects.get(tenant=tenant2, user__email=TEST_USER)
|
||||
user_id = user_membership.user_id
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
@@ -811,6 +851,127 @@ class TestTenantViewSet:
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert Membership.objects.filter(id=user_membership.id).exists()
|
||||
assert User.objects.filter(id=user_id).exists()
|
||||
|
||||
def test_expel_user_deletes_account_if_last_membership(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
# TEST_USER is OWNER of tenant2; user3 is MEMBER only in tenant2
|
||||
_, tenant2, _ = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
assert Membership.objects.filter(user=user3).count() == 1
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Membership.objects.filter(id=membership3.id).exists()
|
||||
assert not User.objects.filter(id=user3.id).exists()
|
||||
|
||||
def test_expel_user_blacklists_refresh_tokens(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
_, tenant2, _ = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
# Issue two refresh tokens to simulate active sessions
|
||||
RefreshToken.for_user(user3)
|
||||
RefreshToken.for_user(user3)
|
||||
outstanding_ids = list(
|
||||
OutstandingToken.objects.filter(user=user3).values_list("id", flat=True)
|
||||
)
|
||||
assert len(outstanding_ids) == 2
|
||||
assert not BlacklistedToken.objects.filter(
|
||||
token_id__in=outstanding_ids
|
||||
).exists()
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert (
|
||||
BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 2
|
||||
)
|
||||
|
||||
def test_expel_user_blacklists_refresh_tokens_is_idempotent(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
# Regression test for the bulk blacklisting path: if one of the
|
||||
# user's refresh tokens is already blacklisted when the expel
|
||||
# endpoint runs, the remaining tokens must still be blacklisted
|
||||
# and the already-blacklisted one must not be duplicated.
|
||||
tenant1, tenant2, _ = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
# Keep the user alive after the expel so the assertions below can
|
||||
# still query OutstandingToken by user_id.
|
||||
Membership.objects.create(
|
||||
user=user3,
|
||||
tenant=tenant1,
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
)
|
||||
|
||||
RefreshToken.for_user(user3)
|
||||
RefreshToken.for_user(user3)
|
||||
outstanding_ids = list(
|
||||
OutstandingToken.objects.filter(user=user3).values_list("id", flat=True)
|
||||
)
|
||||
assert len(outstanding_ids) == 2
|
||||
|
||||
# Pre-blacklist one of the two tokens to simulate a prior revocation.
|
||||
BlacklistedToken.objects.create(token_id=outstanding_ids[0])
|
||||
assert (
|
||||
BlacklistedToken.objects.filter(token_id__in=outstanding_ids).count() == 1
|
||||
)
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
blacklisted = BlacklistedToken.objects.filter(token_id__in=outstanding_ids)
|
||||
assert blacklisted.count() == 2
|
||||
assert set(blacklisted.values_list("token_id", flat=True)) == set(
|
||||
outstanding_ids
|
||||
)
|
||||
|
||||
def test_expel_user_keeps_account_if_has_other_memberships(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
):
|
||||
tenant1, tenant2, _ = tenants_fixture
|
||||
_, user3_membership = extra_users
|
||||
user3, membership3 = user3_membership
|
||||
|
||||
# Give user3 an additional membership in tenant1 so they are not orphaned
|
||||
other_membership = Membership.objects.create(
|
||||
user=user3,
|
||||
tenant=tenant1,
|
||||
role=Membership.RoleChoices.MEMBER,
|
||||
)
|
||||
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership3.id},
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not Membership.objects.filter(id=membership3.id).exists()
|
||||
assert User.objects.filter(id=user3.id).exists()
|
||||
assert Membership.objects.filter(id=other_membership.id).exists()
|
||||
|
||||
def test_tenants_delete_another_membership_as_owner(
|
||||
self, authenticated_client, tenants_fixture, extra_users
|
||||
@@ -882,6 +1043,128 @@ class TestTenantViewSet:
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert Membership.objects.filter(id=other_membership.id).exists()
|
||||
|
||||
def test_delete_membership_cleans_up_orphaned_role_grants(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
"""Test that deleting a membership removes UserRoleRelationship records
|
||||
for that tenant while preserving grants in other tenants."""
|
||||
tenant1, tenant2, _ = tenants_fixture
|
||||
|
||||
# Create a user with memberships in both tenants
|
||||
user = User.objects.create_user(
|
||||
name="Multi-tenant User",
|
||||
password=TEST_PASSWORD,
|
||||
email="multitenant@test.com",
|
||||
)
|
||||
|
||||
# Create memberships in both tenants
|
||||
Membership.objects.create(
|
||||
user=user, tenant=tenant1, role=Membership.RoleChoices.MEMBER
|
||||
)
|
||||
membership2 = Membership.objects.create(
|
||||
user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
# Create roles in both tenants
|
||||
role1 = Role.objects.create(
|
||||
name="Test Role 1", tenant=tenant1, manage_providers=True
|
||||
)
|
||||
role2 = Role.objects.create(
|
||||
name="Test Role 2", tenant=tenant2, manage_scans=True
|
||||
)
|
||||
|
||||
# Create user role relationships for both tenants
|
||||
UserRoleRelationship.objects.create(user=user, role=role1, tenant=tenant1)
|
||||
UserRoleRelationship.objects.create(user=user, role=role2, tenant=tenant2)
|
||||
|
||||
# Verify initial state
|
||||
assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists()
|
||||
assert UserRoleRelationship.objects.filter(user=user, tenant=tenant2).exists()
|
||||
assert Role.objects.filter(id=role1.id).exists()
|
||||
assert Role.objects.filter(id=role2.id).exists()
|
||||
|
||||
# Delete membership from tenant2 (authenticated user is owner of tenant2)
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership2.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
# Verify the membership was deleted
|
||||
assert not Membership.objects.filter(id=membership2.id).exists()
|
||||
|
||||
# Verify UserRoleRelationship for tenant2 was deleted
|
||||
assert not UserRoleRelationship.objects.filter(
|
||||
user=user, tenant=tenant2
|
||||
).exists()
|
||||
|
||||
# Verify UserRoleRelationship for tenant1 is preserved
|
||||
assert UserRoleRelationship.objects.filter(user=user, tenant=tenant1).exists()
|
||||
|
||||
# Verify orphaned role2 was deleted (no more user or invitation relationships)
|
||||
assert not Role.objects.filter(id=role2.id).exists()
|
||||
|
||||
# Verify role1 is preserved (still has user relationship)
|
||||
assert Role.objects.filter(id=role1.id).exists()
|
||||
|
||||
# Verify the user still exists (has other memberships)
|
||||
assert User.objects.filter(id=user.id).exists()
|
||||
|
||||
def test_delete_membership_preserves_role_with_invitation_relationship(
|
||||
self, authenticated_client, tenants_fixture
|
||||
):
|
||||
"""Test that roles are not deleted if they have invitation relationships."""
|
||||
_, tenant2, _ = tenants_fixture
|
||||
|
||||
# Create a user with membership
|
||||
user = User.objects.create_user(
|
||||
name="Test User", password=TEST_PASSWORD, email="testuser@test.com"
|
||||
)
|
||||
membership = Membership.objects.create(
|
||||
user=user, tenant=tenant2, role=Membership.RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
# Create a role and user relationship
|
||||
role = Role.objects.create(
|
||||
name="Shared Role", tenant=tenant2, manage_providers=True
|
||||
)
|
||||
UserRoleRelationship.objects.create(user=user, role=role, tenant=tenant2)
|
||||
|
||||
# Create an invitation with the same role
|
||||
invitation = Invitation.objects.create(email="pending@test.com", tenant=tenant2)
|
||||
InvitationRoleRelationship.objects.create(
|
||||
invitation=invitation, role=role, tenant=tenant2
|
||||
)
|
||||
|
||||
# Verify initial state
|
||||
assert UserRoleRelationship.objects.filter(user=user, role=role).exists()
|
||||
assert InvitationRoleRelationship.objects.filter(
|
||||
invitation=invitation, role=role
|
||||
).exists()
|
||||
assert Role.objects.filter(id=role.id).exists()
|
||||
|
||||
# Delete the membership
|
||||
response = authenticated_client.delete(
|
||||
reverse(
|
||||
"tenant-membership-detail",
|
||||
kwargs={"tenant_pk": tenant2.id, "pk": membership.id},
|
||||
)
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
# Verify UserRoleRelationship was deleted
|
||||
assert not UserRoleRelationship.objects.filter(user=user, role=role).exists()
|
||||
|
||||
# Verify role is preserved because invitation relationship exists
|
||||
assert Role.objects.filter(id=role.id).exists()
|
||||
assert InvitationRoleRelationship.objects.filter(
|
||||
invitation=invitation, role=role
|
||||
).exists()
|
||||
|
||||
def test_tenants_list_no_permissions(
|
||||
self, authenticated_client_no_permissions_rbac, tenants_fixture
|
||||
):
|
||||
|
||||
@@ -83,6 +83,10 @@ from rest_framework.permissions import SAFE_METHODS
|
||||
from rest_framework_json_api import filters as jsonapi_filters
|
||||
from rest_framework_json_api.views import RelationshipView, Response
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from rest_framework_simplejwt.token_blacklist.models import (
|
||||
BlacklistedToken,
|
||||
OutstandingToken,
|
||||
)
|
||||
from tasks.beat import schedule_provider_scan
|
||||
from tasks.jobs.attack_paths import db_utils as attack_paths_db_utils
|
||||
from tasks.jobs.export import get_s3_client
|
||||
@@ -169,6 +173,7 @@ from api.models import (
|
||||
FindingGroupDailySummary,
|
||||
Integration,
|
||||
Invitation,
|
||||
InvitationRoleRelationship,
|
||||
LighthouseConfiguration,
|
||||
LighthouseProviderConfiguration,
|
||||
LighthouseProviderModels,
|
||||
@@ -1330,9 +1335,11 @@ class MembershipViewSet(BaseTenantViewset):
|
||||
),
|
||||
destroy=extend_schema(
|
||||
summary="Delete tenant memberships",
|
||||
description="Delete the membership details of users in a tenant. You need to be one of the owners to delete a "
|
||||
"membership that is not yours. If you are the last owner of a tenant, you cannot delete your own "
|
||||
"membership.",
|
||||
description="Delete a user's membership from a tenant. This action: (1) removes the membership, "
|
||||
"(2) revokes all refresh tokens for the expelled user, (3) removes their role grants for this tenant, "
|
||||
"(4) cleans up orphaned roles, and (5) deletes the user account if this was their last membership. "
|
||||
"You must be a tenant owner to delete another user's membership. The last owner of a tenant cannot "
|
||||
"delete their own membership.",
|
||||
tags=["Tenant"],
|
||||
),
|
||||
)
|
||||
@@ -1341,6 +1348,7 @@ class TenantMembersViewSet(BaseTenantViewset):
|
||||
http_method_names = ["get", "delete"]
|
||||
serializer_class = MembershipSerializer
|
||||
queryset = Membership.objects.none()
|
||||
filterset_class = MembershipFilter
|
||||
# Authorization is handled by get_requesting_membership (owner/member checks),
|
||||
# not by RBAC, since the target tenant differs from the JWT tenant.
|
||||
required_permissions = []
|
||||
@@ -1398,7 +1406,84 @@ class TenantMembersViewSet(BaseTenantViewset):
|
||||
"You do not have permission to delete this membership."
|
||||
)
|
||||
|
||||
membership_to_delete.delete()
|
||||
user_to_check_id = membership_to_delete.user_id
|
||||
tenant_id = membership_to_delete.tenant_id
|
||||
# All writes run on the admin connection so that the uncommitted
|
||||
# membership delete is visible to the subsequent "other memberships"
|
||||
# check. Splitting the delete and the check across the default
|
||||
# (prowler_user, RLS) and admin connections caused the admin side to
|
||||
# miss the just-deleted row and leave the User row orphaned.
|
||||
with transaction.atomic(using=MainRouter.admin_db):
|
||||
Membership.objects.using(MainRouter.admin_db).filter(
|
||||
id=membership_to_delete.id
|
||||
).delete()
|
||||
|
||||
# Remove role grants for this user in this tenant to prevent
|
||||
# orphaned permissions that could allow access after expulsion
|
||||
deleted_role_relationships = UserRoleRelationship.objects.using(
|
||||
MainRouter.admin_db
|
||||
).filter(user_id=user_to_check_id, tenant_id=tenant_id)
|
||||
|
||||
# Collect role IDs that might become orphaned after deletion
|
||||
role_ids_to_check = list(
|
||||
deleted_role_relationships.values_list("role_id", flat=True)
|
||||
)
|
||||
|
||||
# Delete the user role relationships for this tenant
|
||||
deleted_role_relationships.delete()
|
||||
|
||||
# Clean up orphaned roles that have no remaining user or invitation relationships
|
||||
if role_ids_to_check:
|
||||
for role_id in role_ids_to_check:
|
||||
has_user_relationships = (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role_id=role_id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
has_invitation_relationships = (
|
||||
InvitationRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role_id=role_id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
if not has_user_relationships and not has_invitation_relationships:
|
||||
Role.objects.using(MainRouter.admin_db).filter(
|
||||
id=role_id
|
||||
).delete()
|
||||
|
||||
# Revoke any refresh tokens the expelled user still holds so they
|
||||
# cannot mint fresh access tokens. This must happen before the
|
||||
# User row is deleted, because OutstandingToken.user is
|
||||
# on_delete=SET_NULL in djangorestframework-simplejwt 5.5.1
|
||||
# (see rest_framework_simplejwt/token_blacklist/models.py): once
|
||||
# the user row is gone, user_id becomes NULL and we can no longer
|
||||
# look up that user's outstanding tokens. Access tokens already
|
||||
# issued remain valid until SIMPLE_JWT["ACCESS_TOKEN_LIFETIME"]
|
||||
# expires.
|
||||
outstanding_token_ids = list(
|
||||
OutstandingToken.objects.using(MainRouter.admin_db)
|
||||
.filter(user_id=user_to_check_id)
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
if outstanding_token_ids:
|
||||
BlacklistedToken.objects.using(MainRouter.admin_db).bulk_create(
|
||||
[
|
||||
BlacklistedToken(token_id=token_id)
|
||||
for token_id in outstanding_token_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
has_other_memberships = (
|
||||
Membership.objects.using(MainRouter.admin_db)
|
||||
.filter(user_id=user_to_check_id)
|
||||
.exists()
|
||||
)
|
||||
if not has_other_memberships:
|
||||
User.objects.using(MainRouter.admin_db).filter(
|
||||
id=user_to_check_id
|
||||
).delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -140,6 +140,34 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ
|
||||
|
||||
</Note>
|
||||
|
||||
## Expelling a User From an Organization
|
||||
|
||||
Organization owners can expel a member from the organization. Expelling removes the membership immediately, revoking access to all providers, scans, and findings scoped to that organization. Owners expelling themselves are blocked if they are the last remaining owner of the organization.
|
||||
|
||||
To expel a user:
|
||||
|
||||
1. Navigate to the **Users** page.
|
||||
|
||||
2. Locate the user to remove and open the row actions menu.
|
||||
|
||||
3. Select **Expel user**.
|
||||
|
||||
<img src="/images/prowler-app/multi-tenant/expel-user-organization.png" alt="Users table row action menu showing the 'Expel user' destructive option" width="700" />
|
||||
|
||||
|
||||
4. Confirm the action in the dialog. The membership is removed immediately and the expelled user loses access to the organization.
|
||||
|
||||
<img src="/images/prowler-app/multi-tenant/expel-user-organization-modal.png" alt="Confirmation dialog asking to expel the selected user from the current organization" width="700" />
|
||||
|
||||
|
||||
<Warning>
|
||||
Expelling a user revokes any refresh tokens the account holds, but access tokens already issued remain valid until they expire. The default access token lifetime is 30 minutes, so an expelled user may retain access to the organization for up to that window before being fully locked out.
|
||||
</Warning>
|
||||
|
||||
<Warning>
|
||||
If the expelled organization was the user's **only** organization, the account is permanently deleted along with the membership. All personal profile data associated with that account is removed and cannot be recovered. To preserve the account, confirm that the user belongs to another organization before expelling.
|
||||
</Warning>
|
||||
|
||||
## Permissions Reference
|
||||
|
||||
| Action | Required Conditions |
|
||||
@@ -149,3 +177,4 @@ Invitations expire after 7 days. If an invitation has expired, contact the organ
|
||||
| Switch organizations | Any authenticated user |
|
||||
| Edit organization name | Organization owner with **Manage Account** permission |
|
||||
| Delete an organization | Organization owner with **Manage Account** permission; must belong to more than one organization |
|
||||
| Expel a user from an organization | Organization owner (no additional permission required); last remaining owner cannot expel themselves |
|
||||
|
||||
@@ -6,6 +6,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Allows tenant owners to expel users from their organizations [(#10787)](https://github.com/prowler-cloud/prowler/pull/10787)
|
||||
|
||||
### ❌ Removed
|
||||
|
||||
- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767)
|
||||
- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797)
|
||||
|
||||
|
||||
+230
-25
@@ -2,17 +2,64 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
TENANT_MEMBERSHIP_ROLE,
|
||||
type TenantMembershipRole,
|
||||
} from "@/types/users";
|
||||
|
||||
const getUsersSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
query: z.string().default(""),
|
||||
sort: z.string().optional().default(""),
|
||||
filters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.array(z.string()), z.number()]).optional(),
|
||||
)
|
||||
.default({}),
|
||||
pageSize: z.coerce.number().int().min(1).default(10),
|
||||
});
|
||||
|
||||
const updateUserSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
name: z.string().min(1).optional(),
|
||||
email: z.email().optional(),
|
||||
company_name: z.string().optional(),
|
||||
password: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const deleteUserSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
});
|
||||
|
||||
const removeUserFromTenantSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
tenantId: z.uuid(),
|
||||
});
|
||||
|
||||
const updateUserRoleSchema = z.object({
|
||||
userId: z.uuid(),
|
||||
roleId: z.uuid(),
|
||||
});
|
||||
|
||||
type GetUsersInput = z.input<typeof getUsersSchema>;
|
||||
type UpdateUserData = z.infer<typeof updateUserSchema>;
|
||||
type UserAttributes = Omit<UpdateUserData, "userId">;
|
||||
type MembershipResource = { id: string };
|
||||
|
||||
export const getUsers = async (rawParams: Partial<GetUsersInput> = {}) => {
|
||||
const parsed = getUsersSchema.safeParse(rawParams);
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid getUsers params:", parsed.error.flatten());
|
||||
return undefined;
|
||||
}
|
||||
const { page, query, sort, filters, pageSize } = parsed.data;
|
||||
|
||||
export const getUsers = async ({
|
||||
page = 1,
|
||||
query = "",
|
||||
sort = "",
|
||||
filters = {},
|
||||
pageSize = 10,
|
||||
}) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1) redirect("/users?include=roles");
|
||||
@@ -46,22 +93,29 @@ export const getUsers = async ({
|
||||
export const updateUser = async (formData: FormData) => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
const userId = formData.get("userId") as string; // Ensure userId is a string
|
||||
const userName = formData.get("name") as string | null;
|
||||
const userPassword = formData.get("password") as string | null;
|
||||
const userEmail = formData.get("email") as string | null;
|
||||
const userCompanyName = formData.get("company_name") as string | null;
|
||||
const rawData = {
|
||||
userId: formData.get("userId"),
|
||||
name: formData.get("name") ?? undefined,
|
||||
email: formData.get("email") ?? undefined,
|
||||
company_name: formData.get("company_name") ?? undefined,
|
||||
password: formData.get("password") ?? undefined,
|
||||
};
|
||||
const parsed = updateUserSchema.safeParse(rawData);
|
||||
if (!parsed.success) {
|
||||
return { error: "Invalid user data" };
|
||||
}
|
||||
const { userId, name, email, company_name, password } = parsed.data;
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/users/${userId}`);
|
||||
|
||||
// Prepare attributes to send based on changes
|
||||
const attributes: Record<string, any> = {};
|
||||
const attributes: UserAttributes = {};
|
||||
|
||||
// Add only changed fields
|
||||
if (userName !== null) attributes.name = userName;
|
||||
if (userEmail !== null) attributes.email = userEmail;
|
||||
if (userCompanyName !== null) attributes.company_name = userCompanyName;
|
||||
if (userPassword !== null) attributes.password = userPassword;
|
||||
if (name !== undefined) attributes.name = name;
|
||||
if (email !== undefined) attributes.email = email;
|
||||
if (company_name !== undefined) attributes.company_name = company_name;
|
||||
if (password !== undefined) attributes.password = password;
|
||||
|
||||
// If no fields have changed, don't send the request
|
||||
if (Object.keys(attributes).length === 0) {
|
||||
@@ -90,13 +144,14 @@ export const updateUser = async (formData: FormData) => {
|
||||
export const updateUserRole = async (formData: FormData) => {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
|
||||
const userId = formData.get("userId") as string;
|
||||
const roleId = formData.get("roleId") as string;
|
||||
|
||||
// Validate required fields
|
||||
if (!userId || !roleId) {
|
||||
const parsed = updateUserRoleSchema.safeParse({
|
||||
userId: formData.get("userId"),
|
||||
roleId: formData.get("roleId"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { error: "userId and roleId are required" };
|
||||
}
|
||||
const { userId, roleId } = parsed.data;
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/users/${userId}/relationships/roles`);
|
||||
|
||||
@@ -124,11 +179,11 @@ export const updateUserRole = async (formData: FormData) => {
|
||||
|
||||
export const deleteUser = async (formData: FormData) => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const userId = formData.get("userId");
|
||||
|
||||
if (!userId) {
|
||||
const parsed = deleteUserSchema.safeParse({ userId: formData.get("userId") });
|
||||
if (!parsed.success) {
|
||||
return { error: "User ID is required" };
|
||||
}
|
||||
const { userId } = parsed.data;
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/users/${userId}`);
|
||||
|
||||
@@ -158,6 +213,156 @@ export const deleteUser = async (formData: FormData) => {
|
||||
}
|
||||
};
|
||||
|
||||
interface ServerActionErrorDetail {
|
||||
detail: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface ServerActionErrorResponse {
|
||||
errors: ServerActionErrorDetail[];
|
||||
}
|
||||
|
||||
interface RemoveUserFromTenantSuccess {
|
||||
success: true;
|
||||
}
|
||||
|
||||
type RemoveUserFromTenantResult =
|
||||
| RemoveUserFromTenantSuccess
|
||||
| ServerActionErrorResponse;
|
||||
|
||||
const toErrorResponse = (detail: string): ServerActionErrorResponse => ({
|
||||
errors: [{ detail }],
|
||||
});
|
||||
|
||||
export const removeUserFromTenant = async (
|
||||
formData: FormData,
|
||||
): Promise<RemoveUserFromTenantResult> => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const parsed = removeUserFromTenantSchema.safeParse({
|
||||
userId: formData.get("userId"),
|
||||
tenantId: formData.get("tenantId"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return toErrorResponse("userId and tenantId are required");
|
||||
}
|
||||
const { userId, tenantId } = parsed.data;
|
||||
|
||||
// Resolve the target user's membership id for the current tenant on the
|
||||
// server so the client form can open instantly without a prefetch.
|
||||
//
|
||||
// We cannot use `/users/{userId}/memberships` here: that endpoint ignores
|
||||
// the path user id and always returns the authenticated user's memberships,
|
||||
// which would make us try to delete the caller's own membership.
|
||||
const listUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`);
|
||||
listUrl.searchParams.append("filter[user]", userId);
|
||||
listUrl.searchParams.append("page[size]", "1");
|
||||
|
||||
let targetMembershipId: string | null = null;
|
||||
try {
|
||||
const listResponse = await fetch(listUrl.toString(), { headers });
|
||||
if (!listResponse.ok) {
|
||||
const errorData = await listResponse.json().catch(() => ({}));
|
||||
return {
|
||||
errors: errorData.errors ?? [
|
||||
{ detail: "Failed to resolve the user's membership" },
|
||||
],
|
||||
};
|
||||
}
|
||||
const listData = (await listResponse.json()) as {
|
||||
data?: MembershipResource[];
|
||||
};
|
||||
targetMembershipId = listData?.data?.[0]?.id ?? null;
|
||||
} catch (error) {
|
||||
const handled = handleApiError(error);
|
||||
return toErrorResponse(
|
||||
handled?.error ?? "Failed to resolve the user's membership",
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetMembershipId) {
|
||||
return toErrorResponse(
|
||||
"This user is not a member of the current organization.",
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/tenants/${tenantId}/memberships/${targetMembershipId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return {
|
||||
errors: errorData.errors ?? [
|
||||
{ detail: "Failed to expel the user from the organization" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
revalidatePath("/users");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const handled = handleApiError(error);
|
||||
return toErrorResponse(
|
||||
handled?.error ?? "Failed to expel the user from the organization",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
interface MembershipAttributesResource {
|
||||
id: string;
|
||||
attributes?: {
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active user's role inside the current tenant by querying the
|
||||
* tenant memberships list with `filter[user]`. Returns `null` if the role
|
||||
* cannot be determined (missing session, API error, or no match), so the
|
||||
* caller can default-deny the destructive UI action.
|
||||
*/
|
||||
export const getCurrentUserTenantRole =
|
||||
async (): Promise<TenantMembershipRole | null> => {
|
||||
const session = await auth();
|
||||
const userId = session?.userId;
|
||||
const tenantId = session?.tenantId;
|
||||
if (!userId || !tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/tenants/${tenantId}/memberships`);
|
||||
url.searchParams.append("filter[user]", userId);
|
||||
url.searchParams.append("page[size]", "1");
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const body = (await response.json()) as {
|
||||
data?: MembershipAttributesResource[];
|
||||
};
|
||||
const role = body?.data?.[0]?.attributes?.role;
|
||||
if (
|
||||
role === TENANT_MEMBERSHIP_ROLE.Owner ||
|
||||
role === TENANT_MEMBERSHIP_ROLE.Member
|
||||
) {
|
||||
return role;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("Error resolving current user's tenant role:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserInfo = async () => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
|
||||
@@ -2,7 +2,8 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getRoles } from "@/actions/roles/roles";
|
||||
import { getUsers } from "@/actions/users/users";
|
||||
import { getCurrentUserTenantRole, getUsers } from "@/actions/users/users";
|
||||
import { auth } from "@/auth.config";
|
||||
import { FilterControls } from "@/components/filters";
|
||||
import { AddIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
@@ -10,6 +11,7 @@ import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { ColumnsUser, SkeletonTableUser } from "@/components/users/table";
|
||||
import { Role, SearchParamsProps, UserProps } from "@/types";
|
||||
import { TENANT_MEMBERSHIP_ROLE } from "@/types/users";
|
||||
|
||||
export default async function Users({
|
||||
searchParams,
|
||||
@@ -58,19 +60,24 @@ const SSRDataTable = async ({
|
||||
// Extract query from filters
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
const usersData = await getUsers({ query, page, sort, filters, pageSize });
|
||||
const rolesData = await getRoles({});
|
||||
const [usersData, rolesData, currentTenantRole, session] = await Promise.all([
|
||||
getUsers({ query, page, sort, filters, pageSize }),
|
||||
getRoles({}),
|
||||
getCurrentUserTenantRole(),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
const currentUserId = session?.userId;
|
||||
const currentTenantId = session?.tenantId;
|
||||
const isCurrentUserOwner = currentTenantRole === TENANT_MEMBERSHIP_ROLE.Owner;
|
||||
|
||||
// Create a dictionary for roles by user ID
|
||||
const roleDict = (usersData?.included || []).reduce(
|
||||
(acc: Record<string, any>, item: Role) => {
|
||||
if (item.type === "roles") {
|
||||
acc[item.id] = item.attributes;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Role>,
|
||||
);
|
||||
const roleDict: Record<string, Role["attributes"]> = {};
|
||||
for (const item of (usersData?.included || []) as Role[]) {
|
||||
if (item.type === "roles") {
|
||||
roleDict[item.id] = item.attributes;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the array of roles with all the roles available
|
||||
const roles = Array.from(
|
||||
@@ -88,6 +95,11 @@ const SSRDataTable = async ({
|
||||
const roleId = user?.relationships?.roles?.data?.[0]?.id;
|
||||
const role = roleDict?.[roleId] || null;
|
||||
|
||||
// Gate the "Expel" action server-side: only tenant owners may expel,
|
||||
// and never against themselves (self-leave lives elsewhere).
|
||||
const canBeExpelled =
|
||||
isCurrentUserOwner && !!currentTenantId && user.id !== currentUserId;
|
||||
|
||||
return {
|
||||
...user,
|
||||
attributes: {
|
||||
@@ -95,6 +107,8 @@ const SSRDataTable = async ({
|
||||
role,
|
||||
},
|
||||
roles,
|
||||
canBeExpelled,
|
||||
currentTenantId: canBeExpelled ? currentTenantId : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+4
-2
@@ -15,7 +15,8 @@ import { apiBaseUrl } from "./lib";
|
||||
import type { RolePermissionAttributes } from "./types/users";
|
||||
|
||||
interface CustomJwtPayload extends JwtPayload {
|
||||
user_id: string;
|
||||
user_id?: string; // Optional - doesn't actually exist in JWT tokens
|
||||
sub: string; // Standard JWT subject field - contains the actual user ID
|
||||
tenant_id: string;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,8 @@ const applyDecodedClaims = (
|
||||
target.accessTokenExpires = decodedToken.exp
|
||||
? decodedToken.exp * 1000
|
||||
: target.accessTokenExpires;
|
||||
target.user_id = decodedToken.user_id ?? target.user_id;
|
||||
// Map standard JWT "sub" field to user_id
|
||||
target.user_id = decodedToken.sub ?? target.user_id;
|
||||
target.tenant_id = decodedToken.tenant_id ?? target.tenant_id;
|
||||
} catch (decodeError) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction, useTransition } from "react";
|
||||
|
||||
import { removeUserFromTenant } from "@/actions/users/users";
|
||||
import { DeleteIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { useToast } from "@/components/ui";
|
||||
|
||||
interface ExpelUserFormProps {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
tenantId: string;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const ExpelUserForm = ({
|
||||
userId,
|
||||
userName,
|
||||
tenantId,
|
||||
setIsOpen,
|
||||
}: ExpelUserFormProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleExpel = () => {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("userId", userId);
|
||||
formData.append("tenantId", tenantId);
|
||||
|
||||
const data = await removeUserFromTenant(formData);
|
||||
|
||||
if (!data || !("success" in data) || data.success !== true) {
|
||||
const detail =
|
||||
data && "errors" in data && data.errors?.[0]?.detail
|
||||
? data.errors[0].detail
|
||||
: "Failed to expel the user";
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: detail,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "User expelled",
|
||||
description: `${userName ?? "The user"} was removed from this organization.`,
|
||||
});
|
||||
setIsOpen(false);
|
||||
});
|
||||
};
|
||||
|
||||
const displayName = userName ?? "this user";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{displayName}</span> will lose access to
|
||||
this organization. If they don't belong to any other organization,
|
||||
their account will be permanently deleted.
|
||||
</p>
|
||||
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={() => setIsOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
onClick={handleExpel}
|
||||
disabled={isPending}
|
||||
>
|
||||
{!isPending && <DeleteIcon size={24} aria-hidden="true" />}
|
||||
{isPending ? "Expelling…" : "Expel user"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./delete-form";
|
||||
export * from "./edit-form";
|
||||
export * from "./edit-tenant-form";
|
||||
export * from "./expel-user-form";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Pencil, Trash2, UserMinus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -11,24 +11,51 @@ import {
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
import { DeleteForm, EditForm, ExpelUserForm } from "../forms";
|
||||
|
||||
interface DataTableRowActionsProps<UserProps> {
|
||||
interface UserRowRole {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface UserRowAttributes {
|
||||
name?: string;
|
||||
email?: string;
|
||||
company_name?: string;
|
||||
role?: UserRowRole;
|
||||
}
|
||||
|
||||
interface UserRowData {
|
||||
id: string;
|
||||
attributes?: UserRowAttributes;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
}
|
||||
|
||||
interface DataTableRowActionsProps<UserProps extends UserRowData> {
|
||||
row: Row<UserProps>;
|
||||
roles?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function DataTableRowActions<UserProps>({
|
||||
export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
row,
|
||||
roles,
|
||||
}: DataTableRowActionsProps<UserProps>) {
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const userId = (row.original as { id: string }).id;
|
||||
const userName = (row.original as any).attributes?.name;
|
||||
const userEmail = (row.original as any).attributes?.email;
|
||||
const userCompanyName = (row.original as any).attributes?.company_name;
|
||||
const userRole = (row.original as any).attributes?.role?.name;
|
||||
const [isExpelOpen, setIsExpelOpen] = useState(false);
|
||||
const userId = row.original.id;
|
||||
const userName = row.original.attributes?.name;
|
||||
const userEmail = row.original.attributes?.email;
|
||||
const userCompanyName = row.original.attributes?.company_name;
|
||||
const userRole = row.original.attributes?.role?.name;
|
||||
|
||||
// Expel gate is resolved server-side against the active tenant's membership
|
||||
// role (owner vs member), mirroring the backend rule in
|
||||
// TenantMembersViewSet.destroy. The row is only expel-eligible when the
|
||||
// current user is an owner of the active tenant and the row is not theirs.
|
||||
const canExpelUser =
|
||||
row.original.canBeExpelled === true && !!row.original.currentTenantId;
|
||||
const currentTenantId = row.original.currentTenantId;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -55,17 +82,39 @@ export function DataTableRowActions<UserProps>({
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
{canExpelUser && currentTenantId && (
|
||||
<Modal
|
||||
open={isExpelOpen}
|
||||
onOpenChange={setIsExpelOpen}
|
||||
title="Expel user from this organization"
|
||||
>
|
||||
<ExpelUserForm
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
tenantId={currentTenantId}
|
||||
setIsOpen={setIsExpelOpen}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<ActionDropdown>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
icon={<Pencil aria-hidden="true" />}
|
||||
label="Edit User"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
|
||||
@@ -70,6 +70,14 @@ export type RolePermissionAttributes = Pick<
|
||||
PermissionKey
|
||||
>;
|
||||
|
||||
export const TENANT_MEMBERSHIP_ROLE = {
|
||||
Owner: "owner",
|
||||
Member: "member",
|
||||
} as const;
|
||||
|
||||
export type TenantMembershipRole =
|
||||
(typeof TENANT_MEMBERSHIP_ROLE)[keyof typeof TENANT_MEMBERSHIP_ROLE];
|
||||
|
||||
export interface RoleDetail {
|
||||
id: string;
|
||||
type: "roles";
|
||||
|
||||
Reference in New Issue
Block a user