mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 08:47:18 +00:00
Compare commits
29 Commits
ef518c0d1e
...
5.18.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 0449c4d466 | |||
| 987fad3aaf | |||
| b6c7e24856 | |||
| 9a5fe3a809 | |||
| c08d42036c | |||
| c1244ea9b8 | |||
| efc9bc61fd | |||
| 5228ba7af9 | |||
| a9c54a4771 | |||
| 4875ee3506 | |||
| 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
|
||||
|
||||
@@ -61,8 +61,9 @@ jobs:
|
||||
|
||||
- name: Safety
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
run: poetry run safety check --ignore 79023,79027
|
||||
run: poetry run safety check --ignore 79023,79027,84420
|
||||
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
|
||||
# TODO: 84420 from `azure-core`, that we need fix alltogether with `azure-cli-core` and `knack`
|
||||
|
||||
- name: Vulture
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -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"
|
||||
@@ -121,7 +121,8 @@ repos:
|
||||
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
|
||||
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
|
||||
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
|
||||
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027'
|
||||
# TODO: 84420 from `azure-core`, that we need fix alltogether with `azure-cli-core` and `knack`
|
||||
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,84420'
|
||||
language: system
|
||||
|
||||
- id: vulture
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
## [1.19.3] (Prowler v5.18.3)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- GCP provider UID validation regex to allow domain prefixes [(#10078)](https://github.com/prowler-cloud/prowler/pull/10078)
|
||||
|
||||
---
|
||||
|
||||
## [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.3"
|
||||
|
||||
[project.scripts]
|
||||
celery = "src.backend.config.settings.celery"
|
||||
|
||||
@@ -326,10 +326,13 @@ class Provider(RowLevelSecurityProtectedModel):
|
||||
|
||||
@staticmethod
|
||||
def validate_gcp_uid(value):
|
||||
if not re.match(r"^[a-z][a-z0-9-]{5,29}$", value):
|
||||
# Standard format: 6-30 chars, starts with letter, lowercase + digits + hyphens
|
||||
# Legacy App Engine format: domain.com:project-id
|
||||
if not re.match(r"^([a-z][a-z0-9.-]*:)?[a-z][a-z0-9-]{5,29}$", value):
|
||||
raise ModelValidationError(
|
||||
detail="GCP provider ID must be 6 to 30 characters, start with a letter, and contain only lowercase "
|
||||
"letters, numbers, and hyphens.",
|
||||
detail="GCP provider ID must be a valid project ID: 6 to 30 characters, start with a letter, "
|
||||
"and contain only lowercase letters, numbers, and hyphens. "
|
||||
"Legacy App Engine project IDs with a domain prefix (e.g., example.com:my-project) are also accepted.",
|
||||
code="gcp-uid",
|
||||
pointer="/data/attributes/uid",
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Prowler API
|
||||
version: 1.19.0
|
||||
version: 1.19.3
|
||||
description: |-
|
||||
Prowler API specification.
|
||||
|
||||
|
||||
@@ -1079,6 +1079,11 @@ class TestProviderViewSet:
|
||||
[
|
||||
{"provider": "aws", "uid": "111111111111", "alias": "test"},
|
||||
{"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"uid": "example.com:my-project-123456",
|
||||
"alias": "legacy-gcp",
|
||||
},
|
||||
{
|
||||
"provider": "kubernetes",
|
||||
"uid": "kubernetes-test-123456789",
|
||||
@@ -1198,6 +1203,11 @@ class TestProviderViewSet:
|
||||
[
|
||||
{"provider": "aws", "uid": "111111111111", "alias": "test"},
|
||||
{"provider": "gcp", "uid": "a12322-test54321", "alias": "test"},
|
||||
{
|
||||
"provider": "gcp",
|
||||
"uid": "example.com:my-project-123456",
|
||||
"alias": "legacy-gcp",
|
||||
},
|
||||
{
|
||||
"provider": "kubernetes",
|
||||
"uid": "kubernetes-test-123456789",
|
||||
@@ -10841,25 +10851,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 +10935,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 +10963,7 @@ class TestTenantFinishACSView:
|
||||
"firstName": ["John"],
|
||||
"lastName": ["Doe"],
|
||||
"organization": ["testing_company"],
|
||||
"userType": ["viewer"], # This SHOULD be applied
|
||||
"userType": [viewer_role.name],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -11005,10 +11001,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 +11099,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.3"
|
||||
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.2"
|
||||
PROWLER_API_VERSION="5.18.2"
|
||||
```
|
||||
|
||||
<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>
|
||||
|
||||
Generated
+48
-48
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "about-time"
|
||||
@@ -4017,46 +4017,6 @@ files = [
|
||||
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "netifaces"
|
||||
version = "0.11.0"
|
||||
description = "Portable network interface information."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
|
||||
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
|
||||
{file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
|
||||
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
|
||||
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
|
||||
{file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
|
||||
{file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
|
||||
{file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
|
||||
{file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
|
||||
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
|
||||
{file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.2.1"
|
||||
@@ -4290,14 +4250,14 @@ openapi-schema-validator = ">=0.6.0,<0.7.0"
|
||||
|
||||
[[package]]
|
||||
name = "openstacksdk"
|
||||
version = "4.0.1"
|
||||
version = "4.2.0"
|
||||
description = "An SDK for building applications to work with OpenStack"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openstacksdk-4.0.1-py3-none-any.whl", hash = "sha256:d63187a006fff7c1de1486c9e2e1073a787af402620c3c0ed0cf5291225998ac"},
|
||||
{file = "openstacksdk-4.0.1.tar.gz", hash = "sha256:19faa1d5e6a78a2c1dc06a171e65e776ba82e9df23e1d08586225dc5ade9fc63"},
|
||||
{file = "openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6"},
|
||||
{file = "openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4308,10 +4268,10 @@ iso8601 = ">=0.1.11"
|
||||
jmespath = ">=0.9.0"
|
||||
jsonpatch = ">=1.16,<1.20 || >1.20"
|
||||
keystoneauth1 = ">=3.18.0"
|
||||
netifaces = ">=0.10.4"
|
||||
os-service-types = ">=1.7.0"
|
||||
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
|
||||
platformdirs = ">=3"
|
||||
psutil = ">=3.2.2"
|
||||
PyYAML = ">=3.13"
|
||||
requestsexceptions = ">=1.2.0"
|
||||
|
||||
@@ -4768,6 +4728,41 @@ files = [
|
||||
{file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "7.2.2"
|
||||
description = "Cross-platform lib for process and system monitoring."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
|
||||
{file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"},
|
||||
{file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"},
|
||||
{file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"},
|
||||
{file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"},
|
||||
{file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"},
|
||||
{file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"},
|
||||
{file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"},
|
||||
{file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"},
|
||||
{file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"},
|
||||
{file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"},
|
||||
{file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"},
|
||||
{file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"},
|
||||
{file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"},
|
||||
{file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"},
|
||||
{file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"},
|
||||
{file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"},
|
||||
{file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"},
|
||||
{file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"},
|
||||
{file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"},
|
||||
{file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
|
||||
test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
|
||||
|
||||
[[package]]
|
||||
name = "py-iam-expand"
|
||||
version = "0.1.0"
|
||||
@@ -4861,7 +4856,7 @@ description = "C parser in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "dev"]
|
||||
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
|
||||
markers = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
|
||||
files = [
|
||||
{file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"},
|
||||
{file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"},
|
||||
@@ -5874,6 +5869,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
|
||||
@@ -5882,6 +5878,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
|
||||
@@ -5890,6 +5887,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
|
||||
@@ -5898,6 +5896,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
|
||||
@@ -5906,6 +5905,7 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
|
||||
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
|
||||
@@ -6853,4 +6853,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">3.9.1,<3.13"
|
||||
content-hash = "f9ff21ae57caa3ddcd27f3753c29c1b3be2966709baed52e1bbc24e7bdc33f3c"
|
||||
content-hash = "48d1a809c940ba8cf7a6056aca9ff72d931bd3ea5ef6193f83350a1f0b36dbb7"
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
## [5.18.3] (Prowler v5.18.3)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- `pip install prowler` failing on systems without C compiler due to `netifaces` transitive dependency from `openstacksdk` [(#10055)](https://github.com/prowler-cloud/prowler/pull/10055)
|
||||
- `kms_key_not_publicly_accessible` false negative for specific KMS actions (e.g., `kms:DescribeKey`, `kms:Decrypt`) with unrestricted principals [(#10071)](https://github.com/prowler-cloud/prowler/pull/10071)
|
||||
- Remove account_id and location for manual requirements in M365CIS [(#10105)](https://github.com/prowler-cloud/prowler/pull/10105)
|
||||
|
||||
---
|
||||
|
||||
## [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.3"
|
||||
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"
|
||||
|
||||
@@ -77,8 +77,8 @@ class M365CIS(ComplianceOutput):
|
||||
compliance_row = M365CISModel(
|
||||
Provider=compliance.Provider.lower(),
|
||||
Description=compliance.Description,
|
||||
TenantId=finding.account_uid,
|
||||
Location=finding.region,
|
||||
TenantId="",
|
||||
Location="",
|
||||
AssessmentDate=str(timestamp),
|
||||
Requirements_Id=requirement.Id,
|
||||
Requirements_Description=requirement.Description,
|
||||
|
||||
+2
-2
@@ -24,9 +24,9 @@
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws kms put-key-policy --key-id <example_resource_id> --policy-name default --policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::<account_id>:root\"},\"Action\":\"kms:*\",\"Resource\":\"*\"}]}'",
|
||||
"CLI": "aws kms put-key-policy --key-id <example_resource_id> --policy-name default --policy '{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"arn:aws:iam::<account_id>:root\"},\"Action\":\"kms:\\*\",\"Resource\":\"\\*\"}]}'",
|
||||
"NativeIaC": "```yaml\n# CloudFormation: restrict KMS key policy to account root (removes any public access)\nResources:\n <example_resource_name>:\n Type: AWS::KMS::Key\n Properties:\n KeyPolicy:\n Version: '2012-10-17'\n Statement:\n - Effect: Allow\n Principal:\n AWS: arn:aws:iam::<account_id>:root # Critical: only account root can access; prevents public \"*\" principals\n Action: kms:*\n Resource: '*'\n```",
|
||||
"Other": "1. Open AWS Console > Key Management Service (KMS)\n2. Select the affected key and go to the Key policy tab\n3. Click Edit and remove any statement with Principal set to \"*\" (or AWS: \"*\")\n4. Ensure a statement exists that allows only arn:aws:iam::<account_id>:root\n5. Save changes",
|
||||
"Other": "1. Open AWS Console > Key Management Service (KMS)\n2. Select the affected key and go to the Key policy tab\n3. Click Edit and remove any statement with Principal set to \"\\*\" (or AWS: \"\\*\")\n4. Ensure a statement exists that allows only arn:aws:iam::<account_id>:root\n5. Save changes",
|
||||
"Terraform": "```hcl\n# Restrict KMS key policy to the account root to avoid any public (\"*\") principals\ndata \"aws_caller_identity\" \"current\" {}\n\nresource \"aws_kms_key\" \"<example_resource_name>\" {\n policy = jsonencode({\n Version = \"2012-10-17\"\n Statement = [\n {\n Effect = \"Allow\"\n Principal = { AWS = \"arn:aws:iam::${data.aws_caller_identity.current.account_id}:root\" } # Critical: limit to account root to remove public access\n Action = \"kms:*\"\n Resource = \"*\"\n }\n ]\n })\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class kms_key_not_publicly_accessible(Check):
|
||||
if is_policy_public(
|
||||
key.policy,
|
||||
kms_client.audited_account,
|
||||
not_allowed_actions=["kms:*"],
|
||||
not_allowed_actions=[],
|
||||
):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ dependencies = [
|
||||
"microsoft-kiota-abstractions==1.9.2",
|
||||
"msgraph-sdk==1.23.0",
|
||||
"numpy==2.0.2",
|
||||
"openstacksdk==4.0.1",
|
||||
"openstacksdk==4.2.0",
|
||||
"pandas==2.2.3",
|
||||
"py-ocsf-models==0.5.0",
|
||||
"pydantic (>=2.0,<3.0)",
|
||||
@@ -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.3"
|
||||
|
||||
[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")
|
||||
|
||||
@@ -97,8 +97,8 @@ class TestM365CIS:
|
||||
assert output_data_manual.Provider == "m365"
|
||||
assert output_data_manual.Framework == CIS_4_0_M365.Framework
|
||||
assert output_data_manual.Name == CIS_4_0_M365.Name
|
||||
assert output_data_manual.TenantId == TENANT_ID
|
||||
assert output_data_manual.Location == LOCATION
|
||||
assert output_data_manual.TenantId == ""
|
||||
assert output_data_manual.Location == ""
|
||||
assert output_data_manual.Description == CIS_4_0_M365.Description
|
||||
assert output_data_manual.Requirements_Id == CIS_4_0_M365.Requirements[1].Id
|
||||
assert (
|
||||
@@ -184,6 +184,6 @@ class TestM365CIS:
|
||||
|
||||
mock_file.seek(0)
|
||||
content = mock_file.read()
|
||||
expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: <VersioningConfiguration xmlns=`http://s3.amazonaws.com/doc/2006-03-01/`> <Status>Enabled</Status> <MfaDelete>Enabled</MfaDelete></VersioningConfiguration>\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;service_test_check_id;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\n"
|
||||
expected_csv = f"PROVIDER;DESCRIPTION;TENANTID;LOCATION;ASSESSMENTDATE;REQUIREMENTS_ID;REQUIREMENTS_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_SECTION;REQUIREMENTS_ATTRIBUTES_SUBSECTION;REQUIREMENTS_ATTRIBUTES_PROFILE;REQUIREMENTS_ATTRIBUTES_ASSESSMENTSTATUS;REQUIREMENTS_ATTRIBUTES_DESCRIPTION;REQUIREMENTS_ATTRIBUTES_RATIONALESTATEMENT;REQUIREMENTS_ATTRIBUTES_IMPACTSTATEMENT;REQUIREMENTS_ATTRIBUTES_REMEDIATIONPROCEDURE;REQUIREMENTS_ATTRIBUTES_AUDITPROCEDURE;REQUIREMENTS_ATTRIBUTES_ADDITIONALINFORMATION;REQUIREMENTS_ATTRIBUTES_DEFAULTVALUE;REQUIREMENTS_ATTRIBUTES_REFERENCES;STATUS;STATUSEXTENDED;RESOURCEID;RESOURCENAME;CHECKID;MUTED;FRAMEWORK;NAME\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;00000000-0000-0000-0000-000000000000;global;{datetime.now()};2.1.3;Ensure MFA Delete is enabled on S3 buckets;2.1. Simple Storage Service (S3);;Level 1;Automated;Once MFA Delete is enabled on your sensitive and classified S3 bucket it requires the user to have two forms of authentication.;Adding MFA delete to an S3 bucket, requires additional authentication when you change the version state of your bucket or you delete and object version adding another layer of security in the event your security credentials are compromised or unauthorized access is granted.;;Perform the steps below to enable MFA delete on an S3 bucket.Note:-You cannot enable MFA Delete using the AWS Management Console. You must use the AWS CLI or API.-You must use your 'root' account to enable MFA Delete on S3 buckets.**From Command line:**1. Run the s3api put-bucket-versioning command aws s3api put-bucket-versioning --profile my-root-profile --bucket Bucket_Name --versioning-configuration Status=Enabled,MFADelete=Enabled --mfa arn:aws:iam::aws_account_id:mfa/root-account-mfa-device passcode;Perform the steps below to confirm MFA delete is configured on an S3 Bucket**From Console:**1. Login to the S3 console at `https://console.aws.amazon.com/s3/`2. Click the `Check` box next to the Bucket name you want to confirm3. In the window under `Properties`4. Confirm that Versioning is `Enabled`5. Confirm that MFA Delete is `Enabled`**From Command Line:**1. Run the `get-bucket-versioning aws s3api get-bucket-versioning --bucket my-bucket Output example: <VersioningConfiguration xmlns=`http://s3.amazonaws.com/doc/2006-03-01/`> <Status>Enabled</Status> <MfaDelete>Enabled</MfaDelete></VersioningConfiguration>\ If the Console or the CLI output does not show Versioning and MFA Delete `enabled` refer to the remediation below.;;By default, MFA Delete is not enabled on S3 buckets.;https://docs.aws.amazon.com/AmazonS3/latest/dev/Versioning.html#MultiFactorAuthenticationDelete:https://docs.aws.amazon.com/AmazonS3/latest/dev/UsingMFADelete.html:https://aws.amazon.com/blogs/security/securing-access-to-aws-using-mfa-part-3/:https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_mfa_lost-or-broken.html;PASS;;;;service_test_check_id;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\nm365;The CIS Microsoft 365 Foundations Benchmark provides prescriptive guidance for configuring security options for Microsoft 365 with an emphasis on foundational, testable, and architecture agnostic settings.;;;{datetime.now()};2.1.4;Ensure that the controller manager pod specification file permissions are set to 600 or more restrictive;1.1 Control Plane Node Configuration Files;;Level 1;Automated;Ensure that the controller manager pod specification file has permissions of `600` or more restrictive.;The controller manager pod specification file controls various parameters that set the behavior of the Controller Manager on the master node. You should restrict its file permissions to maintain the integrity of the file. The file should be writable by only the administrators on the system.;;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` chmod 600 /etc/kubernetes/manifests/kube-controller-manager.yaml ```;Run the below command (based on the file location on your system) on the Control Plane node. For example, ``` stat -c %a /etc/kubernetes/manifests/kube-controller-manager.yaml ``` Verify that the permissions are `600` or more restrictive.;;By default, the `kube-controller-manager.yaml` file has permissions of `640`.;https://kubernetes.io/docs/admin/kube-apiserver/;MANUAL;Manual check;manual_check;Manual check;manual;False;CIS;CIS Microsoft 365 Foundations Benchmark v4.0.0\r\n"
|
||||
|
||||
assert content == expected_csv
|
||||
|
||||
+110
@@ -129,6 +129,116 @@ class Test_kms_key_not_publicly_accessible:
|
||||
assert result[0].resource_id == key["KeyId"]
|
||||
assert result[0].resource_arn == key["Arn"]
|
||||
|
||||
@mock_aws
|
||||
def test_kms_key_public_accessible_with_describe_key(self):
|
||||
# Generate KMS Client
|
||||
kms_client = client("kms", region_name=AWS_REGION_US_EAST_1)
|
||||
# Create KMS key with public policy allowing kms:DescribeKey
|
||||
key = kms_client.create_key(
|
||||
MultiRegion=False,
|
||||
Policy=json.dumps(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Id": "key-default-1",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowDescribeKeyPermissionForClusterOperator",
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": "*"},
|
||||
"Action": "kms:DescribeKey",
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)["KeyMetadata"]
|
||||
|
||||
from prowler.providers.aws.services.kms.kms_service import KMS
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible.kms_client",
|
||||
new=KMS(aws_provider),
|
||||
),
|
||||
):
|
||||
# Test Check
|
||||
from prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible import (
|
||||
kms_key_not_publicly_accessible,
|
||||
)
|
||||
|
||||
check = kms_key_not_publicly_accessible()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"KMS key {key['KeyId']} may be publicly accessible."
|
||||
)
|
||||
assert result[0].resource_id == key["KeyId"]
|
||||
assert result[0].resource_arn == key["Arn"]
|
||||
|
||||
@mock_aws
|
||||
def test_kms_key_public_accessible_with_decrypt(self):
|
||||
# Generate KMS Client
|
||||
kms_client = client("kms", region_name=AWS_REGION_US_EAST_1)
|
||||
# Create KMS key with public policy allowing kms:Decrypt
|
||||
key = kms_client.create_key(
|
||||
MultiRegion=False,
|
||||
Policy=json.dumps(
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Id": "key-default-1",
|
||||
"Statement": [
|
||||
{
|
||||
"Sid": "AllowDecryptPermissionPublicly",
|
||||
"Effect": "Allow",
|
||||
"Principal": "*",
|
||||
"Action": "kms:Decrypt",
|
||||
"Resource": "*",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
)["KeyMetadata"]
|
||||
|
||||
from prowler.providers.aws.services.kms.kms_service import KMS
|
||||
|
||||
aws_provider = set_mocked_aws_provider([AWS_REGION_US_EAST_1])
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=aws_provider,
|
||||
),
|
||||
mock.patch(
|
||||
"prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible.kms_client",
|
||||
new=KMS(aws_provider),
|
||||
),
|
||||
):
|
||||
# Test Check
|
||||
from prowler.providers.aws.services.kms.kms_key_not_publicly_accessible.kms_key_not_publicly_accessible import (
|
||||
kms_key_not_publicly_accessible,
|
||||
)
|
||||
|
||||
check = kms_key_not_publicly_accessible()
|
||||
result = check.execute()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].status == "FAIL"
|
||||
assert (
|
||||
result[0].status_extended
|
||||
== f"KMS key {key['KeyId']} may be publicly accessible."
|
||||
)
|
||||
assert result[0].resource_id == key["KeyId"]
|
||||
assert result[0].resource_arn == key["Arn"]
|
||||
|
||||
@mock_aws
|
||||
def test_kms_key_empty_principal(self):
|
||||
# Generate KMS Client
|
||||
|
||||
@@ -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,36 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.18.3] (Prowler v5.18.3)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
- Dropdown selects in the "Send to Jira" modal and other dialogs not responding to clicks [(#10097)](https://github.com/prowler-cloud/prowler/pull/10097)
|
||||
- Update credentials for the Alibaba Cloud provider [(#10098)](https://github.com/prowler-cloud/prowler/pull/10098)
|
||||
|
||||
---
|
||||
|
||||
## [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,35 +0,0 @@
|
||||
"use client";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
|
||||
const accounts = [
|
||||
{ key: "audit-test-1", label: "740350143844" },
|
||||
{ key: "audit-test-2", label: "890837126756" },
|
||||
{ key: "audit-test-3", label: "563829104923" },
|
||||
{ key: "audit-test-4", label: "678943217543" },
|
||||
{ key: "audit-test-5", label: "932187465320" },
|
||||
{ key: "audit-test-6", label: "492837106587" },
|
||||
{ key: "audit-test-7", label: "812736459201" },
|
||||
{ key: "audit-test-8", label: "374829106524" },
|
||||
{ key: "audit-test-9", label: "926481053298" },
|
||||
{ key: "audit-test-10", label: "748192364579" },
|
||||
{ key: "audit-test-11", label: "501374829106" },
|
||||
];
|
||||
export const CustomAccountSelection = () => {
|
||||
return (
|
||||
<Select
|
||||
label="Account"
|
||||
aria-label="Select an Account"
|
||||
placeholder="Select an account"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
}}
|
||||
selectionMode="multiple"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
{accounts.map((acc) => (
|
||||
<SelectItem key={acc.key}>{acc.label}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
export const CustomRegionSelection: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const region = "none";
|
||||
// Memoize selected keys based on the URL
|
||||
const selectedKeys = useMemo(() => {
|
||||
const params = searchParams.get("filter[regions]");
|
||||
return params ? params.split(",") : [];
|
||||
}, [searchParams]);
|
||||
|
||||
const applyRegionFilter = useCallback(
|
||||
(values: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (values.length > 0) {
|
||||
params.set("filter[regions]", values.join(","));
|
||||
} else {
|
||||
params.delete("filter[regions]");
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[router, searchParams],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label="Region"
|
||||
aria-label="Select a Region"
|
||||
placeholder="Select a region"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
}}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
selectedKeys={selectedKeys}
|
||||
onSelectionChange={(keys) =>
|
||||
applyRegionFilter(Array.from(keys) as string[])
|
||||
}
|
||||
>
|
||||
<SelectItem key={region}>{region}</SelectItem>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
import { PROVIDER_TYPES, ProviderType } from "@/types/providers";
|
||||
|
||||
import {
|
||||
CustomProviderInputAlibabaCloud,
|
||||
CustomProviderInputAWS,
|
||||
CustomProviderInputAzure,
|
||||
CustomProviderInputGCP,
|
||||
CustomProviderInputGitHub,
|
||||
CustomProviderInputIac,
|
||||
CustomProviderInputKubernetes,
|
||||
CustomProviderInputM365,
|
||||
CustomProviderInputMongoDBAtlas,
|
||||
CustomProviderInputOracleCloud,
|
||||
} from "./custom-provider-inputs";
|
||||
|
||||
const providerDisplayData: Record<
|
||||
ProviderType,
|
||||
{ label: string; component: ReactElement }
|
||||
> = {
|
||||
aws: {
|
||||
label: "Amazon Web Services",
|
||||
component: <CustomProviderInputAWS />,
|
||||
},
|
||||
azure: {
|
||||
label: "Microsoft Azure",
|
||||
component: <CustomProviderInputAzure />,
|
||||
},
|
||||
gcp: {
|
||||
label: "Google Cloud Platform",
|
||||
component: <CustomProviderInputGCP />,
|
||||
},
|
||||
github: {
|
||||
label: "GitHub",
|
||||
component: <CustomProviderInputGitHub />,
|
||||
},
|
||||
iac: {
|
||||
label: "Infrastructure as Code",
|
||||
component: <CustomProviderInputIac />,
|
||||
},
|
||||
kubernetes: {
|
||||
label: "Kubernetes",
|
||||
component: <CustomProviderInputKubernetes />,
|
||||
},
|
||||
m365: {
|
||||
label: "Microsoft 365",
|
||||
component: <CustomProviderInputM365 />,
|
||||
},
|
||||
mongodbatlas: {
|
||||
label: "MongoDB Atlas",
|
||||
component: <CustomProviderInputMongoDBAtlas />,
|
||||
},
|
||||
oraclecloud: {
|
||||
label: "Oracle Cloud Infrastructure",
|
||||
component: <CustomProviderInputOracleCloud />,
|
||||
},
|
||||
alibabacloud: {
|
||||
label: "Alibaba Cloud",
|
||||
component: <CustomProviderInputAlibabaCloud />,
|
||||
},
|
||||
};
|
||||
|
||||
const dataInputsProvider = PROVIDER_TYPES.map((providerType) => ({
|
||||
key: providerType,
|
||||
label: providerDisplayData[providerType].label,
|
||||
value: providerDisplayData[providerType].component,
|
||||
}));
|
||||
|
||||
export const CustomSelectProvider = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const applyProviderFilter = (value: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (value) {
|
||||
params.set("filter[provider_type]", value);
|
||||
} else {
|
||||
params.delete("filter[provider_type]");
|
||||
}
|
||||
router.push(`?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
const currentProvider = searchParams.get("filter[provider_type]") || "";
|
||||
|
||||
const selectedKeys = dataInputsProvider.some(
|
||||
(provider) => provider.key === currentProvider,
|
||||
)
|
||||
? [currentProvider]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Select
|
||||
items={dataInputsProvider}
|
||||
aria-label="Select a Provider"
|
||||
placeholder="Select a provider"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
label: "z-0! mb-2",
|
||||
}}
|
||||
label="Provider"
|
||||
labelPlacement="inside"
|
||||
size="sm"
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
applyProviderFilter(value);
|
||||
}}
|
||||
selectedKeys={selectedKeys}
|
||||
renderValue={(items) => {
|
||||
return items.map((item) => (
|
||||
<div key={item.key} className="flex items-center gap-2">
|
||||
{item.data?.value}
|
||||
</div>
|
||||
));
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<SelectItem key={item.key} textValue={item.key} aria-label={item.label}>
|
||||
<div className="flex items-center gap-2">{item.value}</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -3,30 +3,15 @@
|
||||
import { FilterOption } from "@/types";
|
||||
|
||||
import { DataTableFilterCustom } from "../ui/table";
|
||||
import { CustomAccountSelection } from "./custom-account-selection";
|
||||
import { CustomCheckboxMutedFindings } from "./custom-checkbox-muted-findings";
|
||||
import { CustomDatePicker } from "./custom-date-picker";
|
||||
import { CustomRegionSelection } from "./custom-region-selection";
|
||||
import { CustomSearchInput } from "./custom-search-input";
|
||||
import { CustomSelectProvider } from "./custom-select-provider";
|
||||
|
||||
export interface FilterControlsProps {
|
||||
search?: boolean;
|
||||
providers?: boolean;
|
||||
date?: boolean;
|
||||
regions?: boolean;
|
||||
accounts?: boolean;
|
||||
mutedFindings?: boolean;
|
||||
customFilters?: FilterOption[];
|
||||
}
|
||||
|
||||
export const FilterControls = ({
|
||||
search = false,
|
||||
providers = false,
|
||||
date = false,
|
||||
regions = false,
|
||||
accounts = false,
|
||||
mutedFindings = false,
|
||||
customFilters,
|
||||
}: FilterControlsProps) => {
|
||||
return (
|
||||
@@ -34,11 +19,6 @@ export const FilterControls = ({
|
||||
<div className="mb-4 flex flex-col items-start gap-4 md:flex-row md:items-center">
|
||||
<div className="grid w-full flex-1 grid-cols-1 items-center gap-x-4 gap-y-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{search && <CustomSearchInput />}
|
||||
{providers && <CustomSelectProvider />}
|
||||
{date && <CustomDatePicker />}
|
||||
{regions && <CustomRegionSelection />}
|
||||
{accounts && <CustomAccountSelection />}
|
||||
{mutedFindings && <CustomCheckboxMutedFindings />}
|
||||
</div>
|
||||
</div>
|
||||
{customFilters && customFilters.length > 0 && (
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
export * from "./clear-filters-button";
|
||||
export * from "./custom-account-selection";
|
||||
export * from "./custom-checkbox-muted-findings";
|
||||
export * from "./custom-date-picker";
|
||||
export * from "./custom-provider-inputs";
|
||||
export * from "./custom-region-selection";
|
||||
export * from "./custom-select-provider";
|
||||
export * from "./data-filters";
|
||||
export * from "./filter-controls";
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import type { Selection } from "@react-types/shared";
|
||||
import { Search, Send } from "lucide-react";
|
||||
import { Send } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -14,16 +11,11 @@ import {
|
||||
pollJiraDispatchTask,
|
||||
sendFindingToJira,
|
||||
} from "@/actions/integrations/jira-dispatch";
|
||||
import { JiraIcon } from "@/components/icons/services/IconServices";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomBanner } from "@/components/ui/custom/custom-banner";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Form, FormField, FormMessage } from "@/components/ui/form";
|
||||
import { FormButtons } from "@/components/ui/form/form-buttons";
|
||||
import { IntegrationProps } from "@/types/integrations";
|
||||
|
||||
@@ -42,15 +34,6 @@ const sendToJiraSchema = z.object({
|
||||
|
||||
type SendToJiraFormData = z.infer<typeof sendToJiraSchema>;
|
||||
|
||||
const selectorClassNames = {
|
||||
trigger: "min-h-12",
|
||||
popoverContent: "bg-bg-neutral-secondary",
|
||||
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
|
||||
listbox: "gap-0",
|
||||
label: "tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
|
||||
value: "text-text-neutral-secondary text-small",
|
||||
};
|
||||
|
||||
// The commented code is related to issue types, which are not required for the first implementation, but will be used in the future
|
||||
export const SendToJiraModal = ({
|
||||
isOpen,
|
||||
@@ -61,8 +44,6 @@ export const SendToJiraModal = ({
|
||||
const { toast } = useToast();
|
||||
const [integrations, setIntegrations] = useState<IntegrationProps[]>([]);
|
||||
const [isFetchingIntegrations, setIsFetchingIntegrations] = useState(false);
|
||||
const [searchProjectValue, setSearchProjectValue] = useState("");
|
||||
// const [searchIssueTypeValue, setSearchIssueTypeValue] = useState("");
|
||||
|
||||
const form = useForm<SendToJiraFormData>({
|
||||
resolver: zodResolver(sendToJiraSchema),
|
||||
@@ -75,18 +56,11 @@ export const SendToJiraModal = ({
|
||||
});
|
||||
|
||||
const selectedIntegration = form.watch("integration");
|
||||
// const selectedProject = form.watch("project");
|
||||
|
||||
const hasConnectedIntegration = integrations.some(
|
||||
(i) => i.attributes.connected === true,
|
||||
);
|
||||
|
||||
const getSelectedValue = (keys: Selection): string => {
|
||||
if (keys === "all") return "";
|
||||
const first = Array.from(keys)[0];
|
||||
return first !== null ? String(first) : "";
|
||||
};
|
||||
|
||||
const setOpenForFormButtons: Dispatch<SetStateAction<boolean>> = (value) => {
|
||||
const next = typeof value === "function" ? value(isOpen) : value;
|
||||
onOpenChange(next);
|
||||
@@ -129,8 +103,6 @@ export const SendToJiraModal = ({
|
||||
} else {
|
||||
// Reset form when modal closes
|
||||
form.reset();
|
||||
setSearchProjectValue("");
|
||||
// setSearchIssueTypeValue("");
|
||||
}
|
||||
}, [isOpen, form, toast]);
|
||||
|
||||
@@ -187,32 +159,16 @@ export const SendToJiraModal = ({
|
||||
({} as Record<string, string>);
|
||||
|
||||
const projectEntries = Object.entries(projects);
|
||||
const shouldShowProjectSearch = projectEntries.length > 5;
|
||||
// const issueTypes: string[] =
|
||||
// selectedIntegrationData?.attributes.configuration.issue_types ||
|
||||
// ([] as string[]);
|
||||
|
||||
// Filter projects based on search
|
||||
const filteredProjects = (() => {
|
||||
if (!searchProjectValue) return projectEntries;
|
||||
const integrationOptions = integrations.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.attributes.configuration.domain || integration.id,
|
||||
}));
|
||||
|
||||
const lowerSearch = searchProjectValue.toLowerCase();
|
||||
return projectEntries.filter(
|
||||
([key, name]) =>
|
||||
key.toLowerCase().includes(lowerSearch) ||
|
||||
name.toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
})();
|
||||
|
||||
// Filter issue types based on search
|
||||
// const filteredIssueTypes = useMemo(() => {
|
||||
// if (!searchIssueTypeValue) return issueTypes;
|
||||
|
||||
// const lowerSearch = searchIssueTypeValue.toLowerCase();
|
||||
// return issueTypes.filter((type) =>
|
||||
// type.toLowerCase().includes(lowerSearch),
|
||||
// );
|
||||
// }, [issueTypes, searchIssueTypeValue]);
|
||||
const projectOptions = projectEntries.map(([key, name]) => ({
|
||||
value: key,
|
||||
label: `${key} - ${name}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -236,127 +192,72 @@ export const SendToJiraModal = ({
|
||||
control={form.control}
|
||||
name="integration"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Select
|
||||
label="Jira Integration"
|
||||
placeholder="Select a Jira integration"
|
||||
selectedKeys={
|
||||
field.value ? new Set([field.value]) : new Set()
|
||||
}
|
||||
onSelectionChange={(keys: Selection) => {
|
||||
const value = getSelectedValue(keys);
|
||||
field.onChange(value);
|
||||
// Reset dependent fields
|
||||
form.setValue("project", "");
|
||||
// Keep issue type defaulting to Task
|
||||
form.setValue("issueType", "Task");
|
||||
setSearchProjectValue("");
|
||||
// setSearchIssueTypeValue("");
|
||||
}}
|
||||
variant="bordered"
|
||||
labelPlacement="inside"
|
||||
isDisabled={isFetchingIntegrations}
|
||||
isInvalid={!!form.formState.errors.integration}
|
||||
startContent={<JiraIcon size={16} />}
|
||||
classNames={selectorClassNames}
|
||||
>
|
||||
{integrations.map((integration) => (
|
||||
<SelectItem
|
||||
key={integration.id}
|
||||
textValue={
|
||||
integration.attributes.configuration.domain
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<JiraIcon size={16} />
|
||||
<span>
|
||||
{integration.attributes.configuration.domain}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-integration-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Jira Integration
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-integration-select"
|
||||
options={integrationOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Reset dependent fields
|
||||
form.setValue("project", "");
|
||||
form.setValue("issueType", "Task");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira integration"
|
||||
searchable={true}
|
||||
emptyIndicator="No integrations found."
|
||||
disabled={isFetchingIntegrations}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Project Selection - Enhanced Style */}
|
||||
{selectedIntegration && Object.keys(projects).length > 0 && (
|
||||
{/* Project Selection */}
|
||||
{selectedIntegration && projectEntries.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="project"
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<Select
|
||||
label="Project"
|
||||
placeholder="Select a Jira project"
|
||||
selectedKeys={
|
||||
field.value ? new Set([field.value]) : new Set()
|
||||
}
|
||||
onSelectionChange={(keys: Selection) => {
|
||||
const value = getSelectedValue(keys);
|
||||
field.onChange(value);
|
||||
// Keep issue type defaulting to Task when project changes
|
||||
form.setValue("issueType", "Task");
|
||||
// setSearchIssueTypeValue("");
|
||||
}}
|
||||
variant="bordered"
|
||||
labelPlacement="inside"
|
||||
isInvalid={!!form.formState.errors.project}
|
||||
classNames={selectorClassNames}
|
||||
listboxProps={{
|
||||
topContent: shouldShowProjectSearch ? (
|
||||
<div className="sticky top-0 z-10 py-2">
|
||||
<Input
|
||||
isClearable
|
||||
placeholder="Search projects..."
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<Search size={16} />}
|
||||
value={searchProjectValue}
|
||||
onValueChange={setSearchProjectValue}
|
||||
onClear={() => setSearchProjectValue("")}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
|
||||
input: "text-small",
|
||||
clearButton: "text-default-400",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
{filteredProjects.map(([key, name]) => (
|
||||
<SelectItem key={key} textValue={`${key} - ${name}`}>
|
||||
<div className="flex w-full items-center justify-between py-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-small font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
<span className="text-tiny text-default-500">
|
||||
-
|
||||
</span>
|
||||
<span className="text-small truncate">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="jira-project-select"
|
||||
className="text-text-neutral-secondary text-xs font-light tracking-tight"
|
||||
>
|
||||
Project
|
||||
</label>
|
||||
<EnhancedMultiSelect
|
||||
id="jira-project-select"
|
||||
options={projectOptions}
|
||||
onValueChange={(values) => {
|
||||
const selectedValue = values.at(-1) ?? "";
|
||||
field.onChange(selectedValue);
|
||||
// Keep issue type defaulting to Task when project changes
|
||||
form.setValue("issueType", "Task");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Select a Jira project"
|
||||
searchable={true}
|
||||
emptyIndicator="No projects found."
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
<FormMessage className="text-text-error text-xs" />
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from "@/components/icons/providers-badge";
|
||||
import { ProviderType } from "@/types";
|
||||
|
||||
const PROVIDER_ICONS = {
|
||||
export const PROVIDER_ICONS = {
|
||||
aws: AWSProviderBadge,
|
||||
azure: AzureProviderBadge,
|
||||
gcp: GCPProviderBadge,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "../providers/enhanced-provider-selector";
|
||||
export * from "./api-key/api-key-link-card";
|
||||
export * from "./jira/jira-integration-card";
|
||||
export * from "./jira/jira-integration-form";
|
||||
|
||||
@@ -8,12 +8,18 @@ import { useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { FormButtons } from "@/components/ui/form/form-buttons";
|
||||
import { getAWSCredentialsTemplateLinks } from "@/lib";
|
||||
import { AWSCredentialsRole } from "@/types";
|
||||
@@ -272,18 +278,40 @@ export const S3IntegrationForm = ({
|
||||
|
||||
// Show configuration step (step 0 or editing configuration)
|
||||
if (isEditingConfig || currentStep === 0) {
|
||||
const providerOptions = providers.map((provider) => {
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
description: provider.attributes.connection.connected
|
||||
? "Connected"
|
||||
: "Disconnected",
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Provider Selection */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<EnhancedProviderSelector
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providers"
|
||||
providers={providers}
|
||||
label="Cloud Providers"
|
||||
placeholder="Select providers to integrate with"
|
||||
selectionMode="multiple"
|
||||
enableSearch={true}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<EnhancedMultiSelect
|
||||
options={providerOptions}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || []}
|
||||
placeholder="Select providers to integrate with"
|
||||
searchable={true}
|
||||
maxCount={1}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-text-error max-w-full text-xs" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,15 +6,21 @@ import { Radio, RadioGroup } from "@heroui/radio";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, useForm } from "react-hook-form";
|
||||
|
||||
import { createIntegration, updateIntegration } from "@/actions/integrations";
|
||||
import { EnhancedProviderSelector } from "@/components/providers/enhanced-provider-selector";
|
||||
import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell";
|
||||
import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomLink } from "@/components/ui/custom/custom-link";
|
||||
import { Form, FormControl, FormField } from "@/components/ui/form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { FormButtons } from "@/components/ui/form/form-buttons";
|
||||
import { getAWSCredentialsTemplateLinks } from "@/lib";
|
||||
import { AWSCredentialsRole } from "@/types";
|
||||
@@ -52,7 +58,7 @@ export const SecurityHubIntegrationForm = ({
|
||||
const isEditingConfig = editMode === "configuration";
|
||||
const isEditingCredentials = editMode === "credentials";
|
||||
|
||||
const disabledProviderIds = useMemo(() => {
|
||||
const disabledProviderIds = (() => {
|
||||
// When editing, no providers should be disabled since we're not changing it
|
||||
if (isEditing) {
|
||||
return [];
|
||||
@@ -69,7 +75,7 @@ export const SecurityHubIntegrationForm = ({
|
||||
});
|
||||
|
||||
return usedProviderIds;
|
||||
}, [isEditing, existingIntegrations]);
|
||||
})();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
@@ -107,6 +113,26 @@ export const SecurityHubIntegrationForm = ({
|
||||
const providerIdValue = form.watch("provider_id");
|
||||
const hasErrors = !!form.formState.errors.provider_id || !providerIdValue;
|
||||
|
||||
const providerOptions = providers
|
||||
.filter((provider) => provider.attributes.provider === "aws")
|
||||
.map((provider) => {
|
||||
const isDisabled = disabledProviderIds.includes(provider.id);
|
||||
const connectionLabel = provider.attributes.connection.connected
|
||||
? "Connected"
|
||||
: "Disconnected";
|
||||
|
||||
const Icon = PROVIDER_ICONS[provider.attributes.provider];
|
||||
return {
|
||||
value: provider.id,
|
||||
label: provider.attributes.alias || provider.attributes.uid,
|
||||
icon: Icon ? <Icon width={20} height={20} /> : undefined,
|
||||
description: isDisabled
|
||||
? `${connectionLabel} (Already in use)`
|
||||
: connectionLabel,
|
||||
disabled: isDisabled,
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!useCustomCredentials && isCreating) {
|
||||
setCurrentStep(0);
|
||||
@@ -325,17 +351,29 @@ export const SecurityHubIntegrationForm = ({
|
||||
{!isEditingConfig && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<EnhancedProviderSelector
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider_id"
|
||||
providers={providers}
|
||||
label="AWS Provider"
|
||||
placeholder="Search and select an AWS provider"
|
||||
isInvalid={!!form.formState.errors.provider_id}
|
||||
selectionMode="single"
|
||||
providerType="aws"
|
||||
enableSearch={true}
|
||||
disabledProviderIds={disabledProviderIds}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<FormControl>
|
||||
<EnhancedMultiSelect
|
||||
options={providerOptions}
|
||||
onValueChange={(values) => {
|
||||
field.onChange(values.at(-1) ?? "");
|
||||
}}
|
||||
defaultValue={field.value ? [field.value] : []}
|
||||
placeholder="Search and select an AWS provider"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
maxCount={1}
|
||||
closeOnSelect={true}
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage className="text-text-error max-w-full text-xs" />
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { format } from "date-fns";
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
IntegrationCardHeader,
|
||||
IntegrationSkeleton,
|
||||
} from "@/components/integrations/shared";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Badge, Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { DataTablePagination } from "@/components/ui/table/data-table-pagination";
|
||||
@@ -385,14 +384,13 @@ export const SecurityHubIntegrationsManager = ({
|
||||
{enabledRegions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{enabledRegions.map((region) => (
|
||||
<Chip
|
||||
<Badge
|
||||
key={region}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
className="bg-bg-neutral-secondary"
|
||||
variant="outline"
|
||||
className="border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal"
|
||||
>
|
||||
{region}
|
||||
</Chip>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IntegrationCardHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
chips?: Array<{
|
||||
label: string;
|
||||
color?:
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger";
|
||||
variant?: "solid" | "bordered" | "light" | "flat" | "faded" | "shadow";
|
||||
className?: string;
|
||||
}>;
|
||||
connectionStatus?: {
|
||||
connected: boolean;
|
||||
@@ -63,25 +58,30 @@ export const IntegrationCardHeader = ({
|
||||
{(chips.length > 0 || connectionStatus) && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{chips.map((chip, index) => (
|
||||
<Chip
|
||||
<Badge
|
||||
key={index}
|
||||
size="sm"
|
||||
variant={chip.variant || "flat"}
|
||||
color={chip.color || "default"}
|
||||
className="text-xs"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"border-border-neutral-secondary bg-bg-neutral-secondary text-text-neutral-primary text-xs font-normal",
|
||||
chip.className,
|
||||
)}
|
||||
>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
</Badge>
|
||||
))}
|
||||
{connectionStatus && (
|
||||
<Chip
|
||||
size="sm"
|
||||
color={connectionStatus.connected ? "success" : "danger"}
|
||||
variant="flat"
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"text-xs font-normal",
|
||||
connectionStatus.connected
|
||||
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
|
||||
: "bg-bg-danger-secondary text-text-danger border-transparent",
|
||||
)}
|
||||
>
|
||||
{connectionStatus.label ||
|
||||
(connectionStatus.connected ? "Connected" : "Disconnected")}
|
||||
</Chip>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { MailIcon, ShieldIcon } from "lucide-react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
@@ -6,6 +5,13 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
import { updateInvite } from "@/actions/invitations/invitation";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
@@ -126,27 +132,25 @@ export const EditForm = ({
|
||||
isRequired={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-text-neutral-secondary text-sm font-medium">
|
||||
Role
|
||||
</label>
|
||||
<Controller
|
||||
name="role"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
label="Role"
|
||||
placeholder="Select a role"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
}}
|
||||
variant="flat"
|
||||
selectedKeys={[field.value || ""]}
|
||||
onSelectionChange={(selected) =>
|
||||
field.onChange(selected?.currentKey || "")
|
||||
}
|
||||
>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id}>{role.name}</SelectItem>
|
||||
))}
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { SaveIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -9,6 +8,13 @@ import * as z from "zod";
|
||||
|
||||
import { sendInvite } from "@/actions/invitations/invitation";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
@@ -111,35 +117,33 @@ export const SendInvitationForm = ({
|
||||
name="roleId"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Select
|
||||
{...field}
|
||||
label="Role"
|
||||
placeholder="Select a role"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
}}
|
||||
variant="flat"
|
||||
isDisabled={isSelectorDisabled}
|
||||
selectedKeys={[field.value]}
|
||||
onSelectionChange={(selected) =>
|
||||
field.onChange(selected?.currentKey || "")
|
||||
}
|
||||
value={field.value || undefined}
|
||||
onValueChange={field.onChange}
|
||||
disabled={isSelectorDisabled}
|
||||
>
|
||||
{isSelectorDisabled ? (
|
||||
<SelectItem key={defaultRole}>{defaultRole}</SelectItem>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<SelectItem key={role.id}>{role.name}</SelectItem>
|
||||
))
|
||||
)}
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isSelectorDisabled ? (
|
||||
<SelectItem value={defaultRole}>{defaultRole}</SelectItem>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.roleId && (
|
||||
<p className="text-text-error mt-2 text-sm">
|
||||
{form.formState.errors.roleId.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ import * as z from "zod";
|
||||
|
||||
import { createProviderGroup } from "@/actions/manage-groups";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
@@ -39,6 +40,14 @@ export const AddGroupForm = ({
|
||||
});
|
||||
|
||||
const isLoading = form.formState.isSubmitting;
|
||||
const providerOptions = providers.map((provider) => ({
|
||||
label: provider.name,
|
||||
value: provider.id,
|
||||
}));
|
||||
const roleOptions = roles.map((role) => ({
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
}));
|
||||
|
||||
const onSubmitClient = async (values: FormValues) => {
|
||||
try {
|
||||
@@ -128,15 +137,19 @@ export const AddGroupForm = ({
|
||||
name="providers"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CustomDropdownSelection
|
||||
label="Select Providers"
|
||||
name="providers"
|
||||
values={providers}
|
||||
selectedKeys={field.value || []}
|
||||
onChange={(name, selectedValues) =>
|
||||
field.onChange(selectedValues)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<EnhancedMultiSelect
|
||||
options={providerOptions}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || []}
|
||||
placeholder="Select providers"
|
||||
aria-label="Select providers"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
emptyIndicator="No results found"
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.providers && (
|
||||
@@ -155,15 +168,19 @@ export const AddGroupForm = ({
|
||||
name="roles"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CustomDropdownSelection
|
||||
label="Select Roles"
|
||||
name="roles"
|
||||
values={roles}
|
||||
selectedKeys={field.value || []}
|
||||
onChange={(name, selectedValues) =>
|
||||
field.onChange(selectedValues)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<EnhancedMultiSelect
|
||||
options={roleOptions}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || []}
|
||||
placeholder="Select roles"
|
||||
aria-label="Select roles"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
emptyIndicator="No results found"
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.roles && (
|
||||
|
||||
@@ -9,8 +9,9 @@ import * as z from "zod";
|
||||
|
||||
import { updateProviderGroup } from "@/actions/manage-groups/manage-groups";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { ApiError } from "@/types";
|
||||
|
||||
@@ -176,18 +177,29 @@ export const EditGroupForm = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomDropdownSelection
|
||||
label="Select Providers"
|
||||
name="providers"
|
||||
values={combinedProviders}
|
||||
selectedKeys={field.value?.map((p) => p.id) || []}
|
||||
onChange={(name, selectedValues) => {
|
||||
const selectedProviders = combinedProviders.filter(
|
||||
(provider) => selectedValues.includes(provider.id),
|
||||
);
|
||||
field.onChange(selectedProviders);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<EnhancedMultiSelect
|
||||
options={combinedProviders.map((provider) => ({
|
||||
label: provider.name,
|
||||
value: provider.id,
|
||||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const selectedProviders = combinedProviders.filter(
|
||||
(provider) => selectedValues.includes(provider.id),
|
||||
);
|
||||
field.onChange(selectedProviders);
|
||||
}}
|
||||
defaultValue={
|
||||
field.value?.map((provider) => provider.id) || []
|
||||
}
|
||||
placeholder="Select providers"
|
||||
aria-label="Select providers"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
emptyIndicator="No results found"
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
@@ -216,18 +228,27 @@ export const EditGroupForm = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<CustomDropdownSelection
|
||||
label="Select Roles"
|
||||
name="roles"
|
||||
values={combinedRoles}
|
||||
selectedKeys={field.value?.map((r) => r.id) || []}
|
||||
onChange={(name, selectedValues) => {
|
||||
const selectedRoles = combinedRoles.filter((role) =>
|
||||
selectedValues.includes(role.id),
|
||||
);
|
||||
field.onChange(selectedRoles);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<EnhancedMultiSelect
|
||||
options={combinedRoles.map((role) => ({
|
||||
label: role.name,
|
||||
value: role.id,
|
||||
}))}
|
||||
onValueChange={(selectedValues) => {
|
||||
const selectedRoles = combinedRoles.filter((role) =>
|
||||
selectedValues.includes(role.id),
|
||||
);
|
||||
field.onChange(selectedRoles);
|
||||
}}
|
||||
defaultValue={field.value?.map((role) => role.id) || []}
|
||||
placeholder="Select roles"
|
||||
aria-label="Select roles"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
emptyIndicator="No results found"
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</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,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SelectViaAlibabaCloud } from "@/components/providers/workflow/forms/select-credentials-type/alibabacloud";
|
||||
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
|
||||
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
|
||||
import { SelectViaGitHub } from "@/components/providers/workflow/forms/select-credentials-type/github";
|
||||
@@ -28,6 +29,9 @@ export const CredentialsUpdateInfo = ({
|
||||
if (providerType === "m365") {
|
||||
return <SelectViaM365 initialVia={initialVia} />;
|
||||
}
|
||||
if (providerType === "alibabacloud") {
|
||||
return <SelectViaAlibabaCloud initialVia={initialVia} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@heroui/input";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { SharedSelection } from "@heroui/system";
|
||||
import { CheckSquare, Search, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { FormControl, FormField, FormMessage } from "@/components/ui/form";
|
||||
import { ProviderProps, ProviderType } from "@/types/providers";
|
||||
|
||||
const providerTypeLabels: Record<ProviderType, string> = {
|
||||
aws: "Amazon Web Services",
|
||||
gcp: "Google Cloud Platform",
|
||||
azure: "Microsoft Azure",
|
||||
m365: "Microsoft 365",
|
||||
kubernetes: "Kubernetes",
|
||||
github: "GitHub",
|
||||
iac: "Infrastructure as Code",
|
||||
oraclecloud: "Oracle Cloud Infrastructure",
|
||||
mongodbatlas: "MongoDB Atlas",
|
||||
alibabacloud: "Alibaba Cloud",
|
||||
};
|
||||
|
||||
interface EnhancedProviderSelectorProps<T extends FieldValues> {
|
||||
control: Control<T>;
|
||||
name: Path<T>;
|
||||
providers: ProviderProps[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
isInvalid?: boolean;
|
||||
showFormMessage?: boolean;
|
||||
selectionMode?: "single" | "multiple";
|
||||
providerType?: ProviderType;
|
||||
enableSearch?: boolean;
|
||||
disabledProviderIds?: string[];
|
||||
}
|
||||
|
||||
export const EnhancedProviderSelector = <T extends FieldValues>({
|
||||
control,
|
||||
name,
|
||||
providers,
|
||||
label = "Provider",
|
||||
placeholder = "Select provider",
|
||||
isInvalid = false,
|
||||
showFormMessage = true,
|
||||
selectionMode = "single",
|
||||
providerType,
|
||||
enableSearch = false,
|
||||
disabledProviderIds = [],
|
||||
}: EnhancedProviderSelectorProps<T>) => {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const filteredProviders = (() => {
|
||||
let filtered = providers;
|
||||
|
||||
// Filter by provider type if specified
|
||||
if (providerType) {
|
||||
filtered = filtered.filter((p) => p.attributes.provider === providerType);
|
||||
}
|
||||
|
||||
// Filter by search value
|
||||
if (searchValue && enableSearch) {
|
||||
const lowerSearch = searchValue.toLowerCase();
|
||||
filtered = filtered.filter((p) => {
|
||||
const displayName = p.attributes.alias || p.attributes.uid;
|
||||
const typeLabel = providerTypeLabels[p.attributes.provider];
|
||||
return (
|
||||
displayName.toLowerCase().includes(lowerSearch) ||
|
||||
typeLabel.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort providers
|
||||
return filtered.sort((a, b) => {
|
||||
const typeComparison = a.attributes.provider.localeCompare(
|
||||
b.attributes.provider,
|
||||
);
|
||||
if (typeComparison !== 0) return typeComparison;
|
||||
|
||||
const nameA = a.attributes.alias || a.attributes.uid;
|
||||
const nameB = b.attributes.alias || b.attributes.uid;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field: { onChange, value, onBlur } }) => {
|
||||
const isMultiple = selectionMode === "multiple";
|
||||
const selectedIds: string[] = isMultiple
|
||||
? (value as string[] | undefined) || []
|
||||
: value
|
||||
? [value as string]
|
||||
: [];
|
||||
const allProviderIds = filteredProviders
|
||||
.filter((p) => !disabledProviderIds.includes(p.id))
|
||||
.map((p) => p.id);
|
||||
const isAllSelected =
|
||||
isMultiple &&
|
||||
allProviderIds.length > 0 &&
|
||||
allProviderIds.every((id) => selectedIds.includes(id));
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
onChange([]);
|
||||
} else {
|
||||
onChange(allProviderIds);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectionChange = (keys: SharedSelection) => {
|
||||
if (keys === "all") {
|
||||
onChange(allProviderIds);
|
||||
return;
|
||||
}
|
||||
if (isMultiple) {
|
||||
const selectedArray = Array.from(keys).map(String);
|
||||
onChange(selectedArray);
|
||||
} else {
|
||||
const selectedValue = Array.from(keys)[0];
|
||||
onChange(selectedValue ? String(selectedValue) : "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl>
|
||||
<div className="flex flex-col gap-2">
|
||||
{isMultiple && filteredProviders.length > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleSelectAll}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{isAllSelected ? (
|
||||
<CheckSquare size={16} />
|
||||
) : (
|
||||
<Square size={16} />
|
||||
)}
|
||||
{isAllSelected ? "Deselect All" : "Select All"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
selectionMode={isMultiple ? "multiple" : "single"}
|
||||
selectedKeys={
|
||||
new Set(isMultiple ? value || [] : value ? [value] : [])
|
||||
}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onBlur={onBlur}
|
||||
variant="bordered"
|
||||
labelPlacement="inside"
|
||||
isRequired={false}
|
||||
isInvalid={isInvalid}
|
||||
classNames={{
|
||||
trigger: "min-h-12",
|
||||
popoverContent: "bg-bg-neutral-secondary",
|
||||
listboxWrapper: "max-h-[300px] bg-bg-neutral-secondary",
|
||||
listbox: "gap-0",
|
||||
label:
|
||||
"tracking-tight font-light !text-text-neutral-secondary text-xs z-0!",
|
||||
value: "text-text-neutral-secondary text-small",
|
||||
}}
|
||||
renderValue={(items) => {
|
||||
if (!isMultiple && value) {
|
||||
const provider = providers.find((p) => p.id === value);
|
||||
if (provider) {
|
||||
const displayName =
|
||||
provider.attributes.alias || provider.attributes.uid;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<span className="text-default-500">{placeholder}</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
if (items.length === 1) {
|
||||
const provider = providers.find(
|
||||
(p) => p.id === items[0].key,
|
||||
);
|
||||
if (provider) {
|
||||
const displayName =
|
||||
provider.attributes.alias ||
|
||||
provider.attributes.uid;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-small">
|
||||
{items.length} provider{items.length !== 1 ? "s" : ""}{" "}
|
||||
selected
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}}
|
||||
listboxProps={{
|
||||
topContent: enableSearch ? (
|
||||
<div className="sticky top-0 z-10 py-2">
|
||||
<Input
|
||||
isClearable
|
||||
placeholder="Search providers..."
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
startContent={<Search size={16} />}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
onClear={() => setSearchValue("")}
|
||||
classNames={{
|
||||
inputWrapper:
|
||||
"border-border-input-primary bg-bg-input-primary hover:bg-bg-neutral-secondary",
|
||||
input: "text-small",
|
||||
clearButton: "text-default-400",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
>
|
||||
{filteredProviders.map((provider) => {
|
||||
const providerType = provider.attributes.provider;
|
||||
const displayName =
|
||||
provider.attributes.alias || provider.attributes.uid;
|
||||
const typeLabel = providerTypeLabels[providerType];
|
||||
const isDisabled = disabledProviderIds.includes(
|
||||
provider.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
key={provider.id}
|
||||
textValue={`${displayName} ${typeLabel}`}
|
||||
className={`py-2 ${isDisabled ? "pointer-events-none cursor-not-allowed opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-small truncate font-medium">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-tiny text-text-neutral-secondary truncate">
|
||||
{typeLabel}
|
||||
{isDisabled && (
|
||||
<span className="text-text-error ml-2">
|
||||
(Already used)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
provider.attributes.connection.connected
|
||||
? "bg-bg-pass"
|
||||
: "bg-bg-fail"
|
||||
}`}
|
||||
title={
|
||||
provider.attributes.connection.connected
|
||||
? "Connected"
|
||||
: "Disconnected"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
</FormControl>
|
||||
{showFormMessage && (
|
||||
<FormMessage className="text-text-error max-w-full text-xs" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+47
-41
@@ -1,11 +1,17 @@
|
||||
import { Chip } from "@heroui/chip";
|
||||
import { Divider } from "@heroui/divider";
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { Switch } from "@heroui/switch";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, UseFormSetValue, useWatch } from "react-hook-form";
|
||||
|
||||
import { CredentialsRoleHelper } from "@/components/providers/workflow";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { AWSCredentialsRole } from "@/types";
|
||||
@@ -77,47 +83,47 @@ export const AWSRoleCredentialsForm = ({
|
||||
Specify which AWS credentials to use
|
||||
</span>
|
||||
|
||||
<Select
|
||||
name={ProviderCredentialFields.CREDENTIALS_TYPE}
|
||||
label="Authentication Method"
|
||||
placeholder="Select credentials type"
|
||||
selectedKeys={[credentialsType || defaultCredentialsType]}
|
||||
className="mb-4"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) =>
|
||||
setValue(
|
||||
ProviderCredentialFields.CREDENTIALS_TYPE,
|
||||
Array.from(keys)[0] as "aws-sdk-default" | "access-secret-key",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectItem
|
||||
key="aws-sdk-default"
|
||||
textValue={
|
||||
isCloudEnv
|
||||
? "Prowler Cloud will assume your IAM role"
|
||||
: "AWS SDK Default"
|
||||
}
|
||||
<div className="mb-4 flex flex-col gap-1.5">
|
||||
<Select
|
||||
value={credentialsType || defaultCredentialsType}
|
||||
onValueChange={(value) => {
|
||||
setValue(
|
||||
ProviderCredentialFields.CREDENTIALS_TYPE,
|
||||
value as "aws-sdk-default" | "access-secret-key",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>
|
||||
{isCloudEnv
|
||||
? "Prowler Cloud will assume your IAM role"
|
||||
: "AWS SDK Default"}
|
||||
</span>
|
||||
{isCloudEnv && (
|
||||
<Chip size="sm" variant="flat" color="success" className="ml-2">
|
||||
Recommended
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem key="access-secret-key" textValue="Access & Secret Key">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>Access & Secret Key</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</Select>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select credentials type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[60]">
|
||||
<SelectItem value="aws-sdk-default">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>
|
||||
{isCloudEnv
|
||||
? "Prowler Cloud will assume your IAM role"
|
||||
: "AWS SDK Default"}
|
||||
</span>
|
||||
{isCloudEnv && (
|
||||
<Chip
|
||||
size="sm"
|
||||
variant="flat"
|
||||
color="success"
|
||||
className="ml-2"
|
||||
>
|
||||
Recommended
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="access-secret-key">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>Access & Secret Key</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{credentialsType === "access-secret-key" && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,8 +12,9 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { addRole } from "@/actions/roles/roles";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import { getErrorMessage, permissionFormFields } from "@/lib";
|
||||
import { addRoleFormSchema, ApiError } from "@/types";
|
||||
@@ -232,15 +233,21 @@ export const AddRoleForm = ({
|
||||
name="groups"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CustomDropdownSelection
|
||||
label="Select Groups"
|
||||
name="groups"
|
||||
values={groups}
|
||||
selectedKeys={field.value || []}
|
||||
onChange={(name, selectedValues) =>
|
||||
field.onChange(selectedValues)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<EnhancedMultiSelect
|
||||
options={groups.map((group) => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
}))}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || []}
|
||||
placeholder="Select groups"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
emptyIndicator="No results found"
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.groups && (
|
||||
|
||||
@@ -12,8 +12,9 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { updateRole } from "@/actions/roles/roles";
|
||||
import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomDropdownSelection, CustomInput } from "@/components/ui/custom";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
import { getErrorMessage, permissionFormFields } from "@/lib";
|
||||
import { ApiError, editRoleFormSchema } from "@/types";
|
||||
@@ -250,15 +251,21 @@ export const EditRoleForm = ({
|
||||
name="groups"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<CustomDropdownSelection
|
||||
label="Select Groups"
|
||||
name="groups"
|
||||
values={groups}
|
||||
selectedKeys={field.value}
|
||||
onChange={(name, selectedValues) => {
|
||||
field.onChange(selectedValues);
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<EnhancedMultiSelect
|
||||
options={groups.map((group) => ({
|
||||
label: group.name,
|
||||
value: group.id,
|
||||
}))}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || []}
|
||||
placeholder="Select groups"
|
||||
searchable={true}
|
||||
hideSelectAll={true}
|
||||
emptyIndicator="No results found"
|
||||
resetOnDefaultValueChange={true}
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
tag: "bg-bg-tag border-border-tag text-text-neutral-primary",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -66,7 +66,7 @@ function CommandInput({
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
className="border-border-neutral-primary flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Drawer as DrawerPrimitive } from "vaul";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({ ...props }: ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
return <DrawerPrimitive.Root data-slot="drawer" handleOnly {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
|
||||
@@ -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,9 @@ export function ActionDropdownItem({
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2",
|
||||
destructive && "text-destructive focus:text-destructive",
|
||||
"hover:bg-bg-neutral-tertiary flex cursor-pointer items-start gap-2 rounded-md transition-colors",
|
||||
destructive &&
|
||||
"text-text-error-primary focus:text-text-error-primary hover:bg-destructive/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -100,8 +91,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 +104,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 +115,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,
|
||||
|
||||
@@ -20,16 +20,19 @@ function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
container,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Portal container={container ?? undefined}>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 pointer-events-auto z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, XCircle, XIcon } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { Badge } from "@/components/shadcn/badge/badge";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { Checkbox } from "@/components/shadcn/checkbox/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/shadcn/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import { Separator } from "@/components/shadcn/separator/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MultiSelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: ReactNode;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface EnhancedMultiSelectProps {
|
||||
options: MultiSelectOption[];
|
||||
onValueChange: (values: string[]) => void;
|
||||
defaultValue?: string[];
|
||||
placeholder?: string;
|
||||
searchable?: boolean;
|
||||
hideSelectAll?: boolean;
|
||||
maxCount?: number;
|
||||
closeOnSelect?: boolean;
|
||||
resetOnDefaultValueChange?: boolean;
|
||||
emptyIndicator?: ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
id?: string;
|
||||
"aria-label"?: string;
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((val, index) => val === sortedB[index]);
|
||||
}
|
||||
|
||||
export function EnhancedMultiSelect({
|
||||
options,
|
||||
onValueChange,
|
||||
defaultValue = [],
|
||||
placeholder = "Select options",
|
||||
searchable = true,
|
||||
hideSelectAll = false,
|
||||
maxCount = 3,
|
||||
closeOnSelect = false,
|
||||
resetOnDefaultValueChange = true,
|
||||
emptyIndicator,
|
||||
disabled = false,
|
||||
className,
|
||||
id,
|
||||
"aria-label": ariaLabel,
|
||||
}: EnhancedMultiSelectProps) {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>(defaultValue);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const prevDefaultValueRef = useRef<string[]>(defaultValue);
|
||||
const selectedAtOpenRef = useRef<string[]>(selectedValues);
|
||||
const multiSelectId = useId();
|
||||
const listboxId = `${multiSelectId}-listbox`;
|
||||
|
||||
// Detect dialog container for portal stacking (critical for Jira modal)
|
||||
useEffect(() => {
|
||||
if (!buttonRef.current) return;
|
||||
const closestDialogContainer = buttonRef.current.closest(
|
||||
"[data-slot='dialog-content'], [data-slot='modal-content'], [role='dialog']",
|
||||
);
|
||||
setPortalContainer(
|
||||
closestDialogContainer instanceof HTMLElement
|
||||
? closestDialogContainer
|
||||
: null,
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Reset when defaultValue changes externally (e.g. React Hook Form reset)
|
||||
useEffect(() => {
|
||||
if (!resetOnDefaultValueChange) return;
|
||||
const prev = prevDefaultValueRef.current;
|
||||
if (!arraysEqual(prev, defaultValue)) {
|
||||
if (!arraysEqual(selectedValues, defaultValue)) {
|
||||
setSelectedValues(defaultValue);
|
||||
}
|
||||
prevDefaultValueRef.current = [...defaultValue];
|
||||
}
|
||||
}, [defaultValue, selectedValues, resetOnDefaultValueChange]);
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (nextOpen) {
|
||||
selectedAtOpenRef.current = [...selectedValues];
|
||||
} else {
|
||||
setSearch("");
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
}
|
||||
|
||||
const enabledOptions = options.filter((o) => !o.disabled);
|
||||
|
||||
const filteredOptions = (
|
||||
searchable && search
|
||||
? options.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
o.value.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: options
|
||||
).toSorted((a, b) => {
|
||||
const snapshot = selectedAtOpenRef.current;
|
||||
const aSelected = snapshot.includes(a.value) ? 0 : 1;
|
||||
const bSelected = snapshot.includes(b.value) ? 0 : 1;
|
||||
return aSelected - bSelected;
|
||||
});
|
||||
|
||||
function getOptionByValue(value: string) {
|
||||
return options.find((o) => o.value === value);
|
||||
}
|
||||
|
||||
function toggleOption(value: string) {
|
||||
if (disabled) return;
|
||||
const option = getOptionByValue(value);
|
||||
if (option?.disabled) return;
|
||||
const next = selectedValues.includes(value)
|
||||
? selectedValues.filter((v) => v !== value)
|
||||
: [...selectedValues, value];
|
||||
setSelectedValues(next);
|
||||
onValueChange(next);
|
||||
if (closeOnSelect) setOpen(false);
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
if (disabled) return;
|
||||
if (selectedValues.length === enabledOptions.length) {
|
||||
handleClear();
|
||||
} else {
|
||||
const all = enabledOptions.map((o) => o.value);
|
||||
setSelectedValues(all);
|
||||
onValueChange(all);
|
||||
}
|
||||
if (closeOnSelect) setOpen(false);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (disabled) return;
|
||||
setSelectedValues([]);
|
||||
onValueChange([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
ref={buttonRef}
|
||||
variant="outline"
|
||||
onClick={() => !disabled && setOpen((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-controls={open ? listboxId : undefined}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"border-border-input-primary bg-bg-input-primary text-text-neutral-primary data-[placeholder]:text-text-neutral-tertiary [&_svg:not([class*='text-'])]:text-text-neutral-tertiary aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:bg-bg-input-primary active:bg-bg-input-primary focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press flex h-auto min-h-12 w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-1 focus-visible:ring-offset-1 [&_svg]:pointer-events-auto",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{selectedValues.length > 0 ? (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{selectedValues
|
||||
.slice(0, maxCount)
|
||||
.map((value) => {
|
||||
const option = getOptionByValue(value);
|
||||
if (!option) return null;
|
||||
return (
|
||||
<Badge
|
||||
key={value}
|
||||
variant="tag"
|
||||
className="m-1 cursor-default [&>svg]:pointer-events-auto"
|
||||
>
|
||||
<span className="cursor-default">{option.label}</span>
|
||||
<span
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleOption(value);
|
||||
}}
|
||||
aria-label={`Remove ${option.label} from selection`}
|
||||
className="focus:ring-border-input-primary-press -m-0.5 ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm p-0.5 focus:ring-1 focus:outline-none"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
{selectedValues.length > maxCount && (
|
||||
<Badge
|
||||
variant="tag"
|
||||
className="m-1 cursor-default [&>svg]:pointer-events-auto"
|
||||
>
|
||||
{`+ ${selectedValues.length - maxCount} more`}
|
||||
<span
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const trimmed = selectedValues.slice(0, maxCount);
|
||||
setSelectedValues(trimmed);
|
||||
onValueChange(trimmed);
|
||||
}}
|
||||
className="ml-2 inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center self-center rounded-sm"
|
||||
aria-label="Clear extra selected options"
|
||||
>
|
||||
<XCircle className="h-3 w-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}
|
||||
}}
|
||||
aria-label={`Clear all ${selectedValues.length} selected options`}
|
||||
className="text-text-neutral-tertiary hover:text-text-neutral-primary focus:ring-border-input-primary-press mx-2 flex h-4 w-4 cursor-pointer items-center justify-center rounded-sm focus:ring-2 focus:ring-offset-1 focus:outline-none"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="flex h-full min-h-6"
|
||||
/>
|
||||
<ChevronDown
|
||||
className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex w-full items-center justify-between">
|
||||
<span className="text-text-neutral-tertiary mx-3 text-sm">
|
||||
{placeholder}
|
||||
</span>
|
||||
<ChevronDown className="text-text-neutral-tertiary mx-2 h-4 cursor-pointer" />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
container={portalContainer}
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
aria-multiselectable="true"
|
||||
aria-label="Available options"
|
||||
className="border-border-input-primary bg-bg-input-primary text-text-neutral-primary pointer-events-auto z-50 w-[var(--radix-popover-trigger-width)] max-w-[var(--radix-popover-trigger-width)] touch-manipulation rounded-lg p-0"
|
||||
align="start"
|
||||
onEscapeKeyDown={() => setOpen(false)}
|
||||
>
|
||||
<Command>
|
||||
{searchable && (
|
||||
<CommandInput
|
||||
placeholder="Search options..."
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
aria-label="Search through available options"
|
||||
/>
|
||||
)}
|
||||
<CommandList className="minimal-scrollbar multiselect-scrollbar max-h-[40vh] overflow-x-hidden overflow-y-auto overscroll-y-contain">
|
||||
<CommandEmpty>{emptyIndicator || "No results found."}</CommandEmpty>
|
||||
{!hideSelectAll && !search && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
key="all"
|
||||
onSelect={toggleAll}
|
||||
role="option"
|
||||
aria-selected={
|
||||
selectedValues.length === enabledOptions.length
|
||||
}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedValues.length === enabledOptions.length}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none mr-2 size-4"
|
||||
/>
|
||||
<span>Select All</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.includes(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => toggleOption(option.value)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={option.disabled}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
option.disabled && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={option.disabled}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none mr-2 size-4"
|
||||
/>
|
||||
{option.icon && (
|
||||
<span className="shrink-0">{option.icon}</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between p-1">
|
||||
{selectedValues.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="flex-1"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="flex h-full min-h-6"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
EnhancedMultiSelect.displayName = "EnhancedMultiSelect";
|
||||
export type { EnhancedMultiSelectProps, MultiSelectOption };
|
||||
@@ -224,9 +224,9 @@ export function MultiSelectValue({
|
||||
.filter((value) => items.has(value))
|
||||
.map((value) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
variant="tag"
|
||||
data-selected-item
|
||||
className="text-bg-button-secondary group flex items-center gap-1.5 border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800"
|
||||
className="group flex items-center gap-1.5 px-2 py-1 text-xs font-medium"
|
||||
key={value}
|
||||
onClick={
|
||||
clickToRemove
|
||||
@@ -239,7 +239,7 @@ export function MultiSelectValue({
|
||||
>
|
||||
{items.get(value)}
|
||||
{clickToRemove && (
|
||||
<XIcon className="text-bg-button-secondary group-hover:text-destructive size-3 transition-colors" />
|
||||
<XIcon className="text-text-neutral-primary group-hover:text-destructive size-3 transition-colors" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -247,9 +247,9 @@ export function MultiSelectValue({
|
||||
style={{
|
||||
display: overflowAmount > 0 && !shouldWrap ? "block" : "none",
|
||||
}}
|
||||
variant="outline"
|
||||
variant="tag"
|
||||
ref={overflowRef}
|
||||
className="text-bg-button-secondary border-slate-300 bg-slate-100 px-2 py-1 text-xs font-medium dark:border-slate-600 dark:bg-slate-800"
|
||||
className="px-2 py-1 text-xs font-medium"
|
||||
>
|
||||
+{overflowAmount}
|
||||
</Badge>
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
|
||||
interface CustomDropdownSelectionProps {
|
||||
label: string;
|
||||
name: string;
|
||||
values: { id: string; name: string }[];
|
||||
onChange: (name: string, selectedValues: string[]) => void;
|
||||
selectedKeys?: string[];
|
||||
}
|
||||
|
||||
export const CustomDropdownSelection: React.FC<
|
||||
CustomDropdownSelectionProps
|
||||
> = ({ label, name, values, onChange, selectedKeys = [] }) => {
|
||||
const handleValuesChange = useCallback(
|
||||
(newValues: string[]) => {
|
||||
onChange(name, newValues);
|
||||
},
|
||||
[name, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<MultiSelect values={selectedKeys} onValuesChange={handleValuesChange}>
|
||||
<MultiSelectTrigger>
|
||||
<MultiSelectValue placeholder={`Select ${label.toLowerCase()}`} />
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={{
|
||||
placeholder: `Search ${label.toLowerCase()}...`,
|
||||
emptyMessage: "No results found",
|
||||
}}
|
||||
>
|
||||
{values.map((item) => (
|
||||
<MultiSelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,5 +1,4 @@
|
||||
export * from "./custom-banner";
|
||||
export * from "./custom-dropdown-selection";
|
||||
export * from "./custom-input";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-modal-buttons";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Select, SelectItem } from "@heroui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ShieldIcon, UserIcon } from "lucide-react";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
@@ -9,6 +8,13 @@ import * as z from "zod";
|
||||
|
||||
import { updateUser, updateUserRole } from "@/actions/users/users";
|
||||
import { Card } from "@/components/shadcn";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn/select/select";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { CustomInput } from "@/components/ui/custom";
|
||||
import { Form, FormButtons } from "@/components/ui/form";
|
||||
@@ -168,29 +174,22 @@ export const EditForm = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Controller
|
||||
name="role"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
label="Role"
|
||||
labelPlacement="outside"
|
||||
placeholder="Select a role"
|
||||
classNames={{
|
||||
selectorIcon: "right-2",
|
||||
}}
|
||||
variant="bordered"
|
||||
selectedKeys={[field.value || ""]}
|
||||
onSelectionChange={(selected) => {
|
||||
const selectedKey = Array.from(selected).pop();
|
||||
field.onChange(selectedKey || "");
|
||||
}}
|
||||
>
|
||||
{roles.map((role: { id: string; name: string }) => (
|
||||
<SelectItem key={role.id}>{role.name}</SelectItem>
|
||||
))}
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role: { id: string; name: string }) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -396,3 +396,10 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override vaul's injected user-select: none to allow text selection in drawers */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-vaul-drawer][data-vaul-drawer] {
|
||||
user-select: text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class InvitationsPage extends BasePage {
|
||||
this.emailInput = page.getByRole("textbox", { name: "Email" });
|
||||
|
||||
// Form select
|
||||
this.roleSelect = page.getByRole("button", { name: /Role|Select a role/i });
|
||||
this.roleSelect = page.getByRole("combobox", { name: /Role|Select a role/i });
|
||||
|
||||
// Form details
|
||||
this.reviewInvitationDetailsButton = page.getByRole('button', { name: /Review Invitation Details/i });
|
||||
|
||||
@@ -990,19 +990,16 @@ export class ProvidersPage extends BasePage {
|
||||
}
|
||||
|
||||
async selectAuthenticationMethod(method: AWSCredentialType): Promise<void> {
|
||||
// Select the authentication method
|
||||
// Select the authentication method (shadcn Select renders as combobox + listbox)
|
||||
|
||||
const button = this.page.locator("button").filter({
|
||||
const trigger = this.page.locator('[role="combobox"]').filter({
|
||||
hasText: /AWS SDK Default|Prowler Cloud will assume|Access & Secret Key/i,
|
||||
});
|
||||
|
||||
await button.click();
|
||||
await trigger.click();
|
||||
|
||||
const modal = this.page
|
||||
.locator('[role="dialog"], .modal, [data-testid*="modal"]')
|
||||
.first();
|
||||
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
const listbox = this.page.getByRole("listbox");
|
||||
await expect(listbox).toBeVisible({ timeout: 10000 });
|
||||
|
||||
if (method === AWS_CREDENTIAL_OPTIONS.AWS_ROLE_ARN) {
|
||||
await this.page
|
||||
|
||||
@@ -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