mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
19 Commits
ef518c0d1e
...
5.18.2
| Author | SHA1 | Date | |
|---|---|---|---|
| ce134a01c3 | |||
| 7e25ca719b | |||
| 0e79b70fee | |||
| b424eb302e | |||
| f15cf20b4f | |||
| 366f10cf0c | |||
| 6bba654059 | |||
| 6d94f0fcc3 | |||
| b8da7c9619 | |||
| 6d235278dc | |||
| 9285ad3569 | |||
| 88caa9c198 | |||
| cb4892dbe3 | |||
| 5c4386df5f | |||
| fd05080d12 | |||
| 4ce82d831a | |||
| 4d47e6c2f1 | |||
| 8dc6b3e2a3 | |||
| e1f70321c8 |
+10
-7
@@ -14,7 +14,7 @@ ignored:
|
||||
- "*.md"
|
||||
- "**/*.md"
|
||||
- mkdocs.yml
|
||||
|
||||
|
||||
# Config files that don't affect runtime
|
||||
- .gitignore
|
||||
- .gitattributes
|
||||
@@ -23,7 +23,7 @@ ignored:
|
||||
- .backportrc.json
|
||||
- CODEOWNERS
|
||||
- LICENSE
|
||||
|
||||
|
||||
# IDE/Editor configs
|
||||
- .vscode/**
|
||||
- .idea/**
|
||||
@@ -31,10 +31,13 @@ ignored:
|
||||
# Examples and contrib (not production code)
|
||||
- examples/**
|
||||
- contrib/**
|
||||
|
||||
|
||||
# Skills (AI agent configs, not runtime)
|
||||
- skills/**
|
||||
|
||||
|
||||
# E2E setup helpers (not runnable tests)
|
||||
- ui/tests/setups/**
|
||||
|
||||
# Permissions docs
|
||||
- permissions/**
|
||||
|
||||
@@ -47,18 +50,18 @@ critical:
|
||||
- prowler/config/**
|
||||
- prowler/exceptions/**
|
||||
- prowler/providers/common/**
|
||||
|
||||
|
||||
# API Core
|
||||
- api/src/backend/api/models.py
|
||||
- api/src/backend/config/**
|
||||
- api/src/backend/conftest.py
|
||||
|
||||
|
||||
# UI Core
|
||||
- ui/lib/**
|
||||
- ui/types/**
|
||||
- ui/config/**
|
||||
- ui/middleware.ts
|
||||
|
||||
|
||||
# CI/CD changes
|
||||
- .github/workflows/**
|
||||
- .github/test-impact.yml
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
e2e-tests:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
(needs.impact-analysis.outputs.has-ui-e2e == 'true' || needs.impact-analysis.outputs.run-all == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -200,7 +200,14 @@ jobs:
|
||||
# e.g., "ui/tests/providers/**" -> "tests/providers"
|
||||
TEST_PATHS="${{ env.E2E_TEST_PATHS }}"
|
||||
# Remove ui/ prefix and convert ** to empty (playwright handles recursion)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u | tr '\n' ' ')
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | sed 's|ui/||g' | sed 's|\*\*||g' | tr ' ' '\n' | sort -u)
|
||||
# Drop auth setup helpers (not runnable test suites)
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | grep -v '^tests/setups/')
|
||||
if [[ -z "$TEST_PATHS" ]]; then
|
||||
echo "No runnable E2E test paths after filtering setups"
|
||||
exit 0
|
||||
fi
|
||||
TEST_PATHS=$(echo "$TEST_PATHS" | tr '\n' ' ')
|
||||
echo "Resolved test paths: $TEST_PATHS"
|
||||
pnpm exec playwright test $TEST_PATHS
|
||||
fi
|
||||
@@ -222,8 +229,8 @@ jobs:
|
||||
skip-e2e:
|
||||
needs: impact-analysis
|
||||
if: |
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
|
||||
github.repository == 'prowler-cloud/prowler' &&
|
||||
needs.impact-analysis.outputs.has-ui-e2e != 'true' &&
|
||||
needs.impact-analysis.outputs.run-all != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
name: UI - E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- "v5.*"
|
||||
paths:
|
||||
- '.github/workflows/ui-e2e-tests.yml'
|
||||
- 'ui/**'
|
||||
|
||||
jobs:
|
||||
|
||||
e2e-tests:
|
||||
if: github.repository == 'prowler-cloud/prowler'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AUTH_SECRET: 'fallback-ci-secret-for-testing'
|
||||
AUTH_TRUST_HOST: true
|
||||
NEXTAUTH_URL: 'http://localhost:3000'
|
||||
NEXT_PUBLIC_API_BASE_URL: 'http://localhost:8080/api/v1'
|
||||
E2E_ADMIN_USER: ${{ secrets.E2E_ADMIN_USER }}
|
||||
E2E_ADMIN_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }}
|
||||
E2E_AWS_PROVIDER_ACCOUNT_ID: ${{ secrets.E2E_AWS_PROVIDER_ACCOUNT_ID }}
|
||||
E2E_AWS_PROVIDER_ACCESS_KEY: ${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}
|
||||
E2E_AWS_PROVIDER_SECRET_KEY: ${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}
|
||||
E2E_AWS_PROVIDER_ROLE_ARN: ${{ secrets.E2E_AWS_PROVIDER_ROLE_ARN }}
|
||||
E2E_AZURE_SUBSCRIPTION_ID: ${{ secrets.E2E_AZURE_SUBSCRIPTION_ID }}
|
||||
E2E_AZURE_CLIENT_ID: ${{ secrets.E2E_AZURE_CLIENT_ID }}
|
||||
E2E_AZURE_SECRET_ID: ${{ secrets.E2E_AZURE_SECRET_ID }}
|
||||
E2E_AZURE_TENANT_ID: ${{ secrets.E2E_AZURE_TENANT_ID }}
|
||||
E2E_M365_DOMAIN_ID: ${{ secrets.E2E_M365_DOMAIN_ID }}
|
||||
E2E_M365_CLIENT_ID: ${{ secrets.E2E_M365_CLIENT_ID }}
|
||||
E2E_M365_SECRET_ID: ${{ secrets.E2E_M365_SECRET_ID }}
|
||||
E2E_M365_TENANT_ID: ${{ secrets.E2E_M365_TENANT_ID }}
|
||||
E2E_M365_CERTIFICATE_CONTENT: ${{ secrets.E2E_M365_CERTIFICATE_CONTENT }}
|
||||
E2E_KUBERNETES_CONTEXT: 'kind-kind'
|
||||
E2E_KUBERNETES_KUBECONFIG_PATH: /home/runner/.kube/config
|
||||
E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY: ${{ secrets.E2E_GCP_BASE64_SERVICE_ACCOUNT_KEY }}
|
||||
E2E_GCP_PROJECT_ID: ${{ secrets.E2E_GCP_PROJECT_ID }}
|
||||
E2E_GITHUB_APP_ID: ${{ secrets.E2E_GITHUB_APP_ID }}
|
||||
E2E_GITHUB_BASE64_APP_PRIVATE_KEY: ${{ secrets.E2E_GITHUB_BASE64_APP_PRIVATE_KEY }}
|
||||
E2E_GITHUB_USERNAME: ${{ secrets.E2E_GITHUB_USERNAME }}
|
||||
E2E_GITHUB_PERSONAL_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_PERSONAL_ACCESS_TOKEN }}
|
||||
E2E_GITHUB_ORGANIZATION: ${{ secrets.E2E_GITHUB_ORGANIZATION }}
|
||||
E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN: ${{ secrets.E2E_GITHUB_ORGANIZATION_ACCESS_TOKEN }}
|
||||
E2E_ORGANIZATION_ID: ${{ secrets.E2E_ORGANIZATION_ID }}
|
||||
E2E_OCI_TENANCY_ID: ${{ secrets.E2E_OCI_TENANCY_ID }}
|
||||
E2E_OCI_USER_ID: ${{ secrets.E2E_OCI_USER_ID }}
|
||||
E2E_OCI_FINGERPRINT: ${{ secrets.E2E_OCI_FINGERPRINT }}
|
||||
E2E_OCI_KEY_CONTENT: ${{ secrets.E2E_OCI_KEY_CONTENT }}
|
||||
E2E_OCI_REGION: ${{ secrets.E2E_OCI_REGION }}
|
||||
E2E_NEW_USER_PASSWORD: ${{ secrets.E2E_NEW_USER_PASSWORD }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1
|
||||
with:
|
||||
cluster_name: kind
|
||||
- name: Modify kubeconfig
|
||||
run: |
|
||||
# Modify the kubeconfig to use the kind cluster server to https://kind-control-plane:6443
|
||||
# from worker service into docker-compose.yml
|
||||
kubectl config set-cluster kind-kind --server=https://kind-control-plane:6443
|
||||
kubectl config view
|
||||
- name: Add network kind to docker compose
|
||||
run: |
|
||||
# Add the network kind to the docker compose to interconnect to kind cluster
|
||||
yq -i '.networks.kind.external = true' docker-compose.yml
|
||||
# Add network kind to worker service and default network too
|
||||
yq -i '.services.worker.networks = ["kind","default"]' docker-compose.yml
|
||||
- name: Fix API data directory permissions
|
||||
run: docker run --rm -v $(pwd)/_data/api:/data alpine chown -R 1000:1000 /data
|
||||
- name: Add AWS credentials for testing AWS SDK Default Adding Provider
|
||||
run: |
|
||||
echo "Adding AWS credentials for testing AWS SDK Default Adding Provider..."
|
||||
echo "AWS_ACCESS_KEY_ID=${{ secrets.E2E_AWS_PROVIDER_ACCESS_KEY }}" >> .env
|
||||
echo "AWS_SECRET_ACCESS_KEY=${{ secrets.E2E_AWS_PROVIDER_SECRET_KEY }}" >> .env
|
||||
- name: Start API services
|
||||
run: |
|
||||
# Override docker-compose image tag to use latest instead of stable
|
||||
# This overrides any PROWLER_API_VERSION set in .env file
|
||||
export PROWLER_API_VERSION=latest
|
||||
echo "Using PROWLER_API_VERSION=${PROWLER_API_VERSION}"
|
||||
docker compose up -d api worker worker-beat
|
||||
- name: Wait for API to be ready
|
||||
run: |
|
||||
echo "Waiting for prowler-api..."
|
||||
timeout=150 # 5 minutes max
|
||||
elapsed=0
|
||||
while [ $elapsed -lt $timeout ]; do
|
||||
if curl -s ${NEXT_PUBLIC_API_BASE_URL}/docs >/dev/null 2>&1; then
|
||||
echo "Prowler API is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for prowler-api... (${elapsed}s elapsed)"
|
||||
sleep 5
|
||||
elapsed=$((elapsed + 5))
|
||||
done
|
||||
echo "Timeout waiting for prowler-api to start"
|
||||
exit 1
|
||||
- name: Load database fixtures for E2E tests
|
||||
run: |
|
||||
docker compose exec -T api sh -c '
|
||||
echo "Loading all fixtures from api/fixtures/dev/..."
|
||||
for fixture in api/fixtures/dev/*.json; do
|
||||
if [ -f "$fixture" ]; then
|
||||
echo "Loading $fixture"
|
||||
poetry run python manage.py loaddata "$fixture" --database admin
|
||||
fi
|
||||
done
|
||||
echo "All database fixtures loaded successfully!"
|
||||
'
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: '24.13.0'
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
- name: Setup pnpm and Next.js cache
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
with:
|
||||
path: |
|
||||
${{ env.STORE_PATH }}
|
||||
./ui/node_modules
|
||||
./ui/.next/cache
|
||||
key: ${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-${{ hashFiles('ui/**/*.ts', 'ui/**/*.tsx', 'ui/**/*.js', 'ui/**/*.jsx') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-nextjs-${{ hashFiles('ui/pnpm-lock.yaml') }}-
|
||||
${{ runner.os }}-pnpm-nextjs-
|
||||
- name: Install UI dependencies
|
||||
working-directory: ./ui
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
- name: Build UI application
|
||||
working-directory: ./ui
|
||||
run: pnpm run build
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('ui/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
- name: Install Playwright browsers
|
||||
working-directory: ./ui
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm run test:e2e:install
|
||||
- name: Run E2E tests
|
||||
working-directory: ./ui
|
||||
run: pnpm run test:e2e
|
||||
- name: Upload test reports
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ui/playwright-report/
|
||||
retention-days: 30
|
||||
- name: Cleanup services
|
||||
if: always()
|
||||
run: |
|
||||
echo "Shutting down services..."
|
||||
docker compose down -v || true
|
||||
echo "Cleanup completed"
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.19.2] (Prowler v5.18.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- SAML role mapping now prevents removing the last MANAGE_ACCOUNT user [(#10007)](https://github.com/prowler-cloud/prowler/pull/10007)
|
||||
|
||||
---
|
||||
|
||||
## [1.19.0] (Prowler v5.18.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
Generated
+368
-13
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -24,7 +24,7 @@ dependencies = [
|
||||
"drf-spectacular-jsonapi==0.5.1",
|
||||
"gunicorn==23.0.0",
|
||||
"lxml==5.3.2",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@master",
|
||||
"prowler @ git+https://github.com/prowler-cloud/prowler.git@v5.18",
|
||||
"psycopg2-binary==2.9.9",
|
||||
"pytest-celery[redis] (>=1.0.1,<2.0.0)",
|
||||
"sentry-sdk[django] (>=2.20.0,<3.0.0)",
|
||||
@@ -49,7 +49,7 @@ name = "prowler-api"
|
||||
package-mode = false
|
||||
# Needed for the SDK compatibility
|
||||
requires-python = ">=3.11,<3.13"
|
||||
version = "1.19.0"
|
||||
version = "1.19.2"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.19.0
|
||||
version: 1.19.2
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -10841,25 +10841,20 @@ class TestTenantFinishACSView:
|
||||
assert "sso_saml_failed=true" in response.url
|
||||
|
||||
def test_dispatch_skips_role_mapping_when_single_manage_account_user(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is skipped when tenant has only one user with MANAGE_ACCOUNT role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a single role with manage_account=True for the user
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
manage_users=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
admin_role = admin_role_fixture
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
@@ -10930,35 +10925,26 @@ class TestTenantFinishACSView:
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
|
||||
self, create_test_user, tenants_fixture, saml_setup, settings, monkeypatch
|
||||
def test_dispatch_skips_role_mapping_when_last_manage_account_user_maps_to_existing_role(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
roles_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
|
||||
"""Test that role mapping is skipped when it would remove the last MANAGE_ACCOUNT user"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a second user with manage_account=True
|
||||
second_admin = User.objects.using(MainRouter.admin_db).create(
|
||||
email="admin2@prowler.com", name="Second Admin"
|
||||
)
|
||||
admin_role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name="admin",
|
||||
tenant=tenant,
|
||||
manage_account=True,
|
||||
manage_users=True,
|
||||
manage_billing=True,
|
||||
manage_providers=True,
|
||||
manage_integrations=True,
|
||||
manage_scans=True,
|
||||
unlimited_visibility=True,
|
||||
)
|
||||
admin_role = admin_role_fixture
|
||||
viewer_role = roles_fixture[3]
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=second_admin, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
@@ -10967,7 +10953,7 @@ class TestTenantFinishACSView:
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": ["viewer"], # This SHOULD be applied
|
||||
"userType": [viewer_role.name],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11005,10 +10991,91 @@ class TestTenantFinishACSView:
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify the viewer role was created and assigned (role mapping was applied)
|
||||
viewer_role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name="viewer", tenant=tenant
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=admin_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
assert not (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_when_multiple_manage_account_users(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
roles_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is applied when tenant has multiple users with MANAGE_ACCOUNT role"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
|
||||
# Create a second user with manage_account=True
|
||||
second_admin = User.objects.using(MainRouter.admin_db).create(
|
||||
email="admin2@prowler.com", name="Second Admin"
|
||||
)
|
||||
admin_role = admin_role_fixture
|
||||
viewer_role = roles_fixture[3]
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=second_admin, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": [viewer_role.name], # This SHOULD be applied
|
||||
},
|
||||
)
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = user
|
||||
request.session = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = user
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
# Verify the viewer role was assigned (role mapping was applied)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=user, role=viewer_role, tenant_id=tenant.id)
|
||||
@@ -11022,6 +11089,86 @@ class TestTenantFinishACSView:
|
||||
.exists()
|
||||
)
|
||||
|
||||
def test_dispatch_applies_role_mapping_for_non_admin_user_with_single_admin(
|
||||
self,
|
||||
create_test_user,
|
||||
tenants_fixture,
|
||||
admin_role_fixture,
|
||||
roles_fixture,
|
||||
saml_setup,
|
||||
settings,
|
||||
monkeypatch,
|
||||
):
|
||||
"""Test that role mapping is applied for a non-admin user when a single admin exists"""
|
||||
monkeypatch.setenv("SAML_SSO_CALLBACK_URL", "http://localhost/sso-complete")
|
||||
admin_user = create_test_user
|
||||
tenant = tenants_fixture[0]
|
||||
non_admin_user = User.objects.using(MainRouter.admin_db).create(
|
||||
email="viewer@prowler.com", name="Viewer"
|
||||
)
|
||||
|
||||
admin_role = admin_role_fixture
|
||||
viewer_role = roles_fixture[3]
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db).create(
|
||||
user=admin_user, role=admin_role, tenant_id=tenant.id
|
||||
)
|
||||
|
||||
social_account = SocialAccount(
|
||||
user=non_admin_user,
|
||||
provider="saml",
|
||||
extra_data={
|
||||
"firstName": ["Jane"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": [viewer_role.name],
|
||||
},
|
||||
)
|
||||
|
||||
request = RequestFactory().get(
|
||||
reverse("saml_finish_acs", kwargs={"organization_slug": "testtenant"})
|
||||
)
|
||||
request.user = non_admin_user
|
||||
request.session = {}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"allauth.socialaccount.providers.saml.views.get_app_or_404"
|
||||
) as mock_get_app_or_404,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialApp.objects.get"
|
||||
) as mock_socialapp_get,
|
||||
patch(
|
||||
"allauth.socialaccount.models.SocialAccount.objects.get"
|
||||
) as mock_sa_get,
|
||||
patch("api.models.SAMLDomainIndex.objects.get") as mock_saml_domain_get,
|
||||
patch("api.models.SAMLConfiguration.objects.get") as mock_saml_config_get,
|
||||
patch("api.models.User.objects.get") as mock_user_get,
|
||||
):
|
||||
mock_get_app_or_404.return_value = MagicMock(
|
||||
provider="saml", client_id="testtenant", name="Test App", settings={}
|
||||
)
|
||||
mock_sa_get.return_value = social_account
|
||||
mock_socialapp_get.return_value = MagicMock(provider_id="saml")
|
||||
mock_saml_domain_get.return_value = SimpleNamespace(tenant_id=tenant.id)
|
||||
mock_saml_config_get.return_value = MagicMock()
|
||||
mock_user_get.return_value = non_admin_user
|
||||
|
||||
view = TenantFinishACSView.as_view()
|
||||
response = view(request, organization_slug="testtenant")
|
||||
|
||||
assert response.status_code == 302
|
||||
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=non_admin_user, role=viewer_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
assert (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(user=admin_user, role=admin_role, tenant_id=tenant.id)
|
||||
.exists()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestLighthouseConfigViewSet:
|
||||
|
||||
@@ -392,7 +392,7 @@ class SchemaView(SpectacularAPIView):
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
spectacular_settings.TITLE = "Prowler API"
|
||||
spectacular_settings.VERSION = "1.19.0"
|
||||
spectacular_settings.VERSION = "1.19.2"
|
||||
spectacular_settings.DESCRIPTION = (
|
||||
"Prowler API specification.\n\nThis file is auto-generated."
|
||||
)
|
||||
@@ -763,27 +763,40 @@ class TenantFinishACSView(FinishACSView):
|
||||
.tenant
|
||||
)
|
||||
|
||||
# Check if tenant has only one user with MANAGE_ACCOUNT role
|
||||
users_with_manage_account = (
|
||||
role_name = (
|
||||
extra.get("userType", ["no_permissions"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "no_permissions"
|
||||
)
|
||||
role = (
|
||||
Role.objects.using(MainRouter.admin_db)
|
||||
.filter(name=role_name, tenant=tenant)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Only skip mapping if it would remove the last MANAGE_ACCOUNT user
|
||||
remaining_manage_account_users = (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role__manage_account=True, tenant_id=tenant.id)
|
||||
.exclude(user_id=user_id)
|
||||
.values("user")
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
user_has_manage_account = (
|
||||
UserRoleRelationship.objects.using(MainRouter.admin_db)
|
||||
.filter(role__manage_account=True, tenant_id=tenant.id, user_id=user_id)
|
||||
.exists()
|
||||
)
|
||||
role_manage_account = role.manage_account if role else False
|
||||
would_remove_last_manage_account = (
|
||||
user_has_manage_account
|
||||
and remaining_manage_account_users == 0
|
||||
and not role_manage_account
|
||||
)
|
||||
|
||||
# Only apply role mapping from userType if tenant does NOT have exactly one user with MANAGE_ACCOUNT
|
||||
if users_with_manage_account != 1:
|
||||
role_name = (
|
||||
extra.get("userType", ["no_permissions"])[0].strip()
|
||||
if extra.get("userType")
|
||||
else "no_permissions"
|
||||
)
|
||||
try:
|
||||
role = Role.objects.using(MainRouter.admin_db).get(
|
||||
name=role_name, tenant=tenant
|
||||
)
|
||||
except Role.DoesNotExist:
|
||||
if not would_remove_last_manage_account:
|
||||
if role is None:
|
||||
role = Role.objects.using(MainRouter.admin_db).create(
|
||||
name=role_name,
|
||||
tenant=tenant,
|
||||
|
||||
@@ -115,8 +115,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.17.0"
|
||||
PROWLER_API_VERSION="5.17.0"
|
||||
PROWLER_UI_VERSION="5.18.1"
|
||||
PROWLER_API_VERSION="5.18.1"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -38,6 +38,7 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained
|
||||
|
||||
4. **Configure Token Settings**
|
||||
- **Token name**: Give your token a descriptive name (e.g., "Prowler Security Scanner")
|
||||
- **Resource owner**: Select the account that owns the resources to scan — either a personal account or a specific organization
|
||||
- **Expiration**: Set an appropriate expiration date (recommended: 90 days or less)
|
||||
- **Repository access**: Choose "All repositories" or "Only select repositories" based on your needs
|
||||
|
||||
@@ -56,11 +57,11 @@ GitHub has deprecated Personal Access Tokens (classic) in favor of fine-grained
|
||||
- **Metadata**: Read-only access
|
||||
- **Pull requests**: Read-only access
|
||||
|
||||
- **Organization permissions:**
|
||||
- **Organization permissions** (available when an organization is selected as Resource Owner):
|
||||
- **Administration**: Read-only access
|
||||
- **Members**: Read-only access
|
||||
|
||||
- **Account permissions:**
|
||||
- **Account permissions** (available when a personal account is selected as Resource Owner):
|
||||
- **Email addresses**: Read-only access
|
||||
|
||||
6. **Copy and Store the Token**
|
||||
|
||||
@@ -54,7 +54,7 @@ title: 'Getting Started with GitHub'
|
||||
</Tabs>
|
||||
## Prowler CLI
|
||||
|
||||
### Automatic Login Method Detection
|
||||
### Authentication
|
||||
|
||||
If no login method is explicitly provided, Prowler will automatically attempt to authenticate using environment variables in the following order of precedence:
|
||||
|
||||
@@ -68,15 +68,15 @@ Ensure the corresponding environment variables are set up before running Prowler
|
||||
</Note>
|
||||
For more details on how to set up authentication with GitHub, see [Authentication > GitHub](/user-guide/providers/github/authentication).
|
||||
|
||||
### Personal Access Token (PAT)
|
||||
#### Personal Access Token (PAT)
|
||||
|
||||
Use this method by providing your personal access token directly.
|
||||
Use this method by providing a personal access token directly.
|
||||
|
||||
```console
|
||||
prowler github --personal-access-token pat
|
||||
```
|
||||
|
||||
### OAuth App Token
|
||||
#### OAuth App Token
|
||||
|
||||
Authenticate using an OAuth app token.
|
||||
|
||||
@@ -84,9 +84,62 @@ Authenticate using an OAuth app token.
|
||||
prowler github --oauth-app-token oauth_token
|
||||
```
|
||||
|
||||
### GitHub App Credentials
|
||||
#### GitHub App Credentials
|
||||
|
||||
Use GitHub App credentials by specifying the App ID and the private key path.
|
||||
|
||||
```console
|
||||
prowler github --github-app-id app_id --github-app-key-path app_key_path
|
||||
```
|
||||
|
||||
### Scan Scoping
|
||||
|
||||
By default, Prowler scans all repositories accessible to the authenticated user or organization. To limit the scan to specific repositories or organizations, use the following flags.
|
||||
|
||||
#### Scanning Specific Repositories
|
||||
|
||||
To restrict the scan to one or more repositories, use the `--repository` flag followed by the repository name(s) in `owner/repo-name` format:
|
||||
|
||||
```console
|
||||
prowler github --repository owner/repo-name
|
||||
```
|
||||
|
||||
To scan multiple repositories, specify them as space-separated arguments:
|
||||
|
||||
```console
|
||||
prowler github --repository owner/repo-name-1 owner/repo-name-2
|
||||
```
|
||||
|
||||
#### Scanning Specific Organizations
|
||||
|
||||
To restrict the scan to one or more organizations or user accounts, use the `--organization` flag:
|
||||
|
||||
```console
|
||||
prowler github --organization my-organization
|
||||
```
|
||||
|
||||
To scan multiple organizations, specify them as space-separated arguments:
|
||||
|
||||
```console
|
||||
prowler github --organization org-1 org-2
|
||||
```
|
||||
|
||||
#### Scanning Specific Repositories Within an Organization
|
||||
|
||||
To scan specific repositories within an organization, combine the `--organization` and `--repository` flags. The `--organization` flag qualifies unqualified repository names automatically:
|
||||
|
||||
```console
|
||||
prowler github --organization my-organization --repository my-repo
|
||||
```
|
||||
|
||||
This scans only `my-organization/my-repo`. Fully qualified repository names (`owner/repo-name`) are also supported alongside `--organization`:
|
||||
|
||||
```console
|
||||
prowler github --organization my-org --repository my-repo other-owner/other-repo
|
||||
```
|
||||
|
||||
In this case, `my-repo` is qualified as `my-org/my-repo`, while `other-owner/other-repo` is used as-is.
|
||||
|
||||
<Note>
|
||||
The `--repository` and `--organization` flags can be combined with any authentication method.
|
||||
</Note>
|
||||
|
||||
@@ -2,6 +2,15 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.18.2] (Prowler v5.18.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `--repository` and `--organization` flags combined interaction in GitHub provider, qualifying unqualified repository names with organization [(#10001)](https://github.com/prowler-cloud/prowler/pull/10001)
|
||||
- HPACK library logging tokens in debug mode for Azure, M365, and Cloudflare providers [(#10010)](https://github.com/prowler-cloud/prowler/pull/10010)
|
||||
|
||||
---
|
||||
|
||||
## [5.18.0] (Prowler v5.18.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
@@ -38,7 +38,7 @@ class _MutableTimestamp:
|
||||
|
||||
timestamp = _MutableTimestamp(datetime.today())
|
||||
timestamp_utc = _MutableTimestamp(datetime.now(timezone.utc))
|
||||
prowler_version = "5.18.0"
|
||||
prowler_version = "5.18.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"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from argparse import ArgumentTypeError
|
||||
@@ -217,6 +218,9 @@ class AzureProvider(Provider):
|
||||
"""
|
||||
logger.info("Setting Azure provider ...")
|
||||
|
||||
# Mute HPACK library logs to prevent token leakage in debug mode
|
||||
logging.getLogger("hpack").setLevel(logging.CRITICAL)
|
||||
|
||||
logger.info("Checking if any credentials mode is set ...")
|
||||
|
||||
# Validate the authentication arguments
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Iterable
|
||||
|
||||
@@ -55,6 +56,9 @@ class CloudflareProvider(Provider):
|
||||
):
|
||||
logger.info("Instantiating Cloudflare provider...")
|
||||
|
||||
# Mute HPACK library logs to prevent token leakage in debug mode
|
||||
logging.getLogger("hpack").setLevel(logging.CRITICAL)
|
||||
|
||||
if config_content:
|
||||
self._audit_config = config_content
|
||||
else:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import os
|
||||
from os import environ
|
||||
from typing import Union
|
||||
@@ -134,8 +135,6 @@ class GithubProvider(Provider):
|
||||
logger.info("Instantiating GitHub Provider...")
|
||||
|
||||
# Mute GitHub library logs to reduce noise since it is already handled by the Prowler logger
|
||||
import logging
|
||||
|
||||
logging.getLogger("github").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("github.GithubRetry").setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
@@ -121,15 +121,22 @@ class Repository(GithubService):
|
||||
)
|
||||
):
|
||||
if self.provider.repositories:
|
||||
logger.info(
|
||||
f"Filtering for specific repositories: {self.provider.repositories}"
|
||||
)
|
||||
qualified_repos = []
|
||||
for repo_name in self.provider.repositories:
|
||||
if not self._validate_repository_format(repo_name):
|
||||
if self._validate_repository_format(repo_name):
|
||||
qualified_repos.append(repo_name)
|
||||
elif self.provider.organizations:
|
||||
for org_name in self.provider.organizations:
|
||||
qualified_repos.append(f"{org_name}/{repo_name}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Repository name '{repo_name}' should be in 'owner/repo-name' format. Skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Filtering for specific repositories: {qualified_repos}"
|
||||
)
|
||||
for repo_name in qualified_repos:
|
||||
try:
|
||||
repo = client.get_repo(repo_name)
|
||||
self._process_repository(repo, repos)
|
||||
@@ -138,7 +145,7 @@ class Repository(GithubService):
|
||||
error, "accessing repository", repo_name
|
||||
)
|
||||
|
||||
if self.provider.organizations:
|
||||
elif self.provider.organizations:
|
||||
logger.info(
|
||||
f"Filtering for repositories in organizations: {self.provider.organizations}"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
from argparse import ArgumentTypeError
|
||||
from os import getenv
|
||||
@@ -157,6 +158,9 @@ class M365Provider(Provider):
|
||||
"""
|
||||
logger.info("Setting M365 provider ...")
|
||||
|
||||
# Mute HPACK library logs to prevent token leakage in debug mode
|
||||
logging.getLogger("hpack").setLevel(logging.CRITICAL)
|
||||
|
||||
logger.info("Checking if any credentials mode is set ...")
|
||||
|
||||
# Validate the authentication arguments
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ maintainers = [{name = "Prowler Engineering", email = "engineering@prowler.com"}
|
||||
name = "prowler"
|
||||
readme = "README.md"
|
||||
requires-python = ">3.9.1,<3.13"
|
||||
version = "5.18.0"
|
||||
version = "5.18.2"
|
||||
|
||||
[project.scripts]
|
||||
prowler = "prowler.__main__:prowler"
|
||||
|
||||
@@ -74,6 +74,7 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash
|
||||
- One entry per PR (can link multiple PRs for related changes)
|
||||
- No period at the end
|
||||
- Do NOT start with redundant verbs (section header already provides the action)
|
||||
- **CRITICAL: Preserve section order** — when adding a new section to the UNRELEASED block, insert it in the correct position relative to existing sections (Added → Changed → Deprecated → Removed → Fixed → Security). Never append a new section at the top or bottom without checking order
|
||||
|
||||
### Semantic Versioning Rules
|
||||
|
||||
@@ -177,6 +178,13 @@ This maintains chronological order within each section (oldest at top, newest at
|
||||
### Bad Entries
|
||||
|
||||
```markdown
|
||||
# BAD - Wrong section order (Fixed before Added)
|
||||
### 🐞 Fixed
|
||||
- Some bug fix [(#123)](...)
|
||||
|
||||
### 🚀 Added
|
||||
- Some new feature [(#456)](...)
|
||||
|
||||
- Fixed bug. # Too vague, has period
|
||||
- Added new feature for users # Missing PR link, redundant verb
|
||||
- Add search bar [(#123)] # Redundant verb (section already says "Added")
|
||||
|
||||
@@ -245,19 +245,61 @@ class Test_Repository_Scoping:
|
||||
self.mock_repo2.get_branch.side_effect = Exception("404 Not Found")
|
||||
self.mock_repo2.get_dependabot_alerts.side_effect = Exception("404 Not Found")
|
||||
|
||||
def test_combined_repository_and_organization_scoping(self):
|
||||
"""Test that both repository and organization scoping can be used together"""
|
||||
def test_qualified_repo_with_organization_skips_org_fetch(self):
|
||||
"""Test that a fully qualified repo with --organization does not fetch all org repos"""
|
||||
provider = set_mocked_github_provider()
|
||||
provider.repositories = ["owner1/repo1"]
|
||||
provider.organizations = ["org2"]
|
||||
|
||||
mock_client = MagicMock()
|
||||
# Repository lookup
|
||||
mock_client.get_repo.return_value = self.mock_repo1
|
||||
# Organization lookup
|
||||
mock_org = MagicMock()
|
||||
mock_org.get_repos.return_value = [self.mock_repo2]
|
||||
mock_client.get_organization.return_value = mock_org
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
|
||||
):
|
||||
repository_service = Repository(provider)
|
||||
repository_service.clients = [mock_client]
|
||||
repository_service.provider = provider
|
||||
|
||||
repos = repository_service._list_repositories()
|
||||
|
||||
assert len(repos) == 1
|
||||
assert 1 in repos
|
||||
assert repos[1].name == "repo1"
|
||||
mock_client.get_repo.assert_called_once_with("owner1/repo1")
|
||||
mock_client.get_organization.assert_not_called()
|
||||
|
||||
def test_unqualified_repo_qualified_with_organization(self):
|
||||
"""Test that an unqualified repo name is qualified with the organization"""
|
||||
provider = set_mocked_github_provider()
|
||||
provider.repositories = ["repo1"]
|
||||
provider.organizations = ["owner1"]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_repo.return_value = self.mock_repo1
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
|
||||
):
|
||||
repository_service = Repository(provider)
|
||||
repository_service.clients = [mock_client]
|
||||
repository_service.provider = provider
|
||||
|
||||
repos = repository_service._list_repositories()
|
||||
|
||||
assert len(repos) == 1
|
||||
assert 1 in repos
|
||||
assert repos[1].name == "repo1"
|
||||
mock_client.get_repo.assert_called_once_with("owner1/repo1")
|
||||
|
||||
def test_unqualified_repo_qualified_with_multiple_organizations(self):
|
||||
"""Test that an unqualified repo is qualified with each organization"""
|
||||
provider = set_mocked_github_provider()
|
||||
provider.repositories = ["repo1"]
|
||||
provider.organizations = ["org1", "org2"]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2]
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
|
||||
@@ -269,12 +311,56 @@ class Test_Repository_Scoping:
|
||||
repos = repository_service._list_repositories()
|
||||
|
||||
assert len(repos) == 2
|
||||
assert 1 in repos
|
||||
assert 2 in repos
|
||||
assert repos[1].name == "repo1"
|
||||
assert repos[2].name == "repo2"
|
||||
mock_client.get_repo.assert_called_once_with("owner1/repo1")
|
||||
mock_client.get_organization.assert_called_once_with("org2")
|
||||
mock_client.get_repo.assert_any_call("org1/repo1")
|
||||
mock_client.get_repo.assert_any_call("org2/repo1")
|
||||
|
||||
def test_unqualified_repo_without_organization_is_skipped(self):
|
||||
"""Test that an unqualified repo without --organization is skipped with a warning"""
|
||||
provider = set_mocked_github_provider()
|
||||
provider.repositories = ["repo1"]
|
||||
provider.organizations = []
|
||||
|
||||
mock_client = MagicMock()
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
|
||||
):
|
||||
repository_service = Repository(provider)
|
||||
repository_service.clients = [mock_client]
|
||||
repository_service.provider = provider
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github.services.repository.repository_service.logger"
|
||||
) as mock_logger:
|
||||
repos = repository_service._list_repositories()
|
||||
|
||||
assert len(repos) == 0
|
||||
mock_logger.warning.assert_called_with(
|
||||
"Repository name 'repo1' should be in 'owner/repo-name' format. Skipping."
|
||||
)
|
||||
mock_client.get_repo.assert_not_called()
|
||||
|
||||
def test_mixed_qualified_and_unqualified_repos_with_organization(self):
|
||||
"""Test mix of qualified and unqualified repos with --organization"""
|
||||
provider = set_mocked_github_provider()
|
||||
provider.repositories = ["repo1", "owner2/repo2"]
|
||||
provider.organizations = ["org1"]
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_repo.side_effect = [self.mock_repo1, self.mock_repo2]
|
||||
|
||||
with patch(
|
||||
"prowler.providers.github.services.repository.repository_service.GithubService.__init__"
|
||||
):
|
||||
repository_service = Repository(provider)
|
||||
repository_service.clients = [mock_client]
|
||||
repository_service.provider = provider
|
||||
|
||||
repos = repository_service._list_repositories()
|
||||
|
||||
assert len(repos) == 2
|
||||
mock_client.get_repo.assert_any_call("org1/repo1")
|
||||
mock_client.get_repo.assert_any_call("owner2/repo2")
|
||||
|
||||
|
||||
class Test_Repository_Validation:
|
||||
|
||||
@@ -2,6 +2,27 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.18.2] (Prowler v5.18.2)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- ProviderTypeSelector crashing when an unknown provider type is missing from PROVIDER_DATA [(#9991)](https://github.com/prowler-cloud/prowler/pull/9991)
|
||||
- Infinite memory loop when opening modals from table row action dropdowns due to HeroUI and Radix Dialog overlay conflict [(#9996)](https://github.com/prowler-cloud/prowler/pull/9996)
|
||||
- Filter changes not coordinating with Suspense boundaries in ProviderTypeSelector, AccountsSelector, and muted findings checkbox [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
|
||||
- Scans page pagination not refreshing table data after page change [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
|
||||
- Duplicate `filter[search]` parameter in findings and scans API calls [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013)
|
||||
- Filters on `/findings` silently reverting on first click in production [(#10034)](https://github.com/prowler-cloud/prowler/pull/10034)
|
||||
|
||||
---
|
||||
|
||||
## [1.18.1] (Prowler v5.18.1)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Scans page polling now only refreshes scan table data instead of re-rendering the entire server component tree, eliminating redundant API calls to providers, findings, and compliance endpoints every 5 seconds
|
||||
|
||||
---
|
||||
|
||||
## [1.18.0] (Prowler v5.18.0)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -25,7 +25,10 @@ export const getFindings = async ({
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
// Skip filter[search] since it's already added via the `query` param above
|
||||
if (key !== "filter[search]") {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -63,7 +66,10 @@ export const getLatestFindings = async ({
|
||||
if (sort) url.searchParams.append("sort", sort);
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
// Skip filter[search] since it's already added via the `query` param above
|
||||
if (key !== "filter[search]") {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,10 @@ export const getScans = async ({
|
||||
|
||||
// Add dynamic filters (e.g., "filter[state]", "fields[scans]")
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
url.searchParams.append(key, String(value));
|
||||
// Skip filter[search] since it's already added via the `query` param above
|
||||
if (key !== "filter[search]") {
|
||||
url.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import type { ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
|
||||
@@ -42,8 +43,8 @@ interface AccountsSelectorProps {
|
||||
}
|
||||
|
||||
export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const filterKey = "filter[provider_id__in]";
|
||||
const current = searchParams.get(filterKey) || "";
|
||||
@@ -61,38 +62,37 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
|
||||
);
|
||||
|
||||
const handleMultiValueChange = (ids: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete(filterKey);
|
||||
navigateWithParams((params) => {
|
||||
params.delete(filterKey);
|
||||
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
}
|
||||
|
||||
// Auto-deselect provider types that no longer have any selected accounts
|
||||
if (selectedTypesList.length > 0) {
|
||||
// Get provider types of currently selected accounts
|
||||
const selectedProviders = providers.filter((p) => ids.includes(p.id));
|
||||
const selectedProviderTypes = new Set(
|
||||
selectedProviders.map((p) => p.attributes.provider),
|
||||
);
|
||||
|
||||
// Keep only provider types that still have selected accounts
|
||||
const remainingProviderTypes = selectedTypesList.filter((type) =>
|
||||
selectedProviderTypes.has(type as ProviderType),
|
||||
);
|
||||
|
||||
// Update provider_type__in filter
|
||||
if (remainingProviderTypes.length > 0) {
|
||||
params.set(
|
||||
"filter[provider_type__in]",
|
||||
remainingProviderTypes.join(","),
|
||||
);
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
if (ids.length > 0) {
|
||||
params.set(filterKey, ids.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
// Auto-deselect provider types that no longer have any selected accounts
|
||||
if (selectedTypesList.length > 0) {
|
||||
// Get provider types of currently selected accounts
|
||||
const selectedProviders = providers.filter((p) => ids.includes(p.id));
|
||||
const selectedProviderTypes = new Set(
|
||||
selectedProviders.map((p) => p.attributes.provider),
|
||||
);
|
||||
|
||||
// Keep only provider types that still have selected accounts
|
||||
const remainingProviderTypes = selectedTypesList.filter((type) =>
|
||||
selectedProviderTypes.has(type as ProviderType),
|
||||
);
|
||||
|
||||
// Update provider_type__in filter
|
||||
if (remainingProviderTypes.length > 0) {
|
||||
params.set(
|
||||
"filter[provider_type__in]",
|
||||
remainingProviderTypes.join(","),
|
||||
);
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const selectedLabel = () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
import { type ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const AWSProviderBadge = lazy(() =>
|
||||
@@ -122,8 +123,8 @@ type ProviderTypeSelectorProps = {
|
||||
export const ProviderTypeSelector = ({
|
||||
providers,
|
||||
}: ProviderTypeSelectorProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
|
||||
const selectedTypes = currentProviders
|
||||
@@ -131,20 +132,18 @@ export const ProviderTypeSelector = ({
|
||||
: [];
|
||||
|
||||
const handleMultiValueChange = (values: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
navigateWithParams((params) => {
|
||||
// Update provider_type__in
|
||||
if (values.length > 0) {
|
||||
params.set("filter[provider_type__in]", values.join(","));
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
|
||||
// Update provider_type__in
|
||||
if (values.length > 0) {
|
||||
params.set("filter[provider_type__in]", values.join(","));
|
||||
} else {
|
||||
params.delete("filter[provider_type__in]");
|
||||
}
|
||||
|
||||
// Clear account selection when changing provider types
|
||||
// User should manually select accounts if they want to filter by specific accounts
|
||||
params.delete("filter[provider_id__in]");
|
||||
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
// Clear account selection when changing provider types
|
||||
// User should manually select accounts if they want to filter by specific accounts
|
||||
params.delete("filter[provider_id__in]");
|
||||
});
|
||||
};
|
||||
|
||||
const availableTypes = Array.from(
|
||||
@@ -153,7 +152,7 @@ export const ProviderTypeSelector = ({
|
||||
// .filter((p) => p.attributes.connection?.connected)
|
||||
.map((p) => p.attributes.provider),
|
||||
),
|
||||
) as ProviderType[];
|
||||
).filter((type): type is ProviderType => type in PROVIDER_DATA);
|
||||
|
||||
const renderIcon = (providerType: ProviderType) => {
|
||||
const IconComponent = PROVIDER_DATA[providerType].icon;
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { MuteRuleData } from "@/actions/mute-rules/types";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
|
||||
interface MuteRuleRowActionsProps {
|
||||
muteRule: MuteRuleData;
|
||||
@@ -26,11 +24,8 @@ export function MuteRuleRowActions({
|
||||
}: MuteRuleRowActionsProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
@@ -41,44 +36,22 @@ export function MuteRuleRowActions({
|
||||
className="text-text-neutral-secondary"
|
||||
/>
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Mute rule actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Edit rule name and reason"
|
||||
textValue="Edit"
|
||||
startContent={
|
||||
<Pencil className="text-default-500 pointer-events-none size-4 shrink-0" />
|
||||
}
|
||||
onPress={() => onEdit(muteRule)}
|
||||
>
|
||||
Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
description="Delete this mute rule"
|
||||
textValue="Delete"
|
||||
className="text-danger"
|
||||
color="danger"
|
||||
classNames={{
|
||||
description: "text-danger",
|
||||
}}
|
||||
startContent={
|
||||
<Trash2 className="pointer-events-none size-4 shrink-0" />
|
||||
}
|
||||
onPress={() => onDelete(muteRule)}
|
||||
>
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Mute Rule"
|
||||
onSelect={() => onEdit(muteRule)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete Mute Rule"
|
||||
destructive
|
||||
onSelect={() => onDelete(muteRule)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/components/providers/table";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { ProviderProps, SearchParamsProps } from "@/types";
|
||||
import { PROVIDER_TYPES, ProviderProps, SearchParamsProps } from "@/types";
|
||||
|
||||
export default async function Providers({
|
||||
searchParams,
|
||||
@@ -89,15 +89,22 @@ const ProvidersTable = async ({
|
||||
return acc;
|
||||
}, {}) || {};
|
||||
|
||||
// Exclude provider types not yet supported in the UI
|
||||
const enrichedProviders =
|
||||
providersData?.data?.map((provider: ProviderProps) => {
|
||||
const groupNames =
|
||||
provider.relationships?.provider_groups?.data?.map(
|
||||
(group: { id: string }) =>
|
||||
providerGroupDict[group.id] || "Unknown Group",
|
||||
) || [];
|
||||
return { ...provider, groupNames };
|
||||
}) || [];
|
||||
providersData?.data
|
||||
?.filter((provider: ProviderProps) =>
|
||||
(PROVIDER_TYPES as readonly string[]).includes(
|
||||
provider.attributes.provider,
|
||||
),
|
||||
)
|
||||
.map((provider: ProviderProps) => {
|
||||
const groupNames =
|
||||
provider.relationships?.provider_groups?.data?.map(
|
||||
(group: { id: string }) =>
|
||||
providerGroupDict[group.id] || "Unknown Group",
|
||||
) || [];
|
||||
return { ...provider, groupNames };
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans, getScansByState } from "@/actions/scans";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { auth } from "@/auth.config";
|
||||
import { MutedFindingsConfigButton } from "@/components/providers";
|
||||
import {
|
||||
AutoRefresh,
|
||||
NoProvidersAdded,
|
||||
NoProvidersConnected,
|
||||
ScansFilters,
|
||||
} from "@/components/scans";
|
||||
import { LaunchScanWorkflow } from "@/components/scans/launch-workflow";
|
||||
import { SkeletonTableScans } from "@/components/scans/table";
|
||||
import { ColumnGetScans } from "@/components/scans/table/scans";
|
||||
import { ScansTableWithPolling } from "@/components/scans/table/scans";
|
||||
import { ContentLayout } from "@/components/ui";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import {
|
||||
createProviderDetailsMapping,
|
||||
extractProviderUIDs,
|
||||
@@ -57,15 +55,6 @@ export default async function Scans({
|
||||
|
||||
const hasManageScansPermission = session?.user?.permissions?.manage_scans;
|
||||
|
||||
// Get scans data to check for executing scans
|
||||
const scansData = await getScansByState();
|
||||
|
||||
const hasExecutingScan = scansData?.data?.some(
|
||||
(scan: ScanProps) =>
|
||||
scan.attributes.state === "executing" ||
|
||||
scan.attributes.state === "available",
|
||||
);
|
||||
|
||||
// Extract provider UIDs and create provider details mapping for filtering
|
||||
const providerUIDs = providersData ? extractProviderUIDs(providersData) : [];
|
||||
const providerDetails = providersData
|
||||
@@ -82,7 +71,6 @@ export default async function Scans({
|
||||
|
||||
return (
|
||||
<ContentLayout title="Scans" icon="lucide:timer">
|
||||
<AutoRefresh hasExecutingScan={hasExecutingScan} />
|
||||
<>
|
||||
<>
|
||||
{!hasManageScansPermission ? (
|
||||
@@ -177,11 +165,10 @@ const SSRDataTableScans = async ({
|
||||
}) || [];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
key={`scans-${Date.now()}`}
|
||||
columns={ColumnGetScans}
|
||||
data={expandedScansData || []}
|
||||
metadata={meta}
|
||||
<ScansTableWithPolling
|
||||
initialData={expandedScansData}
|
||||
initialMeta={meta}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { useUrlFilters } from "@/hooks/use-url-filters";
|
||||
|
||||
// Constants for muted filter URL values
|
||||
const MUTED_FILTER_VALUES = {
|
||||
@@ -11,12 +12,10 @@ const MUTED_FILTER_VALUES = {
|
||||
} as const;
|
||||
|
||||
export const CustomCheckboxMutedFindings = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { navigateWithParams } = useUrlFilters();
|
||||
|
||||
// Get the current muted filter value from URL
|
||||
// Middleware ensures filter[muted] is always present when navigating to /findings
|
||||
const mutedFilterValue = searchParams.get("filter[muted]");
|
||||
|
||||
// URL states:
|
||||
@@ -26,22 +25,16 @@ export const CustomCheckboxMutedFindings = () => {
|
||||
|
||||
const handleMutedChange = (checked: boolean | "indeterminate") => {
|
||||
const isChecked = checked === true;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
|
||||
} else {
|
||||
// Exclude muted: apply filter to show only non-muted
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
|
||||
}
|
||||
|
||||
// Reset to page 1 when changing filter
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
navigateWithParams((params) => {
|
||||
if (isChecked) {
|
||||
// Include muted: set special value (API will ignore invalid value and show all)
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
|
||||
} else {
|
||||
// Exclude muted: apply filter to show only non-muted
|
||||
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -66,30 +66,13 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
return [finding.id];
|
||||
};
|
||||
|
||||
const getMuteDescription = (): string => {
|
||||
if (isMuted) {
|
||||
return "This finding is already muted";
|
||||
}
|
||||
const ids = getMuteIds();
|
||||
if (ids.length > 1) {
|
||||
return `Mute ${ids.length} selected findings`;
|
||||
}
|
||||
return "Mute this finding";
|
||||
};
|
||||
|
||||
const getMuteLabel = () => {
|
||||
if (isMuted) return "Muted";
|
||||
if (!isMuted && isCurrentSelected && hasMultipleSelected) {
|
||||
return (
|
||||
<>
|
||||
Mute
|
||||
<span className="ml-1 text-xs text-slate-500">
|
||||
({selectedFindingIds.length})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
const ids = getMuteIds();
|
||||
if (ids.length > 1) {
|
||||
return `Mute ${ids.length} Findings`;
|
||||
}
|
||||
return "Mute";
|
||||
return "Mute Finding";
|
||||
};
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
@@ -146,7 +129,6 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
)
|
||||
}
|
||||
label={getMuteLabel()}
|
||||
description={getMuteDescription()}
|
||||
disabled={isMuted}
|
||||
onSelect={() => {
|
||||
setIsMuteModalOpen(true);
|
||||
@@ -155,7 +137,6 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
<ActionDropdownItem
|
||||
icon={<JiraIcon size={20} />}
|
||||
label="Send to Jira"
|
||||
description="Create a Jira issue for this finding"
|
||||
onSelect={() => setIsJiraModalOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
AddNoteBulkIcon,
|
||||
DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { Eye, Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
@@ -27,7 +20,6 @@ interface DataTableRowActionsProps<InvitationProps> {
|
||||
row: Row<InvitationProps>;
|
||||
roles?: { id: string; name: string }[];
|
||||
}
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions<InvitationProps>({
|
||||
row,
|
||||
@@ -67,65 +59,36 @@ export function DataTableRowActions<InvitationProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="check-details"
|
||||
description="View invitation details"
|
||||
textValue="Check Details"
|
||||
startContent={<AddNoteBulkIcon className={iconClasses} />}
|
||||
onPress={() =>
|
||||
router.push(`/invitations/check-details?id=${invitationId}`)
|
||||
}
|
||||
>
|
||||
Check Details
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Allows you to edit the invitation"
|
||||
textValue="Edit Invitation"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => setIsEditOpen(true)}
|
||||
isDisabled={invitationAccepted === "accepted"}
|
||||
>
|
||||
Edit Invitation
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the invitation permanently"
|
||||
textValue="Delete Invitation"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
isDisabled={invitationAccepted === "accepted"}
|
||||
>
|
||||
Revoke Invitation
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Eye />}
|
||||
label="Check Details"
|
||||
onSelect={() =>
|
||||
router.push(`/invitations/check-details?id=${invitationId}`)
|
||||
}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Invitation"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
disabled={invitationAccepted === "accepted"}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Revoke Invitation"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
disabled={invitationAccepted === "accepted"}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteGroupForm } from "../forms";
|
||||
@@ -25,7 +19,6 @@ import { DeleteGroupForm } from "../forms";
|
||||
interface DataTableRowActionsProps<ProviderProps> {
|
||||
row: Row<ProviderProps>;
|
||||
}
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions<ProviderProps>({
|
||||
row,
|
||||
@@ -47,51 +40,27 @@ export function DataTableRowActions<ProviderProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Allows you to edit the provider group"
|
||||
textValue="Edit Provider Group"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => router.push(`/manage-groups?groupId=${groupId}`)}
|
||||
>
|
||||
Edit Provider Group
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the provider group permanently"
|
||||
textValue="Delete Provider Group"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
Delete Provider Group
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Provider Group"
|
||||
onSelect={() => router.push(`/manage-groups?groupId=${groupId}`)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete Provider Group"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
AddNoteBulkIcon,
|
||||
DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { Pencil, PlugZap, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { checkConnectionProvider } from "@/actions/providers/providers";
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { EditForm } from "../forms";
|
||||
@@ -28,7 +21,6 @@ import { DeleteForm } from "../forms/delete-form";
|
||||
interface DataTableRowActionsProps<ProviderProps> {
|
||||
row: Row<ProviderProps>;
|
||||
}
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions<ProviderProps>({
|
||||
row,
|
||||
@@ -53,12 +45,6 @@ export function DataTableRowActions<ProviderProps>({
|
||||
|
||||
const hasSecret = (row.original as any).relationships?.secret?.data;
|
||||
|
||||
// Calculate disabled keys based on conditions
|
||||
const disabledKeys = [];
|
||||
if (!hasSecret || loading) {
|
||||
disabledKeys.push("new");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
@@ -82,88 +68,52 @@ export function DataTableRowActions<ProviderProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
aria-label="Actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
disabledKeys={disabledKeys}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key={hasSecret ? "update" : "add"}
|
||||
description={
|
||||
hasSecret
|
||||
? "Update the provider credentials"
|
||||
: "Add the provider credentials"
|
||||
}
|
||||
textValue={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
|
||||
)
|
||||
}
|
||||
closeOnSelect={true}
|
||||
>
|
||||
{hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="new"
|
||||
description={
|
||||
hasSecret && !loading
|
||||
? "Check the provider connection"
|
||||
: loading
|
||||
? "Checking provider connection"
|
||||
: "Add credentials to test the connection"
|
||||
}
|
||||
textValue="Check Connection"
|
||||
startContent={<AddNoteBulkIcon className={iconClasses} />}
|
||||
onPress={handleTestConnection}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{loading ? "Testing..." : "Test Connection"}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Allows you to edit the provider"
|
||||
textValue="Edit Provider"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => setIsEditOpen(true)}
|
||||
closeOnSelect={true}
|
||||
>
|
||||
Edit Provider Alias
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the provider permanently"
|
||||
textValue="Delete Provider"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
closeOnSelect={true}
|
||||
>
|
||||
Delete Provider
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label={hasSecret ? "Update Credentials" : "Add Credentials"}
|
||||
onSelect={() =>
|
||||
router.push(
|
||||
`/providers/${hasSecret ? "update" : "add"}-credentials?type=${providerType}&id=${providerId}${providerSecretId ? `&secretId=${providerSecretId}` : ""}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<PlugZap />}
|
||||
label={loading ? "Testing..." : "Test Connection"}
|
||||
description={
|
||||
hasSecret && !loading
|
||||
? "Check the provider connection"
|
||||
: loading
|
||||
? "Checking provider connection"
|
||||
: "Add credentials to test the connection"
|
||||
}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
handleTestConnection();
|
||||
}}
|
||||
disabled={!hasSecret || loading}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Provider Alias"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete Provider"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,6 @@ const FailedFindingsBadge = ({ count }: { count: number }) => {
|
||||
// Row actions dropdown
|
||||
const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const resourceName = row.original.attributes?.name || "Resource";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -102,8 +101,7 @@ const ResourceRowActions = ({ row }: { row: { original: ResourceProps } }) => {
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Eye className="size-5" />}
|
||||
label="View details"
|
||||
description={`View details for ${resourceName}`}
|
||||
label="View Details"
|
||||
onSelect={() => setIsDrawerOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteRoleForm } from "../workflow/forms";
|
||||
interface DataTableRowActionsProps<RoleProps> {
|
||||
row: Row<RoleProps>;
|
||||
}
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions<RoleProps>({
|
||||
row,
|
||||
@@ -43,51 +36,27 @@ export function DataTableRowActions<RoleProps>({
|
||||
<DeleteRoleForm roleId={roleId} setIsOpen={setIsDeleteOpen} />
|
||||
</Modal>
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Edit the role details"
|
||||
textValue="Edit Role"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => router.push(`/roles/edit?roleId=${roleId}`)}
|
||||
>
|
||||
Edit Role
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the role permanently"
|
||||
textValue="Delete Role"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
Delete Role
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Role"
|
||||
onSelect={() => router.push(`/roles/edit?roleId=${roleId}`)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete Role"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Form } from "@/components/ui/form";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import { onDemandScanFormSchema } from "@/types";
|
||||
|
||||
import { SCAN_LAUNCHED_EVENT } from "../table/scans/scans-table-with-polling";
|
||||
import { SelectScanProvider } from "./select-scan-provider";
|
||||
|
||||
type ProviderInfo = {
|
||||
@@ -85,6 +86,8 @@ export const LaunchScanWorkflow = ({
|
||||
});
|
||||
// Reset form after successful submission
|
||||
form.reset();
|
||||
// Notify the scans table to refresh and pick up the new scan
|
||||
window.dispatchEvent(new Event(SCAN_LAUNCHED_EVENT));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
// DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { Download, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { downloadScanZip } from "@/lib/helper";
|
||||
@@ -26,7 +19,6 @@ import { EditScanForm } from "../../forms";
|
||||
interface DataTableRowActionsProps<ScanProps> {
|
||||
row: Row<ScanProps>;
|
||||
}
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions<ScanProps>({
|
||||
row,
|
||||
@@ -52,46 +44,26 @@ export function DataTableRowActions<ScanProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Download reports">
|
||||
<DropdownItem
|
||||
key="export"
|
||||
description="Available only for completed scans"
|
||||
textValue="Download .zip"
|
||||
startContent={<DownloadIcon className={iconClasses} />}
|
||||
onPress={() => downloadScanZip(scanId, toast)}
|
||||
isDisabled={scanState !== "completed"}
|
||||
>
|
||||
Download .zip
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Allows you to edit the scan name"
|
||||
textValue="Edit Scan Name"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => setIsEditOpen(true)}
|
||||
>
|
||||
Edit scan name
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Download />}
|
||||
label="Download .zip"
|
||||
description="Available only for completed scans"
|
||||
onSelect={() => downloadScanZip(scanId, toast)}
|
||||
disabled={scanState !== "completed"}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit Scan Name"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./column-get-scans";
|
||||
export * from "./data-table-row-actions";
|
||||
export * from "./data-table-row-details";
|
||||
export * from "./scans-table-with-polling";
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { MetaDataProps, ScanProps, SearchParamsProps } from "@/types";
|
||||
|
||||
import { ColumnGetScans } from "./column-get-scans";
|
||||
|
||||
export const SCAN_LAUNCHED_EVENT = "scan-launched";
|
||||
|
||||
interface ScansTableWithPollingProps {
|
||||
initialData: ScanProps[];
|
||||
initialMeta?: MetaDataProps;
|
||||
searchParams: SearchParamsProps;
|
||||
}
|
||||
|
||||
const EXECUTING_STATES = ["executing", "available"] as const;
|
||||
|
||||
function expandScansWithProviderInfo(
|
||||
scans: ScanProps[],
|
||||
included?: Array<{ type: string; id: string; attributes: any }>,
|
||||
) {
|
||||
return (
|
||||
scans?.map((scan) => {
|
||||
const providerId = scan.relationships?.provider?.data?.id;
|
||||
|
||||
if (!providerId) {
|
||||
return { ...scan, providerInfo: undefined };
|
||||
}
|
||||
|
||||
const providerData = included?.find(
|
||||
(item) => item.type === "providers" && item.id === providerId,
|
||||
);
|
||||
|
||||
if (!providerData) {
|
||||
return { ...scan, providerInfo: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
...scan,
|
||||
providerInfo: {
|
||||
provider: providerData.attributes.provider,
|
||||
uid: providerData.attributes.uid,
|
||||
alias: providerData.attributes.alias,
|
||||
},
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
}
|
||||
|
||||
export function ScansTableWithPolling({
|
||||
initialData,
|
||||
initialMeta,
|
||||
searchParams,
|
||||
}: ScansTableWithPollingProps) {
|
||||
const [scansData, setScansData] = useState<ScanProps[]>(initialData);
|
||||
const [meta, setMeta] = useState<MetaDataProps | undefined>(initialMeta);
|
||||
|
||||
// Sync state with server data when props change (e.g., pagination or filter changes).
|
||||
// useState only uses its argument on first mount, so without this effect,
|
||||
// navigating to page 2 would change the URL but keep showing page 1 data.
|
||||
useEffect(() => {
|
||||
setScansData(initialData);
|
||||
setMeta(initialMeta);
|
||||
}, [initialData, initialMeta]);
|
||||
|
||||
const hasExecutingScan = scansData.some((scan) =>
|
||||
EXECUTING_STATES.includes(
|
||||
scan.attributes.state as (typeof EXECUTING_STATES)[number],
|
||||
),
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
const page = parseInt(searchParams.page?.toString() || "1", 10);
|
||||
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
|
||||
const sort = searchParams.sort?.toString();
|
||||
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(searchParams).filter(
|
||||
([key]) => key.startsWith("filter[") && key !== "scanId",
|
||||
),
|
||||
);
|
||||
|
||||
const query = (filters["filter[search]"] as string) || "";
|
||||
|
||||
const result = await getScans({
|
||||
query,
|
||||
page,
|
||||
sort,
|
||||
filters,
|
||||
pageSize,
|
||||
include: "provider",
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
const expanded = expandScansWithProviderInfo(
|
||||
result.data,
|
||||
result.included,
|
||||
);
|
||||
setScansData(expanded);
|
||||
|
||||
if (result && "meta" in result) {
|
||||
setMeta(result.meta as MetaDataProps);
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// Listen for scan launch events to trigger an immediate refresh
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
handleRefresh();
|
||||
};
|
||||
window.addEventListener(SCAN_LAUNCHED_EVENT, handler);
|
||||
return () => window.removeEventListener(SCAN_LAUNCHED_EVENT, handler);
|
||||
}, [handleRefresh]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoRefresh
|
||||
hasExecutingScan={hasExecutingScan}
|
||||
onRefresh={handleRefresh}
|
||||
/>
|
||||
<DataTable
|
||||
key={`scans-${scansData.length}-${meta?.pagination?.page}`}
|
||||
columns={ColumnGetScans}
|
||||
data={scansData}
|
||||
metadata={meta}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown";
|
||||
@@ -17,8 +16,6 @@ import {
|
||||
interface ActionDropdownProps {
|
||||
/** The dropdown trigger element. Defaults to a vertical dots icon button */
|
||||
trigger?: ReactNode;
|
||||
/** Label shown at the top of the dropdown */
|
||||
label?: string;
|
||||
/** Alignment of the dropdown content */
|
||||
align?: "start" | "center" | "end";
|
||||
/** Additional className for the content */
|
||||
@@ -30,7 +27,6 @@ interface ActionDropdownProps {
|
||||
|
||||
export function ActionDropdown({
|
||||
trigger,
|
||||
label = "Actions",
|
||||
align = "end",
|
||||
className,
|
||||
ariaLabel = "Open actions menu",
|
||||
@@ -52,16 +48,10 @@ export function ActionDropdown({
|
||||
<DropdownMenuContent
|
||||
align={align}
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary w-56",
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary w-56 rounded-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label && (
|
||||
<>
|
||||
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -91,8 +81,8 @@ export function ActionDropdownItem({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2",
|
||||
destructive && "text-destructive focus:text-destructive",
|
||||
"flex cursor-pointer items-start gap-2",
|
||||
destructive && "text-text-error-primary focus:text-text-error-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -100,8 +90,8 @@ export function ActionDropdownItem({
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 [&>svg]:size-5",
|
||||
destructive && "text-destructive",
|
||||
"text-muted-foreground mt-0.5 shrink-0 [&>svg]:size-4",
|
||||
destructive && "text-text-error-primary",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
@@ -113,7 +103,7 @@ export function ActionDropdownItem({
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground text-xs",
|
||||
destructive && "text-destructive/70",
|
||||
destructive && "text-text-error-primary/70",
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
@@ -124,8 +114,18 @@ export function ActionDropdownItem({
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export commonly used components for convenience
|
||||
export {
|
||||
DropdownMenuLabel as ActionDropdownLabel,
|
||||
DropdownMenuSeparator as ActionDropdownSeparator,
|
||||
} from "./dropdown";
|
||||
export function ActionDropdownDangerZone({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<span className="text-text-neutral-tertiary px-2 py-1.5 text-xs">
|
||||
Danger zone
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
ActionDropdownLabel,
|
||||
ActionDropdownSeparator,
|
||||
} from "./action-dropdown";
|
||||
export {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -21,7 +21,9 @@ export const TableLink = ({ href, label, isDisabled }: TableLinkProps) => {
|
||||
|
||||
return (
|
||||
<Button asChild variant="link" size="sm" className="text-xs">
|
||||
<Link href={href}>{label}</Link>
|
||||
<Link href={href} prefetch={false}>
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
|
||||
import { EnrichedApiKey } from "./types";
|
||||
|
||||
@@ -25,8 +19,6 @@ interface DataTableRowActionsProps {
|
||||
onRevoke: (apiKey: EnrichedApiKey) => void;
|
||||
}
|
||||
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions({
|
||||
row,
|
||||
onEdit,
|
||||
@@ -39,53 +31,29 @@ export function DataTableRowActions({
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="API Key actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Edit the API key name"
|
||||
textValue="Edit name"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => onEdit(apiKey)}
|
||||
>
|
||||
Edit name
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
{canRevoke ? (
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="revoke"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Revoke this API key permanently"
|
||||
textValue="Revoke"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => onRevoke(apiKey)}
|
||||
>
|
||||
Revoke
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
) : null}
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit API Key"
|
||||
onSelect={() => onEdit(apiKey)}
|
||||
/>
|
||||
{canRevoke && (
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Revoke API Key"
|
||||
destructive
|
||||
onSelect={() => onRevoke(apiKey)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownSection,
|
||||
DropdownTrigger,
|
||||
} from "@heroui/dropdown";
|
||||
import {
|
||||
DeleteDocumentBulkIcon,
|
||||
EditDocumentBulkIcon,
|
||||
} from "@heroui/shared-icons";
|
||||
import { Row } from "@tanstack/react-table";
|
||||
import clsx from "clsx";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { VerticalDotsIcon } from "@/components/icons";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
ActionDropdown,
|
||||
ActionDropdownDangerZone,
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
|
||||
import { DeleteForm, EditForm } from "../forms";
|
||||
@@ -25,7 +19,6 @@ interface DataTableRowActionsProps<UserProps> {
|
||||
row: Row<UserProps>;
|
||||
roles?: { id: string; name: string }[];
|
||||
}
|
||||
const iconClasses = "text-2xl text-default-500 pointer-events-none shrink-0";
|
||||
|
||||
export function DataTableRowActions<UserProps>({
|
||||
row,
|
||||
@@ -66,51 +59,27 @@ export function DataTableRowActions<UserProps>({
|
||||
</Modal>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-2">
|
||||
<Dropdown
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
|
||||
placement="bottom"
|
||||
>
|
||||
<DropdownTrigger>
|
||||
<ActionDropdown
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon-sm" className="rounded-full">
|
||||
<VerticalDotsIcon className="text-slate-400" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
closeOnSelect
|
||||
aria-label="Actions"
|
||||
color="default"
|
||||
variant="flat"
|
||||
>
|
||||
<DropdownSection title="Actions">
|
||||
<DropdownItem
|
||||
key="edit"
|
||||
description="Allows you to edit the user"
|
||||
textValue="Edit User"
|
||||
startContent={<EditDocumentBulkIcon className={iconClasses} />}
|
||||
onPress={() => setIsEditOpen(true)}
|
||||
>
|
||||
Edit User
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection title="Danger zone">
|
||||
<DropdownItem
|
||||
key="delete"
|
||||
className="text-text-error"
|
||||
color="danger"
|
||||
description="Delete the user permanently"
|
||||
textValue="Delete User"
|
||||
startContent={
|
||||
<DeleteDocumentBulkIcon
|
||||
className={clsx(iconClasses, "!text-text-error")}
|
||||
/>
|
||||
}
|
||||
onPress={() => setIsDeleteOpen(true)}
|
||||
>
|
||||
Delete User
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={<Pencil />}
|
||||
label="Edit User"
|
||||
onSelect={() => setIsEditOpen(true)}
|
||||
/>
|
||||
<ActionDropdownDangerZone>
|
||||
<ActionDropdownItem
|
||||
icon={<Trash2 />}
|
||||
label="Delete User"
|
||||
destructive
|
||||
onSelect={() => setIsDeleteOpen(true)}
|
||||
/>
|
||||
</ActionDropdownDangerZone>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
ReactNode,
|
||||
TransitionStartFunction,
|
||||
useContext,
|
||||
useTransition,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface FilterTransitionContextType {
|
||||
isPending: boolean;
|
||||
startTransition: TransitionStartFunction;
|
||||
signalFilterChange: () => void;
|
||||
}
|
||||
|
||||
const FilterTransitionContext = createContext<
|
||||
@@ -39,13 +40,33 @@ interface FilterTransitionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides a shared pending state for filter changes.
|
||||
*
|
||||
* Filter components signal the start of navigation via signalFilterChange(),
|
||||
* and use their own local useTransition() for the actual router.push().
|
||||
* This avoids a known Next.js production bug where a shared useTransition()
|
||||
* wrapping router.push() causes the navigation to be silently reverted.
|
||||
*
|
||||
* The pending state auto-resets when searchParams change (navigation completed).
|
||||
*/
|
||||
export const FilterTransitionProvider = ({
|
||||
children,
|
||||
}: FilterTransitionProviderProps) => {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const searchParams = useSearchParams();
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
// Auto-reset pending state when searchParams change (navigation completed)
|
||||
useEffect(() => {
|
||||
setIsPending(false);
|
||||
}, [searchParams]);
|
||||
|
||||
const signalFilterChange = () => {
|
||||
setIsPending(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterTransitionContext.Provider value={{ isPending, startTransition }}>
|
||||
<FilterTransitionContext.Provider value={{ isPending, signalFilterChange }}>
|
||||
{children}
|
||||
</FilterTransitionContext.Provider>
|
||||
);
|
||||
|
||||
+81
-63
@@ -1,89 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useTransition } from "react";
|
||||
|
||||
import { useFilterTransitionOptional } from "@/contexts";
|
||||
|
||||
const FINDINGS_PATH = "/findings";
|
||||
const DEFAULT_MUTED_FILTER = "false";
|
||||
|
||||
/**
|
||||
* Custom hook to handle URL filters and automatically reset
|
||||
* pagination when filters change.
|
||||
*
|
||||
* Uses useTransition to prevent full page reloads when filters change,
|
||||
* keeping the current UI visible while the new data loads.
|
||||
*
|
||||
* When used within a FilterTransitionProvider, the transition state is shared
|
||||
* across all components using this hook, enabling coordinated loading indicators.
|
||||
* Uses client-side router navigation to update query params without
|
||||
* full page reloads when filters change.
|
||||
*/
|
||||
export const useUrlFilters = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const filterTransition = useFilterTransitionOptional();
|
||||
const isPending = false;
|
||||
|
||||
// Use shared context if available, otherwise fall back to local transition
|
||||
const sharedTransition = useFilterTransitionOptional();
|
||||
const [localIsPending, localStartTransition] = useTransition();
|
||||
const ensureFindingsDefaultMuted = (params: URLSearchParams) => {
|
||||
// Findings defaults to excluding muted findings unless user sets it explicitly.
|
||||
if (pathname === FINDINGS_PATH && !params.has("filter[muted]")) {
|
||||
params.set("filter[muted]", DEFAULT_MUTED_FILTER);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = sharedTransition?.isPending ?? localIsPending;
|
||||
const startTransition =
|
||||
sharedTransition?.startTransition ?? localStartTransition;
|
||||
const navigate = (params: URLSearchParams) => {
|
||||
ensureFindingsDefaultMuted(params);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
(key: string, value: string | string[] | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
const queryString = params.toString();
|
||||
if (queryString === searchParams.toString()) return;
|
||||
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
const targetUrl = queryString ? `${pathname}?${queryString}` : pathname;
|
||||
filterTransition?.signalFilterChange();
|
||||
router.push(targetUrl, { scroll: false });
|
||||
};
|
||||
|
||||
const currentValue = params.get(filterKey);
|
||||
const nextValue = Array.isArray(value)
|
||||
? value.length > 0
|
||||
? value.join(",")
|
||||
: null
|
||||
: value === null
|
||||
? null
|
||||
: value;
|
||||
const updateFilter = (key: string, value: string | string[] | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// If effective value is unchanged, do nothing (avoids redundant fetches)
|
||||
if (currentValue === nextValue) return;
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
|
||||
// Only reset page to 1 if page parameter already exists
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
const currentValue = params.get(filterKey);
|
||||
const nextValue = Array.isArray(value)
|
||||
? value.length > 0
|
||||
? value.join(",")
|
||||
: null
|
||||
: value === null
|
||||
? null
|
||||
: value;
|
||||
|
||||
if (nextValue === null) {
|
||||
params.delete(filterKey);
|
||||
} else {
|
||||
params.set(filterKey, nextValue);
|
||||
}
|
||||
// If effective value is unchanged, do nothing (avoids redundant fetches)
|
||||
if (currentValue === nextValue) return;
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
[router, searchParams, pathname, startTransition],
|
||||
);
|
||||
|
||||
const clearFilter = useCallback(
|
||||
(key: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
// Only reset page to 1 if page parameter already exists
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
if (nextValue === null) {
|
||||
params.delete(filterKey);
|
||||
} else {
|
||||
params.set(filterKey, nextValue);
|
||||
}
|
||||
|
||||
// Only reset page to 1 if page parameter already exists
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
navigate(params);
|
||||
};
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
},
|
||||
[router, searchParams, pathname, startTransition],
|
||||
);
|
||||
const clearFilter = (key: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`;
|
||||
|
||||
const clearAllFilters = useCallback(() => {
|
||||
params.delete(filterKey);
|
||||
|
||||
// Only reset page to 1 if page parameter already exists
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
navigate(params);
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
Array.from(params.keys()).forEach((key) => {
|
||||
if (key.startsWith("filter[") || key === "sort") {
|
||||
@@ -93,17 +94,33 @@ export const useUrlFilters = () => {
|
||||
|
||||
params.delete("page");
|
||||
|
||||
startTransition(() => {
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
});
|
||||
}, [router, searchParams, pathname, startTransition]);
|
||||
navigate(params);
|
||||
};
|
||||
|
||||
const hasFilters = useCallback(() => {
|
||||
const hasFilters = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
return Array.from(params.keys()).some(
|
||||
(key) => key.startsWith("filter[") || key === "sort",
|
||||
);
|
||||
}, [searchParams]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Low-level navigation function for complex filter updates that need
|
||||
* to modify multiple params atomically (e.g., setting provider_type
|
||||
* while clearing provider_id). The modifier receives a mutable
|
||||
* URLSearchParams; page is auto-reset if already present.
|
||||
*/
|
||||
const navigateWithParams = (modifier: (params: URLSearchParams) => void) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
modifier(params);
|
||||
|
||||
// Only reset page to 1 if page parameter already exists
|
||||
if (params.has("page")) {
|
||||
params.set("page", "1");
|
||||
}
|
||||
|
||||
navigate(params);
|
||||
};
|
||||
|
||||
return {
|
||||
updateFilter,
|
||||
@@ -111,5 +128,6 @@ export const useUrlFilters = () => {
|
||||
clearAllFilters,
|
||||
hasFilters,
|
||||
isPending,
|
||||
navigateWithParams,
|
||||
};
|
||||
};
|
||||
|
||||
-14
@@ -50,20 +50,6 @@ export default auth((req: NextRequest & { auth: any }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect /findings to include default muted filter if not present
|
||||
if (
|
||||
pathname === "/findings" &&
|
||||
!req.nextUrl.searchParams.has("filter[muted]")
|
||||
) {
|
||||
const findingsUrl = new URL("/findings", req.url);
|
||||
// Preserve existing search params
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
findingsUrl.searchParams.set(key, value);
|
||||
});
|
||||
findingsUrl.searchParams.set("filter[muted]", "false");
|
||||
return NextResponse.redirect(findingsUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
});
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ export class ScansPage extends BasePage {
|
||||
await expect(this.scanTable).toBeVisible();
|
||||
|
||||
// Find a row that contains the account ID (provider UID in Cloud Provider column)
|
||||
// Note: Use a more specific locator strategy if possible in the future
|
||||
const rowWithAccountId = this.scanTable
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: accountId })
|
||||
|
||||
Reference in New Issue
Block a user