mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-06-09 21:04:53 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21e7f29153 | |||
| de51eed96c | |||
| 835dbddc6a | |||
| 103761f146 | |||
| 0e6268e159 | |||
| f48984e6a1 | |||
| 6df80a4890 |
@@ -145,7 +145,7 @@ SENTRY_RELEASE=local
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT}
|
||||
|
||||
#### Prowler release version ####
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.0
|
||||
NEXT_PUBLIC_PROWLER_RELEASE_VERSION=v5.29.2
|
||||
|
||||
# Social login credentials
|
||||
SOCIAL_GOOGLE_OAUTH_CALLBACK_URL="${AUTH_URL}/api/auth/callback/google"
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.30.1] (Prowler v5.29.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `GET /api/v1/findings` N+1 query loading `resources__tags` when listing findings [(#11420)](https://github.com/prowler-cloud/prowler/pull/11420)
|
||||
- Clean up the scan tmp output directory when `scan-report` fails so partial files do not accumulate and fill the worker disk (`No space left on device`) [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
|
||||
|
||||
---
|
||||
|
||||
## [1.30.0] (Prowler v5.29.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
+2
-2
@@ -43,7 +43,7 @@ dependencies = [
|
||||
"defusedxml==0.7.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==6.1.0",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.29",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (==1.3.0)",
|
||||
"sentry-sdk[django] (==2.56.0)",
|
||||
@@ -68,7 +68,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.30.0"
|
||||
version = "1.30.2"
|
||||
|
||||
[tool.uv]
|
||||
# Transitive pins matching master to avoid silent drift; bump deliberately.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.30.0
|
||||
version: 1.30.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ from conftest import (
|
||||
today_after_n_days,
|
||||
)
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from django.urls import reverse
|
||||
from django_celery_results.models import TaskResult
|
||||
from rest_framework import status
|
||||
@@ -64,6 +66,7 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Resource,
|
||||
ResourceFindingMapping,
|
||||
ResourceTag,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
@@ -3856,16 +3859,20 @@ class TestScanViewSet:
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
dummy_task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
dummy_task.id = "dummy-task-id"
|
||||
dummy_task_data = {"id": dummy_task.id, "state": StateChoices.EXECUTING}
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=task_result,
|
||||
)
|
||||
dummy_task_data = {"id": str(task.id), "state": StateChoices.EXECUTING}
|
||||
|
||||
with (
|
||||
patch("api.v1.views.Task.objects.get", return_value=dummy_task),
|
||||
patch(
|
||||
"api.v1.views.TaskSerializer",
|
||||
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
|
||||
),
|
||||
with patch(
|
||||
"api.v1.views.TaskSerializer",
|
||||
return_value=type("DummySerializer", (), {"data": dummy_task_data}),
|
||||
):
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
@@ -4186,6 +4193,88 @@ class TestScanViewSet:
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"] == presigned_url
|
||||
|
||||
def test_compliance_s3_returns_latest_match(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""When several files match, the most recently modified one is served."""
|
||||
scan = scans_fixture[0]
|
||||
bucket = "bucket"
|
||||
scan.output_location = f"s3://{bucket}/path/scan.zip"
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"api.v1.views.env",
|
||||
type("env", (), {"str": lambda self, *args, **kwargs: "test-bucket"})(),
|
||||
)
|
||||
|
||||
old_key = "path/compliance/prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
latest_key = "path/compliance/prowler-output-aws-20240202000000_cis_1.4_aws.csv"
|
||||
|
||||
class FakeS3Client:
|
||||
def list_objects_v2(self, Bucket, Prefix):
|
||||
return {
|
||||
"Contents": [
|
||||
{
|
||||
"Key": old_key,
|
||||
"LastModified": datetime(2024, 1, 1, tzinfo=timezone.utc),
|
||||
},
|
||||
{
|
||||
"Key": latest_key,
|
||||
"LastModified": datetime(2024, 2, 2, tzinfo=timezone.utc),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def generate_presigned_url(self, ClientMethod, Params, ExpiresIn):
|
||||
assert Params["Key"] == latest_key
|
||||
return "https://test-bucket.s3.amazonaws.com/latest"
|
||||
|
||||
monkeypatch.setattr("api.v1.views.get_s3_client", lambda: FakeS3Client())
|
||||
|
||||
url = reverse("scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"})
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_302_FOUND
|
||||
assert resp["Location"].endswith("/latest")
|
||||
|
||||
def test_compliance_local_returns_latest_match(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
"""The local branch serves the most recently modified matching file."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
comp_dir = Path(tmp) / "reports" / "compliance"
|
||||
comp_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_file = comp_dir / "prowler-output-aws-20240101000000_cis_1.4_aws.csv"
|
||||
old_file.write_bytes(b"old")
|
||||
latest_file = comp_dir / "prowler-output-aws-20240202000000_cis_1.4_aws.csv"
|
||||
latest_file.write_bytes(b"latest")
|
||||
# Make `latest_file` newer regardless of creation order.
|
||||
os.utime(old_file, (1_700_000_000, 1_700_000_000))
|
||||
os.utime(latest_file, (1_700_000_100, 1_700_000_100))
|
||||
|
||||
scan.output_location = str(Path(tmp) / "reports" / "scan.zip")
|
||||
scan.save()
|
||||
|
||||
monkeypatch.setattr(
|
||||
glob,
|
||||
"glob",
|
||||
lambda p: [str(old_file), str(latest_file)],
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
"scan-compliance", kwargs={"pk": scan.id, "name": "cis_1.4_aws"}
|
||||
)
|
||||
resp = authenticated_client.get(url)
|
||||
assert resp.status_code == status.HTTP_200_OK
|
||||
assert resp.content == b"latest"
|
||||
assert resp["Content-Disposition"].endswith(
|
||||
f'filename="{latest_file.name}"'
|
||||
)
|
||||
|
||||
def test_compliance_s3_not_found(
|
||||
self, authenticated_client, scans_fixture, monkeypatch
|
||||
):
|
||||
@@ -4294,18 +4383,24 @@ class TestScanViewSet:
|
||||
assert cd.startswith('attachment; filename="')
|
||||
assert cd.endswith(f'filename="{fname.name}"')
|
||||
|
||||
@patch("api.v1.views.Task.objects.get")
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
def test__get_task_status_returns_none_if_task_not_executing(
|
||||
self, mock_task_serializer, mock_task_get, authenticated_client, scans_fixture
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
):
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
task = Task.objects.create(tenant_id=scan.tenant_id)
|
||||
mock_task_get.return_value = task
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=task_result,
|
||||
)
|
||||
mock_task_serializer.return_value.data = {
|
||||
"id": str(task.id),
|
||||
"state": StateChoices.COMPLETED,
|
||||
@@ -4326,6 +4421,7 @@ class TestScanViewSet:
|
||||
scan.save()
|
||||
|
||||
task_result = TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
)
|
||||
@@ -4346,6 +4442,51 @@ class TestScanViewSet:
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert response.data["id"] == str(task.id)
|
||||
|
||||
@patch("api.v1.views.TaskSerializer")
|
||||
def test__get_task_status_returns_latest_task(
|
||||
self, mock_task_serializer, authenticated_client, scans_fixture
|
||||
):
|
||||
"""With several scan-report tasks for the scan, the most recent is used."""
|
||||
scan = scans_fixture[0]
|
||||
scan.state = StateChoices.COMPLETED
|
||||
scan.output_location = "dummy"
|
||||
scan.save()
|
||||
|
||||
old_task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
),
|
||||
)
|
||||
new_task = Task.objects.create(
|
||||
tenant_id=scan.tenant_id,
|
||||
task_runner_task=TaskResult.objects.create(
|
||||
task_id=str(uuid4()),
|
||||
task_name="scan-report",
|
||||
task_kwargs={"scan_id": str(scan.id)},
|
||||
),
|
||||
)
|
||||
# `inserted_at` is `auto_now_add`, and within the test transaction the DB
|
||||
# `now()` is constant, so force distinct timestamps to make order_by stable.
|
||||
base = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
||||
Task.objects.filter(pk=old_task.pk).update(inserted_at=base)
|
||||
Task.objects.filter(pk=new_task.pk).update(
|
||||
inserted_at=base + timedelta(hours=1)
|
||||
)
|
||||
|
||||
mock_task_serializer.side_effect = lambda instance, *a, **k: SimpleNamespace(
|
||||
data={"id": str(instance.id), "state": StateChoices.EXECUTING}
|
||||
)
|
||||
|
||||
url = reverse("scan-report", kwargs={"pk": scan.id})
|
||||
response = authenticated_client.get(url)
|
||||
|
||||
assert response.status_code == status.HTTP_202_ACCEPTED
|
||||
assert str(new_task.id) in response["Content-Location"]
|
||||
assert str(old_task.id) not in response["Content-Location"]
|
||||
|
||||
@patch("api.v1.views.get_s3_client")
|
||||
@patch("api.v1.views.sentry_sdk.capture_exception")
|
||||
def test_compliance_list_objects_client_error(
|
||||
@@ -6916,6 +7057,80 @@ class TestFindingViewSet:
|
||||
== findings_fixture[0].status
|
||||
)
|
||||
|
||||
def test_findings_list_resource_tags_no_n_plus_one(
|
||||
self, authenticated_client, findings_fixture
|
||||
):
|
||||
"""Listing findings must load every resource's tags in a constant
|
||||
number of queries, no matter how many findings/resources are returned.
|
||||
|
||||
This guards ``FindingViewSet._optimize_tags_loading`` against
|
||||
regressions that would reintroduce one extra query per resource (the
|
||||
N+1 the prefetch was added to remove).
|
||||
"""
|
||||
scan = findings_fixture[0].scan
|
||||
tenant_id = findings_fixture[0].tenant_id
|
||||
provider = scan.provider
|
||||
|
||||
def _create_finding_with_tagged_resource(index):
|
||||
resource = Resource.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
provider=provider,
|
||||
uid=f"arn:aws:ec2:us-east-1:123456789012:instance/n-plus-one-{index}",
|
||||
name=f"N+1 Instance {index}",
|
||||
region="us-east-1",
|
||||
service="ec2",
|
||||
type="prowler-test",
|
||||
)
|
||||
resource.upsert_or_delete_tags(
|
||||
[
|
||||
ResourceTag.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
key=f"key-{index}",
|
||||
value=f"value-{index}",
|
||||
)
|
||||
]
|
||||
)
|
||||
finding = Finding.objects.create(
|
||||
tenant_id=tenant_id,
|
||||
uid=f"n_plus_one_finding_{index}",
|
||||
scan=scan,
|
||||
status=Status.FAIL,
|
||||
status_extended="n+1 status",
|
||||
impact=Severity.medium,
|
||||
severity=Severity.medium,
|
||||
check_id="test_check_id",
|
||||
check_metadata={"CheckId": "test_check_id", "servicename": "ec2"},
|
||||
first_seen_at="2024-01-02T00:00:00Z",
|
||||
)
|
||||
finding.add_resources([resource])
|
||||
return finding
|
||||
|
||||
params = {"filter[inserted_at]": TODAY, "include": "resources"}
|
||||
|
||||
# Baseline: the two findings provided by the fixture.
|
||||
with CaptureQueriesContext(connection) as baseline:
|
||||
response = authenticated_client.get(reverse("finding-list"), params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
# Add more findings, each with its own resource carrying tags.
|
||||
extra_findings = 5
|
||||
for index in range(extra_findings):
|
||||
_create_finding_with_tagged_resource(index)
|
||||
|
||||
with CaptureQueriesContext(connection) as scaled:
|
||||
response = authenticated_client.get(reverse("finding-list"), params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()["data"]) == len(findings_fixture) + extra_findings
|
||||
|
||||
# The query count must not grow with the number of findings/resources.
|
||||
assert len(scaled.captured_queries) == len(baseline.captured_queries), (
|
||||
"Resource tags are not being prefetched: "
|
||||
f"{len(baseline.captured_queries)} queries for {len(findings_fixture)} "
|
||||
f"findings vs {len(scaled.captured_queries)} for "
|
||||
f"{len(findings_fixture) + extra_findings}. Likely an N+1 regression "
|
||||
"in FindingViewSet._optimize_tags_loading."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"include_values, expected_resources",
|
||||
[
|
||||
|
||||
@@ -2059,12 +2059,17 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
if scan_instance.state == StateChoices.EXECUTING and scan_instance.task:
|
||||
task = scan_instance.task
|
||||
else:
|
||||
try:
|
||||
task = Task.objects.get(
|
||||
# A scan can have several `scan-report` tasks (e.g. re-runs); take the
|
||||
# most recent one. `.first()` also avoids `MultipleObjectsReturned`.
|
||||
task = (
|
||||
Task.objects.filter(
|
||||
task_runner_task__task_name="scan-report",
|
||||
task_runner_task__task_kwargs__contains=str(scan_instance.id),
|
||||
)
|
||||
except Task.DoesNotExist:
|
||||
.order_by("-inserted_at")
|
||||
.first()
|
||||
)
|
||||
if task is None:
|
||||
return None
|
||||
|
||||
self.response_serializer_class = TaskSerializer
|
||||
@@ -2139,27 +2144,32 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
contents = resp.get("Contents", [])
|
||||
keys = []
|
||||
matches = []
|
||||
for obj in contents:
|
||||
key = obj["Key"]
|
||||
key_basename = os.path.basename(key)
|
||||
if any(ch in suffix for ch in ("*", "?", "[")):
|
||||
if fnmatch.fnmatch(key_basename, suffix):
|
||||
keys.append(key)
|
||||
matches.append(obj)
|
||||
elif key_basename == suffix:
|
||||
keys.append(key)
|
||||
matches.append(obj)
|
||||
elif key.endswith(suffix):
|
||||
# Backward compatibility if suffix already includes directories
|
||||
keys.append(key)
|
||||
if not keys:
|
||||
matches.append(obj)
|
||||
if not matches:
|
||||
return Response(
|
||||
{
|
||||
"detail": f"No compliance file found for name '{os.path.splitext(suffix)[0]}'."
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
# path_pattern here is prefix, but in compliance we build correct suffix check before
|
||||
key = keys[0]
|
||||
# Return the most recently modified match (latest report) when
|
||||
# several files share the prefix/suffix. `list_objects_v2` always
|
||||
# returns `LastModified`; the fallback keeps ordering deterministic
|
||||
# if it is ever absent.
|
||||
key = max(matches, key=lambda o: (o.get("LastModified", ""), o["Key"]))[
|
||||
"Key"
|
||||
]
|
||||
else:
|
||||
# path_pattern is exact key; HEAD before presigning to preserve the 404 contract.
|
||||
key = path_pattern
|
||||
@@ -2209,7 +2219,9 @@ class ScanViewSet(BaseRLSViewSet):
|
||||
},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
filepath = files[0]
|
||||
# Return the most recently modified match (latest report) when the
|
||||
# pattern resolves to several files.
|
||||
filepath = max(files, key=os.path.getmtime)
|
||||
with open(filepath, "rb") as f:
|
||||
content = f.read()
|
||||
filename = os.path.basename(filepath)
|
||||
@@ -3749,6 +3761,16 @@ class FindingViewSet(PaginateByPkMixin, BaseRLSViewSet):
|
||||
return queryset
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
def _optimize_tags_loading(self, queryset):
|
||||
"""Prefetch resource tags to avoid N+1 queries when serializing findings"""
|
||||
return queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"resources__tags",
|
||||
queryset=ResourceTag.objects.filter(tenant_id=self.request.tenant_id),
|
||||
to_attr="prefetched_tags",
|
||||
)
|
||||
)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
filtered_queryset = self.filter_queryset(self.get_queryset())
|
||||
return self.paginate_by_pk(
|
||||
|
||||
@@ -467,8 +467,31 @@ def delete_tenant_task(tenant_id: str):
|
||||
return delete_tenant(pk=tenant_id)
|
||||
|
||||
|
||||
def _scan_tmp_output_directory(tenant_id: str, scan_id: str) -> Path:
|
||||
"""Root tmp output directory for a scan ({tmp}/{tenant_id}/{scan_id})."""
|
||||
return Path(DJANGO_TMP_OUTPUT_DIRECTORY) / str(tenant_id) / str(scan_id)
|
||||
|
||||
|
||||
class ScanReportRLSTask(RLSTask):
|
||||
"""
|
||||
RLS task that removes the scan's tmp output directory when the task fails.
|
||||
|
||||
Covers failures both inside and outside the task body (e.g. ENOSPC mid-write,
|
||||
or setup errors) so partial artifacts do not accumulate on the worker disk.
|
||||
"""
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, _einfo): # noqa: ARG002
|
||||
del args # Required by Celery's Task.on_failure signature; not used.
|
||||
tenant_id = kwargs.get("tenant_id")
|
||||
scan_id = kwargs.get("scan_id")
|
||||
|
||||
if tenant_id and scan_id:
|
||||
logger.error(f"Scan report task {task_id} failed: {exc}")
|
||||
rmtree(_scan_tmp_output_directory(tenant_id, scan_id), ignore_errors=True)
|
||||
|
||||
|
||||
@shared_task(
|
||||
base=RLSTask,
|
||||
base=ScanReportRLSTask,
|
||||
name="scan-report",
|
||||
queue="scan-reports",
|
||||
)
|
||||
@@ -518,6 +541,9 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
out_dir, comp_dir = _generate_output_directory(
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY, provider_uid, tenant_id, scan_id
|
||||
)
|
||||
# Removed on success here and on failure by ScanReportRLSTask.on_failure,
|
||||
# so partial artifacts do not accumulate and fill the disk (ENOSPC).
|
||||
scan_tmp_dir = _scan_tmp_output_directory(tenant_id, scan_id)
|
||||
|
||||
def get_writer(writer_map, name, factory, is_last):
|
||||
"""
|
||||
@@ -666,7 +692,7 @@ def generate_outputs_task(scan_id: str, provider_id: str, tenant_id: str):
|
||||
# TODO: We need to create a new periodic task to delete the output files
|
||||
# This task shouldn't be responsible for deleting the output files
|
||||
try:
|
||||
rmtree(Path(compressed).parent, ignore_errors=True)
|
||||
rmtree(scan_tmp_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting output files: {e}")
|
||||
final_location, did_upload = upload_uri, True
|
||||
|
||||
@@ -15,8 +15,10 @@ from tasks.jobs.lighthouse_providers import (
|
||||
from tasks.tasks import (
|
||||
DJANGO_TMP_OUTPUT_DIRECTORY,
|
||||
STALE_TMP_OUTPUT_MAX_AGE_HOURS,
|
||||
ScanReportRLSTask,
|
||||
_cleanup_orphan_scheduled_scans,
|
||||
_perform_scan_complete_tasks,
|
||||
_scan_tmp_output_directory,
|
||||
check_integrations_task,
|
||||
check_lighthouse_provider_connection_task,
|
||||
generate_outputs_task,
|
||||
@@ -771,6 +773,38 @@ class TestGenerateOutputs:
|
||||
mock_s3_task.assert_called_once()
|
||||
|
||||
|
||||
class TestScanReportRLSTaskOnFailure:
|
||||
def test_on_failure_removes_scan_tmp_directory(self):
|
||||
task = ScanReportRLSTask()
|
||||
|
||||
with patch("tasks.tasks.rmtree") as mock_rmtree:
|
||||
task.on_failure(
|
||||
exc=OSError("No space left on device"),
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={"tenant_id": "t-1", "scan_id": "s-1"},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_rmtree.assert_called_once_with(
|
||||
_scan_tmp_output_directory("t-1", "s-1"), ignore_errors=True
|
||||
)
|
||||
|
||||
def test_on_failure_skips_when_missing_kwargs(self):
|
||||
task = ScanReportRLSTask()
|
||||
|
||||
with patch("tasks.tasks.rmtree") as mock_rmtree:
|
||||
task.on_failure(
|
||||
exc=OSError("No space left on device"),
|
||||
task_id="task-abc",
|
||||
args=(),
|
||||
kwargs={},
|
||||
_einfo=None,
|
||||
)
|
||||
|
||||
mock_rmtree.assert_not_called()
|
||||
|
||||
|
||||
class TestScanCompleteTasks:
|
||||
@patch("tasks.tasks.aggregate_attack_surface_task.apply_async")
|
||||
@patch("tasks.tasks.chain")
|
||||
|
||||
Generated
+54
-4
@@ -4410,8 +4410,8 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler"
|
||||
version = "5.27.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=master#0abbb7fc590eaf7de6ed354dd5a217bca261d2b0" }
|
||||
version = "5.29.0"
|
||||
source = { git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29#a769e3761532d9332cb64078ef09ebf7ffb15292" }
|
||||
dependencies = [
|
||||
{ name = "alibabacloud-actiontrail20200706" },
|
||||
{ name = "alibabacloud-credentials" },
|
||||
@@ -4484,9 +4484,13 @@ dependencies = [
|
||||
{ name = "pygithub" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "scaleway" },
|
||||
{ name = "schema" },
|
||||
{ name = "shodan" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "stackit-core" },
|
||||
{ name = "stackit-iaas" },
|
||||
{ name = "stackit-resourcemanager" },
|
||||
{ name = "tabulate" },
|
||||
{ name = "tzlocal" },
|
||||
{ name = "uuid6" },
|
||||
@@ -4494,7 +4498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "prowler-api"
|
||||
version = "1.30.0"
|
||||
version = "1.30.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "cartography" },
|
||||
@@ -4590,7 +4594,7 @@ requires-dist = [
|
||||
{ name = "matplotlib", specifier = "==3.10.8" },
|
||||
{ name = "neo4j", specifier = "==6.1.0" },
|
||||
{ name = "openai", specifier = "==1.109.1" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=master" },
|
||||
{ name = "prowler", git = "https://github.com/prowler-cloud/prowler.git?rev=v5.29" },
|
||||
{ name = "psycopg2-binary", specifier = "==2.9.9" },
|
||||
{ name = "pytest-celery", extras = ["redis"], specifier = "==1.3.0" },
|
||||
{ name = "reportlab", specifier = "==4.4.10" },
|
||||
@@ -5526,6 +5530,52 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-core"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/90/20f9ec7387eec4067cfd3d29055d0e2b5e1e0322c601a7f48125fd8ea35f/stackit_core-0.2.0.tar.gz", hash = "sha256:b8af91877cdb060d6969a303d8cf20bc0b33b345afd91f679c44a987381e2d47", size = 8987, upload-time = "2025-06-12T08:24:45.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/b4/7b53187ce68956870d864ccb9ccfb68066c9df9de1c9568fd2feb03c4504/stackit_core-0.2.0-py3-none-any.whl", hash = "sha256:04632fc6742790d08ddfcb7f2313e04d1254827397a80250f838a2f81b92645b", size = 10240, upload-time = "2025-06-12T08:24:44.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-iaas"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/07/24e65278300d5c3cb19cb1660bff924c80812cf8aad3e715f826bae5aa80/stackit_iaas-1.4.0.tar.gz", hash = "sha256:93523b23442350c7ebefd9129485c4c2a539f694a9c36a0f8edfaba9862057ea", size = 116236, upload-time = "2026-05-13T09:43:15.996Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/51/2201164d7bfacf47539888c735f10f6320c188252384957aa1b23121a210/stackit_iaas-1.4.0-py3-none-any.whl", hash = "sha256:3f4a32321b57ac238f73e5d660c6428186b92cc0425c1f0783ba801e377149d9", size = 316588, upload-time = "2026-05-13T09:43:14.943Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stackit-resourcemanager"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "requests" },
|
||||
{ name = "stackit-core" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/2d/f458f18e48ed2b1c83df52cff7dbdfd5dd904fb2980ffd9385876e47bbd9/stackit_resourcemanager-0.8.0.tar.gz", hash = "sha256:f44542beab4130857f5a7f465cf02defeef657bdf63c1beeb3102f0ba3c003fe", size = 33943, upload-time = "2026-05-13T09:43:08.667Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9c/38a74d0f7a89b4320f6d2366fb660638bda8860daa08748b12c713d84381/stackit_resourcemanager-0.8.0-py3-none-any.whl", hash = "sha256:dd04bb8353d041a137c4dcba190beabded7acfaff1bc98b218fce20a99389ebc", size = 81288, upload-time = "2026-05-13T09:43:07.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "statsd"
|
||||
version = "4.0.1"
|
||||
|
||||
@@ -47,7 +47,11 @@ Follow these steps to remove a user of your account:
|
||||
1. Navigate to **Users** from the side menu.
|
||||
2. Click the delete button of your current user.
|
||||
|
||||
> **Note: Each user will be able to delete himself and not others, regardless of his permissions.**
|
||||
> **Note: Each user can only delete their own account, regardless of their permissions. For this reason, the delete button is only shown on your own row and not on other users' rows.**
|
||||
|
||||
Deleting a user removes the **entire user account** from Prowler, not just its membership in your organization. Because a single account can belong to more than one tenant, allowing one administrator to delete it outright could affect organizations they don't manage and irreversibly remove another person's identity. To keep this destructive action under the control of the account owner, the API only permits a user to delete themselves (it rejects any other target with a `400` response), and the UI mirrors this by showing the delete button exclusively on your own row.
|
||||
|
||||
To remove **another** user from your organization, use the [_Expel from organization_](/user-guide/tutorials/prowler-app-multi-tenant#expelling-a-user-from-an-organization) action instead. Expelling removes the user's membership, role grants, and active sessions for your tenant only, and deletes the underlying account just for that user if your organization was their last remaining membership. This action is reserved for tenant **owners**.
|
||||
|
||||
<img src="/images/prowler-app/rbac/user_remove.png" alt="Remove User" width="700" />
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.29.1] (Prowler v5.29.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- OCSF output writer now re-raises I/O errors (e.g. `ENOSPC`) instead of logging them per finding and leaving a truncated file [(#11421)](https://github.com/prowler-cloud/prowler/pull/11421)
|
||||
|
||||
---
|
||||
|
||||
## [5.29.0] (Prowler v5.29.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -48,7 +48,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.29.0"
|
||||
prowler_version = "5.29.2"
|
||||
html_logo_url = "https://github.com/prowler-cloud/prowler/"
|
||||
square_logo_img = "https://raw.githubusercontent.com/prowler-cloud/prowler/dc7d2d5aeb92fdf12e8604f42ef6472cd3e8e889/docs/img/prowler-logo-black.png"
|
||||
aws_logo = "https://user-images.githubusercontent.com/38561120/235953920-3e3fba08-0795-41dc-b480-9bea57db9f2e.png"
|
||||
|
||||
@@ -227,6 +227,10 @@ class OCSF(Output):
|
||||
json_output = finding.json(exclude_none=True, indent=4)
|
||||
self._file_descriptor.write(json_output)
|
||||
self._file_descriptor.write(",")
|
||||
except OSError:
|
||||
# I/O errors (e.g. ENOSPC) are not recoverable per finding:
|
||||
# fail fast instead of logging once per finding.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
@@ -239,6 +243,10 @@ class OCSF(Output):
|
||||
self._file_descriptor.truncate()
|
||||
self._file_descriptor.write("]")
|
||||
self._file_descriptor.close()
|
||||
except OSError:
|
||||
# Propagate unrecoverable I/O errors (e.g. ENOSPC) so the caller can
|
||||
# fail fast instead of producing a corrupt output file.
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<3.13"
|
||||
version = "5.29.0"
|
||||
version = "5.29.2"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -2,8 +2,10 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
from mock import patch
|
||||
@@ -300,6 +302,36 @@ class TestOCSF:
|
||||
def test_batch_write_data_to_file_without_findings(self):
|
||||
assert not OCSF([])._file_descriptor
|
||||
|
||||
def test_batch_write_data_to_file_propagates_oserror(self):
|
||||
"""An I/O error (e.g. ENOSPC) while writing a finding must propagate
|
||||
instead of being swallowed, so the caller can fail fast."""
|
||||
findings = [
|
||||
generate_finding_output(
|
||||
status="FAIL",
|
||||
severity="low",
|
||||
muted=False,
|
||||
region=AWS_REGION_EU_WEST_1,
|
||||
timestamp=datetime.now(),
|
||||
resource_details="resource_details",
|
||||
resource_name="resource_name",
|
||||
resource_uid="resource-id",
|
||||
status_extended="status extended",
|
||||
)
|
||||
]
|
||||
|
||||
output = OCSF(findings)
|
||||
mock_file = MagicMock()
|
||||
mock_file.closed = False
|
||||
# Non-zero so the "[" prelude is skipped and the failure happens on the
|
||||
# per-finding write, the exact path that hit ENOSPC in production.
|
||||
mock_file.tell.return_value = 1
|
||||
mock_file.write.side_effect = OSError(28, "No space left on device")
|
||||
output._file_descriptor = mock_file
|
||||
|
||||
with pytest.raises(OSError) as excinfo:
|
||||
output.batch_write_data_to_file()
|
||||
assert excinfo.value.errno == 28
|
||||
|
||||
def test_finding_output_cloud_pass_low_muted(self):
|
||||
finding_output = generate_finding_output(
|
||||
status="PASS",
|
||||
|
||||
@@ -2,6 +2,23 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.29.2] (Prowler v5.29.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
- Users page now shows the "Delete User" action only on the current user's row, matching the backend rule that a user can only delete their own account [(#11447)](https://github.com/prowler-cloud/prowler/pull/11447)
|
||||
|
||||
### 🔐 Security
|
||||
|
||||
- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424)
|
||||
|
||||
---
|
||||
|
||||
## [1.29.0] (Prowler v5.29.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
);
|
||||
},
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -220,4 +220,45 @@ describe("AccountsSelector", () => {
|
||||
|
||||
expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false });
|
||||
});
|
||||
|
||||
it("shows the provider icon next to the name in the trigger for a single selection", async () => {
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={providers}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(within(trigger).getByText("Production AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders one icon per selected account without deduping by provider type", async () => {
|
||||
const secondAws = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: {
|
||||
...providers[0].attributes,
|
||||
uid: "999999999999",
|
||||
alias: "Staging AWS",
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<AccountsSelector
|
||||
providers={[providers[0], secondAws]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["provider-1", "provider-2"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
// Two AWS accounts -> two AWS icons in the trigger (no dedupe).
|
||||
expect(await within(trigger).findAllByText("AWS")).toHaveLength(2);
|
||||
expect(
|
||||
within(trigger).getByText("2 Providers selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
@@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = {
|
||||
type AccountSelectorFilter =
|
||||
(typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER];
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
aws: <AWSProviderBadge width={18} height={18} />,
|
||||
azure: <AzureProviderBadge width={18} height={18} />,
|
||||
gcp: <GCPProviderBadge width={18} height={18} />,
|
||||
kubernetes: <KS8ProviderBadge width={18} height={18} />,
|
||||
m365: <M365ProviderBadge width={18} height={18} />,
|
||||
github: <GitHubProviderBadge width={18} height={18} />,
|
||||
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
|
||||
iac: <IacProviderBadge width={18} height={18} />,
|
||||
image: <ImageProviderBadge width={18} height={18} />,
|
||||
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
|
||||
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
|
||||
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
|
||||
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
|
||||
openstack: <OpenStackProviderBadge width={18} height={18} />,
|
||||
vercel: <VercelProviderBadge width={18} height={18} />,
|
||||
okta: <OktaProviderBadge width={18} height={18} />,
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface AccountsSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -158,10 +125,36 @@ export function AccountsSelector({
|
||||
if (selectedIds.length === 1) {
|
||||
const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]);
|
||||
const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0];
|
||||
return <span className="truncate">{name}</span>;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{p && (
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={p.attributes.provider} />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// One icon per selected account (no dedupe): two accounts of the same
|
||||
// provider show two icons, disambiguated by the UID tooltip on hover.
|
||||
const items = selectedIds
|
||||
.map((selectedId) =>
|
||||
providers.find((pr) => getProviderValue(pr) === selectedId),
|
||||
)
|
||||
.filter((p): p is ProviderProps => Boolean(p))
|
||||
.map((p) => ({
|
||||
key: p.id,
|
||||
type: p.attributes.provider as ProviderType,
|
||||
tooltip: p.attributes.uid,
|
||||
}));
|
||||
return (
|
||||
<span className="truncate">{selectedIds.length} Providers selected</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack items={items} />
|
||||
<span className="truncate">
|
||||
{selectedIds.length} Providers selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,7 +201,6 @@ export function AccountsSelector({
|
||||
const isDisabled = disabledValuesSet.has(value);
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const providerType = p.attributes.provider as ProviderType;
|
||||
const icon = PROVIDER_ICON[providerType];
|
||||
const searchKeywords = [
|
||||
displayName,
|
||||
p.attributes.alias,
|
||||
@@ -228,7 +220,9 @@ export function AccountsSelector({
|
||||
if (closeOnSelect) setSelectorOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true">{icon}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProviderTypeSelector } from "./provider-type-selector";
|
||||
@@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
<div data-testid="trigger">{children}</div>
|
||||
),
|
||||
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
|
||||
<span>{placeholder}</span>
|
||||
@@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => {
|
||||
).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("All selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows one icon per selected type and a count in the trigger", async () => {
|
||||
const azure = {
|
||||
...providers[0],
|
||||
id: "provider-2",
|
||||
attributes: { ...providers[0].attributes, provider: "azure" as const },
|
||||
};
|
||||
|
||||
render(
|
||||
<ProviderTypeSelector
|
||||
providers={[providers[0], azure]}
|
||||
onBatchChange={vi.fn()}
|
||||
selectedValues={["aws", "azure"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByTestId("trigger");
|
||||
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
|
||||
expect(
|
||||
within(trigger).getByText("2 Provider Types selected"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
ProviderTypeIconStack,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
@@ -14,163 +18,6 @@ import {
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
const PROVIDER_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: {
|
||||
label: "Amazon Web Services",
|
||||
icon: AWSProviderBadge,
|
||||
},
|
||||
azure: {
|
||||
label: "Microsoft Azure",
|
||||
icon: AzureProviderBadge,
|
||||
},
|
||||
gcp: {
|
||||
label: "Google Cloud Platform",
|
||||
icon: GCPProviderBadge,
|
||||
},
|
||||
kubernetes: {
|
||||
label: "Kubernetes",
|
||||
icon: KS8ProviderBadge,
|
||||
},
|
||||
m365: {
|
||||
label: "Microsoft 365",
|
||||
icon: M365ProviderBadge,
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
icon: GitHubProviderBadge,
|
||||
},
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: {
|
||||
label: "Infrastructure as Code",
|
||||
icon: IacProviderBadge,
|
||||
},
|
||||
image: {
|
||||
label: "Container Registry",
|
||||
icon: ImageProviderBadge,
|
||||
},
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: {
|
||||
label: "MongoDB Atlas",
|
||||
icon: MongoDBAtlasProviderBadge,
|
||||
},
|
||||
alibabacloud: {
|
||||
label: "Alibaba Cloud",
|
||||
icon: AlibabaCloudProviderBadge,
|
||||
},
|
||||
cloudflare: {
|
||||
label: "Cloudflare",
|
||||
icon: CloudflareProviderBadge,
|
||||
},
|
||||
openstack: {
|
||||
label: "OpenStack",
|
||||
icon: OpenStackProviderBadge,
|
||||
},
|
||||
vercel: {
|
||||
label: "Vercel",
|
||||
icon: VercelProviderBadge,
|
||||
},
|
||||
okta: {
|
||||
label: "Okta",
|
||||
icon: OktaProviderBadge,
|
||||
},
|
||||
};
|
||||
|
||||
/** Common props shared by both batch and instant modes. */
|
||||
interface ProviderTypeSelectorBaseProps {
|
||||
providers: ProviderProps[];
|
||||
@@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_DATA)
|
||||
.filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA)
|
||||
.sort((a, b) =>
|
||||
PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label),
|
||||
PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label),
|
||||
);
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
|
||||
<IconComponent width={24} height={24} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
if (selectedTypes.length === 0) return null;
|
||||
if (selectedTypes.length === 1) {
|
||||
const providerType = selectedTypes[0] as ProviderType;
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{renderIcon(providerType)}
|
||||
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} />
|
||||
</span>
|
||||
<span className="truncate">
|
||||
{PROVIDER_TYPE_DATA[providerType].label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ProviderTypeIconStack
|
||||
items={(selectedTypes as ProviderType[]).map((type) => ({
|
||||
key: type,
|
||||
type,
|
||||
tooltip: PROVIDER_TYPE_DATA[type].label,
|
||||
}))}
|
||||
/>
|
||||
<span className="min-w-0 truncate">
|
||||
{selectedTypes.length} Provider Types selected
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
|
||||
<MultiSelectItem
|
||||
key={providerType}
|
||||
value={providerType}
|
||||
badgeLabel={PROVIDER_DATA[providerType].label}
|
||||
keywords={[providerType, PROVIDER_DATA[providerType].label]}
|
||||
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
|
||||
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
|
||||
keywords={[
|
||||
providerType,
|
||||
PROVIDER_TYPE_DATA[providerType].label,
|
||||
]}
|
||||
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
|
||||
>
|
||||
<span aria-hidden="true">{renderIcon(providerType)}</span>
|
||||
<span>{PROVIDER_DATA[providerType].label}</span>
|
||||
<span aria-hidden="true">
|
||||
<ProviderTypeIcon type={providerType} size={24} />
|
||||
</span>
|
||||
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -109,6 +109,9 @@ const SSRDataTable = async ({
|
||||
roles,
|
||||
canBeExpelled,
|
||||
currentTenantId: canBeExpelled ? currentTenantId : undefined,
|
||||
// Users may only delete their own account; gate the delete action so the
|
||||
// UI matches the backend rule and never offers an action that would fail.
|
||||
isCurrentUser: user.id === currentUserId,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { ProviderIconCell } from "./provider-icon-cell";
|
||||
|
||||
// Render the lazy provider badges as plain text so we can assert on them. The
|
||||
// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose.
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
OktaProviderBadge: () => <span>Okta</span>,
|
||||
}));
|
||||
|
||||
describe("ProviderIconCell", () => {
|
||||
it("renders the shared provider-type icon for a known provider", async () => {
|
||||
render(<ProviderIconCell provider="aws" />);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a '?' placeholder for a provider type missing from the map", () => {
|
||||
render(
|
||||
<ProviderIconCell
|
||||
provider={"future-provider" as unknown as ProviderType}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("?")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +1,10 @@
|
||||
import {
|
||||
AlibabaCloudProviderBadge,
|
||||
AWSProviderBadge,
|
||||
AzureProviderBadge,
|
||||
CloudflareProviderBadge,
|
||||
GCPProviderBadge,
|
||||
GitHubProviderBadge,
|
||||
GoogleWorkspaceProviderBadge,
|
||||
IacProviderBadge,
|
||||
ImageProviderBadge,
|
||||
KS8ProviderBadge,
|
||||
M365ProviderBadge,
|
||||
MongoDBAtlasProviderBadge,
|
||||
OktaProviderBadge,
|
||||
OpenStackProviderBadge,
|
||||
OracleCloudProviderBadge,
|
||||
VercelProviderBadge,
|
||||
} from "@/components/icons/providers-badge";
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
export const PROVIDER_ICONS = {
|
||||
aws: AWSProviderBadge,
|
||||
azure: AzureProviderBadge,
|
||||
gcp: GCPProviderBadge,
|
||||
kubernetes: KS8ProviderBadge,
|
||||
m365: M365ProviderBadge,
|
||||
github: GitHubProviderBadge,
|
||||
googleworkspace: GoogleWorkspaceProviderBadge,
|
||||
iac: IacProviderBadge,
|
||||
image: ImageProviderBadge,
|
||||
oraclecloud: OracleCloudProviderBadge,
|
||||
mongodbatlas: MongoDBAtlasProviderBadge,
|
||||
alibabacloud: AlibabaCloudProviderBadge,
|
||||
cloudflare: CloudflareProviderBadge,
|
||||
openstack: OpenStackProviderBadge,
|
||||
vercel: VercelProviderBadge,
|
||||
okta: OktaProviderBadge,
|
||||
} as const;
|
||||
|
||||
interface ProviderIconCellProps {
|
||||
provider: ProviderType;
|
||||
size?: number;
|
||||
@@ -49,9 +16,9 @@ export const ProviderIconCell = ({
|
||||
size = 26,
|
||||
className = "size-8 rounded-md bg-white",
|
||||
}: ProviderIconCellProps) => {
|
||||
const IconComponent = PROVIDER_ICONS[provider];
|
||||
|
||||
if (!IconComponent) {
|
||||
// Unknown provider types (present in the data but missing from the shared
|
||||
// PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon.
|
||||
if (!(provider in PROVIDER_TYPE_DATA)) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center", className)}>
|
||||
<span className="text-text-neutral-secondary text-xs">?</span>
|
||||
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IconComponent width={size} height={size} />
|
||||
<ProviderTypeIcon type={provider} size={size} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon";
|
||||
|
||||
// A provider type the API may return but this UI build does not know about.
|
||||
const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType;
|
||||
|
||||
// Render the lazy provider badges as plain text so we can assert on them.
|
||||
vi.mock("@/components/icons/providers-badge", () => ({
|
||||
AWSProviderBadge: () => <span>AWS</span>,
|
||||
AzureProviderBadge: () => <span>Azure</span>,
|
||||
GCPProviderBadge: () => <span>GCP</span>,
|
||||
KS8ProviderBadge: () => <span>Kubernetes</span>,
|
||||
M365ProviderBadge: () => <span>M365</span>,
|
||||
GitHubProviderBadge: () => <span>GitHub</span>,
|
||||
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
|
||||
IacProviderBadge: () => <span>IaC</span>,
|
||||
ImageProviderBadge: () => <span>Image</span>,
|
||||
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
|
||||
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
|
||||
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
|
||||
CloudflareProviderBadge: () => <span>Cloudflare</span>,
|
||||
OpenStackProviderBadge: () => <span>OpenStack</span>,
|
||||
VercelProviderBadge: () => <span>Vercel</span>,
|
||||
OktaProviderBadge: () => <span>Okta</span>,
|
||||
}));
|
||||
|
||||
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Badge: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="badge">{children}</span>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<span data-testid="tooltip">{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProviderTypeIcon", () => {
|
||||
it("renders the badge for the given provider type", async () => {
|
||||
render(<ProviderTypeIcon type="aws" />);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a sized placeholder instead of crashing for an unknown type", () => {
|
||||
// Regression guard for #9991: an unknown provider type must not throw.
|
||||
const { container } = render(
|
||||
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText("AWS")).not.toBeInTheDocument();
|
||||
expect(container.querySelector("div")).toHaveStyle({
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderTypeIconStack", () => {
|
||||
it("renders one icon per item without deduping by type", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
{ key: "b", type: "aws", tooltip: "222" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Two AWS accounts -> two AWS icons (no dedupe).
|
||||
expect(await screen.findAllByText("AWS")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows each item's tooltip text on the icon", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
|
||||
"account-uid-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses items beyond `max` into a +N badge", async () => {
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
max={3}
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "1" },
|
||||
{ key: "b", type: "azure", tooltip: "2" },
|
||||
{ key: "c", type: "gcp", tooltip: "3" },
|
||||
{ key: "d", type: "github", tooltip: "4" },
|
||||
{ key: "e", type: "okta", tooltip: "5" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId("badge")).toHaveTextContent("+2");
|
||||
// First icon is shown; items sliced beyond `max` never reach the DOM.
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Okta")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders known icons and skips unknown types without crashing", async () => {
|
||||
// Regression guard for #9991: an unknown type in the stack must not throw.
|
||||
render(
|
||||
<ProviderTypeIconStack
|
||||
items={[
|
||||
{ key: "a", type: "aws", tooltip: "111" },
|
||||
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText("AWS")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentType, lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProviderType } from "@/types/providers";
|
||||
|
||||
type IconProps = { width: number; height: number };
|
||||
|
||||
const IconPlaceholder = ({ width, height }: IconProps) => (
|
||||
<div style={{ width, height }} />
|
||||
);
|
||||
|
||||
// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred
|
||||
// chunk instead of being eagerly bundled wherever a selector is imported.
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AWSProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AzureProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AzureProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GCPProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GCPProviderBadge,
|
||||
})),
|
||||
);
|
||||
const KS8ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.KS8ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const M365ProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.M365ProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GitHubProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GitHubProviderBadge,
|
||||
})),
|
||||
);
|
||||
const GoogleWorkspaceProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.GoogleWorkspaceProviderBadge,
|
||||
})),
|
||||
);
|
||||
const IacProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.IacProviderBadge,
|
||||
})),
|
||||
);
|
||||
const ImageProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.ImageProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OracleCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OracleCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const MongoDBAtlasProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.MongoDBAtlasProviderBadge,
|
||||
})),
|
||||
);
|
||||
const AlibabaCloudProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.AlibabaCloudProviderBadge,
|
||||
})),
|
||||
);
|
||||
const CloudflareProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.CloudflareProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OpenStackProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OpenStackProviderBadge,
|
||||
})),
|
||||
);
|
||||
const VercelProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.VercelProviderBadge,
|
||||
})),
|
||||
);
|
||||
const OktaProviderBadge = lazy(() =>
|
||||
import("@/components/icons/providers-badge").then((m) => ({
|
||||
default: m.OktaProviderBadge,
|
||||
})),
|
||||
);
|
||||
|
||||
/**
|
||||
* Single source of truth mapping each provider type to its human-readable
|
||||
* label and (lazy) badge component. Shared by the account and provider-type
|
||||
* selectors so both stay in sync on labels, icons, and sizing.
|
||||
*/
|
||||
export const PROVIDER_TYPE_DATA: Record<
|
||||
ProviderType,
|
||||
{ label: string; icon: ComponentType<IconProps> }
|
||||
> = {
|
||||
aws: { label: "Amazon Web Services", icon: AWSProviderBadge },
|
||||
azure: { label: "Microsoft Azure", icon: AzureProviderBadge },
|
||||
gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge },
|
||||
kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge },
|
||||
m365: { label: "Microsoft 365", icon: M365ProviderBadge },
|
||||
github: { label: "GitHub", icon: GitHubProviderBadge },
|
||||
googleworkspace: {
|
||||
label: "Google Workspace",
|
||||
icon: GoogleWorkspaceProviderBadge,
|
||||
},
|
||||
iac: { label: "Infrastructure as Code", icon: IacProviderBadge },
|
||||
image: { label: "Container Registry", icon: ImageProviderBadge },
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
icon: OracleCloudProviderBadge,
|
||||
},
|
||||
mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge },
|
||||
alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge },
|
||||
cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge },
|
||||
openstack: { label: "OpenStack", icon: OpenStackProviderBadge },
|
||||
vercel: { label: "Vercel", icon: VercelProviderBadge },
|
||||
okta: { label: "Okta", icon: OktaProviderBadge },
|
||||
};
|
||||
|
||||
interface ProviderTypeIconProps {
|
||||
type: ProviderType;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single provider-type badge with a sized placeholder fallback.
|
||||
*
|
||||
* Falls back to the placeholder for provider types missing from
|
||||
* `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI
|
||||
* build does not). The `type` is statically typed as `ProviderType`, so this
|
||||
* only guards the runtime case — see #9991, which fixed the same crash class.
|
||||
*/
|
||||
export const ProviderTypeIcon = ({
|
||||
type,
|
||||
size = 18,
|
||||
}: ProviderTypeIconProps) => {
|
||||
const data = PROVIDER_TYPE_DATA[type];
|
||||
if (!data) return <IconPlaceholder width={size} height={size} />;
|
||||
|
||||
const Icon = data.icon;
|
||||
return (
|
||||
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
|
||||
<Icon width={size} height={size} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export interface ProviderTypeIconStackItem {
|
||||
/** Stable React key (account id for accounts, provider type for types). */
|
||||
key: string;
|
||||
type: ProviderType;
|
||||
/** Text shown on hover to disambiguate the icon (e.g. an account UID). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface ProviderTypeIconStackProps {
|
||||
items: ProviderTypeIconStackItem[];
|
||||
max?: number;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a
|
||||
* Radix portal, so the tooltip is not clipped by the selector trigger and we do
|
||||
* not need to portal it ourselves. `delayDuration` is set on the tooltip itself
|
||||
* because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider`
|
||||
* (delay 0), which would otherwise override an ancestor provider's delay.
|
||||
*/
|
||||
const IconWithTooltip = ({
|
||||
item,
|
||||
size,
|
||||
}: {
|
||||
item: ProviderTypeIconStackItem;
|
||||
size: number;
|
||||
}) => {
|
||||
const icon = (
|
||||
<span className="inline-flex shrink-0">
|
||||
<ProviderTypeIcon type={item.type} size={size} />
|
||||
</span>
|
||||
);
|
||||
|
||||
if (!item.tooltip) return icon;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>{icon}</TooltipTrigger>
|
||||
<TooltipContent side="top">{item.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders up to `max` provider-type icons followed by a `+N` badge for the
|
||||
* remainder. Each icon shows its `tooltip` on hover. Items are rendered as
|
||||
* passed (one per selection) — callers decide whether to dedupe.
|
||||
*/
|
||||
export const ProviderTypeIconStack = ({
|
||||
items,
|
||||
max = 3,
|
||||
size = 18,
|
||||
className,
|
||||
}: ProviderTypeIconStackProps) => {
|
||||
const visible = items.slice(0, max);
|
||||
const overflow = items.slice(max);
|
||||
const overflowLabel = overflow
|
||||
.map((item) => item.tooltip)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
return (
|
||||
<span className={cn("flex shrink-0 items-center gap-1", className)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{visible.map((item) => (
|
||||
<IconWithTooltip key={item.key} item={item} size={size} />
|
||||
))}
|
||||
</span>
|
||||
{overflow.length > 0 && (
|
||||
<Tooltip delayDuration={150}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
|
||||
+{overflow.length}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
{overflowLabel && (
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{overflowLabel}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,10 @@ import { useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -279,11 +282,14 @@ export const S3IntegrationForm = ({
|
||||
// Show configuration step (step 0 or editing configuration)
|
||||
if (isEditingConfig || currentStep === 0) {
|
||||
const providerOptions = providers.map((provider) => {
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
const providerType = provider.attributes.provider;
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
icon:
|
||||
providerType in PROVIDER_TYPE_DATA ? (
|
||||
<ProviderTypeIcon type={providerType} size={20} />
|
||||
) : undefined,
|
||||
description: provider.attributes.connection.connected
|
||||
? "Connected"
|
||||
: "Disconnected",
|
||||
|
||||
@@ -10,7 +10,10 @@ import { useEffect, useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import {
|
||||
PROVIDER_TYPE_DATA,
|
||||
ProviderTypeIcon,
|
||||
} from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
@@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({
|
||||
? "Connected"
|
||||
: "Disconnected";
|
||||
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
const providerType = provider.attributes.provider;
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
icon:
|
||||
providerType in PROVIDER_TYPE_DATA ? (
|
||||
<ProviderTypeIcon type={providerType} size={20} />
|
||||
) : undefined,
|
||||
description: isDisabled
|
||||
? `${connectionLabel} (Already in use)`
|
||||
: connectionLabel,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { InfoIcon } from "@/components/icons/Icons";
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NO_PROVIDERS_ADDED_ACTION = {
|
||||
BUTTON: "button",
|
||||
LINK: "link",
|
||||
} as const;
|
||||
|
||||
interface NoProvidersAddedBaseProps {
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps {
|
||||
action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON;
|
||||
onOpenWizard: () => void;
|
||||
href?: never;
|
||||
}
|
||||
|
||||
interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps {
|
||||
action: typeof NO_PROVIDERS_ADDED_ACTION.LINK;
|
||||
href: string;
|
||||
onOpenWizard?: never;
|
||||
}
|
||||
|
||||
type NoProvidersAddedProps =
|
||||
| NoProvidersAddedButtonProps
|
||||
| NoProvidersAddedLinkProps;
|
||||
|
||||
const renderCta = (props: NoProvidersAddedProps) => {
|
||||
if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
>
|
||||
<Link href={props.href}>Get Started</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={props.onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-labelledby="no-providers-added-title"
|
||||
className={cn(
|
||||
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
|
||||
props.containerClassName,
|
||||
)}
|
||||
>
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2
|
||||
id="no-providers-added-title"
|
||||
className="text-2xl font-bold text-gray-800 dark:text-white"
|
||||
>
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{renderCta(props)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
refreshMock: vi.fn(),
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/providers",
|
||||
useRouter: () => ({
|
||||
refresh: refreshMock,
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/table", () => ({
|
||||
SkeletonTableProviders: () => <div data-testid="providers-skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/add-provider-button", () => ({
|
||||
AddProviderButton: () => <button type="button">Add provider</button>,
|
||||
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
|
||||
<button type="button" onClick={onOpenWizard}>
|
||||
Add Provider
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
@@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-filters", () => ({
|
||||
ProvidersFilters: () => <div data-testid="providers-filters">Filters</div>,
|
||||
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
|
||||
<div data-testid="providers-filters">
|
||||
Filters
|
||||
{actions}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
|
||||
ProviderWizardModal: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog">
|
||||
Provider wizard
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import { ProvidersAccountsView } from "./providers-accounts-view";
|
||||
@@ -36,8 +80,55 @@ const metadata: MetaDataProps = {
|
||||
version: "latest",
|
||||
};
|
||||
|
||||
const disconnectedProviders: ProviderProps[] = [
|
||||
{
|
||||
id: "provider-1",
|
||||
type: "providers",
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
status: "completed",
|
||||
resources: 0,
|
||||
connection: {
|
||||
connected: false,
|
||||
last_checked_at: "2026-04-13T00:00:00Z",
|
||||
},
|
||||
scanner_args: {
|
||||
only_logs: false,
|
||||
excluded_checks: [],
|
||||
aws_retries_max_attempts: 3,
|
||||
},
|
||||
inserted_at: "2026-04-13T00:00:00Z",
|
||||
updated_at: "2026-04-13T00:00:00Z",
|
||||
created_by: {
|
||||
object: "user",
|
||||
id: "user-1",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: {
|
||||
data: null,
|
||||
},
|
||||
provider_groups: {
|
||||
meta: {
|
||||
count: 0,
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("ProvidersAccountsView", () => {
|
||||
it("keeps the same vertical spacing between filters and table as other views", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
it("shows a full page empty state without filters or table when there are no providers", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
@@ -48,11 +139,170 @@ describe("ProvidersAccountsView", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("region", { name: /no providers configured/i }),
|
||||
).toHaveClass("min-h-[calc(100dvh-28rem)]");
|
||||
expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the no providers CTA", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=connected&addProvider=true";
|
||||
window.history.replaceState(
|
||||
{},
|
||||
"",
|
||||
"/providers?tab=connected&addProvider=true",
|
||||
);
|
||||
// Spy only after the URL setup so we measure what the component does on mount.
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
expect(replaceStateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=connected&addProvider=true";
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
// The URL is cleaned via the History API (no RSC refetch). We must NOT
|
||||
// refresh/replace here: re-running the /providers Server Component on close
|
||||
// read as a full page reload. The provider-creation actions already
|
||||
// revalidatePath("/providers"), so the table is fresh behind the modal.
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(
|
||||
null,
|
||||
"",
|
||||
"/providers?tab=connected",
|
||||
);
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not touch the URL or refetch when a manually opened wizard closes", async () => {
|
||||
// Given: no addProvider param in the URL, wizard opened via the CTA.
|
||||
searchParamsValue.current = "";
|
||||
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When: open the wizard from the empty-state CTA, then close it.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then: nothing to clean and no refresh — the creation actions own the
|
||||
// data refresh via revalidatePath.
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(replaceStateSpy).not.toHaveBeenCalled();
|
||||
expect(refreshMock).not.toHaveBeenCalled();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps filters and table visible when providers are disconnected", () => {
|
||||
// Given/When
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("providers-filters").parentElement).toHaveClass(
|
||||
"flex",
|
||||
"flex-col",
|
||||
"gap-6",
|
||||
);
|
||||
expect(screen.getByTestId("providers-table")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("No Providers Configured"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the provider wizard from the normal Add Provider button", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud={false}
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /add provider/i }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AddProviderButton } from "@/components/providers/add-provider-button";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button";
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
|
||||
import { ProvidersFilters } from "@/components/providers/providers-filters";
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
@@ -11,6 +13,10 @@ import type {
|
||||
OrgWizardInitialData,
|
||||
ProviderWizardInitialData,
|
||||
} from "@/components/providers/wizard/types";
|
||||
import {
|
||||
ADD_PROVIDER_SEARCH_PARAM,
|
||||
ADD_PROVIDER_SEARCH_VALUE,
|
||||
} from "@/lib/providers-navigation";
|
||||
import type { FilterOption, MetaDataProps, ProviderProps } from "@/types";
|
||||
import type { ProvidersTableRow } from "@/types/providers-table";
|
||||
|
||||
@@ -29,7 +35,14 @@ export function ProvidersAccountsView({
|
||||
providers,
|
||||
rows,
|
||||
}: ProvidersAccountsViewProps) {
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const hasNoProviders = providers.length === 0;
|
||||
const shouldOpenProviderWizardFromUrl =
|
||||
searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE;
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(
|
||||
() => shouldOpenProviderWizardFromUrl,
|
||||
);
|
||||
const [providerWizardInitialData, setProviderWizardInitialData] = useState<
|
||||
ProviderWizardInitialData | undefined
|
||||
>(undefined);
|
||||
@@ -52,38 +65,64 @@ export function ProvidersAccountsView({
|
||||
const handleWizardOpenChange = (open: boolean) => {
|
||||
setIsProviderWizardOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setProviderWizardInitialData(undefined);
|
||||
setOrgWizardInitialData(undefined);
|
||||
if (open) return;
|
||||
|
||||
setProviderWizardInitialData(undefined);
|
||||
setOrgWizardInitialData(undefined);
|
||||
|
||||
// Only clean the one-shot ?addProvider intent from the URL bar, via the
|
||||
// History API so it does NOT trigger an RSC refetch. We must not refresh
|
||||
// here: the provider-creation actions (addProvider / addCredentialsProvider
|
||||
// / checkConnectionProvider) already revalidatePath("/providers"), so the
|
||||
// table updates behind the modal. A router.refresh()/replace() on close
|
||||
// re-ran the whole /providers Server Component, which read as a full reload.
|
||||
if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(ADD_PROVIDER_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
query ? `${pathname}?${query}` : pathname,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
{hasNoProviders ? (
|
||||
<NoProvidersAdded
|
||||
action="button"
|
||||
containerClassName="min-h-[calc(100dvh-28rem)]"
|
||||
onOpenWizard={() => openProviderWizard()}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ProvidersFilters
|
||||
filters={filters}
|
||||
providers={providers}
|
||||
actions={
|
||||
<>
|
||||
<MutedFindingsConfigButton />
|
||||
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<ProvidersAccountsTable
|
||||
isCloud={isCloud}
|
||||
metadata={metadata}
|
||||
rows={rows}
|
||||
onOpenProviderWizard={openProviderWizard}
|
||||
onOpenOrganizationWizard={openOrganizationWizard}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={handleWizardOpenChange}
|
||||
initialData={providerWizardInitialData}
|
||||
orgInitialData={orgWizardInitialData}
|
||||
refreshOnClose={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
// When false, the caller skips the post-close router.refresh() and relies on
|
||||
// the provider-creation actions' revalidatePath("/providers") to refresh the
|
||||
// data. Defaults to true so standalone callers keep refreshing.
|
||||
refreshOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function useProviderWizardController({
|
||||
@@ -57,6 +61,7 @@ export function useProviderWizardController({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose = true,
|
||||
}: UseProviderWizardControllerProps) {
|
||||
const router = useRouter();
|
||||
const initialProviderId = initialData?.providerId ?? null;
|
||||
@@ -185,7 +190,9 @@ export function useProviderWizardController({
|
||||
setProviderTypeHint(null);
|
||||
setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS);
|
||||
onOpenChange(false);
|
||||
router.refresh();
|
||||
if (refreshOnClose) {
|
||||
router.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (nextOpen: boolean) => {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface ProviderWizardModalProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialData?: ProviderWizardInitialData;
|
||||
orgInitialData?: OrgWizardInitialData;
|
||||
refreshOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderWizardModal({
|
||||
@@ -45,6 +46,7 @@ export function ProviderWizardModal({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose,
|
||||
}: ProviderWizardModalProps) {
|
||||
const {
|
||||
backToProviderFlow,
|
||||
@@ -72,6 +74,7 @@ export function ProviderWizardModal({
|
||||
onOpenChange,
|
||||
initialData,
|
||||
orgInitialData,
|
||||
refreshOnClose,
|
||||
});
|
||||
const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`;
|
||||
const { containerRef, sentinelRef, showScrollHint } = useScrollHint({
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, CardContent } from "@/components/shadcn";
|
||||
|
||||
import { InfoIcon } from "../icons/Icons";
|
||||
|
||||
interface NoProvidersAddedProps {
|
||||
onOpenWizard: () => void;
|
||||
}
|
||||
|
||||
export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card variant="base" className="mx-auto w-full max-w-3xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
|
||||
No Providers Configured
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
|
||||
No providers have been configured. Start by setting up a provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
aria-label="Open Add Provider modal"
|
||||
className="w-full max-w-xs justify-center"
|
||||
size="lg"
|
||||
onClick={onOpenWizard}
|
||||
>
|
||||
Get Started
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -1,73 +1,43 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
|
||||
|
||||
import { ScansProvidersEmptyState } from "./scans-providers-empty-state";
|
||||
|
||||
const { replaceMock, searchParamsValue } = vi.hoisted(() => ({
|
||||
replaceMock: vi.fn(),
|
||||
searchParamsValue: { current: "" },
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/scans",
|
||||
useRouter: () => ({
|
||||
replace: replaceMock,
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(searchParamsValue.current),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/wizard", () => ({
|
||||
ProviderWizardModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div role="dialog">Provider wizard</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("./no-providers-connected", () => ({
|
||||
NoProvidersConnected: () => <div>No Connected Providers</div>,
|
||||
}));
|
||||
|
||||
describe("ScansProvidersEmptyState", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
searchParamsValue.current = "";
|
||||
});
|
||||
|
||||
it("shows the add provider message and opens the provider wizard", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
it("shows the add provider message with a providers page CTA", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
});
|
||||
|
||||
it("clears the launch scan URL intent before opening the provider wizard", async () => {
|
||||
// Given
|
||||
searchParamsValue.current = "tab=completed&launchScan=true";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", {
|
||||
scroll: false,
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: /open add provider modal/i,
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
|
||||
|
||||
expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF);
|
||||
expect(cta.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("does not render the provider wizard in Scans", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the no connected providers message", () => {
|
||||
// Given/When
|
||||
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
"use client";
|
||||
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
|
||||
import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ProviderWizardModal } from "@/components/providers/wizard";
|
||||
import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation";
|
||||
|
||||
import { NoProvidersAdded } from "./no-providers-added";
|
||||
import { NoProvidersConnected } from "./no-providers-connected";
|
||||
|
||||
interface ScansProvidersEmptyStateProps {
|
||||
@@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps {
|
||||
export function ScansProvidersEmptyState({
|
||||
thereIsNoProviders,
|
||||
}: ScansProvidersEmptyStateProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false);
|
||||
|
||||
const openProviderWizard = () => {
|
||||
if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(LAUNCH_SCAN_SEARCH_PARAM);
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}
|
||||
|
||||
setIsProviderWizardOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{thereIsNoProviders ? (
|
||||
<NoProvidersAdded onOpenWizard={openProviderWizard} />
|
||||
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
|
||||
) : (
|
||||
<NoProvidersConnected />
|
||||
)}
|
||||
<ProviderWizardModal
|
||||
open={isProviderWizardOpen}
|
||||
onOpenChange={setIsProviderWizardOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The forms pull in server actions (`@/actions/users/users`) that can't run in
|
||||
// jsdom, so stub them with identifiable markers to assert which modal opens.
|
||||
vi.mock("../forms", () => ({
|
||||
DeleteForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="delete-form">delete-form:{userId}</div>
|
||||
),
|
||||
EditForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="edit-form">edit-form:{userId}</div>
|
||||
),
|
||||
ExpelUserForm: ({ userId }: { userId: string }) => (
|
||||
<div data-testid="expel-form">expel-form:{userId}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
|
||||
interface RowOptions {
|
||||
id?: string;
|
||||
isCurrentUser?: boolean;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
}
|
||||
|
||||
const createRow = ({
|
||||
id = "user-1",
|
||||
isCurrentUser,
|
||||
canBeExpelled,
|
||||
currentTenantId,
|
||||
}: RowOptions = {}) =>
|
||||
({
|
||||
original: {
|
||||
id,
|
||||
attributes: {
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
company_name: "Acme",
|
||||
role: { name: "admin" },
|
||||
},
|
||||
isCurrentUser,
|
||||
canBeExpelled,
|
||||
currentTenantId,
|
||||
},
|
||||
}) as unknown as Row<{ id: string }>;
|
||||
|
||||
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: "Open actions menu" }));
|
||||
};
|
||||
|
||||
describe("DataTableRowActions (users)", () => {
|
||||
it("always renders the Edit User action", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow()} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Edit User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Delete User only for the current user's row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Delete User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Delete User for another user's row", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: false })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does NOT show Delete User when isCurrentUser is undefined", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({})} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Danger zone entirely when the user can neither be deleted nor expelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({ isCurrentUser: false, canBeExpelled: false })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
// Only the non-destructive Edit action remains.
|
||||
expect(screen.getByText("Edit User")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Danger zone")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Expel from organization"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Expel but not Delete User for an expellable, non-current user", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({
|
||||
isCurrentUser: false,
|
||||
canBeExpelled: true,
|
||||
currentTenantId: "tenant-1",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
expect(screen.getByText("Danger zone")).toBeInTheDocument();
|
||||
expect(screen.getByText("Expel from organization")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete User")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Delete User with destructive styling", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DataTableRowActions row={createRow({ isCurrentUser: true })} />);
|
||||
|
||||
await openMenu(user);
|
||||
|
||||
const menuItem = screen
|
||||
.getByText("Delete User")
|
||||
.closest("[role='menuitem']");
|
||||
expect(menuItem).toBeInTheDocument();
|
||||
expect(menuItem).toHaveClass("text-text-error-primary");
|
||||
});
|
||||
|
||||
it("opens the delete confirmation modal when Delete User is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={createRow({ id: "user-42", isCurrentUser: true })}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openMenu(user);
|
||||
await user.click(screen.getByText("Delete User"));
|
||||
|
||||
expect(screen.getByText("Are you absolutely sure?")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("delete-form")).toHaveTextContent(
|
||||
"delete-form:user-42",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ interface UserRowData {
|
||||
attributes?: UserRowAttributes;
|
||||
canBeExpelled?: boolean;
|
||||
currentTenantId?: string;
|
||||
isCurrentUser?: boolean;
|
||||
}
|
||||
|
||||
interface DataTableRowActionsProps<UserProps extends UserRowData> {
|
||||
@@ -57,6 +58,10 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
row.original.canBeExpelled === true && !!row.original.currentTenantId;
|
||||
const currentTenantId = row.original.currentTenantId;
|
||||
|
||||
// A user can only delete their own account (enforced by the backend), so the
|
||||
// delete action is shown exclusively for the current user's row.
|
||||
const canDeleteUser = row.original.isCurrentUser === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -74,14 +79,16 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
setIsOpen={setIsEditOpen}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
{canDeleteUser && (
|
||||
<Modal
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete your user account and remove your data from the server."
|
||||
>
|
||||
<DeleteForm userId={userId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
)}
|
||||
{canExpelUser && currentTenantId && (
|
||||
<Modal
|
||||
open={isExpelOpen}
|
||||
@@ -104,22 +111,26 @@ export function DataTableRowActions<UserProps extends UserRowData>({
|
||||
label="Edit User"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
{(canExpelUser || canDeleteUser) && (
|
||||
<ActionDropdownDangerZone>
|
||||
{canExpelUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<UserMinus aria-hidden="true" />}
|
||||
label="Expel from organization"
|
||||
destructive
|
||||
onSelect={() => setIsExpelOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{canDeleteUser && (
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 aria-hidden="true" />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</ActionDropdownDangerZone>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
|
||||
+12
-12
@@ -778,26 +778,26 @@
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/browser-playwright",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "@vitest/coverage-v8",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -978,10 +978,10 @@
|
||||
{
|
||||
"section": "devDependencies",
|
||||
"name": "vitest",
|
||||
"from": "4.1.6",
|
||||
"to": "4.0.18",
|
||||
"from": "4.0.18",
|
||||
"to": "4.1.8",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-05-14T10:22:47.378Z"
|
||||
"generatedAt": "2026-06-02T11:34:46.264Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const ADD_PROVIDER_SEARCH_PARAM = "addProvider";
|
||||
export const ADD_PROVIDER_SEARCH_VALUE = "true";
|
||||
export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`;
|
||||
+4
-4
@@ -133,9 +133,9 @@
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"@vitest/browser": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/coverage-v8": "4.0.18",
|
||||
"@vitest/browser": "4.1.8",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"dotenv": "16.6.1",
|
||||
"dotenv-expand": "12.0.3",
|
||||
@@ -158,7 +158,7 @@
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.0.18",
|
||||
"vitest": "4.1.8",
|
||||
"vitest-browser-react": "2.0.4"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d",
|
||||
|
||||
Generated
+103
-110
@@ -325,14 +325,14 @@ importers:
|
||||
specifier: 5.1.2
|
||||
version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/browser':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/browser-playwright':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: 4.0.18
|
||||
version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
babel-plugin-react-compiler:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0
|
||||
@@ -400,11 +400,11 @@ importers:
|
||||
specifier: 5.5.4
|
||||
version: 5.5.4
|
||||
vitest:
|
||||
specifier: 4.0.18
|
||||
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)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
specifier: 4.1.8
|
||||
version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
vitest-browser-react:
|
||||
specifier: 2.0.4
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18)
|
||||
version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -737,6 +737,9 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@blazediff/core@1.9.1':
|
||||
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1':
|
||||
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
|
||||
|
||||
@@ -4795,54 +4798,54 @@ 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==}
|
||||
'@vitest/browser-playwright@4.1.8':
|
||||
resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==}
|
||||
peerDependencies:
|
||||
playwright: '*'
|
||||
vitest: 4.0.18
|
||||
vitest: 4.1.8
|
||||
|
||||
'@vitest/browser@4.0.18':
|
||||
resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==}
|
||||
'@vitest/browser@4.1.8':
|
||||
resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==}
|
||||
peerDependencies:
|
||||
vitest: 4.0.18
|
||||
vitest: 4.1.8
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
'@vitest/coverage-v8@4.1.8':
|
||||
resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
'@vitest/browser': 4.1.8
|
||||
vitest: 4.1.8
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
||||
'@vitest/expect@4.1.8':
|
||||
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||
|
||||
'@vitest/mocker@4.0.18':
|
||||
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
|
||||
'@vitest/mocker@4.1.8':
|
||||
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0-0
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
||||
'@vitest/runner@4.1.8':
|
||||
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
||||
'@vitest/snapshot@4.1.8':
|
||||
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||
|
||||
'@vitest/spy@4.0.18':
|
||||
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
||||
'@vitest/spy@4.1.8':
|
||||
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
'@vitest/utils@4.1.8':
|
||||
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
@@ -5048,8 +5051,8 @@ packages:
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==}
|
||||
ast-v8-to-istanbul@1.0.3:
|
||||
resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==}
|
||||
|
||||
async-function@1.0.0:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
@@ -5650,9 +5653,6 @@ packages:
|
||||
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
@@ -7246,10 +7246,6 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
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'}
|
||||
@@ -7537,6 +7533,7 @@ packages:
|
||||
recharts@2.15.4:
|
||||
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
|
||||
engines: {node: '>=14'}
|
||||
deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
@@ -7829,8 +7826,8 @@ packages:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
@@ -8347,20 +8344,23 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18:
|
||||
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
||||
vitest@4.1.8:
|
||||
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.0.18
|
||||
'@vitest/browser-preview': 4.0.18
|
||||
'@vitest/browser-webdriverio': 4.0.18
|
||||
'@vitest/ui': 4.0.18
|
||||
'@vitest/browser-playwright': 4.1.8
|
||||
'@vitest/browser-preview': 4.1.8
|
||||
'@vitest/browser-webdriverio': 4.1.8
|
||||
'@vitest/coverage-istanbul': 4.1.8
|
||||
'@vitest/coverage-v8': 4.1.8
|
||||
'@vitest/ui': 4.1.8
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
@@ -8374,6 +8374,10 @@ packages:
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
@@ -9319,6 +9323,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@blazediff/core@1.9.1': {}
|
||||
|
||||
'@braintree/sanitize-url@7.1.1': {}
|
||||
|
||||
'@cfworker/json-schema@4.1.1': {}
|
||||
@@ -14261,29 +14267,29 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
|
||||
'@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
playwright: 1.56.1
|
||||
tinyrainbow: 3.1.0
|
||||
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)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)':
|
||||
'@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.0.18
|
||||
'@blazediff/core': 1.9.1
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pixelmatch: 7.1.0
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
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)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
ws: 8.20.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -14291,60 +14297,62 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)':
|
||||
'@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.12
|
||||
'@vitest/utils': 4.1.8
|
||||
ast-v8-to-istanbul: 1.0.3
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.2
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
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)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
'@vitest/expect@4.1.8':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/spy': 4.1.8
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4)
|
||||
vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
'@vitest/runner@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.18
|
||||
'@vitest/utils': 4.1.8
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
'@vitest/snapshot@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.0.18': {}
|
||||
'@vitest/spy@4.1.8': {}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
'@vitest/utils@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
@@ -14604,7 +14612,7 @@ snapshots:
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
ast-v8-to-istanbul@0.3.12:
|
||||
ast-v8-to-istanbul@1.0.3:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
@@ -15273,8 +15281,6 @@ snapshots:
|
||||
iterator.prototype: 1.1.5
|
||||
safe-array-concat: 1.1.3
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
@@ -17280,10 +17286,6 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
pixelmatch@7.1.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
@@ -17980,7 +17982,7 @@ snapshots:
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
dependencies:
|
||||
@@ -18487,31 +18489,31 @@ snapshots:
|
||||
terser: 5.47.1
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18):
|
||||
vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8):
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
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)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0)
|
||||
vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.8
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.8)
|
||||
|
||||
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)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0):
|
||||
vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
es-module-lexer: 1.7.0
|
||||
'@vitest/expect': 4.1.8
|
||||
'@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/runner': 4.1.8
|
||||
'@vitest/snapshot': 4.1.8
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 3.10.0
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.16
|
||||
@@ -18521,20 +18523,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@types/node': 24.10.8
|
||||
'@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)
|
||||
'@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)
|
||||
'@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)
|
||||
jsdom: 27.4.0
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
|
||||
+27
-4
@@ -132,6 +132,24 @@ export async function addAWSProvider(
|
||||
await scansPage.verifyPageLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the providers page to settle and reports whether the data table is
|
||||
* present. With zero providers the page renders a full-page empty state
|
||||
* ("No Providers Configured") instead of the table, so callers must not assume
|
||||
* the table is always there.
|
||||
*/
|
||||
async function providersTableVisibleOrEmptyState(
|
||||
page: ProvidersPage,
|
||||
): Promise<boolean> {
|
||||
const emptyState = page.page.getByRole("region", {
|
||||
name: /no providers configured/i,
|
||||
});
|
||||
await expect(page.providersTable.or(emptyState)).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
return page.providersTable.isVisible().catch(() => false);
|
||||
}
|
||||
|
||||
export async function deleteProviderIfExists(
|
||||
page: ProvidersPage,
|
||||
providerUID: string,
|
||||
@@ -140,7 +158,11 @@ export async function deleteProviderIfExists(
|
||||
|
||||
// Navigate to providers page
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
// With zero providers the page shows the empty state, not the table, so there
|
||||
// is nothing to delete.
|
||||
if (!(await providersTableVisibleOrEmptyState(page))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allRows = page.providersTable.locator("tbody tr");
|
||||
|
||||
@@ -180,7 +202,7 @@ export async function deleteProviderIfExists(
|
||||
// Provider not found, nothing to delete
|
||||
// Navigate back to providers page to ensure clean state
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
await providersTableVisibleOrEmptyState(page);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -217,7 +239,8 @@ export async function deleteProviderIfExists(
|
||||
// Wait for modal to close (this indicates deletion was initiated)
|
||||
await expect(modal).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Navigate back to providers page to ensure clean state
|
||||
// Navigate back to providers page to ensure clean state. Deleting the last
|
||||
// provider reveals the empty state instead of an empty table.
|
||||
await page.goto();
|
||||
await expect(page.providersTable).toBeVisible({ timeout: 10000 });
|
||||
await providersTableVisibleOrEmptyState(page);
|
||||
}
|
||||
|
||||
@@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage {
|
||||
name: /Adding A Provider|Update Provider Credentials/i,
|
||||
});
|
||||
|
||||
// Button to add a new provider
|
||||
// Button to add a new provider. When providers exist this is the filter-bar
|
||||
// "Add Provider" control; with zero providers the page renders the empty
|
||||
// state whose CTA is labelled "Open Add Provider modal" (button on
|
||||
// /providers, link on /scans). Only one of these is ever in the DOM at once.
|
||||
this.addProviderButton = page
|
||||
.getByRole("button", {
|
||||
name: "Add Provider",
|
||||
@@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage {
|
||||
name: "Add Provider",
|
||||
exact: true,
|
||||
}),
|
||||
);
|
||||
)
|
||||
.or(page.getByRole("button", { name: "Open Add Provider modal" }))
|
||||
.or(page.getByRole("link", { name: "Open Add Provider modal" }));
|
||||
|
||||
// Table displaying existing providers
|
||||
this.providersTable = page.getByRole("table");
|
||||
|
||||
Reference in New Issue
Block a user