Compare commits

...

4 Commits

Author SHA1 Message Date
Pablo F.G
97237502fe docs(ui): add changelog entry for pre-commit GIT_WORK_TREE fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:48:39 +02:00
Pablo F.G
892d41360c fix(ui): unset GIT_WORK_TREE in pre-commit hook to fix path resolution
When Python pre-commit framework runs hooks, it sets GIT_WORK_TREE to
the subdirectory, causing git rev-parse --show-toplevel to return the
wrong path. This broke .env loading, staged file detection, and the
cd to ui/ directory. Unsetting GIT_WORK_TREE restores correct behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:32:40 +02:00
Pablo Fernandez Guerra (PFE)
961f9c86da feat(ui): Add tenant management (#10491)
Co-authored-by: Pablo Fernandez <pfe@NB0240.local>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: David <david.copo@gmail.com>
2026-04-06 10:31:30 +02:00
Andoni Alonso
0f1da703d1 docs(image): add Prowler App documentation and authentication guide (#10527)
Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
2026-04-06 09:59:56 +02:00
36 changed files with 1702 additions and 167 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

@@ -274,7 +274,8 @@
{
"group": "Image",
"pages": [
"user-guide/providers/image/getting-started-image"
"user-guide/providers/image/getting-started-image",
"user-guide/providers/image/authentication"
]
},
{

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

View File

@@ -0,0 +1,50 @@
---
title: "Image Authentication in Prowler"
---
Prowler's Image provider enables container image security scanning using [Trivy](https://trivy.dev/). No authentication is required for public images. Prowler supports the following authentication methods for private registries:
* [**Basic Authentication (Environment Variables)**](https://trivy.dev/latest/docs/advanced/private-registries/docker-hub/): `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`
* [**Token-Based Authentication**](https://distribution.github.io/distribution/spec/auth/token/): `REGISTRY_TOKEN`
* [**Manual Docker Login**](https://docs.docker.com/reference/cli/docker/login/): Existing credentials in Docker's credential store
Prowler uses the first available method in this priority order.
## Basic Authentication (Environment Variables)
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler passes these credentials to Trivy, which handles authentication with the registry transparently:
```bash
export REGISTRY_USERNAME="myuser"
export REGISTRY_PASSWORD="mypassword"
prowler image -I myregistry.io/myapp:v1.0
```
Both variables must be set for this method to activate.
## Token-Based Authentication
To authenticate using a registry token (such as a bearer or OAuth2 token), set the `REGISTRY_TOKEN` environment variable. Prowler passes the token directly to Trivy:
```bash
export REGISTRY_TOKEN="my-registry-token"
prowler image -I myregistry.io/myapp:v1.0
```
This method is useful for registries that support token-based access without requiring a username and password.
## Manual Docker Login (Fallback)
If no environment variables are set, Prowler relies on existing credentials in Docker's credential store (`~/.docker/config.json`). To configure credentials manually before scanning:
```bash
docker login myregistry.io
prowler image -I myregistry.io/myapp:v1.0
```
<Note>
This method is available in Prowler CLI only. In Prowler Cloud, use basic authentication or token-based authentication instead.
</Note>

View File

@@ -9,18 +9,69 @@ Prowler's Image provider enables comprehensive container image security scanning
## How It Works
* **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues.
* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan.
* **Authentication:** No registry authentication is required for public images. For private registries, credentials can be provided via environment variables or manual `docker login`.
* Check the [Image Authentication](/user-guide/providers/image/authentication) page for more details.
* **Mutelist logic:** [Filtering](https://trivy.dev/latest/docs/configuration/filtering/) is handled by Trivy, not Prowler.
* **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
<CardGroup cols={2}>
<Card title="Prowler Cloud" icon="cloud" href="#prowler-cloud">
Scan container images using Prowler Cloud
</Card>
<Card title="Prowler CLI" icon="terminal" href="#prowler-cli">
Scan container images using Prowler CLI
</Card>
</CardGroup>
## Prowler Cloud
<VersionBadge version="5.21.0" />
### Supported Scanners
Prowler Cloud does not support scanner selection. The vulnerability, secret, and misconfiguration scanners run automatically during each scan.
### Step 1: Access Prowler Cloud
1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app)
2. Navigate to "Configuration" > "Cloud Providers"
![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png)
3. Click "Add Cloud Provider"
![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png)
4. Select "Container Registry"
![Select Container Registry](/user-guide/img/select-container-registry.png)
5. Enter the container registry URL (e.g., `docker.io/myorg` or `myregistry.io`) and an optional alias, then click "Next"
![Add Container Registry URL](/user-guide/img/add-registry-url.png)
### Step 2: Enter Authentication and Scan Filters
6. Optionally provide [authentication](/user-guide/providers/image/authentication) credentials for private registries, then configure the following scan filters to control which images are scanned:
* **Image filter:** A regex pattern to filter repositories by name (e.g., `^prod/.*`)
* **Tag filter:** A regex pattern to filter tags within repositories (e.g., `^(latest|v\d+\.\d+\.\d+)$`)
Then click "Next"
![Image Authentication and Filters](/user-guide/img/image-authentication-filters.png)
### Step 3: Verify Connection & Start Scan
7. Review the provider configuration and click "Launch scan" to initiate the scan
![Verify Connection & Start Scan](/user-guide/img/image-verify-connection.png)
## Prowler CLI
<VersionBadge version="5.19.0" />
<Note>
The Image provider is currently available in Prowler CLI only.
</Note>
### Install Trivy
Install Trivy using one of the following methods:
@@ -55,7 +106,7 @@ Prowler CLI supports the following scanners:
* [Misconfiguration](https://trivy.dev/docs/latest/guide/scanner/misconfiguration/)
* [License](https://trivy.dev/docs/latest/guide/scanner/license/)
By default, only vulnerability and secret scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below.
By default, vulnerability, secret, and misconfiguration scanners run during a scan. To specify which scanners to use, refer to the [Specify Scanners](#specify-scanners) section below.
### Scan Container Images
@@ -112,7 +163,7 @@ Valid examples:
#### Specify Scanners
To select which scanners Trivy runs, use the `--scanners` option. By default, Prowler enables `vuln` and `secret` scanners:
To select which scanners Trivy runs, use the `--scanners` option:
```bash
# Vulnerability scanning only
@@ -272,7 +323,7 @@ To scan images from private registries, the Image provider supports three authen
#### 1. Basic Authentication (Environment Variables)
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler automatically runs `docker login`, pulls the image, and performs a `docker logout` after the scan completes:
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler passes these credentials to Trivy, which handles authentication with the registry transparently:
```bash
export REGISTRY_USERNAME="myuser"
@@ -281,7 +332,7 @@ export REGISTRY_PASSWORD="mypassword"
prowler image -I myregistry.io/myapp:v1.0
```
Both variables must be set for this method to activate. Prowler handles the full lifecycle — login, pull, scan, and cleanup — without any manual Docker commands.
Both variables must be set for this method to activate.
#### 2. Token-Based Authentication
@@ -306,7 +357,7 @@ prowler image -I myregistry.io/myapp:v1.0
```
<Note>
When basic authentication is active (method 1), Prowler automatically logs out from all authenticated registries after the scan completes. Manual `docker login` sessions (method 3) are not affected by this cleanup.
Credentials provided via environment variables are only passed to the Trivy subprocess and are not persisted beyond the scan.
</Note>
### Troubleshooting Common Scan Errors

View File

@@ -6,6 +6,9 @@
set -e
# Ensure git resolves the real repo root, not a pre-commit framework override
unset GIT_WORK_TREE
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'

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)
- Resource events tool to Lighthouse AI [(#10412)](https://github.com/prowler-cloud/prowler/pull/10412)
@@ -16,8 +17,10 @@ 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)
- Pre-commit hook now unsets `GIT_WORK_TREE` to fix path resolution when the variable is inherited from parent processes [(#10574)](https://github.com/prowler-cloud/prowler/pull/10574)
---

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