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:
Davidm4r
2026-04-22 09:28:39 +02:00
committed by GitHub
parent 29a2f8fac8
commit 97a085bf21
15 changed files with 832 additions and 55 deletions
+9 -1
View File
@@ -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)
---
+1
View File
@@ -330,6 +330,7 @@ class MembershipFilter(FilterSet):
model = Membership
fields = {
"tenant": ["exact"],
"user": ["exact"],
"role": ["exact"],
"date_joined": ["date", "gte", "lte"],
}
+283
View File
@@ -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
):
+89 -4
View File
@@ -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 |
+4
View File
@@ -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
View File
@@ -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(
+26 -12
View File
@@ -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
View File
@@ -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&apos;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
View File
@@ -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)}
+8
View File
@@ -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";