mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-04 22:46:55 +00:00
Compare commits
69 Commits
dependabot
...
feat/prowl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e4bf7e89d | ||
|
|
0440272f36 | ||
|
|
393373368c | ||
|
|
9417293e5b | ||
|
|
a309595f00 | ||
|
|
684d496055 | ||
|
|
941b90eed2 | ||
|
|
e7f6401a98 | ||
|
|
10f5311c39 | ||
|
|
db5be85416 | ||
|
|
34fd0cdf94 | ||
|
|
05d2bdaac2 | ||
|
|
4b31f58ad7 | ||
|
|
e375724590 | ||
|
|
5a5e888e50 | ||
|
|
91d88fafb4 | ||
|
|
55d9115348 | ||
|
|
9f8f788631 | ||
|
|
18e8e4567f | ||
|
|
e3ef58e34f | ||
|
|
32997d3e1a | ||
|
|
cded7f1564 | ||
|
|
726b580293 | ||
|
|
6270e84377 | ||
|
|
6573da8f43 | ||
|
|
500b941126 | ||
|
|
cf2696644c | ||
|
|
177d684724 | ||
|
|
dd21c204fc | ||
|
|
460b1c7ef7 | ||
|
|
4532dcc497 | ||
|
|
8729e31fa0 | ||
|
|
51145e76ff | ||
|
|
36a8483b35 | ||
|
|
0977844bc7 | ||
|
|
d8b677ec11 | ||
|
|
a6d4527140 | ||
|
|
8eca2c0d3b | ||
|
|
451b35cdcd | ||
|
|
78fba2bde7 | ||
|
|
54f2bdcc65 | ||
|
|
eabad326ea | ||
|
|
650691ee94 | ||
|
|
a198a0e0e8 | ||
|
|
8f7c19822e | ||
|
|
9cab9cacc8 | ||
|
|
e77af0043e | ||
|
|
9b30ed184a | ||
|
|
17e59c64c0 | ||
|
|
1b40fdc9a7 | ||
|
|
5dccc264e2 | ||
|
|
06ed21c8e9 | ||
|
|
3777f63b58 | ||
|
|
96b5da678b | ||
|
|
cf17c77834 | ||
|
|
a470c52fe9 | ||
|
|
e0eb7d0484 | ||
|
|
40ba07888c | ||
|
|
50100befa4 | ||
|
|
01804e594b | ||
|
|
8fe132d018 | ||
|
|
74181e5490 | ||
|
|
48c69dcaf2 | ||
|
|
2b5a4c890f | ||
|
|
a12693d1e5 | ||
|
|
c18212c60f | ||
|
|
535c07e403 | ||
|
|
a30dbf5e05 | ||
|
|
872f8b3708 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -60,6 +60,7 @@ htmlcov/
|
||||
**/mcp-config.json
|
||||
**/mcpServers.json
|
||||
.mcp/
|
||||
.mcp.json
|
||||
|
||||
# AI Coding Assistants - Cursor
|
||||
.cursorignore
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
42
ui/components/users/forms/create-tenant-form.test.tsx
Normal file
42
ui/components/users/forms/create-tenant-form.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
97
ui/components/users/forms/create-tenant-form.tsx
Normal file
97
ui/components/users/forms/create-tenant-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
90
ui/components/users/forms/delete-tenant-form.test.tsx
Normal file
90
ui/components/users/forms/delete-tenant-form.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
171
ui/components/users/forms/delete-tenant-form.tsx
Normal file
171
ui/components/users/forms/delete-tenant-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
50
ui/components/users/forms/switch-tenant-form.test.tsx
Normal file
50
ui/components/users/forms/switch-tenant-form.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
60
ui/components/users/forms/switch-tenant-form.tsx
Normal file
60
ui/components/users/forms/switch-tenant-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
169
ui/components/users/profile/membership-item.test.tsx
Normal file
169
ui/components/users/profile/membership-item.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
97
ui/components/users/profile/memberships-card-client.tsx
Normal file
97
ui/components/users/profile/memberships-card-client.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
1
ui/lib/navigation.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const reloadPage = () => window.location.reload();
|
||||
@@ -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
133
ui/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -150,6 +150,11 @@ export interface TenantDetailData {
|
||||
};
|
||||
}
|
||||
|
||||
export interface TenantOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type IncludedItem = RoleDetail | MembershipDetailData | TenantDetailData;
|
||||
|
||||
export interface UserProfileResponse {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user