Compare commits

...

19 Commits

Author SHA1 Message Date
César Arroba ce134a01c3 chore: update changelogs 2026-02-12 11:44:46 +01:00
Prowler Bot 7e25ca719b chore(ui): improve changelog wording for v1.18.2 bug fixes (#10045)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-12 11:38:10 +01:00
Prowler Bot 0e79b70fee fix(ui): reapply filter transition opacity overlay on filter changes (#10037)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-02-11 22:25:46 +01:00
Prowler Bot b424eb302e fix(ui): move default muted filter from middleware to client-side hook (#10035)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
2026-02-11 22:05:56 +01:00
Prowler Bot f15cf20b4f fix(ui): fix filter navigation and pagination bugs in findings and scans pages (#10015)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-11 11:31:29 +01:00
Prowler Bot 366f10cf0c fix(sdk): mute HPACK library logs to prevent token leakage (#10014)
Co-authored-by: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com>
2026-02-11 11:15:08 +01:00
Prowler Bot 6bba654059 fix(saml): prevent SAML role mapping from removing last manage-account user (#10009)
Co-authored-by: Adrián Peña <adrianjpr@gmail.com>
2026-02-11 09:49:02 +01:00
Prowler Bot 6d94f0fcc3 fix(github): combine --repository and --organization flags for scan scoping (#10005)
Co-authored-by: Andoni Alonso <14891798+andoniaf@users.noreply.github.com>
2026-02-10 14:44:01 +01:00
Prowler Bot b8da7c9619 fix(ui): replace HeroUI dropdowns with Radix ActionDropdown to fix overlay conflict (#10002)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-10 10:47:06 +01:00
Prowler Bot 6d235278dc fix(ui): guard against unknown provider types in ProviderTypeSelector (#9995)
Co-authored-by: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
2026-02-10 09:56:30 +01:00
Prowler Bot 9285ad3569 chore(api): Bump version to v1.19.2 (#9980)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-06 12:45:59 +01:00
Prowler Bot 88caa9c198 chore(release): Bump version to v5.18.2 (#9979)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-06 12:45:46 +01:00
Prowler Bot cb4892dbe3 docs: Update version to v5.18.1 (#9981)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-06 12:45:32 +01:00
César Arroba 5c4386df5f chore: update UI changelog 2026-02-06 12:03:09 +01:00
Prowler Bot fd05080d12 fix(ui): optimize scans page polling to avoid redundant API calls (#9976)
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
Co-authored-by: pedrooot <pedromarting3@gmail.com>
2026-02-06 11:24:17 +01:00
Prowler Bot 4ce82d831a chore(api): Bump version to v1.19.1 (#9969)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 22:18:18 +01:00
Prowler Bot 4d47e6c2f1 chore(release): Bump version to v5.18.1 (#9967)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 22:17:16 +01:00
Prowler Bot 8dc6b3e2a3 docs: Update version to v5.18.0 (#9966)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 22:16:57 +01:00
Prowler Bot e1f70321c8 chore(api): Update prowler dependency to v5.18 for release 5.18.0 (#9963)
Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com>
2026-02-05 14:06:12 +01:00
50 changed files with 1434 additions and 1005 deletions
+10 -7
View File
@@ -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
+11 -4
View File
@@ -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:
-172
View File
@@ -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"
+8
View File
@@ -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
+368 -13
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Prowler API
version: 1.19.0
version: 1.19.2
description: |-
Prowler API specification.
+185 -38
View File
@@ -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:
+28 -15
View File
@@ -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>
+9
View File
@@ -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
+1 -1
View File
@@ -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 -2
View File
@@ -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}"
)
+4
View File
@@ -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
View File
@@ -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"
+8
View File
@@ -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:
+21
View File
@@ -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
+8 -2
View File
@@ -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 {
+4 -1
View File
@@ -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>
);
}
+16 -9
View File
@@ -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 (
<>
+6 -19
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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>
</>
);
+26 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
+1
View File
@@ -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 })