Compare commits

...

69 Commits

Author SHA1 Message Date
Pablo F.G
9e4bf7e89d Merge remote-tracking branch 'origin/master' into feat/prowler-450-organization-switch 2026-04-01 16:32:17 +02:00
Pablo F.G
0440272f36 revert: vitest browser test 2026-04-01 16:22:27 +02:00
Pablo F.G
393373368c fix: format 2026-03-31 13:40:05 +02:00
Pablo F.G
9417293e5b test: fix vite optimization triggering test fail 2026-03-31 13:35:23 +02:00
Pablo F.G
a309595f00 test: fix 2026-03-31 13:26:32 +02:00
Pablo F.G
684d496055 test: fix 2026-03-31 13:24:10 +02:00
Pablo F.G
941b90eed2 ci: added browser install 2026-03-31 11:00:09 +02:00
Pablo F.G
e7f6401a98 Merge remote-tracking branch 'origin/feat/prowler-450-organization-switch' into feat/prowler-450-organization-switch 2026-03-31 10:53:43 +02:00
Pablo F.G
10f5311c39 test: stub on every test 2026-03-31 10:53:36 +02:00
Pablo F.G
db5be85416 ci: added missing browser install 2026-03-31 10:51:54 +02:00
Pablo F.G
34fd0cdf94 Merge remote-tracking branch 'origin/master' into feat/prowler-450-organization-switch 2026-03-31 10:12:41 +02:00
David
05d2bdaac2 fix(api): Refactor dispatch Tenant view 2026-03-31 09:41:34 +02:00
Pablo F.G
4b31f58ad7 Merge remote-tracking branch 'origin/master' into feat/prowler-450-organization-switch 2026-03-30 14:30:29 +02:00
Pablo F.G
e375724590 test(ui): update tests for permission model and review feedback
- Replace isOwner prop with hasManageAccount in test renderCard helper
- Add ALL_OWNERS fixtures for tests requiring owner on every membership
- Update harness from alertdialog to dialog role (AlertModal→Modal)
- Assert reloadPage() is called after successful form submissions
- Assert reloadPage() is NOT called on switch failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:22:56 +02:00
Pablo F.G
5a5e888e50 refactor(ui): address PR review feedback
- Use shadcn Input component instead of raw <input> in delete confirmation
- Add aria-label to delete confirmation input for accessibility
- Use discriminated unions for state types (CreateTenant, DeleteTenant,
  SwitchThenDeleteTenant) to prevent invalid state combinations
- Export API_BASE from MSW handlers as shared constant
- Type MSW request body explicitly instead of `as any`
- Scope process.env in vitest browser config to specific vars
- Remove unused `import React` from page.tsx

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:21:41 +02:00
Pablo F.G
91d88fafb4 refactor(ui): replace AlertModal with existing Modal component
AlertDialog/AlertModal were unnecessary duplicates of the existing Modal
component (208 lines). Both switch and delete confirmations are safely
dismissable — no data loss on cancel — so Dialog semantics are correct.

Also extracts TenantOption type to types/users.ts for reuse.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:19:44 +02:00
Pablo F.G
55d9115348 Merge remote-tracking branch 'origin/feat/prowler-450-organization-switch' into feat/prowler-450-organization-switch 2026-03-30 13:39:46 +02:00
Pablo F.G
9f8f788631 fix: correct when delete org button is visible to match API 2026-03-30 13:37:27 +02:00
David
18e8e4567f fix(api): Delete ignores in Safety 2026-03-30 12:11:12 +02:00
David
e3ef58e34f fix(api): Tenants deleting permissions 2026-03-30 12:07:48 +02:00
David
32997d3e1a Merge branch 'feat/prowler-450-organization-switch' of github.com:prowler-cloud/prowler into feat/prowler-450-organization-switch 2026-03-30 10:14:06 +02:00
David
cded7f1564 fix(api): Tenants deleting permissions 2026-03-30 10:13:56 +02:00
Pablo F.G
726b580293 Merge remote-tracking branch 'origin/master' into feat/prowler-450-organization-switch
# Conflicts:
#	api/CHANGELOG.md
#	ui/CHANGELOG.md
#	ui/vitest.setup.ts
2026-03-27 13:25:54 +01:00
Pablo F.G
6270e84377 Merge remote-tracking branch 'origin/feat/prowler-450-organization-switch' into feat/prowler-450-organization-switch 2026-03-27 11:23:06 +01:00
Pablo F.G
6573da8f43 fix: issue when deleting the current org not refreshing active org (visual only) 2026-03-27 11:22:55 +01:00
David
500b941126 fix(api): Wrap admin role creation in atomic transaction 2026-03-27 10:58:42 +01:00
Pablo F.G
cf2696644c chore: clean up 2026-03-27 09:19:12 +01:00
Pablo F.G
177d684724 Merge remote-tracking branch 'origin/feat/prowler-450-organization-switch' into feat/prowler-450-organization-switch 2026-03-27 09:09:56 +01:00
David
dd21c204fc fix(api): Add ignore to safety SFTY-20260218-01424 2026-03-27 09:06:16 +01:00
Pablo F.G
460b1c7ef7 chore: clean up 2026-03-27 09:05:49 +01:00
Pablo F.G
4532dcc497 test: updated default mock api url 2026-03-27 08:48:18 +01:00
Pablo F.G
8729e31fa0 docs: updated changelog 2026-03-27 08:45:31 +01:00
Pablo F.G
51145e76ff chore: lint 2026-03-27 08:43:50 +01:00
Pablo F.G
36a8483b35 Merge branch 'master' into feat/prowler-450-organization-switch 2026-03-27 08:31:11 +01:00
Pablo F.G
0977844bc7 test: added test and fixed reload issue 2026-03-27 08:31:01 +01:00
Pablo F.G
d8b677ec11 test: added fake env var for api url 2026-03-27 08:30:35 +01:00
Pablo F.G
a6d4527140 chore(pnpm): approve build 2026-03-27 08:29:29 +01:00
Pablo F.G
8eca2c0d3b fix: delete current tennat 2026-03-27 08:28:11 +01:00
Pablo F.G
451b35cdcd Merge branch 'master' into feat/prowler-450-organization-switch 2026-03-26 15:35:15 +01:00
Pablo F.G
78fba2bde7 test: added page test, harness and msw handlers 2026-03-26 15:34:46 +01:00
Pablo F.G
54f2bdcc65 test: removed component components 2026-03-26 15:33:41 +01:00
Pablo F.G
eabad326ea Merge remote-tracking branch 'origin/feat/prowler-450-organization-switch' into feat/prowler-450-organization-switch 2026-03-26 15:23:56 +01:00
David
650691ee94 Merge branch 'feat/prowler-450-organization-switch' of github.com:prowler-cloud/prowler into feat/prowler-450-organization-switch 2026-03-26 13:16:50 +01:00
David
a198a0e0e8 fix(api): filter RBAC role lookup by tenant_id to prevent cross-tenant privilege leak 2026-03-26 13:16:31 +01:00
Pablo F.G
8f7c19822e feat(ui): now any user can create an organization 2026-03-26 13:09:01 +01:00
Pablo F.G
9cab9cacc8 Merge branch 'master' into feat/prowler-450-organization-switch 2026-03-26 11:24:32 +01:00
Pablo F.G
e77af0043e Merge remote-tracking branch 'origin/feat/prowler-450-organization-switch' into feat/prowler-450-organization-switch 2026-03-26 11:10:36 +01:00
David
9b30ed184a fix(api): assign admin RBAC role to user on tenant creation 2026-03-26 11:07:05 +01:00
Pablo F.G
17e59c64c0 fix: format 2026-03-26 10:44:37 +01:00
Pablo F.G
1b40fdc9a7 Merge remote-tracking branch 'origin/master' into feat/prowler-450-organization-switch 2026-03-26 08:43:56 +01:00
Pablo F.G
5dccc264e2 style(ui): apply lint and format fixes
Import ordering, semicolons, and Prettier adjustments
from pre-commit hooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:42:38 +01:00
Pablo F.G
06ed21c8e9 test(ui): add browser tests with Harness for create/delete tenant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:25:47 +01:00
Pablo F.G
3777f63b58 test(ui): extend MembershipItem tests for delete button visibility
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:22:05 +01:00
Pablo F.G
96b5da678b test(ui): add jsdom tests for DeleteTenantForm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:35:10 +01:00
Pablo F.G
cf17c77834 test(ui): add jsdom tests for CreateTenantForm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:28:45 +01:00
Pablo F.G
a470c52fe9 feat(ui): add Create/Delete buttons, server/client split for MembershipsCard
Split MembershipsCard into server wrapper and MembershipsCardClient to own
modal state. Add Delete button to MembershipItem with confirmation dialog.
Pass hasManageAccount to MembershipsCard for Create org button visibility.
Update existing tests with new required props.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:19:15 +01:00
Pablo F.G
e0eb7d0484 feat(ui): add DeleteTenantForm with name confirmation and target Select
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:12:22 +01:00
Pablo F.G
40ba07888c feat(ui): add CreateTenantForm component with auto-switch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:08:12 +01:00
Pablo F.G
50100befa4 feat(ui): add deleteTenant server action
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:05:17 +01:00
Pablo F.G
01804e594b feat(ui): add createTenant server action
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 16:02:12 +01:00
Pablo Fernandez
8fe132d018 feat(ui): set up Vitest Browser Mode infrastructure with MSW service worker
Establish a robust testing foundation for browser-mode component tests using
the official MSW + Vitest Browser Mode pattern (test context extension with
auto fixture). This enables future browser tests to intercept network requests
via service worker instead of fragile module-level mocks.

- Add MSW with setupWorker and shared API handlers (testing/msw/)
- Add test-extend.ts with worker fixture (auto: true, onUnhandledRequest: error)
- Add render-browser.tsx utility with SessionProvider wrapper
- Improve vitest.config.ts: mock hygiene (restoreMocks, mockReset, unstubEnvs, unstubGlobals)
- Add environment-aware vitest.setup.ts: disable CSS animations in browser mode
- Add per-project npm scripts (test:unit, test:browser, test:browser:headed)
- Update browser test to use extended test context
- Add __screenshots__/ to .gitignore

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:07:27 +01:00
Pablo Fernandez
74181e5490 feat(ui): introduce Vitest Browser Mode with projects config and harness pattern
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:32:35 +01:00
Pablo Fernandez
48c69dcaf2 test(ui): add jsdom+RTL tests for SwitchTenantForm and MembershipItem
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:32:20 +01:00
Pablo Fernandez
2b5a4c890f feat(ui): add Switch button and Active badge to MembershipItem, migrate Chip to Badge
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:31:32 +01:00
Pablo Fernandez
a12693d1e5 feat(ui): add SwitchTenantForm component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:31:15 +01:00
Pablo Fernandez
c18212c60f feat(ui): handle session update trigger in JWT callback for tenant switch
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:31:00 +01:00
Pablo Fernandez
535c07e403 feat(ui): add switchTenant server action
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:30:47 +01:00
Pablo Fernandez
a30dbf5e05 feat(ui): add AlertModal component (shadcn equivalent of CustomAlertModal)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:30:34 +01:00
Pablo Fernandez
872f8b3708 feat(ui): add shadcn AlertDialog primitives via CLI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:30:10 +01:00
28 changed files with 1585 additions and 156 deletions

1
.gitignore vendored
View File

@@ -60,6 +60,7 @@ htmlcov/
**/mcp-config.json
**/mcpServers.json
.mcp/
.mcp.json
# AI Coding Assistants - Cursor
.cursorignore

View File

@@ -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)

View File

@@ -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:

View File

@@ -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]:

View File

@@ -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)

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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)
### 🔄 Changed
@@ -15,6 +16,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)

View File

@@ -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<UpdateTenantNameState> {
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<SwitchTenantState> {
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<CreateTenantState> {
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<DeleteTenantState> {
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<SwitchThenDeleteTenantState> {
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<string, string> = {
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,
};
}
}

View File

@@ -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 ({
<MembershipsCard
memberships={membershipsIncluded}
tenantsMap={tenantsMap}
isOwner={isOwner && hasManageAccount}
hasManageAccount={hasManageAccount}
sessionTenantId={session?.tenantId}
/>
{hasManageAccount && <ApiKeysCard searchParams={searchParams} />}
</div>

View File

@@ -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) {

View File

@@ -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(<CreateTenantForm setIsOpen={setIsOpen} />);
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(<CreateTenantForm setIsOpen={setIsOpen} />);
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(setIsOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -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<SetStateAction<boolean>>;
}) => {
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 (
<form action={formAction} className="flex flex-col gap-4">
<CustomServerInput
name="name"
label="Organization name"
placeholder="Enter organization name"
labelPlacement="outside"
variant="bordered"
isRequired={true}
isInvalid={!!(state && "error" in state)}
errorMessage={state && "error" in state ? state.error : undefined}
/>
<FormButtons
setIsOpen={setIsOpen}
submitText="Create"
loadingText="Creating"
rightIcon={null}
/>
</form>
);
};

View File

@@ -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(<DeleteTenantForm {...baseProps} />);
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(<DeleteTenantForm {...baseProps} />);
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(<DeleteTenantForm {...baseProps} />);
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(<DeleteTenantForm {...baseProps} />);
expect(
screen.queryByText(/switch to after deletion/i),
).not.toBeInTheDocument();
});
it("shows target tenant select for active tenant", () => {
render(<DeleteTenantForm {...baseProps} isActiveTenant={true} />);
expect(screen.getByText(/switch to after deletion/i)).toBeInTheDocument();
});
it("closes modal on cancel click", async () => {
const user = userEvent.setup();
render(<DeleteTenantForm {...baseProps} />);
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(baseProps.setIsOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -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<SetStateAction<boolean>>;
}
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<HTMLFormElement>) => {
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 (
<form
action={isActiveTenant ? undefined : deleteFormAction}
onSubmit={isActiveTenant ? handleActiveTenantDelete : undefined}
className="flex flex-col gap-4"
>
<input type="hidden" name="tenantId" value={tenantId} />
{isActiveTenant && targetTenantId && (
<input type="hidden" name="targetTenantId" value={targetTenantId} />
)}
<div className="text-sm">
Type <span className="font-bold">{tenantName}</span> to confirm
deletion:
</div>
<Input
value={confirmName}
onChange={(e) => setConfirmName(e.target.value)}
placeholder={tenantName}
aria-label={`Type ${tenantName} to confirm deletion`}
autoComplete="off"
/>
{isActiveTenant && (
<div className="flex flex-col gap-2">
<div className="text-sm">
This is your active organization. Select which organization to
switch to after deletion:
</div>
<Select value={targetTenantId} onValueChange={setTargetTenantId}>
<SelectTrigger>
<SelectValue placeholder="Select organization" />
</SelectTrigger>
<SelectContent>
{availableTenants.map((tenant) => (
<SelectItem key={tenant.id} value={tenant.id}>
{tenant.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<FormButtons
setIsOpen={setIsOpen}
submitText={isSubmitting ? "Deleting" : "Delete"}
loadingText="Deleting"
submitColor="danger"
isDisabled={!canSubmit || isSubmitting}
rightIcon={null}
/>
</form>
);
};

View File

@@ -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(<SwitchTenantForm tenantId="test-uuid" setIsOpen={setIsOpen} />);
expect(
screen.getByRole("button", { name: /confirm/i }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
it("includes hidden tenantId input", () => {
render(<SwitchTenantForm tenantId="test-uuid" setIsOpen={setIsOpen} />);
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(<SwitchTenantForm tenantId="test-uuid" setIsOpen={setIsOpen} />);
await user.click(screen.getByRole("button", { name: /cancel/i }));
expect(setIsOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -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<SetStateAction<boolean>>;
}) => {
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 (
<form action={formAction}>
<input type="hidden" name="tenantId" value={tenantId} />
<FormButtons
setIsOpen={setIsOpen}
submitText="Confirm"
loadingText="Switching"
rightIcon={null}
/>
</form>
);
};

View File

@@ -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(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="different-tenant"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByRole("button", { name: /switch/i })).toBeInTheDocument();
expect(screen.queryByText("Active")).not.toBeInTheDocument();
});
it("shows Active badge when active tenant", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByText("Active")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /switch/i }),
).not.toBeInTheDocument();
});
it("shows Edit button when user is owner", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={true}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument();
});
it("hides Edit button when user is not owner", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(
screen.queryByRole("button", { name: /edit/i }),
).not.toBeInTheDocument();
});
it("displays membership role as badge", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(screen.getByText("owner")).toBeInTheDocument();
});
it("shows Delete button when isOrgOwner and membershipCount > 1", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={true}
sessionTenantId="tenant-1"
availableTenants={[{ id: "tenant-2", name: "Other Org" }]}
membershipCount={2}
/>,
);
expect(screen.getByRole("button", { name: /delete/i })).toBeInTheDocument();
});
it("hides Delete button when membershipCount === 1", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={true}
sessionTenantId="tenant-1"
availableTenants={[]}
membershipCount={1}
/>,
);
expect(
screen.queryByRole("button", { name: /delete/i }),
).not.toBeInTheDocument();
});
it("hides Delete button when not isOrgOwner", () => {
render(
<MembershipItem
membership={baseMembership}
tenantName="Test Org"
tenantId="tenant-1"
isOrgOwner={false}
sessionTenantId="tenant-1"
availableTenants={[{ id: "tenant-2", name: "Other Org" }]}
membershipCount={2}
/>,
);
expect(
screen.queryByRole("button", { name: /delete/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -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}
/>
</Modal>
<Modal
open={isSwitchingOpen}
onOpenChange={setIsSwitchingOpen}
title="Confirm organization switch"
description="The session will be updated and the page will reload to apply the change."
>
<SwitchTenantForm tenantId={tenantId} setIsOpen={setIsSwitchingOpen} />
</Modal>
<Modal
open={isDeletingOpen}
onOpenChange={setIsDeletingOpen}
title="Delete organization"
description="This will permanently delete the organization and all its data. Users with no other organizations will lose access. This action cannot be undone."
>
<DeleteTenantForm
tenantId={tenantId}
tenantName={tenantName}
isActiveTenant={isActiveTenant}
availableTenants={availableTenants}
setIsOpen={setIsDeletingOpen}
/>
</Modal>
<Card variant="inner" className="p-2">
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<Chip size="sm" variant="flat" color="secondary">
{membership.attributes.role}
</Chip>
<Badge variant="secondary">{membership.attributes.role}</Badge>
<div className="flex flex-row flex-wrap gap-1 gap-x-4">
<InfoField label="Name" inline variant="transparent">
@@ -53,17 +84,46 @@ export const MembershipItem = ({
</InfoField>
</div>
{isOwner && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsEditOpen(true)}
className="ml-auto"
>
Edit
</Button>
)}
<div className="ml-auto flex items-center gap-2">
{isOrgOwner && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsEditOpen(true)}
>
Edit
</Button>
)}
{canDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setIsDeletingOpen(true)}
>
Delete
</Button>
)}
{isActiveTenant ? (
<Badge
variant="outline"
className="border-emerald-600 text-emerald-600"
>
Active
</Badge>
) : (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsSwitchingOpen(true)}
>
Switch
</Button>
)}
</div>
</div>
</Card>
</>

View File

@@ -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<string, TenantDetailData>;
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 (
<>
<Modal
open={isCreateOpen}
onOpenChange={setIsCreateOpen}
title="Create organization"
>
<CreateTenantForm setIsOpen={setIsCreateOpen} />
</Modal>
<Card variant="base" padding="none" className="p-4">
<CardHeader>
<div className="flex flex-col gap-1">
<CardTitle>Organizations</CardTitle>
<p className="text-xs text-gray-500">
Organizations this user is associated with
</p>
</div>
<CardAction>
<Button
variant="default"
size="sm"
onClick={() => setIsCreateOpen(true)}
>
Create organization
</Button>
</CardAction>
</CardHeader>
<CardContent>
{memberships.length === 0 ? (
<div className="text-sm text-gray-500">No memberships found.</div>
) : (
<div className="flex flex-col gap-2">
{memberships.map((membership) => {
const tenantId = membership.relationships.tenant.data.id;
return (
<MembershipItem
key={membership.id}
membership={membership}
tenantId={tenantId}
tenantName={tenantsMap[tenantId]?.attributes.name}
isOrgOwner={
hasManageAccount && membership.attributes.role === "owner"
}
sessionTenantId={sessionTenantId}
availableTenants={availableTenants.filter(
(t) => t.id !== tenantId,
)}
membershipCount={memberships.length}
/>
);
})}
</div>
)}
</CardContent>
</Card>
</>
);
};

View File

@@ -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<string, TenantDetailData>;
isOwner: boolean;
hasManageAccount: boolean;
sessionTenantId: string | undefined;
}) => {
return (
<Card variant="base" padding="none" className="p-4">
<CardContent>
<div className="mb-6 flex flex-col gap-1">
<h4 className="text-lg font-bold">Organizations</h4>
<p className="text-xs text-gray-500">
Organizations this user is associated with
</p>
</div>
{memberships.length === 0 ? (
<div className="text-sm text-gray-500">No memberships found.</div>
) : (
<div className="flex flex-col gap-2">
{memberships.map((membership) => {
const tenantId = membership.relationships.tenant.data.id;
return (
<MembershipItem
key={membership.id}
membership={membership}
tenantId={tenantId}
tenantName={tenantsMap[tenantId]?.attributes.name}
isOwner={isOwner}
/>
);
})}
</div>
)}
</CardContent>
</Card>
<MembershipsCardClient
memberships={memberships}
tenantsMap={tenantsMap}
hasManageAccount={hasManageAccount}
sessionTenantId={sessionTenantId}
/>
);
};

View File

@@ -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();
}

1
ui/lib/navigation.ts Normal file
View File

@@ -0,0 +1 @@
export const reloadPage = () => window.location.reload();

View File

@@ -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[],

133
ui/pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -150,6 +150,11 @@ export interface TenantDetailData {
};
}
export interface TenantOption {
id: string;
name: string;
}
export type IncludedItem = RoleDetail | MembershipDetailData | TenantDetailData;
export interface UserProfileResponse {

View File

@@ -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: [