From 786059bfb24abe690bbcba2cdc9f4ef8431acf5e Mon Sep 17 00:00:00 2001 From: Prowler Bot Date: Tue, 5 May 2026 10:45:07 +0200 Subject: [PATCH 001/187] chore(docs): Bump version to v5.25.2 (#10993) Co-authored-by: prowler-bot <179230569+prowler-bot@users.noreply.github.com> --- docs/getting-started/installation/prowler-app.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/getting-started/installation/prowler-app.mdx b/docs/getting-started/installation/prowler-app.mdx index 0b9d15852b..cf88d1aded 100644 --- a/docs/getting-started/installation/prowler-app.mdx +++ b/docs/getting-started/installation/prowler-app.mdx @@ -121,8 +121,8 @@ To update the environment file: Edit the `.env` file and change version values: ```env -PROWLER_UI_VERSION="5.25.1" -PROWLER_API_VERSION="5.25.1" +PROWLER_UI_VERSION="5.25.2" +PROWLER_API_VERSION="5.25.2" ``` From d23c2f3b5317ca8aa1bb3031093b7ba85941f172 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Tue, 5 May 2026 14:39:54 +0200 Subject: [PATCH 002/187] refactor(ui): standardize "Providers" wording across UI and docs (#10971) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.7 (1M context) --- docs/developer-guide/provider.mdx | 2 +- .../basic-usage/prowler-app.mdx | 6 +-- .../getting-started-alibabacloud.mdx | 8 +-- .../providers/aws/getting-started-aws.mdx | 8 +-- .../providers/azure/getting-started-azure.mdx | 8 +-- .../cloudflare/getting-started-cloudflare.mdx | 8 +-- .../providers/gcp/getting-started-gcp.mdx | 8 +-- .../providers/github/authentication.mdx | 2 +- .../github/getting-started-github.mdx | 8 +-- .../getting-started-googleworkspace.mdx | 8 +-- .../providers/iac/getting-started-iac.mdx | 8 +-- .../providers/image/getting-started-image.mdx | 8 +-- .../kubernetes/getting-started-k8s.mdx | 8 +-- .../microsoft365/getting-started-m365.mdx | 8 +-- .../getting-started-mongodbatlas.mdx | 2 +- .../providers/oci/getting-started-oci.mdx | 4 +- .../openstack/getting-started-openstack.mdx | 2 +- .../vercel/getting-started-vercel.mdx | 8 +-- .../user-guide/tutorials/prowler-app-rbac.mdx | 4 +- .../tutorials/prowler-app-s3-integration.mdx | 2 +- docs/user-guide/tutorials/prowler-app.mdx | 4 +- .../prowler-cloud-aws-organizations.mdx | 4 +- ui/CHANGELOG.md | 8 +++ ui/actions/manage-groups/manage-groups.ts | 6 +-- .../_components/accounts-selector.tsx | 6 +-- .../_components/provider-type-selector.tsx | 3 +- .../_components/scan-list-table.tsx | 2 +- ui/app/(prowler)/manage-groups/page.tsx | 4 +- ui/app/(prowler)/providers/page.test.ts | 2 +- ui/app/(prowler)/providers/page.tsx | 22 ++++---- ...ontent.tsx => provider-groups-content.tsx} | 10 ++-- .../providers/provider-page-tabs.shared.ts | 8 +-- .../providers/provider-page-tabs.test.tsx | 51 +++++++++---------- .../providers/provider-page-tabs.tsx | 24 ++++----- .../compliance/compliance-card.test.tsx | 4 +- ui/components/filters/data-filters.ts | 4 +- .../table/column-standalone-findings.tsx | 2 +- .../manage-groups/forms/delete-group-form.tsx | 2 +- .../manage-groups/forms/edit-group-form.tsx | 4 +- .../manage-groups/manage-groups-button.tsx | 2 +- .../table/data-table-row-actions.tsx | 6 +-- .../table/skeleton-table-new-findings.tsx | 2 +- .../providers/table/column-providers.tsx | 6 +-- .../table/skeleton-table-provider.tsx | 8 +-- .../wizard/provider-wizard-modal.tsx | 2 +- .../provider-wizard-modal.utils.test.ts | 2 +- .../wizard/provider-wizard-modal.utils.ts | 4 +- .../providers/wizard/wizard-stepper.tsx | 2 +- .../resources/table/column-resources.tsx | 2 +- .../launch-workflow/select-scan-provider.tsx | 6 +-- ui/components/scans/no-providers-added.tsx | 8 +-- .../scans/no-providers-connected.tsx | 12 ++--- .../scans/table/scans/column-get-scans.tsx | 2 +- ui/lib/helper.ts | 5 +- ui/lib/menu-list.ts | 2 +- ui/tests/providers/providers-page.ts | 10 ++-- ui/tests/scans/scans-page.ts | 4 +- .../manage-cloud-providers.auth.setup.ts | 2 +- 58 files changed, 191 insertions(+), 186 deletions(-) rename ui/app/(prowler)/providers/{account-groups-content.tsx => provider-groups-content.tsx} (93%) diff --git a/docs/developer-guide/provider.mdx b/docs/developer-guide/provider.mdx index ec3e106150..f7a1057d11 100644 --- a/docs/developer-guide/provider.mdx +++ b/docs/developer-guide/provider.mdx @@ -1003,7 +1003,7 @@ class ProwlerArgumentParser: formatter_class=RawTextHelpFormatter, usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,dashboard,iac,your_provider} ...", epilog=""" -Available Cloud Providers: +Available Providers: {aws,azure,gcp,kubernetes,m365,github,iac,nhn,your_provider} aws AWS Provider azure Azure Provider diff --git a/docs/getting-started/basic-usage/prowler-app.mdx b/docs/getting-started/basic-usage/prowler-app.mdx index 26d7d8ee2e..bc39353dcc 100644 --- a/docs/getting-started/basic-usage/prowler-app.mdx +++ b/docs/getting-started/basic-usage/prowler-app.mdx @@ -32,11 +32,11 @@ Access Prowler App by logging in with **email and password**. Log In -## Add Cloud Provider +## Add Provider -Configure a cloud provider for scanning: +Configure a provider for scanning: -1. Navigate to `Settings > Cloud Providers` and click `Add Account`. +1. Navigate to `Settings > Providers` and click `Add Provider`. 2. Select the cloud provider. 3. Enter the provider's identifier (Optional: Add an alias): - **AWS**: Account ID diff --git a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx index a3de5fc8fa..36520a8f8c 100644 --- a/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx +++ b/docs/user-guide/providers/alibabacloud/getting-started-alibabacloud.mdx @@ -40,13 +40,13 @@ Before you begin, make sure you have: ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Alibaba Cloud" diff --git a/docs/user-guide/providers/aws/getting-started-aws.mdx b/docs/user-guide/providers/aws/getting-started-aws.mdx index 8127e873e7..f0c5ab882a 100644 --- a/docs/user-guide/providers/aws/getting-started-aws.mdx +++ b/docs/user-guide/providers/aws/getting-started-aws.mdx @@ -19,13 +19,13 @@ title: 'Getting Started With AWS on Prowler' ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Amazon Web Services" diff --git a/docs/user-guide/providers/azure/getting-started-azure.mdx b/docs/user-guide/providers/azure/getting-started-azure.mdx index 456c226aab..66b3b14e3a 100644 --- a/docs/user-guide/providers/azure/getting-started-azure.mdx +++ b/docs/user-guide/providers/azure/getting-started-azure.mdx @@ -35,13 +35,13 @@ For detailed instructions on how to create the Service Principal and configure p ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Navigate to `Configuration` > `Cloud Providers` +2. Navigate to `Configuration` > `Providers` - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click on `Add Cloud Provider` +3. Click on `Add Provider` - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select `Microsoft Azure` diff --git a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx index 2bc18bcca8..00320bd34f 100644 --- a/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx +++ b/docs/user-guide/providers/cloudflare/getting-started-cloudflare.mdx @@ -42,13 +42,13 @@ The Account ID is a 32-character hexadecimal string (e.g., `372e67954025e0ba6aaa ### Step 2: Open Prowler Cloud 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Cloudflare". diff --git a/docs/user-guide/providers/gcp/getting-started-gcp.mdx b/docs/user-guide/providers/gcp/getting-started-gcp.mdx index c70250c8a9..e16114e19a 100644 --- a/docs/user-guide/providers/gcp/getting-started-gcp.mdx +++ b/docs/user-guide/providers/gcp/getting-started-gcp.mdx @@ -14,13 +14,13 @@ title: 'Getting Started With GCP on Prowler' ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Google Cloud Platform" diff --git a/docs/user-guide/providers/github/authentication.mdx b/docs/user-guide/providers/github/authentication.mdx index adbfd235a8..0b5ab6b720 100644 --- a/docs/user-guide/providers/github/authentication.mdx +++ b/docs/user-guide/providers/github/authentication.mdx @@ -275,7 +275,7 @@ For step-by-step setup instructions for Prowler Cloud, see the [Getting Started ### Using Personal Access Token -1. In Prowler Cloud, navigate to **Configuration** > **Cloud Providers** > **Add Cloud Provider** > **GitHub**. +1. In Prowler Cloud, navigate to **Configuration** > **Providers** > **Add Provider** > **GitHub**. 2. Enter your GitHub Account ID (username or organization name). diff --git a/docs/user-guide/providers/github/getting-started-github.mdx b/docs/user-guide/providers/github/getting-started-github.mdx index 3211d7058d..e946660b51 100644 --- a/docs/user-guide/providers/github/getting-started-github.mdx +++ b/docs/user-guide/providers/github/getting-started-github.mdx @@ -49,13 +49,13 @@ Before adding GitHub to Prowler Cloud/App, ensure you have: ### Step 1: Access Prowler Cloud/App 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to **Configuration** → **Cloud Providers** +2. Go to **Configuration** → **Providers** - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click **Add Cloud Provider** +3. Click **Add Provider** - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select **GitHub** diff --git a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx index af09ab75c3..8931c43ebd 100644 --- a/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx +++ b/docs/user-guide/providers/googleworkspace/getting-started-googleworkspace.mdx @@ -43,13 +43,13 @@ The Customer ID starts with the letter "C" followed by alphanumeric characters ( ### Step 2: Open Prowler Cloud 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Google Workspace". diff --git a/docs/user-guide/providers/iac/getting-started-iac.mdx b/docs/user-guide/providers/iac/getting-started-iac.mdx index 2a1e588018..d1e978dc75 100644 --- a/docs/user-guide/providers/iac/getting-started-iac.mdx +++ b/docs/user-guide/providers/iac/getting-started-iac.mdx @@ -42,13 +42,13 @@ Scanner selection is not configurable in Prowler App. Default scanners, misconfi ### Step 1: Access Prowler Cloud/App 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Infrastructure as Code" diff --git a/docs/user-guide/providers/image/getting-started-image.mdx b/docs/user-guide/providers/image/getting-started-image.mdx index 9a3d67258d..b9c305d0ef 100644 --- a/docs/user-guide/providers/image/getting-started-image.mdx +++ b/docs/user-guide/providers/image/getting-started-image.mdx @@ -34,13 +34,13 @@ Prowler Cloud does not support scanner selection. The vulnerability, secret, and ### Step 1: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Navigate to "Configuration" > "Cloud Providers" +2. Navigate to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Container Registry" diff --git a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx index aff63b81a3..9ca791110f 100644 --- a/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx +++ b/docs/user-guide/providers/kubernetes/getting-started-k8s.mdx @@ -7,13 +7,13 @@ title: 'Getting Started with Kubernetes' ### Step 1: Access Prowler Cloud/App 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app) -2. Go to "Configuration" > "Cloud Providers" +2. Go to "Configuration" > "Providers" - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider" +3. Click "Add Provider" - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Kubernetes" diff --git a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx index 1e6830c722..a21b796b5c 100644 --- a/docs/user-guide/providers/microsoft365/getting-started-m365.mdx +++ b/docs/user-guide/providers/microsoft365/getting-started-m365.mdx @@ -42,13 +42,13 @@ Set up authentication for Microsoft 365 with the [Microsoft 365 Authentication]( ### Step 2: Open Prowler Cloud 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Microsoft 365". diff --git a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx index c10c6aac30..c68bfac9c2 100644 --- a/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx +++ b/docs/user-guide/providers/mongodbatlas/getting-started-mongodbatlas.mdx @@ -38,7 +38,7 @@ If **Require IP Access List for the Atlas Administration API** is enabled in you ### Step 1: Add the provider -1. Navigate to **Cloud Providers** and click **Add Cloud Provider**. +1. Navigate to **Providers** and click **Add Provider**. ![Add provider list](./img/add-provider-list.png) 2. Select **MongoDB Atlas** from the provider list. 3. Enter your **Organization ID** (24 hex characters). This value is visible in the Atlas UI under **Organization Settings**. diff --git a/docs/user-guide/providers/oci/getting-started-oci.mdx b/docs/user-guide/providers/oci/getting-started-oci.mdx index 459d9ad685..d3024bc1c1 100644 --- a/docs/user-guide/providers/oci/getting-started-oci.mdx +++ b/docs/user-guide/providers/oci/getting-started-oci.mdx @@ -16,8 +16,8 @@ The following steps apply to Prowler Cloud and the self-hosted Prowler App. ### Step 2: Access Prowler Cloud 1. Navigate to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Go to **Configuration** → **Cloud Providers** and click **Add Cloud Provider**. -![Add OCI Cloud Provider](./images/oci-add-cloud-provider.png) +2. Go to **Configuration** → **Providers** and click **Add Provider**. +![Add OCI Provider](./images/oci-add-cloud-provider.png) 3. Select **Oracle Cloud** and enter the **Tenancy OCID** and an optional alias, then choose **Next**. ![Add OCI Cloud Tenancy](./images/oci-add-tenancy.png) diff --git a/docs/user-guide/providers/openstack/getting-started-openstack.mdx b/docs/user-guide/providers/openstack/getting-started-openstack.mdx index b80ebe0e9f..1ff80c3e2e 100644 --- a/docs/user-guide/providers/openstack/getting-started-openstack.mdx +++ b/docs/user-guide/providers/openstack/getting-started-openstack.mdx @@ -34,7 +34,7 @@ Before running Prowler with the OpenStack provider, ensure you have: ### Step 1: Add the Provider -1. Navigate to "Cloud Providers" and click "Add Cloud Provider". +1. Navigate to "Providers" and click "Add Provider". ![Providers List](./images/select-provider.png) 2. Select "OpenStack" from the provider list. 3. Enter the "Project ID" from the OpenStack provider. diff --git a/docs/user-guide/providers/vercel/getting-started-vercel.mdx b/docs/user-guide/providers/vercel/getting-started-vercel.mdx index 8a6fdecc22..c39c5f1e6a 100644 --- a/docs/user-guide/providers/vercel/getting-started-vercel.mdx +++ b/docs/user-guide/providers/vercel/getting-started-vercel.mdx @@ -29,13 +29,13 @@ Set up authentication for Vercel with the [Vercel Authentication](/user-guide/pr ### Step 1: Add the Provider 1. Go to [Prowler Cloud](https://cloud.prowler.com/) or launch [Prowler App](/user-guide/tutorials/prowler-app). -2. Navigate to "Configuration" > "Cloud Providers". +2. Navigate to "Configuration" > "Providers". - ![Cloud Providers Page](/images/prowler-app/cloud-providers-page.png) + ![Providers Page](/images/prowler-app/cloud-providers-page.png) -3. Click "Add Cloud Provider". +3. Click "Add Provider". - ![Add a Cloud Provider](/images/prowler-app/add-cloud-provider.png) + ![Add a Provider](/images/prowler-app/add-cloud-provider.png) 4. Select "Vercel". diff --git a/docs/user-guide/tutorials/prowler-app-rbac.mdx b/docs/user-guide/tutorials/prowler-app-rbac.mdx index 31fd4a730e..cccbbc27cc 100644 --- a/docs/user-guide/tutorials/prowler-app-rbac.mdx +++ b/docs/user-guide/tutorials/prowler-app-rbac.mdx @@ -123,7 +123,7 @@ The Roles section in Prowler is designed to facilitate the assignment of custom ### Provider Groups -Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Cloud Provider visibility. This ensures that users with that role have access only to the Cloud Providers that are required. +Provider Groups control visibility across specific providers. When creating a new role, you can assign specific groups to define their Provider visibility. This ensures that users with that role have access only to the Providers that are required. By default, a new user role does not have visibility into any group. @@ -223,7 +223,7 @@ Assign administrative permissions by selecting from the following options: | Invite and Manage Users | All | Invite new users and manage existing ones. | | Manage Account | All | Adjust account settings, delete users and read/manage users permissions. | | Manage Scans | All | Run and review scans. | -| Manage Cloud Providers | All | Add or modify connected cloud providers. | +| Manage Providers | All | Add or modify connected providers. | | Manage Integrations | All | Add or modify the Prowler Integrations. | | Manage Ingestions | Prowler Cloud | Allow or deny the ability to submit findings ingestion batches via the API. | | Manage Billing | Prowler Cloud | Access and manage billing settings and subscription information. | diff --git a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx index 728e4a4354..284b10eaaf 100644 --- a/docs/user-guide/tutorials/prowler-app-s3-integration.mdx +++ b/docs/user-guide/tutorials/prowler-app-s3-integration.mdx @@ -320,7 +320,7 @@ Once the required permissions are set up, proceed to configure the S3 integratio ![Add integration button](/images/prowler-app/s3/s3-integration-ui-3.png) 4. Complete the configuration form with the following details: - - **Cloud Providers:** Select the providers whose scan results should be exported to this S3 bucket + - **Providers:** Select the providers whose scan results should be exported to this S3 bucket - **Bucket Name:** Enter the name of the target S3 bucket (e.g., `my-security-findings-bucket`) - **Output Directory:** Specify the directory path within the bucket (e.g., `/prowler-findings/`, defaults to `output`) diff --git a/docs/user-guide/tutorials/prowler-app.mdx b/docs/user-guide/tutorials/prowler-app.mdx index 0b368c5ad4..5e99a41ae7 100644 --- a/docs/user-guide/tutorials/prowler-app.mdx +++ b/docs/user-guide/tutorials/prowler-app.mdx @@ -72,8 +72,8 @@ To perform security scans, link a cloud provider account. Prowler supports the f Steps to add a provider: -1. Navigate to `Settings > Cloud Providers`. -2. Click `Add Account` to set up a new provider and provide your credentials. +1. Navigate to `Settings > Providers`. +2. Click `Add Provider` to set up a new provider and provide your credentials. Add Provider diff --git a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx index e56627ee20..d9c17aaa5f 100644 --- a/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx +++ b/docs/user-guide/tutorials/prowler-cloud-aws-organizations.mdx @@ -246,10 +246,10 @@ Now that both roles are deployed — the management account role (Step 1) and th ### Open the Wizard -1. Navigate to **Cloud Providers** and click **Add Cloud Provider**. +1. Navigate to **Providers** and click **Add Provider**. - Cloud Providers page showing the Add Cloud Provider button + Providers page showing the Add Provider button 2. Select **Amazon Web Services** as the provider. diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8b0f8d17e6..994c5c9116 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler UI** are documented in this file. +## [1.26.0] (Prowler UNRELEASED) + +### 🔄 Changed + +- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971) + +--- + ## [1.25.2] (Prowler v5.25.2) ### 🔄 Changed diff --git a/ui/actions/manage-groups/manage-groups.ts b/ui/actions/manage-groups/manage-groups.ts index 8758b66f68..933dabbdbc 100644 --- a/ui/actions/manage-groups/manage-groups.ts +++ b/ui/actions/manage-groups/manage-groups.ts @@ -23,7 +23,7 @@ export const getProviderGroups = async ({ const headers = await getAuthHeaders({ contentType: false }); if (isNaN(Number(page)) || page < 1) - redirect("/providers?tab=account-groups"); + redirect("/providers?tab=provider-groups"); const url = new URL(`${apiBaseUrl}/provider-groups`); @@ -112,7 +112,7 @@ export const createProviderGroup = async (formData: FormData) => { body, }); - return await handleApiResponse(response, "/providers?tab=account-groups"); + return await handleApiResponse(response, "/providers?tab=provider-groups"); } catch (error) { handleApiError(error); } @@ -169,7 +169,7 @@ export const deleteProviderGroup = async (formData: FormData) => { if (!providerGroupId) { return { - errors: [{ detail: "Account Group ID is required." }], + errors: [{ detail: "Provider Group ID is required." }], }; } diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index e134625d98..7d20c96355 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -146,7 +146,7 @@ export function AccountsSelector({ const filterDescription = selectedProviderTypes && selectedProviderTypes.length > 0 ? `Accounts for ${selectedProviderTypes.map(getProviderDisplayName).join(", ")}` - : "All connected cloud provider accounts"; + : "All connected provider accounts"; return (
@@ -155,8 +155,8 @@ export function AccountsSelector({ className="sr-only" id="accounts-label" > - Filter by cloud provider account. {filterDescription}. Select one or - more accounts to view findings. + Filter by provider account. {filterDescription}. Select one or more + accounts to view findings. - Filter by cloud provider type. Select one or more providers to view - findings. + Filter by provider type. Select one or more providers to view findings. ( - + ), cell: ({ row }) => ( { ); const source = readFileSync(columnsPath, "utf8"); - // Account is fixed, Account Groups is fluid (no explicit size) + // Provider is fixed, Provider Groups is fluid (no explicit size) expect(source).toContain("size: 420"); expect(source).toContain("size: 160"); expect(source).toContain("size: 140"); diff --git a/ui/app/(prowler)/providers/page.tsx b/ui/app/(prowler)/providers/page.tsx index 8cbdfb3459..cb2dae2e8b 100644 --- a/ui/app/(prowler)/providers/page.tsx +++ b/ui/app/(prowler)/providers/page.tsx @@ -7,7 +7,7 @@ import { ContentLayout } from "@/components/ui"; import { FilterTransitionWrapper } from "@/contexts"; import { SearchParamsProps } from "@/types"; -import { AccountGroupsContent } from "./account-groups-content"; +import { ProviderGroupsContent } from "./provider-groups-content"; import { ProviderPageTabs } from "./provider-page-tabs"; import { getProviderTab } from "./provider-page-tabs.shared"; import { loadProvidersAccountsViewData } from "./providers-page.utils"; @@ -25,24 +25,24 @@ export default async function Providers({ const searchParamsKey = JSON.stringify(paramsWithoutTab); return ( - + } > - + } - accountGroupsContent={ + providerGroupsContent={ } + fallback={} > - + } /> @@ -59,7 +59,7 @@ const ProvidersTableFallback = () => { {/* Organizations filter */} - {/* Account Groups filter */} + {/* Provider Groups filter */} {/* Status filter */} @@ -74,7 +74,7 @@ const ProvidersTableFallback = () => { ); }; -const AccountGroupsFallback = () => { +const ProviderGroupsFallback = () => { return (
@@ -95,7 +95,7 @@ const AccountGroupsFallback = () => { ); }; -const ProvidersAccountsContent = async ({ +const ProvidersTabContent = async ({ searchParams, }: { searchParams: SearchParamsProps; diff --git a/ui/app/(prowler)/providers/account-groups-content.tsx b/ui/app/(prowler)/providers/provider-groups-content.tsx similarity index 93% rename from ui/app/(prowler)/providers/account-groups-content.tsx rename to ui/app/(prowler)/providers/provider-groups-content.tsx index f171f521f9..2bb4cd9ae8 100644 --- a/ui/app/(prowler)/providers/account-groups-content.tsx +++ b/ui/app/(prowler)/providers/provider-groups-content.tsx @@ -9,7 +9,7 @@ import { ColumnGroups } from "@/components/manage-groups/table"; import { DataTable } from "@/components/ui/table"; import { ProviderProps, Role, SearchParamsProps } from "@/types"; -export const AccountGroupsContent = async ({ +export const ProviderGroupsContent = async ({ searchParams, }: { searchParams: SearchParamsProps; @@ -57,10 +57,10 @@ export const AccountGroupsContent = async ({ ) : (

- Create a new account group + Create a new provider group

- Create a new account group to manage the providers and roles. + Create a new provider group to manage the providers and roles.

@@ -127,9 +127,9 @@ const EditGroupSection = ({ return (
-

Edit account group

+

Edit provider group

- Edit the account group to manage the providers and roles. + Edit the provider group to manage the providers and roles.

{ pushMock.mockClear(); }); - it("falls back to accounts when tab search params are invalid", () => { - expect(getProviderTab(undefined)).toBe(PROVIDER_TAB.ACCOUNTS); - expect(getProviderTab(["account-groups"])).toBe(PROVIDER_TAB.ACCOUNTS); - expect(getProviderTab("invalid-tab")).toBe(PROVIDER_TAB.ACCOUNTS); - expect(getProviderTab(PROVIDER_TAB.ACCOUNT_GROUPS)).toBe( - PROVIDER_TAB.ACCOUNT_GROUPS, + it("falls back to providers when tab search params are invalid", () => { + expect(getProviderTab(undefined)).toBe(PROVIDER_TAB.PROVIDERS); + expect(getProviderTab(["provider-groups"])).toBe(PROVIDER_TAB.PROVIDERS); + expect(getProviderTab("invalid-tab")).toBe(PROVIDER_TAB.PROVIDERS); + expect(getProviderTab(PROVIDER_TAB.PROVIDER_GROUPS)).toBe( + PROVIDER_TAB.PROVIDER_GROUPS, ); }); - it("shows the accounts tab when the route changes back to accounts", () => { + it("shows the providers tab when the route changes back to providers", () => { const { rerender } = render( Accounts content
} - accountGroupsContent={
Account groups content
} + activeTab={PROVIDER_TAB.PROVIDER_GROUPS} + providersContent={
Providers content
} + providerGroupsContent={
Provider groups content
} />, ); - expect(screen.getByRole("tab", { name: "Account Groups" })).toHaveAttribute( - "data-state", - "active", - ); + expect( + screen.getByRole("tab", { name: "Provider Groups" }), + ).toHaveAttribute("data-state", "active"); rerender( Accounts content
} - accountGroupsContent={
Account groups content
} + activeTab={PROVIDER_TAB.PROVIDERS} + providersContent={
Providers content
} + providerGroupsContent={
Provider groups content
} />, ); - expect(screen.getByRole("tab", { name: "Accounts" })).toHaveAttribute( + expect(screen.getByRole("tab", { name: "Providers" })).toHaveAttribute( "data-state", "active", ); - expect(screen.getByText("Accounts content")).toBeVisible(); + expect(screen.getByText("Providers content")).toBeVisible(); }); it("does not switch the active tab before navigation updates the route", async () => { @@ -63,21 +62,21 @@ describe("ProviderPageTabs", () => { render( Accounts content
} - accountGroupsContent={
Account groups content
} + activeTab={PROVIDER_TAB.PROVIDERS} + providersContent={
Providers content
} + providerGroupsContent={
Provider groups content
} />, ); - await user.click(screen.getByRole("tab", { name: "Account Groups" })); + await user.click(screen.getByRole("tab", { name: "Provider Groups" })); - expect(pushMock).toHaveBeenCalledWith("/providers?tab=account-groups"); - expect(screen.getByRole("tab", { name: "Accounts" })).toHaveAttribute( + expect(pushMock).toHaveBeenCalledWith("/providers?tab=provider-groups"); + expect(screen.getByRole("tab", { name: "Providers" })).toHaveAttribute( "data-state", "active", ); expect( - screen.getByRole("tab", { name: "Account Groups" }), + screen.getByRole("tab", { name: "Provider Groups" }), ).not.toHaveAttribute("data-state", "active"); }); }); diff --git a/ui/app/(prowler)/providers/provider-page-tabs.tsx b/ui/app/(prowler)/providers/provider-page-tabs.tsx index 437c8b6da0..61934fbdf0 100644 --- a/ui/app/(prowler)/providers/provider-page-tabs.tsx +++ b/ui/app/(prowler)/providers/provider-page-tabs.tsx @@ -9,14 +9,14 @@ import { PROVIDER_TAB, type ProviderTab } from "./provider-page-tabs.shared"; interface ProviderPageTabsProps { activeTab: ProviderTab; - accountsContent: ReactNode; - accountGroupsContent: ReactNode; + providersContent: ReactNode; + providerGroupsContent: ReactNode; } export const ProviderPageTabs = ({ activeTab, - accountsContent, - accountGroupsContent, + providersContent, + providerGroupsContent, }: ProviderPageTabsProps) => { const router = useRouter(); @@ -27,7 +27,7 @@ export const ProviderPageTabs = ({ return; } - if (typedTab === PROVIDER_TAB.ACCOUNTS) { + if (typedTab === PROVIDER_TAB.PROVIDERS) { router.push("/providers"); } else { router.push(`/providers?tab=${typedTab}`); @@ -41,18 +41,18 @@ export const ProviderPageTabs = ({ className="flex w-full flex-col gap-6" > - Accounts - - Account Groups + Providers + + Provider Groups - - {accountsContent} + + {providersContent} - - {accountGroupsContent} + + {providerGroupsContent} ); diff --git a/ui/components/compliance/compliance-card.test.tsx b/ui/components/compliance/compliance-card.test.tsx index c7a199a7fc..996c8cb979 100644 --- a/ui/components/compliance/compliance-card.test.tsx +++ b/ui/components/compliance/compliance-card.test.tsx @@ -13,9 +13,9 @@ describe("ComplianceCard", () => { expect(source).toContain('variant="base"'); }); - it("uses a responsive stacked layout for narrow screens", () => { + it("uses a single-column stacked layout", () => { expect(source).toContain("flex-col"); - expect(source).toContain("sm:flex-row"); + expect(source).not.toContain("sm:flex-row"); }); it("uses the shadcn progress component instead of Hero UI", () => { diff --git a/ui/components/filters/data-filters.ts b/ui/components/filters/data-filters.ts index 0baf89a3df..2457b1a8b2 100644 --- a/ui/components/filters/data-filters.ts +++ b/ui/components/filters/data-filters.ts @@ -24,7 +24,7 @@ export const filterProviders: FilterOption[] = [ }, { key: "provider__in", - labelCheckboxGroup: "Cloud Provider", + labelCheckboxGroup: "Provider", values: [...PROVIDER_TYPES], valueLabelMapping: PROVIDER_TYPE_MAPPING, }, @@ -34,7 +34,7 @@ export const filterProviders: FilterOption[] = [ export const filterScans = [ { key: "provider_type__in", - labelCheckboxGroup: "Cloud Provider", + labelCheckboxGroup: "Provider", values: [...PROVIDER_TYPES], valueLabelMapping: PROVIDER_TYPE_MAPPING, index: 0, diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index 59e30ea6a3..0f457140f1 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -169,7 +169,7 @@ export function getStandaloneFindingColumns({ { accessorKey: "provider", header: ({ column }) => ( - + ), cell: ({ row }) => { const provider = getProviderData(row, "provider"); diff --git a/ui/components/manage-groups/forms/delete-group-form.tsx b/ui/components/manage-groups/forms/delete-group-form.tsx index 7f79817440..0504178cb7 100644 --- a/ui/components/manage-groups/forms/delete-group-form.tsx +++ b/ui/components/manage-groups/forms/delete-group-form.tsx @@ -48,7 +48,7 @@ export const DeleteGroupForm = ({ title: "Success!", description: "The provider group was removed successfully.", }); - router.push("/providers?tab=account-groups"); + router.push("/providers?tab=provider-groups"); } setIsOpen(false); // Close the modal on success } diff --git a/ui/components/manage-groups/forms/edit-group-form.tsx b/ui/components/manage-groups/forms/edit-group-form.tsx index 907e372b0e..02503940f1 100644 --- a/ui/components/manage-groups/forms/edit-group-form.tsx +++ b/ui/components/manage-groups/forms/edit-group-form.tsx @@ -130,7 +130,7 @@ export const EditGroupForm = ({ title: "Success!", description: "The group was updated successfully.", }); - router.push("/providers?tab=account-groups"); + router.push("/providers?tab=provider-groups"); } } catch (_error) { toast({ @@ -263,7 +263,7 @@ export const EditGroupForm = ({ type="button" variant="ghost" onClick={() => { - router.push("/providers?tab=account-groups"); + router.push("/providers?tab=provider-groups"); }} disabled={isLoading} > diff --git a/ui/components/manage-groups/manage-groups-button.tsx b/ui/components/manage-groups/manage-groups-button.tsx index 3b08f5d8eb..48dc1d42c1 100644 --- a/ui/components/manage-groups/manage-groups-button.tsx +++ b/ui/components/manage-groups/manage-groups-button.tsx @@ -10,7 +10,7 @@ export const ManageGroupsButton = () => { ); diff --git a/ui/components/manage-groups/table/data-table-row-actions.tsx b/ui/components/manage-groups/table/data-table-row-actions.tsx index 6787897450..e05a16c807 100644 --- a/ui/components/manage-groups/table/data-table-row-actions.tsx +++ b/ui/components/manage-groups/table/data-table-row-actions.tsx @@ -41,15 +41,15 @@ export function DataTableRowActions({ } - label="Edit Account Group" + label="Edit Provider Group" onSelect={() => - router.push(`/providers?tab=account-groups&groupId=${groupId}`) + router.push(`/providers?tab=provider-groups&groupId=${groupId}`) } /> } - label="Delete Account Group" + label="Delete Provider Group" destructive onSelect={() => setIsDeleteOpen(true)} /> diff --git a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx index df5017712e..76c7851d30 100644 --- a/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx +++ b/ui/components/overview/new-findings-table/table/skeleton-table-new-findings.tsx @@ -85,7 +85,7 @@ export const SkeletonTableNewFindings = () => { - {/* Cloud Provider */} + {/* Provider */} diff --git a/ui/components/providers/table/column-providers.tsx b/ui/components/providers/table/column-providers.tsx index 1203234a31..ec5766f491 100644 --- a/ui/components/providers/table/column-providers.tsx +++ b/ui/components/providers/table/column-providers.tsx @@ -140,7 +140,7 @@ export function getColumnProviders(
@@ -200,7 +200,7 @@ export function getColumnProviders( accessorKey: "groupNames", size: 160, header: ({ column }) => ( - + ), cell: ({ row }) => { if (isProvidersOrganizationRow(row.original)) { @@ -251,7 +251,7 @@ export function getColumnProviders( if (isProvidersOrganizationRow(row.original)) { return ( - {row.original.providerCount} Accounts + {row.original.providerCount} Providers ); } diff --git a/ui/components/providers/table/skeleton-table-provider.tsx b/ui/components/providers/table/skeleton-table-provider.tsx index 7d57112291..1fe557b808 100644 --- a/ui/components/providers/table/skeleton-table-provider.tsx +++ b/ui/components/providers/table/skeleton-table-provider.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; const SkeletonTableRow = () => { return ( - {/* Account: provider logo + alias + UID */} + {/* Provider: logo + alias + UID */}
@@ -13,7 +13,7 @@ const SkeletonTableRow = () => {
- {/* Account Groups: badge chips */} + {/* Provider Groups: badge chips */}
@@ -68,11 +68,11 @@ export const SkeletonTableProviders = () => { - {/* Account */} + {/* Provider */} - {/* Account Groups */} + {/* Provider Groups */} diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index 706045a829..d731ca5786 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -93,7 +93,7 @@ export function ProviderWizardModal({
- For assistance connecting a Cloud Provider visit + For assistance connecting a Provider visit
+ + + {columns.map((column) => ( + + ))} + + + + {data.map((alert) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ +
+ {column.cell?.({ row: { original: alert } })} +
+
+ ), +})); + +vi.mock("@/components/ui/table/data-table-column-header", () => ({ + DataTableColumnHeader: ({ title }: { title: string }) => {title}, +})); + +interface AlertRuleOverrides extends Partial> { + attributes?: Partial; +} + +const makeRule = (overrides: AlertRuleOverrides = {}): AlertRule => ({ + id: overrides.id ?? "alert-1", + type: "alert-rules", + attributes: { + name: "Critical findings", + description: "Notify security", + enabled: true, + trigger: ALERT_TRIGGER_KINDS.AFTER_SCAN, + condition: { + op: ALERT_AGGREGATE_OPS.ANY, + filter: { severity: ["critical"] }, + }, + schema_version: 1, + recipient_emails: ["security@example.com"], + inserted_at: "2026-01-01T10:00:00Z", + updated_at: "2026-01-02T11:30:00Z", + ...overrides.attributes, + }, +}); + +describe("AlertsTable", () => { + beforeEach(() => { + navigationMocks.currentSearch = ""; + navigationMocks.routerPush.mockClear(); + }); + + it("should render alert rows with dropdown actions and shared pagination", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.getByRole("cell", { name: /critical findings/i }), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: /actions for critical findings/i }), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: /edit critical findings/i }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/12 total entries/i)).toBeVisible(); + expect(screen.getByTestId("column-actions")).toHaveAttribute( + "data-size", + "72", + ); + expect(screen.getByTestId("column-name")).toHaveAttribute( + "data-size", + "320", + ); + expect(screen.getByTestId("column-inserted_at")).toHaveAttribute( + "data-size", + "170", + ); + expect(screen.getByTestId("column-updated_at")).toHaveAttribute( + "data-size", + "170", + ); + expect(screen.getByText("Jan 01, 2026")).toBeVisible(); + expect(screen.getByText("Jan 02, 2026")).toBeVisible(); + expect( + screen.queryByRole("button", { name: /run preview|test/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: /critical findings/i }), + ).not.toBeInTheDocument(); + }); + + it("should truncate long descriptions in the name column", () => { + // Given + const description = + "This alert description is intentionally long enough to overflow the alerts table if it is not constrained by the cell renderer."; + + // When + render( + , + ); + + // Then + expect(screen.getByText(description)).toHaveClass("truncate"); + expect(screen.getByText(description).parentElement).toHaveClass( + "max-w-[320px]", + ); + expect(screen.getByText(description)).toHaveAttribute("title", description); + }); + + it("should call row action callbacks for edit, toggle, and delete", async () => { + // Given + const user = userEvent.setup(); + const alert = makeRule({ id: "alert-enabled" }); + const onEdit = vi.fn(); + const onToggleEnabled = vi.fn(); + const onDelete = vi.fn(); + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /actions for critical findings/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /edit/i })); + await user.click( + screen.getByRole("button", { name: /actions for critical findings/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /disable/i })); + await user.click( + screen.getByRole("button", { name: /actions for critical findings/i }), + ); + await user.click(screen.getByRole("menuitem", { name: /delete/i })); + + // Then + expect(onEdit).toHaveBeenCalledWith(alert); + expect(onToggleEnabled).toHaveBeenCalledWith(alert); + expect(onDelete).toHaveBeenCalledWith(alert); + }); + + it("should edit the alert directly when clicking the alert name", async () => { + // Given + const user = userEvent.setup(); + const alert = makeRule(); + const onEdit = vi.fn(); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Critical findings" })); + + // Then + expect(onEdit).toHaveBeenCalledWith(alert); + expect(screen.queryByRole("menuitem", { name: /edit/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /disable/i })).toBeNull(); + expect(screen.queryByRole("menuitem", { name: /delete/i })).toBeNull(); + }); +}); diff --git a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx new file mode 100644 index 0000000000..b6fca816df --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx @@ -0,0 +1,384 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ComponentProps, ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { AlertCondition } from "@/app/(prowler)/alerts/_types"; +import type { + AlertFormSubmitResult, + AlertFormValues, +} from "@/app/(prowler)/alerts/_types/alert-form"; + +const routerMocks = vi.hoisted(() => ({ + push: vi.fn(), + refresh: vi.fn(), +})); + +const actionMocks = vi.hoisted(() => ({ + createAlert: vi.fn(), + seedAlertRule: vi.fn(), +})); + +const toastMock = vi.hoisted(() => vi.fn()); + +vi.mock("next/navigation", () => ({ + useRouter: () => routerMocks, +})); + +vi.mock("@/components/ui", () => ({ + ToastAction: ({ + asChild, + children, + ...props + }: ComponentProps<"button"> & { + asChild?: boolean; + children?: ReactNode; + }) => (asChild ? children : ), + useToast: () => ({ toast: toastMock }), +})); + +vi.mock("@/app/(prowler)/alerts/_actions", () => ({ + createAlert: actionMocks.createAlert, + seedAlertRule: actionMocks.seedAlertRule, +})); + +vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({ + AlertFormModal: ({ + open, + seededCondition, + selectedFindingsFilterChips, + defaultName, + onSubmit, + }: { + open: boolean; + seededCondition?: AlertCondition | null; + selectedFindingsFilterChips?: Array<{ + label: string; + displayValue?: string; + value: string; + }>; + defaultName?: string; + onSubmit: (values: AlertFormValues) => Promise; + }) => + open ? ( +
+ + {JSON.stringify(seededCondition)} + + + {(selectedFindingsFilterChips ?? []) + .map((chip) => `${chip.label}:${chip.displayValue ?? chip.value}`) + .join("|")} + + +
+ ) : null, +})); + +import { SeedFromFindingsButton } from "../seed-from-findings-button"; + +describe("SeedFromFindingsButton", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should explain why creating an alert is disabled when no real filters are applied", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + const button = screen.getByRole("button", { + name: /Create Alert/i, + }); + const tooltipTrigger = button.parentElement; + expect(tooltipTrigger).not.toBeNull(); + await user.hover(tooltipTrigger as HTMLElement); + + // Then + expect(button).toBeDisabled(); + expect( + await screen.findAllByText(/at least one findings filter/i), + ).not.toHaveLength(0); + }); + + it("should enable creation from the first real filter, including unsupported backend filters", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.getByRole("button", { name: /Create Alert/i }), + ).not.toBeDisabled(); + expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveClass( + "h-10", + ); + }); + + it("should add all severities when Findings only has non-portable default filters", async () => { + // Given + const user = userEvent.setup(); + const seededCondition: AlertCondition = { + op: "any", + filter: { + severity: ["critical", "high", "medium", "low", "informational"], + }, + }; + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: seededCondition, + schema_version: 1, + warnings: [], + }, + }, + }); + const filterBag = { + "filter[status__in]": "FAIL", + "filter[muted]": "false", + "filter[scan__in]": "11111111-1111-1111-1111-111111111111", + }; + render(); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + + // Then + await waitFor(() => + expect(actionMocks.seedAlertRule).toHaveBeenCalledWith({ + ...filterBag, + "filter[severity__in]": [ + "critical", + "high", + "medium", + "low", + "informational", + ], + }), + ); + expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible(); + expect(screen.getByTestId("seeded-condition")).toHaveTextContent( + "severity", + ); + }); + + it("should seed from the full Findings filter bag before opening the modal", async () => { + // Given + const user = userEvent.setup(); + const seededCondition: AlertCondition = { + op: "any", + filter: { severity: ["critical", "high"] }, + }; + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: seededCondition, + schema_version: 1, + warnings: [], + }, + }, + }); + const filterBag = { + "filter[status__in]": "FAIL", + "filter[muted]": "false", + "filter[scan__in]": "11111111-1111-1111-1111-111111111111", + "filter[severity__in]": "critical,high", + }; + render(); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + + // Then + await waitFor(() => + expect(actionMocks.seedAlertRule).toHaveBeenCalledWith(filterBag), + ); + expect(screen.getByRole("dialog", { name: /create alert/i })).toBeVisible(); + expect(routerMocks.push).not.toHaveBeenCalled(); + expect(screen.getByTestId("selected-filter-chips")).toHaveTextContent( + /severity:\+2/i, + ); + expect(screen.getByTestId("seeded-condition")).toHaveTextContent( + "severity", + ); + expect(screen.getByTestId("selected-filter-chips")).not.toHaveTextContent( + /status/i, + ); + }); + + it("should create the alert through the existing alert action from the modal", async () => { + // Given + const user = userEvent.setup(); + const seededCondition: AlertCondition = { + op: "any", + filter: { severity: ["critical"] }, + }; + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: seededCondition, + schema_version: 1, + warnings: [], + }, + }, + }); + actionMocks.createAlert.mockResolvedValue({ + data: { + id: "alert-1", + attributes: { name: "Findings filter alert" }, + }, + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + await user.click( + screen.getByRole("button", { name: /submit mock alert/i }), + ); + + // Then + await waitFor(() => + expect(actionMocks.createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Findings filter alert", + trigger: "after_scan", + condition: seededCondition, + recipientEmails: ["security@example.com"], + }), + ), + ); + expect(routerMocks.refresh).toHaveBeenCalled(); + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Alert created", + action: expect.anything(), + }), + ); + }); + + it("should add a toast action to navigate to alerts after creating an alert", async () => { + // Given + const user = userEvent.setup(); + actionMocks.seedAlertRule.mockResolvedValue({ + data: { + attributes: { + condition: { op: "any", filter: { severity: ["critical"] } }, + schema_version: 1, + warnings: [], + }, + }, + }); + actionMocks.createAlert.mockResolvedValue({ + data: { + id: "alert-1", + attributes: { name: "Findings filter alert" }, + }, + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + await user.click( + screen.getByRole("button", { name: /submit mock alert/i }), + ); + + // Then + await waitFor(() => expect(toastMock).toHaveBeenCalled()); + const toastAction = toastMock.mock.calls[0][0].action; + render(toastAction); + expect(screen.getByRole("link", { name: /view alerts/i })).toHaveAttribute( + "href", + "/alerts", + ); + }); + + it("should show a toast and keep the modal closed when seed fails", async () => { + // Given + const user = userEvent.setup(); + actionMocks.seedAlertRule.mockResolvedValue({ + error: "invalid_shape", + }); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /Create Alert/i })); + + // Then + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + title: "Alert seed failed", + }), + ), + ); + expect( + screen.queryByRole("dialog", { name: /create alert/i }), + ).not.toBeInTheDocument(); + }); + + it("should render disabled as a Cloud-only feature in OSS", () => { + // Given + render( + , + ); + + // When + const button = screen.getByRole("button", { name: /Create Alert/i }); + + // Then + expect(button).toBeDisabled(); + expect(button.className).not.toContain("min-w"); + expect(button).not.toHaveClass("justify-start"); + const pricingLink = screen.getByRole("link", { + name: /available in prowler cloud/i, + }); + expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing"); + expect(pricingLink).toHaveClass("whitespace-nowrap"); + expect(pricingLink).toHaveTextContent("Available in Prowler Cloud"); + expect(pricingLink.closest("button")).toBeNull(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(actionMocks.seedAlertRule).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx new file mode 100644 index 0000000000..e3529d4056 --- /dev/null +++ b/ui/app/(prowler)/alerts/_components/alert-form-modal.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { useState } from "react"; + +import { + previewAlertCondition, + seedAlertRule, +} from "@/app/(prowler)/alerts/_actions"; +import { listAlertRecipients } from "@/app/(prowler)/alerts/_actions/recipients"; +import { + ALERT_TRIGGER_KINDS, + type AlertCondition, + type AlertPreviewResponse, + type AlertRecipient, + type AlertRule, + type AlertTriggerKind, +} from "@/app/(prowler)/alerts/_types"; +import type { FilterChip } from "@/components/filters/filter-summary-strip"; +import { FilterSummaryStrip } from "@/components/filters/filter-summary-strip"; +import { FindingsFilterBatchControls } from "@/components/findings/findings-filters"; +import { + Badge, + Button, + Card, + CardContent, + Field, + FieldError, + FieldLabel, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, + Textarea, +} from "@/components/shadcn"; +import { Modal } from "@/components/shadcn/modal"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + MultiSelectSelectAll, + MultiSelectSeparator, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { useMountEffect } from "@/hooks/use-mount-effect"; +import type { ScanEntity } from "@/types"; +import type { ProviderProps } from "@/types/providers"; + +import { + getAlertFormDefaults, + getEmptyAlertFormDefaults, + getFindingsFiltersFromAlertCondition, +} from "../_lib/alert-adapter"; +import { alertFormSchema } from "../_lib/alert-form-schema"; +import type { + AlertFormSubmitResult, + AlertFormValues, +} from "../_types/alert-form"; +import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form"; + +interface AlertFormModalProps { + open: boolean; + defaultFrequency: AlertTriggerKind; + providers?: ProviderProps[]; + completedScanIds?: string[]; + scanDetails?: { [key: string]: ScanEntity }[]; + uniqueRegions?: string[]; + uniqueServices?: string[]; + uniqueResourceTypes?: string[]; + uniqueCategories?: string[]; + uniqueGroups?: string[]; + editingAlert?: AlertRule | null; + seededCondition?: AlertCondition | null; + selectedFindingsFilterChips?: FilterChip[]; + defaultName?: string; + onOpenChange: (open: boolean) => void; + onSubmit: (values: AlertFormValues) => Promise; +} + +interface FormErrors { + name?: string; + recipientEmails?: string; + root?: string; +} + +const normalizeEmail = (email: string): string => email.trim().toLowerCase(); + +const getRecipientEmails = (selectedEmails: Set): string[] => + Array.from(selectedEmails); + +const ALERT_FREQUENCY_OPTIONS = [ + { + value: ALERT_TRIGGER_KINDS.AFTER_SCAN, + label: "After each scan", + }, + { + value: ALERT_TRIGGER_KINDS.DAILY, + label: "Daily digest", + }, + { + value: ALERT_TRIGGER_KINDS.BOTH, + label: "After each scan and daily", + }, +] as const; + +const ALERT_SEED_ERROR = "Apply at least one alert-compatible Findings filter."; + +const serializeCondition = (condition: AlertCondition | null): string => + condition ? JSON.stringify(condition) : "none"; + +const getAlertFormModalResetKey = ({ + open, + defaultFrequency, + editingAlert, + seededCondition, +}: Pick< + AlertFormModalProps, + "open" | "defaultFrequency" | "editingAlert" | "seededCondition" +>): string => + [ + open ? "open" : "closed", + editingAlert?.id ?? "create", + editingAlert?.attributes.updated_at ?? "", + defaultFrequency, + serializeCondition(seededCondition ?? null), + ].join("|"); + +const allowInitialDialogFocus = () => undefined; + +const uniqueValues = (values: string[]): string[] => + Array.from(new Set(values)); + +interface PreviewState { + status: "success" | "error"; + data?: AlertPreviewResponse; + error?: string; +} + +const formatPreviewNumber = (value: number): string => + new Intl.NumberFormat("en-US").format(value); + +const getPreviewSeverityLabel = (severity: string): string => + severity.charAt(0).toUpperCase() + severity.slice(1); + +const getPreviewMessage = (data: AlertPreviewResponse): string => { + const totalFindings = data.summary.finding_count_total ?? 0; + if (totalFindings === 0) { + return "These filters did not match any findings for the latest scan."; + } + + const findingLabel = totalFindings === 1 ? "finding" : "findings"; + const topSeverity = data.summary.top_severity; + const severityClause = topSeverity + ? `, including ${getPreviewSeverityLabel(topSeverity)} severity` + : ""; + + return `It found ${formatPreviewNumber(totalFindings)} ${findingLabel}${severityClause}.`; +}; + +const PreviewSummarySkeleton = () => ( + + +
+ + +
+ +
+
+); + +const PreviewSummary = ({ preview }: { preview: PreviewState }) => { + if (preview.status === "error") { + return ( + + +
+ + Test result + + Error +
+

{preview.error}

+
+
+ ); + } + + const data = preview.data; + if (!data) return null; + + return ( + + + + Test result + +

+ {getPreviewMessage(data)} +

+
+
+ ); +}; + +const normalizeFindingsFilterKey = (filterKey: string): string => + filterKey.startsWith("filter[") ? filterKey : `filter[${filterKey}]`; + +interface AlertRecipientsSelectProps { + selectedEmails: Set; + onValuesChange: (emails: string[]) => void; +} + +interface RecipientOption { + email: string; + status?: AlertRecipient["attributes"]["status"]; +} + +const getRecipientStatusLabel = ( + status: AlertRecipient["attributes"]["status"], +): string => status.charAt(0).toUpperCase() + status.slice(1); + +const getRecipientOptions = ( + recipients: AlertRecipient[], + selectedEmails: string[], +): RecipientOption[] => { + const options = new Map(); + + recipients.forEach((recipient) => { + const email = normalizeEmail(recipient.attributes.email); + if (!email) return; + options.set(email, { email, status: recipient.attributes.status }); + }); + + selectedEmails.forEach((email) => { + const normalizedEmail = normalizeEmail(email); + if (!normalizedEmail || options.has(normalizedEmail)) return; + options.set(normalizedEmail, { email: normalizedEmail }); + }); + + return Array.from(options.values()).sort((left, right) => + left.email.localeCompare(right.email), + ); +}; + +const AlertRecipientsSelect = ({ + selectedEmails, + onValuesChange, +}: AlertRecipientsSelectProps) => { + const [recipients, setRecipients] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useMountEffect(() => { + listAlertRecipients({ + "page[size]": "100", + sort: "email", + }).then((result) => { + setLoading(false); + if (result?.error) { + setRecipients([]); + setError(result.error); + return; + } + setRecipients(result.data); + setError(null); + }); + }); + + const selectedValues = Array.from(selectedEmails); + const options = getRecipientOptions(recipients, selectedValues); + + return ( +
+ + + + + + option.email)} + > + Select All + + + {options.map((option) => ( + + {option.email} + {option.status && ( + + {getRecipientStatusLabel(option.status)} + + )} + + ))} + + + {error &&

{error}

} +
+ ); +}; + +export const AlertFormModal = (props: AlertFormModalProps) => { + const resetKey = getAlertFormModalResetKey(props); + + return ; +}; + +const AlertFormModalContent = ({ + open, + defaultFrequency, + providers = [], + completedScanIds = [], + scanDetails = [], + uniqueRegions = [], + uniqueServices = [], + uniqueResourceTypes = [], + uniqueCategories = [], + uniqueGroups = [], + editingAlert = null, + seededCondition = null, + selectedFindingsFilterChips = [], + defaultName = "Findings filter alert", + onOpenChange, + onSubmit, +}: AlertFormModalProps) => { + const defaults = editingAlert + ? getAlertFormDefaults(editingAlert) + : getEmptyAlertFormDefaults(defaultFrequency, seededCondition ?? undefined); + const initialName = editingAlert + ? defaults.name + : defaults.name || defaultName; + + // Local state needed: user edits are buffered until the modal form is submitted. + const [name, setName] = useState(initialName); + const [description, setDescription] = useState(defaults.description); + const [frequency, setFrequency] = useState( + defaults.frequency, + ); + const [pendingFilters, setPendingFilters] = useState< + Record + >( + editingAlert + ? getFindingsFiltersFromAlertCondition(editingAlert.attributes.condition) + : {}, + ); + const [selectedRecipientEmails, setSelectedRecipientEmails] = useState( + () => new Set(defaults.recipientEmails.map(normalizeEmail)), + ); + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [previewLoading, setPreviewLoading] = useState(false); + const [preview, setPreview] = useState(null); + + const submitLabel = editingAlert ? "Save" : "Create"; + + const setRecipientEmails = (emails: string[]) => + setSelectedRecipientEmails( + new Set(emails.map(normalizeEmail).filter(Boolean)), + ); + + const setPendingFilter = (filterKey: string, values: string[]) => { + setPendingFilters((current) => ({ + ...current, + [normalizeFindingsFilterKey(filterKey)]: uniqueValues(values), + })); + setPreview(null); + }; + + const getPendingFilterValue = (filterKey: string): string[] => + pendingFilters[normalizeFindingsFilterKey(filterKey)] ?? []; + + const buildCurrentValues = (condition: AlertCondition): AlertFormValues => ({ + name, + description, + method: ALERT_NOTIFICATION_METHODS.EMAIL, + frequency, + condition, + recipientEmails: getRecipientEmails(selectedRecipientEmails), + enabled: defaults.enabled, + }); + + const handlePreview = async () => { + if (!editingAlert) return; + + const seedResult = await seedAlertRule(pendingFilters); + if (seedResult?.error) { + setPreview({ + status: "error", + error: ALERT_SEED_ERROR, + }); + return; + } + + const values = buildCurrentValues(seedResult.data.attributes.condition); + const parsed = alertFormSchema.safeParse(values); + if (!parsed.success) { + setPreview({ + status: "error", + error: "Fix alert fields before running test.", + }); + return; + } + + setPreviewLoading(true); + const result = await previewAlertCondition({ + condition: parsed.data.condition, + }); + setPreviewLoading(false); + + if (result?.error) { + setPreview({ status: "error", error: result.error }); + return; + } + + const previewData = result.data.attributes as AlertPreviewResponse; + if (previewData.evaluation_failed) { + setPreview({ + status: "error", + error: previewData.last_error ?? "Preview evaluation failed.", + }); + return; + } + + setPreview({ status: "success", data: previewData }); + }; + + const handleSubmit = async () => { + const seedResult = editingAlert + ? await seedAlertRule(pendingFilters) + : null; + if (seedResult?.error) { + setErrors({ root: ALERT_SEED_ERROR }); + return; + } + + const values = buildCurrentValues( + seedResult?.data.attributes.condition ?? defaults.condition, + ); + const parsed = alertFormSchema.safeParse(values); + if (!parsed.success) { + const fieldErrors = parsed.error.flatten().fieldErrors; + setErrors({ + name: fieldErrors.name?.[0], + recipientEmails: fieldErrors.recipientEmails?.[0], + }); + return; + } + + setSaving(true); + const result = await onSubmit(parsed.data); + setSaving(false); + if (result.ok) { + setErrors({}); + onOpenChange(false); + return; + } + setErrors({ root: result.error ?? "Could not save alert." }); + }; + + return ( + +
+ + + Name + setName(event.target.value)} + /> + {errors.name && {errors.name}} + + + Description +