diff --git a/.gitignore b/.gitignore index c9bf8f0f6f..8686f4f143 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ htmlcov/ **/mcp-config.json **/mcpServers.json .mcp/ +.mcp.json # AI Coding Assistants - Cursor .cursorignore diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 7855d26390..43b836d4da 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the **Prowler API** are documented in this file. ### 🚀 Added +- Pin all unpinned dependencies to exact versions to prevent supply chain attacks and ensure reproducible builds [(#10469)](https://github.com/prowler-cloud/prowler/pull/10469) +- Filter RBAC role lookup by `tenant_id` to prevent cross-tenant privilege leak [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) - `VALKEY_SCHEME`, `VALKEY_USERNAME`, and `VALKEY_PASSWORD` environment variables to configure Celery broker TLS/auth connection details for Valkey/ElastiCache [(#10420)](https://github.com/prowler-cloud/prowler/pull/10420) - `Vercel` provider support [(#10190)](https://github.com/prowler-cloud/prowler/pull/10190) diff --git a/api/src/backend/api/base_views.py b/api/src/backend/api/base_views.py index 7cdf8fdd7f..b14cc39529 100644 --- a/api/src/backend/api/base_views.py +++ b/api/src/backend/api/base_views.py @@ -1,10 +1,10 @@ from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from rest_framework import permissions from rest_framework.exceptions import NotAuthenticated from rest_framework.filters import SearchFilter from rest_framework.permissions import SAFE_METHODS +from rest_framework.response import Response from rest_framework_json_api import filters from rest_framework_json_api.views import ModelViewSet @@ -12,7 +12,7 @@ from api.authentication import CombinedJWTOrAPIKeyAuthentication from api.db_router import MainRouter, reset_read_db_alias, set_read_db_alias from api.db_utils import POSTGRES_USER_VAR, rls_transaction from api.filters import CustomDjangoFilterBackend -from api.models import Role, Tenant +from api.models import Role, UserRoleRelationship from api.rbac.permissions import HasPermissions @@ -113,27 +113,22 @@ class BaseTenantViewset(BaseViewSet): if request is not None: request.db_alias = self.db_alias - with transaction.atomic(using=self.db_alias): - tenant = super().dispatch(request, *args, **kwargs) - - try: - # If the request is a POST, create the admin role - if request.method == "POST": - isinstance(tenant, dict) and self._create_admin_role( - tenant.data["id"] - ) - except Exception as e: - self._handle_creation_error(e, tenant) - raise - - return tenant + if request.method == "POST": + with transaction.atomic(using=MainRouter.admin_db): + tenant = super().dispatch(request, *args, **kwargs) + if isinstance(tenant, Response) and tenant.status_code == 201: + self._create_admin_role(tenant.data["id"]) + return tenant + else: + with transaction.atomic(using=self.db_alias): + return super().dispatch(request, *args, **kwargs) finally: if alias_token is not None: reset_read_db_alias(alias_token) self.db_alias = MainRouter.default_db def _create_admin_role(self, tenant_id): - Role.objects.using(MainRouter.admin_db).create( + admin_role = Role.objects.using(MainRouter.admin_db).create( name="admin", tenant_id=tenant_id, manage_users=True, @@ -144,15 +139,11 @@ class BaseTenantViewset(BaseViewSet): manage_scans=True, unlimited_visibility=True, ) - - def _handle_creation_error(self, error, tenant): - if tenant.data.get("id"): - try: - Tenant.objects.using(MainRouter.admin_db).filter( - id=tenant.data["id"] - ).delete() - except ObjectDoesNotExist: - pass # Tenant might not exist, handle gracefully + UserRoleRelationship.objects.using(MainRouter.admin_db).create( + user=self.request.user, + role=admin_role, + tenant_id=tenant_id, + ) def initial(self, request, *args, **kwargs): if request.auth is None: diff --git a/api/src/backend/api/rbac/permissions.py b/api/src/backend/api/rbac/permissions.py index 6dd5064979..cfbabf6c0b 100644 --- a/api/src/backend/api/rbac/permissions.py +++ b/api/src/backend/api/rbac/permissions.py @@ -1,7 +1,7 @@ from enum import Enum -from typing import Optional from django.db.models import QuerySet +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission from api.db_router import MainRouter @@ -29,11 +29,17 @@ class HasPermissions(BasePermission): if not required_permissions: return True + tenant_id = getattr(request, "tenant_id", None) + if not tenant_id: + tenant_id = request.auth.get("tenant_id") if request.auth else None + if not tenant_id: + return False + user_roles = ( User.objects.using(MainRouter.admin_db) .get(id=request.user.id) .roles.using(MainRouter.admin_db) - .all() + .filter(tenant_id=tenant_id) ) if not user_roles: return False @@ -45,14 +51,17 @@ class HasPermissions(BasePermission): return True -def get_role(user: User) -> Optional[Role]: +def get_role(user: User, tenant_id: str) -> Role: """ - Retrieve the first role assigned to the given user. + Retrieve the role assigned to the given user in the specified tenant. - Returns: - The user's first Role instance if the user has any roles, otherwise None. + Raises: + PermissionDenied: If the user has no role in the given tenant. """ - return user.roles.first() + role = user.roles.using(MainRouter.admin_db).filter(tenant_id=tenant_id).first() + if role is None: + raise PermissionDenied("User has no role in this tenant.") + return role def get_providers(role: Role) -> QuerySet[Provider]: diff --git a/api/src/backend/api/tests/integration/test_authentication.py b/api/src/backend/api/tests/integration/test_authentication.py index 26d5b8e108..061c2efac0 100644 --- a/api/src/backend/api/tests/integration/test_authentication.py +++ b/api/src/backend/api/tests/integration/test_authentication.py @@ -215,6 +215,21 @@ class TestTokenSwitchTenant: tenant_id = tenants_fixture[0].id user_instance = User.objects.get(email=test_user) Membership.objects.create(user=user_instance, tenant_id=tenant_id) + # Assign an admin role in the target tenant so the user can access resources + target_role = Role.objects.create( + name="admin", + tenant_id=tenant_id, + manage_users=True, + manage_account=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.create( + user=user_instance, role=target_role, tenant_id=tenant_id + ) # Check that using our new user's credentials we can authenticate and get the providers access_token, _ = get_api_tokens(client, test_user, test_password) diff --git a/api/src/backend/api/tests/test_rbac.py b/api/src/backend/api/tests/test_rbac.py index addd8b4ec6..684a9e44b9 100644 --- a/api/src/backend/api/tests/test_rbac.py +++ b/api/src/backend/api/tests/test_rbac.py @@ -2,7 +2,7 @@ import json from unittest.mock import ANY, Mock, patch import pytest -from conftest import TODAY +from conftest import TEST_PASSWORD, TODAY from django.urls import reverse from rest_framework import status @@ -830,3 +830,66 @@ class TestUserRoleLinkPermissions: ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestCrossTenantRoleLeak: + """Regression tests for get_role() cross-tenant privilege leak. + + get_role() must query admin_db (bypassing RLS) so that a user with a role + in tenant A cannot accidentally pass role checks when authenticated against + tenant B where they have no role. + """ + + def test_user_with_role_in_tenant_a_denied_in_tenant_b(self, tenants_fixture): + """User has admin role in tenant A, membership in tenant B but no role. + Hitting an RBAC-protected endpoint with a tenant-B token must return 403.""" + from rest_framework.test import APIClient + + tenant_a = tenants_fixture[0] + tenant_b = tenants_fixture[1] + + user = User.objects.create_user( + name="cross_tenant_user", + email="cross_tenant@test.com", + password=TEST_PASSWORD, + ) + Membership.objects.create( + user=user, tenant=tenant_a, role=Membership.RoleChoices.OWNER + ) + Membership.objects.create( + user=user, tenant=tenant_b, role=Membership.RoleChoices.OWNER + ) + + # Role only in tenant A + role = Role.objects.create( + name="admin", + tenant_id=tenant_a.id, + manage_users=True, + manage_account=True, + manage_billing=True, + manage_providers=True, + manage_integrations=True, + manage_scans=True, + unlimited_visibility=True, + ) + UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant_a.id) + + # Mint token scoped to tenant B (where user has NO role) + serializer = TokenSerializer( + data={ + "type": "tokens", + "email": "cross_tenant@test.com", + "password": TEST_PASSWORD, + "tenant_id": tenant_b.id, + } + ) + serializer.is_valid(raise_exception=True) + access_token = serializer.validated_data["access"] + + client = APIClient() + client.defaults["HTTP_AUTHORIZATION"] = f"Bearer {access_token}" + + # user-list requires manage_users permission via HasPermissions + response = client.get(reverse("user-list")) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/api/src/backend/api/tests/test_views.py b/api/src/backend/api/tests/test_views.py index 09f04751b1..3fcd515f6a 100644 --- a/api/src/backend/api/tests/test_views.py +++ b/api/src/backend/api/tests/test_views.py @@ -516,6 +516,13 @@ class TestTenantViewSet: response.json()["data"]["attributes"]["name"] == valid_tenant_payload["name"] ) + new_tenant_id = response.json()["data"]["id"] + user = authenticated_client.user + assert UserRoleRelationship.objects.filter( + user=user, + tenant_id=new_tenant_id, + role__name="admin", + ).exists() def test_tenants_invalid_create(self, authenticated_client, invalid_tenant_payload): response = authenticated_client.post( @@ -575,22 +582,66 @@ class TestTenantViewSet: Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete() delete_tenant_mock.side_effect = _delete_tenant + # Use tenant2 where the user is OWNER + _, tenant2, _ = tenants_fixture + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant2.id}) + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + assert Membership.objects.filter(tenant_id=tenant2.id).count() == 0 + # User is not deleted because it has another membership (tenant1) + assert User.objects.count() == 1 + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_as_member_forbidden( + self, delete_tenant_mock, authenticated_client, tenants_fixture + ): + # tenant1: user is MEMBER, not OWNER -> should be forbidden tenant1, *_ = tenants_fixture response = authenticated_client.delete( reverse("tenant-detail", kwargs={"pk": tenant1.id}) ) + assert response.status_code == status.HTTP_403_FORBIDDEN + delete_tenant_mock.assert_not_called() + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_cross_tenant( + self, delete_tenant_mock, authenticated_client, tenants_fixture + ): + # tenant3: user has no membership -> should be 404 + _, _, tenant3 = tenants_fixture + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant3.id}) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + delete_tenant_mock.assert_not_called() + + @patch("api.v1.views.delete_tenant_task.apply_async") + def test_tenants_delete_only_removes_exclusive_users( + self, delete_tenant_mock, authenticated_client, tenants_fixture, extra_users + ): + def _delete_tenant(kwargs): + Tenant.objects.filter(pk=kwargs.get("tenant_id")).delete() + + delete_tenant_mock.side_effect = _delete_tenant + _, tenant2, _ = tenants_fixture + # extra_users adds user2 (OWNER in tenant2) and user3 (MEMBER in tenant2) + # user2 and user3 are ONLY in tenant2, so they should be deleted + # The test user is in tenant1 + tenant2, so should NOT be deleted + initial_user_count = User.objects.count() # test_user + user2 + user3 = 3 + assert initial_user_count == 3 + + response = authenticated_client.delete( + reverse("tenant-detail", kwargs={"pk": tenant2.id}) + ) assert response.status_code == status.HTTP_204_NO_CONTENT - assert Tenant.objects.count() == len(tenants_fixture) - 1 - assert Membership.objects.filter(tenant_id=tenant1.id).count() == 0 - # User is not deleted because it has another membership + # user2 and user3 are deleted (no other memberships), test_user remains assert User.objects.count() == 1 def test_tenants_delete_invalid(self, authenticated_client): response = authenticated_client.delete( reverse("tenant-detail", kwargs={"pk": "random_id"}) ) - # To change if we implement RBAC - # (user might not have permissions to see if the tenant exists or not -> 200 empty) assert response.status_code == status.HTTP_404_NOT_FOUND def test_tenants_list_filter_search(self, authenticated_client, tenants_fixture): @@ -694,7 +745,6 @@ class TestTenantViewSet: # Test user + 2 extra users for tenant 2 assert len(response.json()["data"]) == 3 - @patch("api.v1.views.TenantMembersViewSet.required_permissions", []) def test_tenants_list_memberships_as_member( self, authenticated_client, tenants_fixture, extra_users ): @@ -807,6 +857,30 @@ class TestTenantViewSet: ) assert response.status_code == status.HTTP_404_NOT_FOUND + def test_tenants_delete_membership_cross_tenant( + self, authenticated_client, tenants_fixture + ): + # Create a tenant with a different user's membership + other_tenant = Tenant.objects.create(name="Other Tenant") + other_user = User.objects.create_user( + name="other", password=TEST_PASSWORD, email="other@test.com" + ) + other_membership = Membership.objects.create( + user=other_user, + tenant=other_tenant, + role=Membership.RoleChoices.OWNER, + ) + + # Authenticated user is NOT a member of other_tenant -> 404 + response = authenticated_client.delete( + reverse( + "tenant-membership-detail", + kwargs={"tenant_pk": other_tenant.id, "pk": other_membership.id}, + ) + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + assert Membership.objects.filter(id=other_membership.id).exists() + def test_tenants_list_no_permissions( self, authenticated_client_no_permissions_rbac, tenants_fixture ): @@ -8168,6 +8242,8 @@ class TestUserRoleRelationshipViewSet: manage_scans=False, unlimited_visibility=False, ) + # Assign the role to the user + UserRoleRelationship.objects.create(user=user, role=only_role, tenant=tenant) # Switch token to this tenant serializer = TokenSerializer( diff --git a/api/src/backend/api/v1/views.py b/api/src/backend/api/v1/views.py index 68ba0abfab..4f015f266e 100644 --- a/api/src/backend/api/v1/views.py +++ b/api/src/backend/api/v1/views.py @@ -949,7 +949,12 @@ class UserViewSet(BaseUserViewset): def get_serializer_context(self): context = super().get_serializer_context() if self.request.user.is_authenticated: - context["role"] = get_role(self.request.user) + tenant_id = getattr(self.request, "tenant_id", None) + if tenant_id: + try: + context["role"] = get_role(self.request.user, tenant_id) + except PermissionDenied: + context["role"] = None return context @action(detail=False, methods=["get"], url_name="me") @@ -1231,28 +1236,44 @@ class TenantViewSet(BaseTenantViewset): def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - tenant = serializer.save() - Membership.objects.create( + tenant = Tenant.objects.using(MainRouter.admin_db).create( + **serializer.validated_data + ) + Membership.objects.using(MainRouter.admin_db).create( user=self.request.user, tenant=tenant, role=Membership.RoleChoices.OWNER ) + serializer.instance = tenant return Response(data=serializer.data, status=status.HTTP_201_CREATED) def destroy(self, request, *args, **kwargs): - # This will perform validation and raise a 404 if the tenant does not exist - tenant_id = kwargs.get("pk") - get_object_or_404(Tenant, id=tenant_id) + tenant = self.get_object() + tenant_id = str(tenant.id) + + # Only owners can delete a tenant + membership = Membership.objects.filter(user=request.user, tenant=tenant).first() + if not membership or membership.role != Membership.RoleChoices.OWNER: + raise PermissionDenied("Only owners can delete a tenant.") with transaction.atomic(): - # Delete memberships + # Collect user IDs from this tenant's memberships before deleting them + tenant_user_ids = set( + Membership.objects.using(MainRouter.admin_db) + .filter(tenant_id=tenant_id) + .values_list("user_id", flat=True) + ) + + # Delete memberships for this tenant Membership.objects.using(MainRouter.admin_db).filter( tenant_id=tenant_id ).delete() - # Delete users without memberships - User.objects.using(MainRouter.admin_db).filter( - membership__isnull=True - ).delete() - # Delete tenant in batches + # Delete only users that were exclusively in this tenant + if tenant_user_ids: + User.objects.using(MainRouter.admin_db).filter( + id__in=tenant_user_ids, membership__isnull=True + ).delete() + + # Delete tenant data in background delete_tenant_task.apply_async(kwargs={"tenant_id": tenant_id}) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1317,8 +1338,12 @@ class TenantMembersViewSet(BaseTenantViewset): http_method_names = ["get", "delete"] serializer_class = MembershipSerializer queryset = Membership.objects.none() - # RBAC required permissions - required_permissions = [Permissions.MANAGE_ACCOUNT] + # Authorization is handled by get_requesting_membership (owner/member checks), + # not by RBAC, since the target tenant differs from the JWT tenant. + required_permissions = [] + + def set_required_permissions(self): + self.required_permissions = [] def get_queryset(self): tenant = self.get_tenant() @@ -1331,8 +1356,10 @@ class TenantMembersViewSet(BaseTenantViewset): def get_tenant(self): tenant_id = self.kwargs.get("tenant_pk") - tenant = get_object_or_404(Tenant, id=tenant_id) - return tenant + return get_object_or_404( + Tenant.objects.filter(membership__user=self.request.user), + id=tenant_id, + ) def get_requesting_membership(self, tenant): try: @@ -1419,7 +1446,7 @@ class ProviderGroupViewSet(BaseRLSViewSet): self.required_permissions = [Permissions.MANAGE_PROVIDERS] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) # Check if any of the user's roles have UNLIMITED_VISIBILITY if user_roles.unlimited_visibility: # User has unlimited visibility, return all provider groups @@ -1588,7 +1615,7 @@ class ProviderViewSet(DisablePaginationMixin, BaseRLSViewSet): self.required_permissions = [Permissions.MANAGE_PROVIDERS] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all providers queryset = Provider.objects.filter(tenant_id=self.request.tenant_id) @@ -1843,7 +1870,7 @@ class ScanViewSet(BaseRLSViewSet): self.required_permissions = [Permissions.MANAGE_SCANS] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all scans queryset = Scan.objects.filter(tenant_id=self.request.tenant_id) @@ -2494,7 +2521,7 @@ class AttackPathsScanViewSet(BaseRLSViewSet): return super().get_serializer_class() def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) base_queryset = AttackPathsScan.objects.filter(tenant_id=self.request.tenant_id) if user_roles.unlimited_visibility: @@ -2831,7 +2858,7 @@ class ResourceViewSet(PaginateByPkMixin, BaseRLSViewSet): required_permissions = [] def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all scans queryset = Resource.all_objects.filter(tenant_id=self.request.tenant_id) @@ -3453,7 +3480,7 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet): def get_queryset(self): tenant_id = self.request.tenant_id - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all findings queryset = Finding.all_objects.filter(tenant_id=tenant_id) @@ -4056,9 +4083,9 @@ class RoleViewSet(BaseRLSViewSet): ) ) def partial_update(self, request, *args, **kwargs): - user_role = get_role(request.user) + user_role = get_role(request.user, request.tenant_id) # If the user is the owner of the role, the manage_account field is not editable - if user_role and kwargs["pk"] == str(user_role.id): + if kwargs["pk"] == str(user_role.id): request.data["manage_account"] = str(user_role.manage_account).lower() return super().partial_update(request, *args, **kwargs) @@ -4314,7 +4341,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): required_permissions = [] def get_queryset(self): - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) @@ -4356,7 +4383,7 @@ class ComplianceOverviewViewSet(BaseRLSViewSet, TaskManagementMixin): def _compliance_summaries_queryset(self, scan_id): """Return pre-aggregated summaries constrained by RBAC visibility.""" - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) unlimited_visibility = getattr( role, Permissions.UNLIMITED_VISIBILITY.value, False ) @@ -4898,7 +4925,7 @@ class OverviewViewSet(BaseRLSViewSet): required_permissions = [] def get_queryset(self): - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) providers = get_providers(role) if not role.unlimited_visibility: @@ -6071,7 +6098,7 @@ class IntegrationViewSet(BaseRLSViewSet): allowed_providers = None def get_queryset(self): - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all integrations queryset = Integration.objects.filter(tenant_id=self.request.tenant_id) @@ -6160,7 +6187,7 @@ class IntegrationJiraViewSet(BaseRLSViewSet): def get_queryset(self): tenant_id = self.request.tenant_id - user_roles = get_role(self.request.user) + user_roles = get_role(self.request.user, self.request.tenant_id) if user_roles.unlimited_visibility: # User has unlimited visibility, return all findings queryset = Finding.all_objects.filter(tenant_id=tenant_id) @@ -6902,7 +6929,7 @@ class FindingGroupViewSet(BaseRLSViewSet): def get_queryset(self): """Get the base FindingGroupDailySummary queryset with RLS filtering.""" tenant_id = self.request.tenant_id - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) queryset = FindingGroupDailySummary.objects.filter(tenant_id=tenant_id) if not role.unlimited_visibility: @@ -6912,7 +6939,7 @@ class FindingGroupViewSet(BaseRLSViewSet): def _get_finding_queryset(self): """Get the Finding queryset for resources drill-down (with RBAC).""" - role = get_role(self.request.user) + role = get_role(self.request.user, self.request.tenant_id) providers = get_providers(role) tenant_id = self.request.tenant_id diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index e519db384a..62071cfcca 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added +- Multi-tenant organization management: create, switch, edit, and delete organizations from the profile page [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) - Findings grouped view with drill-down table showing resources per check, resource detail drawer, infinite scroll pagination, and bulk mute support [(#10425)](https://github.com/prowler-cloud/prowler/pull/10425) - Resource events tool to Lighthouse AI [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412) @@ -16,6 +17,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🐞 Fixed +- Deleting the active organization now switches to the target org before deleting, preventing JWT rejection from the backend [(#10491)](https://github.com/prowler-cloud/prowler/pull/10491) - Clear Filters now resets all filters including muted findings and auto-applies, Clear all in pills only removes pill-visible sub-filters, and the discard icon is now an Undo text button [(#10446)](https://github.com/prowler-cloud/prowler/pull/10446) - Send to Jira modal now dynamically fetches and displays available issue types per project instead of hardcoding `"Task"`, fixing failures on non-English Jira instances [(#10534)](https://github.com/prowler-cloud/prowler/pull/10534) diff --git a/ui/actions/users/tenants.ts b/ui/actions/users/tenants.ts index f730a02b7f..b43b467d93 100644 --- a/ui/actions/users/tenants.ts +++ b/ui/actions/users/tenants.ts @@ -1,5 +1,6 @@ "use server"; +import { revalidatePath } from "next/cache"; import { z } from "zod"; import { apiBaseUrl, getAuthHeaders } from "@/lib/helper"; @@ -37,7 +38,15 @@ const editTenantFormSchema = z path: ["name"], }); -export async function updateTenantName(_prevState: any, formData: FormData) { +export type UpdateTenantNameState = + | { errors: { name?: string } } + | { success: string } + | { error: string }; + +export async function updateTenantName( + _prevState: UpdateTenantNameState | null, + formData: FormData, +): Promise { const headers = await getAuthHeaders({ contentType: true }); const formDataObject = Object.fromEntries(formData); const validatedData = editTenantFormSchema.safeParse(formDataObject); @@ -82,3 +91,311 @@ export async function updateTenantName(_prevState: any, formData: FormData) { return handleApiError(error); } } + +const switchTenantSchema = z.object({ + tenantId: z.uuid(), +}); + +interface SwitchTenantSuccess { + success: true; + accessToken: string; + refreshToken: string; +} + +interface SwitchTenantError { + error: string; +} + +export type SwitchTenantState = SwitchTenantSuccess | SwitchTenantError; + +export async function switchTenant( + _prevState: SwitchTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = switchTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant ID" }; + } + + const { tenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: true }); + + const payload = { + data: { + type: "tokens-switch-tenant", + attributes: { + tenant_id: tenantId, + }, + }, + }; + + try { + const url = new URL(`${apiBaseUrl}/tokens/switch`); + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to switch tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + + const data = await response.json(); + const accessToken = data?.data?.attributes?.access; + const refreshToken = data?.data?.attributes?.refresh; + + if (!accessToken || !refreshToken) { + throw new Error("Missing tokens in switch tenant response"); + } + + return { success: true, accessToken, refreshToken }; + } catch (error) { + return handleApiError(error); + } +} + +const createTenantSchema = z.object({ + name: z + .string() + .trim() + .min(1, { message: "Name is required" }) + .max(100, { message: "Name must be 100 characters or less" }), +}); + +interface CreateTenantSuccess { + success: true; + tenantId: string; +} + +interface CreateTenantError { + error: string; +} + +export type CreateTenantState = CreateTenantSuccess | CreateTenantError; + +export async function createTenant( + _prevState: CreateTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = createTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + const fieldErrors = validatedData.error.flatten().fieldErrors; + return { error: fieldErrors?.name?.[0] || "Invalid input" }; + } + + const { name } = validatedData.data; + const headers = await getAuthHeaders({ contentType: true }); + + const payload = { + data: { + type: "tenants", + attributes: { name }, + }, + }; + + try { + const url = new URL(`${apiBaseUrl}/tenants`); + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to create tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + + const data = await response.json(); + const tenantId = data?.data?.id; + + if (!tenantId) { + throw new Error("Missing tenant ID in create response"); + } + + revalidatePath("/profile"); + return { success: true, tenantId }; + } catch (error) { + return handleApiError(error); + } +} + +const deleteTenantSchema = z.object({ + tenantId: z.uuid(), +}); + +const switchThenDeleteTenantSchema = z.object({ + tenantId: z.uuid(), + targetTenantId: z.uuid(), +}); + +interface DeleteTenantSuccess { + success: true; +} + +interface DeleteTenantError { + error: string; +} + +export type DeleteTenantState = DeleteTenantSuccess | DeleteTenantError; + +export async function deleteTenant( + _prevState: DeleteTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = deleteTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant ID" }; + } + + const { tenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: false }); + + try { + const url = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const response = await fetch(url.toString(), { + method: "DELETE", + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to delete tenant: ${response.statusText}`; + throw new Error(errorDetail); + } + + revalidatePath("/profile"); + return { success: true }; + } catch (error) { + return handleApiError(error); + } +} + +interface SwitchThenDeleteSuccess { + success: true; + accessToken: string; + refreshToken: string; +} + +interface SwitchThenDeleteError { + error: string; + accessToken?: string; + refreshToken?: string; +} + +export type SwitchThenDeleteTenantState = + | SwitchThenDeleteSuccess + | SwitchThenDeleteError; + +export async function switchThenDeleteTenant( + _prevState: SwitchThenDeleteTenantState | null, + formData: FormData, +): Promise { + const formDataObject = Object.fromEntries(formData); + const validatedData = switchThenDeleteTenantSchema.safeParse(formDataObject); + + if (!validatedData.success) { + return { error: "Invalid tenant or target tenant ID" }; + } + + const { tenantId, targetTenantId } = validatedData.data; + const headers = await getAuthHeaders({ contentType: true }); + + // Step 1: Switch to the target tenant (current token is still valid) + const switchPayload = { + data: { + type: "tokens-switch-tenant", + attributes: { + tenant_id: targetTenantId, + }, + }, + }; + + let newAccessToken: string; + let newRefreshToken: string; + + try { + const switchUrl = new URL(`${apiBaseUrl}/tokens/switch`); + const switchResponse = await fetch(switchUrl.toString(), { + method: "POST", + headers, + body: JSON.stringify(switchPayload), + }); + + if (!switchResponse.ok) { + const errorData = await switchResponse.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to switch tenant: ${switchResponse.statusText}`; + throw new Error(errorDetail); + } + + const switchData = await switchResponse.json(); + newAccessToken = switchData?.data?.attributes?.access; + newRefreshToken = switchData?.data?.attributes?.refresh; + + if (!newAccessToken || !newRefreshToken) { + throw new Error("Missing tokens in switch tenant response"); + } + } catch (error) { + return handleApiError(error); + } + + // Step 2: Delete the old tenant using the NEW token + const deleteHeaders: Record = { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${newAccessToken}`, + }; + + try { + const deleteUrl = new URL(`${apiBaseUrl}/tenants/${tenantId}`); + const deleteResponse = await fetch(deleteUrl.toString(), { + method: "DELETE", + headers: deleteHeaders, + }); + + if (!deleteResponse.ok) { + const errorData = await deleteResponse.json().catch(() => null); + const errorDetail = + errorData?.errors?.[0]?.detail || + `Failed to delete tenant: ${deleteResponse.statusText}`; + // Switch succeeded but delete failed — return tokens so client can still update session + return { + error: errorDetail, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } + + revalidatePath("/profile"); + return { + success: true, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } catch (error) { + // Switch succeeded but delete threw — return tokens so client can still update session + const errorResult = handleApiError(error); + return { + ...errorResult, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } +} diff --git a/ui/app/(prowler)/profile/page.tsx b/ui/app/(prowler)/profile/page.tsx index 41ff81a5fb..77cf9d50fc 100644 --- a/ui/app/(prowler)/profile/page.tsx +++ b/ui/app/(prowler)/profile/page.tsx @@ -1,7 +1,8 @@ -import React, { Suspense } from "react"; +import { Suspense } from "react"; import { getSamlConfig } from "@/actions/integrations/saml"; import { getUserInfo } from "@/actions/users/users"; +import { auth } from "@/auth.config"; import { SamlIntegrationCard } from "@/components/integrations/saml/saml-integration-card"; import { ContentLayout } from "@/components/ui"; import { ApiKeysCard, UserBasicInfoCard } from "@/components/users/profile"; @@ -37,6 +38,7 @@ const SSRDataUser = async ({ }: { searchParams: SearchParamsProps; }) => { + const session = await auth(); const userProfile = (await getUserInfo()) as UserProfileResponse | undefined; if (!userProfile?.data) { return null; @@ -95,12 +97,6 @@ const SSRDataUser = async ({ userRoleIds.includes(role.id), ); - const isOwner = membershipsIncluded.some( - (m) => - m.attributes.role === "owner" && - m.relationships?.user?.data?.id === userData.id, - ); - const samlConfig = hasManageIntegrations ? await getSamlConfig() : undefined; return ( @@ -117,7 +113,8 @@ const SSRDataUser = async ({ {hasManageAccount && } diff --git a/ui/auth.config.ts b/ui/auth.config.ts index cc075abe3d..4bf7a660cb 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -300,9 +300,17 @@ export const authConfig = { return true; }, - jwt: async ({ token, account, user }) => { + jwt: async ({ token, account, user, trigger, session }) => { const authToken = token as AuthToken; + // Handle tenant switch: update tokens from client-side useSession().update() + if (trigger === "update" && session?.accessToken) { + authToken.accessToken = session.accessToken; + authToken.refreshToken = session.refreshToken; + applyDecodedClaims(authToken, authToken.accessToken, "tenant switch"); + return authToken; + } + applyDecodedClaims(authToken, authToken.accessToken); if (account && user) { diff --git a/ui/components/users/forms/create-tenant-form.test.tsx b/ui/components/users/forms/create-tenant-form.test.tsx new file mode 100644 index 0000000000..0da2677949 --- /dev/null +++ b/ui/components/users/forms/create-tenant-form.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { CreateTenantForm } from "./create-tenant-form"; + +const mockUpdate = vi.fn(); +vi.mock("next-auth/react", () => ({ + useSession: () => ({ update: mockUpdate }), +})); + +vi.mock("@/auth.config", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/actions/users/tenants", () => ({ + createTenant: vi.fn(), + switchTenant: vi.fn(), +})); + +const mockToast = vi.fn(); +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +describe("CreateTenantForm", () => { + const setIsOpen = vi.fn(); + + it("renders name input and form buttons", () => { + render(); + expect(screen.getByLabelText(/organization name/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + it("closes modal on cancel click", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/components/users/forms/create-tenant-form.tsx b/ui/components/users/forms/create-tenant-form.tsx new file mode 100644 index 0000000000..ba883022c2 --- /dev/null +++ b/ui/components/users/forms/create-tenant-form.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { Dispatch, SetStateAction, useActionState, useEffect } from "react"; + +import { + createTenant, + switchTenant, + SwitchTenantState, +} from "@/actions/users/tenants"; +import { useToast } from "@/components/ui"; +import { CustomServerInput } from "@/components/ui/custom"; +import { FormButtons } from "@/components/ui/form"; +import { reloadPage } from "@/lib/navigation"; + +export const CreateTenantForm = ({ + setIsOpen, +}: { + setIsOpen: Dispatch>; +}) => { + const [state, formAction] = useActionState(createTenant, null); + const { update } = useSession(); + const { toast } = useToast(); + useEffect(() => { + if (!state) return; + + let cancelled = false; + + const handleCreate = async () => { + if ("success" in state) { + // Two-step: create succeeded, now switch to the new tenant + const fd = new FormData(); + fd.set("tenantId", state.tenantId); + const switchResult: SwitchTenantState = await switchTenant(null, fd); + + if (cancelled) return; + + if ("success" in switchResult) { + await update({ + accessToken: switchResult.accessToken, + refreshToken: switchResult.refreshToken, + }); + toast({ + title: "Organization created", + description: "Switching to the new organization.", + }); + reloadPage(); + } else { + // Create succeeded but switch failed — org exists, user can switch manually + toast({ + variant: "destructive", + title: "Organization created, but switch failed", + description: + switchResult.error || + "You can switch manually from the organizations list.", + }); + setIsOpen(false); + } + } else { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.error, + }); + } + }; + + handleCreate(); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + return ( +
+ + + + + ); +}; diff --git a/ui/components/users/forms/delete-tenant-form.test.tsx b/ui/components/users/forms/delete-tenant-form.test.tsx new file mode 100644 index 0000000000..d92134f99e --- /dev/null +++ b/ui/components/users/forms/delete-tenant-form.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { DeleteTenantForm } from "./delete-tenant-form"; + +const mockUpdate = vi.fn(); +vi.mock("next-auth/react", () => ({ + useSession: () => ({ update: mockUpdate }), +})); + +vi.mock("@/auth.config", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/actions/users/tenants", () => ({ + deleteTenant: vi.fn(), + switchTenant: vi.fn(), + switchThenDeleteTenant: vi.fn(), +})); + +const mockToast = vi.fn(); +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +const baseProps = { + tenantId: "tenant-1", + tenantName: "My Organization", + isActiveTenant: false, + availableTenants: [{ id: "tenant-2", name: "Other Org" }], + setIsOpen: vi.fn(), +}; + +describe("DeleteTenantForm", () => { + it("renders confirmation input and form buttons", () => { + render(); + expect(screen.getByPlaceholderText("My Organization")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + it("submit button is disabled until name matches exactly", async () => { + const user = userEvent.setup(); + render(); + + const submitBtn = screen.getByRole("button", { name: /delete/i }); + expect(submitBtn).toBeDisabled(); + + await user.type(screen.getByPlaceholderText("My Organization"), "My Org"); + expect(submitBtn).toBeDisabled(); + + await user.clear(screen.getByPlaceholderText("My Organization")); + await user.type( + screen.getByPlaceholderText("My Organization"), + "My Organization", + ); + expect(submitBtn).toBeEnabled(); + }); + + it("is case-sensitive — lowercase does not match", async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByPlaceholderText("My Organization"), + "my organization", + ); + expect(screen.getByRole("button", { name: /delete/i })).toBeDisabled(); + }); + + it("does not show target tenant select for non-active tenant", () => { + render(); + expect( + screen.queryByText(/switch to after deletion/i), + ).not.toBeInTheDocument(); + }); + + it("shows target tenant select for active tenant", () => { + render(); + expect(screen.getByText(/switch to after deletion/i)).toBeInTheDocument(); + }); + + it("closes modal on cancel click", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(baseProps.setIsOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/components/users/forms/delete-tenant-form.tsx b/ui/components/users/forms/delete-tenant-form.tsx new file mode 100644 index 0000000000..79bbc7f0bc --- /dev/null +++ b/ui/components/users/forms/delete-tenant-form.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { + Dispatch, + FormEvent, + SetStateAction, + useActionState, + useEffect, + useState, +} from "react"; + +import { deleteTenant, switchThenDeleteTenant } from "@/actions/users/tenants"; +import { Input } from "@/components/shadcn/input/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/shadcn/select/select"; +import { useToast } from "@/components/ui"; +import { FormButtons } from "@/components/ui/form"; +import { reloadPage } from "@/lib/navigation"; +import { TenantOption } from "@/types/users"; + +interface DeleteTenantFormProps { + tenantId: string; + tenantName: string; + isActiveTenant: boolean; + availableTenants: TenantOption[]; + setIsOpen: Dispatch>; +} + +export const DeleteTenantForm = ({ + tenantId, + tenantName, + isActiveTenant, + availableTenants, + setIsOpen, +}: DeleteTenantFormProps) => { + const [deleteState, deleteFormAction] = useActionState(deleteTenant, null); + + const { update } = useSession(); + const { toast } = useToast(); + const [confirmName, setConfirmName] = useState(""); + const [targetTenantId, setTargetTenantId] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const nameMatches = confirmName === tenantName; + const canSubmit = isActiveTenant + ? nameMatches && targetTenantId !== "" + : nameMatches; + + useEffect(() => { + if (!deleteState) return; + + if ("success" in deleteState) { + toast({ + title: "Organization deleted", + description: "The organization has been permanently deleted.", + }); + setIsOpen(false); + } else { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: deleteState.error, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deleteState]); + + // Handle active-tenant delete: call server action directly to avoid + // React's RSC reconciliation unmounting this component before we can + // update the session with the new tokens. + const handleActiveTenantDelete = async (e: FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + const formData = new FormData(e.currentTarget); + const result = await switchThenDeleteTenant(null, formData); + + if ("success" in result) { + await update({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); + toast({ + title: "Organization deleted", + description: "Switching to another organization.", + }); + reloadPage(); + } else if (result.accessToken) { + // Partial success: switch OK but delete failed — still update session + await update({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); + toast({ + variant: "destructive", + title: "Switch succeeded but delete failed", + description: result.error, + }); + reloadPage(); + } else { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: result.error, + }); + setIsSubmitting(false); + } + }; + + return ( +
+ + {isActiveTenant && targetTenantId && ( + + )} + +
+ Type {tenantName} to confirm + deletion: +
+ + setConfirmName(e.target.value)} + placeholder={tenantName} + aria-label={`Type ${tenantName} to confirm deletion`} + autoComplete="off" + /> + + {isActiveTenant && ( +
+
+ This is your active organization. Select which organization to + switch to after deletion: +
+ +
+ )} + + + + ); +}; diff --git a/ui/components/users/forms/switch-tenant-form.test.tsx b/ui/components/users/forms/switch-tenant-form.test.tsx new file mode 100644 index 0000000000..95ef443509 --- /dev/null +++ b/ui/components/users/forms/switch-tenant-form.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { SwitchTenantForm } from "./switch-tenant-form"; + +const mockUpdate = vi.fn(); +vi.mock("next-auth/react", () => ({ + useSession: () => ({ update: mockUpdate }), +})); + +vi.mock("@/actions/users/tenants", () => ({ + switchTenant: vi.fn(), +})); + +const mockToast = vi.fn(); +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +describe("SwitchTenantForm", () => { + const setIsOpen = vi.fn(); + + it("renders confirm and cancel buttons", () => { + render(); + + expect( + screen.getByRole("button", { name: /confirm/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); + }); + + it("includes hidden tenantId input", () => { + render(); + + const hiddenInput = document.querySelector( + 'input[name="tenantId"]', + ) as HTMLInputElement; + expect(hiddenInput).toBeTruthy(); + expect(hiddenInput.value).toBe("test-uuid"); + }); + + it("closes modal on cancel click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/components/users/forms/switch-tenant-form.tsx b/ui/components/users/forms/switch-tenant-form.tsx new file mode 100644 index 0000000000..a48b462876 --- /dev/null +++ b/ui/components/users/forms/switch-tenant-form.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useSession } from "next-auth/react"; +import { Dispatch, SetStateAction, useActionState, useEffect } from "react"; + +import { switchTenant } from "@/actions/users/tenants"; +import { useToast } from "@/components/ui"; +import { FormButtons } from "@/components/ui/form"; +import { reloadPage } from "@/lib/navigation"; + +export const SwitchTenantForm = ({ + tenantId, + setIsOpen, +}: { + tenantId: string; + setIsOpen: Dispatch>; +}) => { + const [state, formAction] = useActionState(switchTenant, null); + const { update } = useSession(); + const { toast } = useToast(); + useEffect(() => { + if (!state) return; + + const handleSwitch = async () => { + if ("success" in state) { + await update({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + }); + toast({ + title: "Organization switched", + description: "The page will reload to apply the change.", + }); + reloadPage(); + } else { + toast({ + variant: "destructive", + title: "Oops! Something went wrong", + description: state.error, + }); + setIsOpen(false); + } + }; + + handleSwitch(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state]); + + return ( +
+ + + + ); +}; diff --git a/ui/components/users/profile/membership-item.test.tsx b/ui/components/users/profile/membership-item.test.tsx new file mode 100644 index 0000000000..54006a0afc --- /dev/null +++ b/ui/components/users/profile/membership-item.test.tsx @@ -0,0 +1,169 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { MembershipItem } from "./membership-item"; + +vi.mock("next-auth/react", () => ({ + useSession: () => ({ update: vi.fn() }), +})); + +vi.mock("@/auth.config", () => ({ + auth: vi.fn(), +})); + +vi.mock("@/actions/users/tenants", () => ({ + switchTenant: vi.fn(), + updateTenantName: vi.fn(), + deleteTenant: vi.fn(), +})); + +vi.mock("@/components/ui", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +const baseMembership = { + id: "mem-1", + type: "memberships" as const, + attributes: { role: "owner", date_joined: "2025-05-19T11:31:00Z" }, + relationships: { + tenant: { data: { type: "tenants", id: "tenant-1" } }, + user: { data: { type: "users", id: "user-1" } }, + }, +}; + +describe("MembershipItem", () => { + it("shows Switch button when not active tenant", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /switch/i })).toBeInTheDocument(); + expect(screen.queryByText("Active")).not.toBeInTheDocument(); + }); + + it("shows Active badge when active tenant", () => { + render( + , + ); + + expect(screen.getByText("Active")).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /switch/i }), + ).not.toBeInTheDocument(); + }); + + it("shows Edit button when user is owner", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument(); + }); + + it("hides Edit button when user is not owner", () => { + render( + , + ); + + expect( + screen.queryByRole("button", { name: /edit/i }), + ).not.toBeInTheDocument(); + }); + + it("displays membership role as badge", () => { + render( + , + ); + + expect(screen.getByText("owner")).toBeInTheDocument(); + }); + + it("shows Delete button when isOrgOwner and membershipCount > 1", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument(); + }); + + it("hides Delete button when membershipCount === 1", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: /delete/i }), + ).not.toBeInTheDocument(); + }); + + it("hides Delete button when not isOrgOwner", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: /delete/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/users/profile/membership-item.tsx b/ui/components/users/profile/membership-item.tsx index 23c616600c..acb28c5bdb 100644 --- a/ui/components/users/profile/membership-item.tsx +++ b/ui/components/users/profile/membership-item.tsx @@ -1,27 +1,38 @@ "use client"; -import { Chip } from "@heroui/chip"; import { useState } from "react"; -import { Button, Card } from "@/components/shadcn"; +import { Badge, Button, Card } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; import { DateWithTime, InfoField } from "@/components/ui/entities"; -import { MembershipDetailData } from "@/types/users"; - -import { EditTenantForm } from "../forms"; +import { EditTenantForm } from "@/components/users/forms"; +import { DeleteTenantForm } from "@/components/users/forms/delete-tenant-form"; +import { SwitchTenantForm } from "@/components/users/forms/switch-tenant-form"; +import { MembershipDetailData, TenantOption } from "@/types/users"; export const MembershipItem = ({ membership, tenantName, tenantId, - isOwner, + isOrgOwner, + sessionTenantId, + availableTenants, + membershipCount, }: { membership: MembershipDetailData; tenantName: string; tenantId: string; - isOwner: boolean; + isOrgOwner: boolean; + sessionTenantId: string | undefined; + availableTenants: TenantOption[]; + membershipCount: number; }) => { const [isEditOpen, setIsEditOpen] = useState(false); + const [isSwitchingOpen, setIsSwitchingOpen] = useState(false); + const [isDeletingOpen, setIsDeletingOpen] = useState(false); + + const isActiveTenant = tenantId === sessionTenantId; + const canDelete = isOrgOwner && membershipCount > 1; return ( <> @@ -32,11 +43,31 @@ export const MembershipItem = ({ setIsOpen={setIsEditOpen} /> + + + + + +
- - {membership.attributes.role} - + {membership.attributes.role}
@@ -53,17 +84,46 @@ export const MembershipItem = ({
- {isOwner && ( - - )} +
+ {isOrgOwner && ( + + )} + {canDelete && ( + + )} + {isActiveTenant ? ( + + Active + + ) : ( + + )} +
diff --git a/ui/components/users/profile/memberships-card-client.tsx b/ui/components/users/profile/memberships-card-client.tsx new file mode 100644 index 0000000000..cab2a3f924 --- /dev/null +++ b/ui/components/users/profile/memberships-card-client.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; + +import { + Button, + Card, + CardAction, + CardContent, + CardHeader, + CardTitle, +} from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { CreateTenantForm } from "@/components/users/forms/create-tenant-form"; +import { MembershipDetailData, TenantDetailData } from "@/types/users"; + +import { MembershipItem } from "./membership-item"; + +interface MembershipsCardClientProps { + memberships: MembershipDetailData[]; + tenantsMap: Record; + hasManageAccount: boolean; + sessionTenantId: string | undefined; +} + +export const MembershipsCardClient = ({ + memberships, + tenantsMap, + hasManageAccount, + sessionTenantId, +}: MembershipsCardClientProps) => { + const [isCreateOpen, setIsCreateOpen] = useState(false); + + // Compute available tenants for delete target Select + const availableTenants = memberships.map((m) => { + const id = m.relationships.tenant.data.id; + return { id, name: tenantsMap[id]?.attributes.name || id }; + }); + + return ( + <> + + + + + +
+ Organizations +

+ Organizations this user is associated with +

+
+ + + +
+ + {memberships.length === 0 ? ( +
No memberships found.
+ ) : ( +
+ {memberships.map((membership) => { + const tenantId = membership.relationships.tenant.data.id; + return ( + t.id !== tenantId, + )} + membershipCount={memberships.length} + /> + ); + })} +
+ )} +
+
+ + ); +}; diff --git a/ui/components/users/profile/memberships-card.tsx b/ui/components/users/profile/memberships-card.tsx index 252721c1c0..56e03cb9f8 100644 --- a/ui/components/users/profile/memberships-card.tsx +++ b/ui/components/users/profile/memberships-card.tsx @@ -1,45 +1,24 @@ -import { Card, CardContent } from "@/components/shadcn"; import { MembershipDetailData, TenantDetailData } from "@/types/users"; -import { MembershipItem } from "./membership-item"; +import { MembershipsCardClient } from "./memberships-card-client"; export const MembershipsCard = ({ memberships, tenantsMap, - isOwner, + hasManageAccount, + sessionTenantId, }: { memberships: MembershipDetailData[]; tenantsMap: Record; - isOwner: boolean; + hasManageAccount: boolean; + sessionTenantId: string | undefined; }) => { return ( - - -
-

Organizations

-

- Organizations this user is associated with -

-
- {memberships.length === 0 ? ( -
No memberships found.
- ) : ( -
- {memberships.map((membership) => { - const tenantId = membership.relationships.tenant.data.id; - return ( - - ); - })} -
- )} -
-
+ ); }; diff --git a/ui/hooks/use-infinite-resources.test.ts b/ui/hooks/use-infinite-resources.test.ts index 1e9ac2f0ff..49ab0f9105 100644 --- a/ui/hooks/use-infinite-resources.test.ts +++ b/ui/hooks/use-infinite-resources.test.ts @@ -27,8 +27,6 @@ class MockIntersectionObserver { } } -vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); - /** Simulate the sentinel becoming visible in the scroll container. */ function triggerIntersection() { latestObserverCallback?.([ @@ -98,6 +96,7 @@ async function flushAsync() { describe("useInfiniteResources", () => { beforeEach(() => { + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); for (const mockFn of Object.values(findingGroupActionsMock)) { mockFn.mockReset(); } diff --git a/ui/lib/navigation.ts b/ui/lib/navigation.ts new file mode 100644 index 0000000000..eea56fce45 --- /dev/null +++ b/ui/lib/navigation.ts @@ -0,0 +1 @@ +export const reloadPage = () => window.location.reload(); diff --git a/ui/lib/permissions.ts b/ui/lib/permissions.ts index 7d1d4448e8..f75655621e 100644 --- a/ui/lib/permissions.ts +++ b/ui/lib/permissions.ts @@ -1,5 +1,9 @@ import { RolePermissionAttributes } from "@/types/users"; +/** + * Check if a user is owner of any organization and has manage_account permission. + * Currently unused — kept as a utility for future use outside the profile page. + */ export const isUserOwnerAndHasManageAccount = ( roles: any[], memberships: any[], diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index b44be9093a..c845d7ff65 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -365,7 +365,7 @@ importers: version: 5.1.2(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) '@vitest/coverage-v8': specifier: 4.0.18 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2)) + version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) autoprefixer: specifier: 10.4.19 version: 10.4.19(postcss@8.4.38) @@ -446,7 +446,7 @@ importers: version: 5.5.4 vitest: specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) packages: @@ -2234,8 +2234,8 @@ packages: '@cfworker/json-schema': optional: true - '@mswjs/interceptors@0.40.0': - resolution: {integrity: sha512-EFd6cVbHsgLa6wa4RljGj6Wk75qoHxUSyc5asLyyPSyuhIcdS2Q3Phw6ImS1q+CkALthJRShiYfKANcQMuMqsQ==} + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} '@napi-rs/wasm-runtime@0.2.12': @@ -2547,6 +2547,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@prisma/instrumentation@6.19.0': resolution: {integrity: sha512-QcuYy25pkXM8BJ37wVFBO7Zh34nyRV1GOb2n3lPkkbRYfl4hWl3PTcImP41P0KrzVXfa/45p6eVCos27x3exIg==} peerDependencies: @@ -5165,6 +5168,17 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/browser-playwright@4.0.18': + resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + peerDependencies: + playwright: '*' + vitest: 4.0.18 + + '@vitest/browser@4.0.18': + resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + peerDependencies: + vitest: 4.0.18 + '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -7672,11 +7686,15 @@ packages: motion-utils@11.18.1: resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.7: - resolution: {integrity: sha512-retd5i3xCZDVWMYjHEVuKTmhqY8lSsxujjVrZiGbbdoxxIBg5S7rCuYy/YQpfrTYIxpd/o0Kyb/3H+1udBMoYg==} + msw@2.12.14: + resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -8025,6 +8043,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pixelmatch@7.1.0: + resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} + hasBin: true + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -8042,6 +8064,10 @@ packages: engines: {node: '>=18'} hasBin: true + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -8456,8 +8482,8 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.7.0: - resolution: {integrity: sha512-LPRKoHnLKd/r3dVxcwO7vhCW+orkOGj9ViueosEBK6ie89CijnfRlhaDhHq/3Hxu4CkWQtxwlBG0mzTQY6uQjw==} + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -8617,6 +8643,10 @@ packages: simple-wcswidth@1.1.2: resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -8908,6 +8938,10 @@ packages: resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} hasBin: true + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tough-cookie@6.0.0: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} @@ -12313,7 +12347,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.40.0': + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -12645,6 +12679,9 @@ snapshots: dependencies: playwright: 1.56.1 + '@polka/url@1.0.0-next.29': + optional: true + '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -16013,7 +16050,39 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2))': + '@vitest/browser-playwright@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)': + dependencies: + '@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + playwright: 1.56.1 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)': + dependencies: + '@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/utils': 4.0.18 + magic-string: 0.30.21 + pixelmatch: 7.1.0 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.0.3 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + + '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -16025,7 +16094,9 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + optionalDependencies: + '@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/expect@4.0.18': dependencies: @@ -16036,13 +16107,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.7(@types/node@24.10.8)(typescript@5.5.4) + msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4) vite: 7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': @@ -19027,12 +19098,15 @@ snapshots: motion-utils@11.18.1: {} + mrmime@2.0.1: + optional: true + ms@2.1.3: {} - msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4): + msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.10.8) - '@mswjs/interceptors': 0.40.0 + '@mswjs/interceptors': 0.41.3 '@open-draft/deferred-promise': 2.2.0 '@types/statuses': 2.0.6 cookie: 1.1.1 @@ -19042,7 +19116,7 @@ snapshots: outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.7.0 + rettime: 0.10.1 statuses: 2.0.2 strict-event-emitter: 0.5.1 tough-cookie: 6.0.0 @@ -19369,6 +19443,11 @@ snapshots: pidtree@0.6.0: {} + pixelmatch@7.1.0: + dependencies: + pngjs: 7.0.0 + optional: true + pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -19385,6 +19464,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@7.0.0: + optional: true + points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -19854,7 +19936,7 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.7.0: {} + rettime@0.10.1: {} reusify@1.1.0: {} @@ -20039,7 +20121,7 @@ snapshots: fuzzysort: 3.1.0 https-proxy-agent: 7.0.6 kleur: 4.1.5 - msw: 2.12.7(@types/node@24.10.8)(typescript@5.5.4) + msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4) node-fetch: 3.3.2 open: 11.0.0 ora: 8.2.0 @@ -20176,6 +20258,13 @@ snapshots: simple-wcswidth@1.1.2: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + optional: true + sisteransi@1.0.5: {} slice-ansi@5.0.0: @@ -20471,6 +20560,9 @@ snapshots: dependencies: commander: 2.20.3 + totalist@3.0.1: + optional: true + tough-cookie@6.0.0: dependencies: tldts: 7.0.19 @@ -20791,10 +20883,10 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2): + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -20816,6 +20908,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.8 + '@vitest/browser-playwright': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) jsdom: 27.4.0(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti diff --git a/ui/types/users.ts b/ui/types/users.ts index a6ea551c03..88d73da5d0 100644 --- a/ui/types/users.ts +++ b/ui/types/users.ts @@ -150,6 +150,11 @@ export interface TenantDetailData { }; } +export interface TenantOption { + id: string; + name: string; +} + export type IncludedItem = RoleDetail | MembershipDetailData | TenantDetailData; export interface UserProfileResponse { diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index d5b791f355..b22d7f72cd 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, + restoreMocks: true, + mockReset: true, + unstubEnvs: true, + unstubGlobals: true, setupFiles: ["./vitest.setup.ts"], include: ["**/*.test.{ts,tsx}"], exclude: [