From e60a4462e5417cea26ad3eebdd0a7f4030b3f5d7 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:08:06 +0200 Subject: [PATCH] fix(ui): refine add-provider wizard flow between scans and providers (#11424) --- ui/CHANGELOG.md | 16 ++ .../_components/accounts-selector.test.tsx | 45 ++- .../_components/accounts-selector.tsx | 76 +++-- .../provider-type-selector.test.tsx | 26 +- .../_components/provider-type-selector.tsx | 212 +++----------- .../table/provider-icon-cell.test.tsx | 45 +++ .../findings/table/provider-icon-cell.tsx | 47 +--- .../provider-type-icon.test.tsx | 126 +++++++++ .../providers-badge/provider-type-icon.tsx | 250 +++++++++++++++++ .../integrations/s3/s3-integration-form.tsx | 12 +- .../security-hub-integration-form.tsx | 12 +- .../providers/no-providers-added.tsx | 92 +++++++ .../providers-accounts-view.test.tsx | 260 +++++++++++++++++- .../providers/providers-accounts-view.tsx | 83 ++++-- .../hooks/use-provider-wizard-controller.ts | 9 +- .../wizard/provider-wizard-modal.tsx | 3 + ui/components/scans/no-providers-added.tsx | 38 --- .../scans-providers-empty-state.test.tsx | 74 ++--- .../scans/scans-providers-empty-state.tsx | 34 +-- ui/dependency-log.json | 24 +- ui/lib/providers-navigation.ts | 3 + ui/package.json | 8 +- ui/pnpm-lock.yaml | 213 +++++++------- ui/tests/helpers.ts | 31 ++- ui/tests/providers/providers-page.ts | 9 +- 25 files changed, 1198 insertions(+), 550 deletions(-) create mode 100644 ui/components/findings/table/provider-icon-cell.test.tsx create mode 100644 ui/components/icons/providers-badge/provider-type-icon.test.tsx create mode 100644 ui/components/icons/providers-badge/provider-type-icon.tsx create mode 100644 ui/components/providers/no-providers-added.tsx delete mode 100644 ui/components/scans/no-providers-added.tsx create mode 100644 ui/lib/providers-navigation.ts diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 8ed4a4418c..db38de5fd3 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -10,6 +10,22 @@ All notable changes to the **Prowler UI** are documented in this file. --- +## [1.29.2] (Prowler v5.29.2) + +### 🔄 Changed + +- Account and provider-type selector triggers now show the provider icon, with a non-deduped icon stack [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🐞 Fixed + +- Add Provider modal now closes without reloading the providers page [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +### 🔐 Security + +- Vitest toolchain upgraded `4.0.18` → `4.1.8` to clear two critical `pnpm audit` advisories [(#11424)](https://github.com/prowler-cloud/prowler/pull/11424) + +--- + ## [1.29.0] (Prowler v5.29.0) ### 🚀 Added diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx index 626e0985da..19c63cf3d2 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -57,7 +57,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({ ); }, MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -220,4 +220,45 @@ describe("AccountsSelector", () => { expect(multiSelectSpy).toHaveBeenLastCalledWith({ open: false }); }); + + it("shows the provider icon next to the name in the trigger for a single selection", async () => { + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect(within(trigger).getByText("Production AWS")).toBeInTheDocument(); + }); + + it("renders one icon per selected account without deduping by provider type", async () => { + const secondAws = { + ...providers[0], + id: "provider-2", + attributes: { + ...providers[0].attributes, + uid: "999999999999", + alias: "Staging AWS", + }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + // Two AWS accounts -> two AWS icons in the trigger (no dedupe). + expect(await within(trigger).findAllByText("AWS")).toHaveLength(2); + expect( + within(trigger).getByText("2 Providers selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index a37807e793..0c389febb7 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -1,26 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { ReactNode, useState } from "react"; +import { useState } from "react"; import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { Badge } from "@/components/shadcn"; import { MultiSelect, @@ -45,25 +31,6 @@ const ACCOUNT_SELECTOR_FILTER = { type AccountSelectorFilter = (typeof ACCOUNT_SELECTOR_FILTER)[keyof typeof ACCOUNT_SELECTOR_FILTER]; -const PROVIDER_ICON: Record = { - aws: , - azure: , - gcp: , - kubernetes: , - m365: , - github: , - googleworkspace: , - iac: , - image: , - oraclecloud: , - mongodbatlas: , - alibabacloud: , - cloudflare: , - openstack: , - vercel: , - okta: , -}; - /** Common props shared by both batch and instant modes. */ interface AccountsSelectorBaseProps { providers: ProviderProps[]; @@ -158,10 +125,36 @@ export function AccountsSelector({ if (selectedIds.length === 1) { const p = providers.find((pr) => getProviderValue(pr) === selectedIds[0]); const name = p ? p.attributes.alias || p.attributes.uid : selectedIds[0]; - return {name}; + return ( + + {p && ( + + )} + {name} + + ); } + // One icon per selected account (no dedupe): two accounts of the same + // provider show two icons, disambiguated by the UID tooltip on hover. + const items = selectedIds + .map((selectedId) => + providers.find((pr) => getProviderValue(pr) === selectedId), + ) + .filter((p): p is ProviderProps => Boolean(p)) + .map((p) => ({ + key: p.id, + type: p.attributes.provider as ProviderType, + tooltip: p.attributes.uid, + })); return ( - {selectedIds.length} Providers selected + + + + {selectedIds.length} Providers selected + + ); }; @@ -208,7 +201,6 @@ export function AccountsSelector({ const isDisabled = disabledValuesSet.has(value); const displayName = p.attributes.alias || p.attributes.uid; const providerType = p.attributes.provider as ProviderType; - const icon = PROVIDER_ICON[providerType]; const searchKeywords = [ displayName, p.attributes.alias, @@ -228,7 +220,9 @@ export function AccountsSelector({ if (closeOnSelect) setSelectorOpen(false); }} > - + {displayName} {isDisabled && Disconnected} diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx index 5cd94a3087..b2c05b336d 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ProviderTypeSelector } from "./provider-type-selector"; @@ -39,7 +39,7 @@ vi.mock("@/components/shadcn/select/multiselect", () => ({
{children}
), MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => ( -
{children}
+
{children}
), MultiSelectValue: ({ placeholder }: { placeholder: string }) => ( {placeholder} @@ -145,4 +145,26 @@ describe("ProviderTypeSelector", () => { ).toHaveAttribute("aria-disabled", "true"); expect(screen.getByText("All selected")).toBeInTheDocument(); }); + + it("shows one icon per selected type and a count in the trigger", async () => { + const azure = { + ...providers[0], + id: "provider-2", + attributes: { ...providers[0].attributes, provider: "azure" as const }, + }; + + render( + , + ); + + const trigger = screen.getByTestId("trigger"); + expect(await within(trigger).findByText("AWS")).toBeInTheDocument(); + expect( + within(trigger).getByText("2 Provider Types selected"), + ).toBeInTheDocument(); + }); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 15d069b49d..e78412eeeb 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -1,8 +1,12 @@ "use client"; import { useSearchParams } from "next/navigation"; -import { type ComponentType, lazy, Suspense } from "react"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, + ProviderTypeIconStack, +} from "@/components/icons/providers-badge/provider-type-icon"; import { MultiSelect, MultiSelectContent, @@ -14,163 +18,6 @@ import { import { useUrlFilters } from "@/hooks/use-url-filters"; import { type ProviderProps, ProviderType } from "@/types/providers"; -const AWSProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AWSProviderBadge, - })), -); -const AzureProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AzureProviderBadge, - })), -); -const GCPProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GCPProviderBadge, - })), -); -const KS8ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.KS8ProviderBadge, - })), -); -const M365ProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.M365ProviderBadge, - })), -); -const GitHubProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GitHubProviderBadge, - })), -); -const IacProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.IacProviderBadge, - })), -); -const ImageProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.ImageProviderBadge, - })), -); -const OracleCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OracleCloudProviderBadge, - })), -); -const MongoDBAtlasProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.MongoDBAtlasProviderBadge, - })), -); -const AlibabaCloudProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.AlibabaCloudProviderBadge, - })), -); -const CloudflareProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.CloudflareProviderBadge, - })), -); -const OpenStackProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OpenStackProviderBadge, - })), -); -const GoogleWorkspaceProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.GoogleWorkspaceProviderBadge, - })), -); -const VercelProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.VercelProviderBadge, - })), -); -const OktaProviderBadge = lazy(() => - import("@/components/icons/providers-badge").then((m) => ({ - default: m.OktaProviderBadge, - })), -); - -type IconProps = { width: number; height: number }; - -const IconPlaceholder = ({ width, height }: IconProps) => ( -
-); - -const PROVIDER_DATA: Record< - ProviderType, - { label: string; icon: ComponentType } -> = { - aws: { - label: "Amazon Web Services", - icon: AWSProviderBadge, - }, - azure: { - label: "Microsoft Azure", - icon: AzureProviderBadge, - }, - gcp: { - label: "Google Cloud Platform", - icon: GCPProviderBadge, - }, - kubernetes: { - label: "Kubernetes", - icon: KS8ProviderBadge, - }, - m365: { - label: "Microsoft 365", - icon: M365ProviderBadge, - }, - github: { - label: "GitHub", - icon: GitHubProviderBadge, - }, - googleworkspace: { - label: "Google Workspace", - icon: GoogleWorkspaceProviderBadge, - }, - iac: { - label: "Infrastructure as Code", - icon: IacProviderBadge, - }, - image: { - label: "Container Registry", - icon: ImageProviderBadge, - }, - oraclecloud: { - label: "Oracle Cloud Infrastructure", - icon: OracleCloudProviderBadge, - }, - mongodbatlas: { - label: "MongoDB Atlas", - icon: MongoDBAtlasProviderBadge, - }, - alibabacloud: { - label: "Alibaba Cloud", - icon: AlibabaCloudProviderBadge, - }, - cloudflare: { - label: "Cloudflare", - icon: CloudflareProviderBadge, - }, - openstack: { - label: "OpenStack", - icon: OpenStackProviderBadge, - }, - vercel: { - label: "Vercel", - icon: VercelProviderBadge, - }, - okta: { - label: "Okta", - icon: OktaProviderBadge, - }, -}; - /** Common props shared by both batch and instant modes. */ interface ProviderTypeSelectorBaseProps { providers: ProviderProps[]; @@ -247,34 +94,38 @@ export const ProviderTypeSelector = ({ .map((p) => p.attributes.provider), ), ) - .filter((type): type is ProviderType => type in PROVIDER_DATA) + .filter((type): type is ProviderType => type in PROVIDER_TYPE_DATA) .sort((a, b) => - PROVIDER_DATA[a].label.localeCompare(PROVIDER_DATA[b].label), + PROVIDER_TYPE_DATA[a].label.localeCompare(PROVIDER_TYPE_DATA[b].label), ); - const renderIcon = (providerType: ProviderType) => { - const IconComponent = PROVIDER_DATA[providerType].icon; - return ( - }> - - - ); - }; - const selectedLabel = () => { if (selectedTypes.length === 0) return null; if (selectedTypes.length === 1) { const providerType = selectedTypes[0] as ProviderType; return ( - {renderIcon(providerType)} - {PROVIDER_DATA[providerType].label} + + + {PROVIDER_TYPE_DATA[providerType].label} + ); } return ( - - {selectedTypes.length} Provider Types selected + + ({ + key: type, + type, + tooltip: PROVIDER_TYPE_DATA[type].label, + }))} + /> + + {selectedTypes.length} Provider Types selected + ); }; @@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({ - - {PROVIDER_DATA[providerType].label} + + {PROVIDER_TYPE_DATA[providerType].label} ))} diff --git a/ui/components/findings/table/provider-icon-cell.test.tsx b/ui/components/findings/table/provider-icon-cell.test.tsx new file mode 100644 index 0000000000..593122d33a --- /dev/null +++ b/ui/components/findings/table/provider-icon-cell.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProviderType } from "@/types/providers"; + +import { ProviderIconCell } from "./provider-icon-cell"; + +// Render the lazy provider badges as plain text so we can assert on them. The +// real PROVIDER_TYPE_DATA map (and its `in` guard) is exercised on purpose. +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + OracleCloudProviderBadge: () => Oracle Cloud, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + CloudflareProviderBadge: () => Cloudflare, + OpenStackProviderBadge: () => OpenStack, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +describe("ProviderIconCell", () => { + it("renders the shared provider-type icon for a known provider", async () => { + render(); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); + + it("renders a '?' placeholder for a provider type missing from the map", () => { + render( + , + ); + + expect(screen.getByText("?")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/findings/table/provider-icon-cell.tsx b/ui/components/findings/table/provider-icon-cell.tsx index 05a35b3dc2..350e730e78 100644 --- a/ui/components/findings/table/provider-icon-cell.tsx +++ b/ui/components/findings/table/provider-icon-cell.tsx @@ -1,43 +1,10 @@ import { - AlibabaCloudProviderBadge, - AWSProviderBadge, - AzureProviderBadge, - CloudflareProviderBadge, - GCPProviderBadge, - GitHubProviderBadge, - GoogleWorkspaceProviderBadge, - IacProviderBadge, - ImageProviderBadge, - KS8ProviderBadge, - M365ProviderBadge, - MongoDBAtlasProviderBadge, - OktaProviderBadge, - OpenStackProviderBadge, - OracleCloudProviderBadge, - VercelProviderBadge, -} from "@/components/icons/providers-badge"; + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { cn } from "@/lib/utils"; import { ProviderType } from "@/types"; -export const PROVIDER_ICONS = { - aws: AWSProviderBadge, - azure: AzureProviderBadge, - gcp: GCPProviderBadge, - kubernetes: KS8ProviderBadge, - m365: M365ProviderBadge, - github: GitHubProviderBadge, - googleworkspace: GoogleWorkspaceProviderBadge, - iac: IacProviderBadge, - image: ImageProviderBadge, - oraclecloud: OracleCloudProviderBadge, - mongodbatlas: MongoDBAtlasProviderBadge, - alibabacloud: AlibabaCloudProviderBadge, - cloudflare: CloudflareProviderBadge, - openstack: OpenStackProviderBadge, - vercel: VercelProviderBadge, - okta: OktaProviderBadge, -} as const; - interface ProviderIconCellProps { provider: ProviderType; size?: number; @@ -49,9 +16,9 @@ export const ProviderIconCell = ({ size = 26, className = "size-8 rounded-md bg-white", }: ProviderIconCellProps) => { - const IconComponent = PROVIDER_ICONS[provider]; - - if (!IconComponent) { + // Unknown provider types (present in the data but missing from the shared + // PROVIDER_TYPE_DATA map) render an explicit "?" rather than an empty icon. + if (!(provider in PROVIDER_TYPE_DATA)) { return (
? @@ -66,7 +33,7 @@ export const ProviderIconCell = ({ className, )} > - +
); }; diff --git a/ui/components/icons/providers-badge/provider-type-icon.test.tsx b/ui/components/icons/providers-badge/provider-type-icon.test.tsx new file mode 100644 index 0000000000..96b4f5f4d4 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-type-icon.test.tsx @@ -0,0 +1,126 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ProviderType } from "@/types/providers"; + +import { ProviderTypeIcon, ProviderTypeIconStack } from "./provider-type-icon"; + +// A provider type the API may return but this UI build does not know about. +const UNKNOWN_TYPE = "future-provider" as unknown as ProviderType; + +// Render the lazy provider badges as plain text so we can assert on them. +vi.mock("@/components/icons/providers-badge", () => ({ + AWSProviderBadge: () => AWS, + AzureProviderBadge: () => Azure, + GCPProviderBadge: () => GCP, + KS8ProviderBadge: () => Kubernetes, + M365ProviderBadge: () => M365, + GitHubProviderBadge: () => GitHub, + GoogleWorkspaceProviderBadge: () => Google Workspace, + IacProviderBadge: () => IaC, + ImageProviderBadge: () => Image, + OracleCloudProviderBadge: () => Oracle Cloud, + MongoDBAtlasProviderBadge: () => MongoDB Atlas, + AlibabaCloudProviderBadge: () => Alibaba Cloud, + CloudflareProviderBadge: () => Cloudflare, + OpenStackProviderBadge: () => OpenStack, + VercelProviderBadge: () => Vercel, + OktaProviderBadge: () => Okta, +})); + +// Render the tooltip pieces inline so the hover content is queryable in jsdom. +vi.mock("@/components/shadcn", () => ({ + Badge: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ), + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +describe("ProviderTypeIcon", () => { + it("renders the badge for the given provider type", async () => { + render(); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); + + it("renders a sized placeholder instead of crashing for an unknown type", () => { + // Regression guard for #9991: an unknown provider type must not throw. + const { container } = render( + , + ); + + expect(screen.queryByText("AWS")).not.toBeInTheDocument(); + expect(container.querySelector("div")).toHaveStyle({ + width: "24px", + height: "24px", + }); + }); +}); + +describe("ProviderTypeIconStack", () => { + it("renders one icon per item without deduping by type", async () => { + render( + , + ); + + // Two AWS accounts -> two AWS icons (no dedupe). + expect(await screen.findAllByText("AWS")).toHaveLength(2); + }); + + it("shows each item's tooltip text on the icon", async () => { + render( + , + ); + + expect(await screen.findByTestId("tooltip")).toHaveTextContent( + "account-uid-123", + ); + }); + + it("collapses items beyond `max` into a +N badge", async () => { + render( + , + ); + + expect(await screen.findByTestId("badge")).toHaveTextContent("+2"); + // First icon is shown; items sliced beyond `max` never reach the DOM. + expect(await screen.findByText("AWS")).toBeInTheDocument(); + expect(screen.queryByText("Okta")).not.toBeInTheDocument(); + }); + + it("renders known icons and skips unknown types without crashing", async () => { + // Regression guard for #9991: an unknown type in the stack must not throw. + render( + , + ); + + expect(await screen.findByText("AWS")).toBeInTheDocument(); + }); +}); diff --git a/ui/components/icons/providers-badge/provider-type-icon.tsx b/ui/components/icons/providers-badge/provider-type-icon.tsx new file mode 100644 index 0000000000..c3915f7d40 --- /dev/null +++ b/ui/components/icons/providers-badge/provider-type-icon.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { type ComponentType, lazy, Suspense } from "react"; + +import { + Badge, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn"; +import { cn } from "@/lib/utils"; +import type { ProviderType } from "@/types/providers"; + +type IconProps = { width: number; height: number }; + +const IconPlaceholder = ({ width, height }: IconProps) => ( +
+); + +// Lazy-load every provider badge so the ~16 SVGs ship in a single deferred +// chunk instead of being eagerly bundled wherever a selector is imported. +const AWSProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AWSProviderBadge, + })), +); +const AzureProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AzureProviderBadge, + })), +); +const GCPProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GCPProviderBadge, + })), +); +const KS8ProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.KS8ProviderBadge, + })), +); +const M365ProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.M365ProviderBadge, + })), +); +const GitHubProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GitHubProviderBadge, + })), +); +const GoogleWorkspaceProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.GoogleWorkspaceProviderBadge, + })), +); +const IacProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.IacProviderBadge, + })), +); +const ImageProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.ImageProviderBadge, + })), +); +const OracleCloudProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OracleCloudProviderBadge, + })), +); +const MongoDBAtlasProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.MongoDBAtlasProviderBadge, + })), +); +const AlibabaCloudProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.AlibabaCloudProviderBadge, + })), +); +const CloudflareProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.CloudflareProviderBadge, + })), +); +const OpenStackProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OpenStackProviderBadge, + })), +); +const VercelProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.VercelProviderBadge, + })), +); +const OktaProviderBadge = lazy(() => + import("@/components/icons/providers-badge").then((m) => ({ + default: m.OktaProviderBadge, + })), +); + +/** + * Single source of truth mapping each provider type to its human-readable + * label and (lazy) badge component. Shared by the account and provider-type + * selectors so both stay in sync on labels, icons, and sizing. + */ +export const PROVIDER_TYPE_DATA: Record< + ProviderType, + { label: string; icon: ComponentType } +> = { + aws: { label: "Amazon Web Services", icon: AWSProviderBadge }, + azure: { label: "Microsoft Azure", icon: AzureProviderBadge }, + gcp: { label: "Google Cloud Platform", icon: GCPProviderBadge }, + kubernetes: { label: "Kubernetes", icon: KS8ProviderBadge }, + m365: { label: "Microsoft 365", icon: M365ProviderBadge }, + github: { label: "GitHub", icon: GitHubProviderBadge }, + googleworkspace: { + label: "Google Workspace", + icon: GoogleWorkspaceProviderBadge, + }, + iac: { label: "Infrastructure as Code", icon: IacProviderBadge }, + image: { label: "Container Registry", icon: ImageProviderBadge }, + oraclecloud: { + label: "Oracle Cloud Infrastructure", + icon: OracleCloudProviderBadge, + }, + mongodbatlas: { label: "MongoDB Atlas", icon: MongoDBAtlasProviderBadge }, + alibabacloud: { label: "Alibaba Cloud", icon: AlibabaCloudProviderBadge }, + cloudflare: { label: "Cloudflare", icon: CloudflareProviderBadge }, + openstack: { label: "OpenStack", icon: OpenStackProviderBadge }, + vercel: { label: "Vercel", icon: VercelProviderBadge }, + okta: { label: "Okta", icon: OktaProviderBadge }, +}; + +interface ProviderTypeIconProps { + type: ProviderType; + size?: number; +} + +/** + * Renders a single provider-type badge with a sized placeholder fallback. + * + * Falls back to the placeholder for provider types missing from + * `PROVIDER_TYPE_DATA` (e.g. a brand-new provider the API knows but this UI + * build does not). The `type` is statically typed as `ProviderType`, so this + * only guards the runtime case — see #9991, which fixed the same crash class. + */ +export const ProviderTypeIcon = ({ + type, + size = 18, +}: ProviderTypeIconProps) => { + const data = PROVIDER_TYPE_DATA[type]; + if (!data) return ; + + const Icon = data.icon; + return ( + }> + + + ); +}; + +export interface ProviderTypeIconStackItem { + /** Stable React key (account id for accounts, provider type for types). */ + key: string; + type: ProviderType; + /** Text shown on hover to disambiguate the icon (e.g. an account UID). */ + tooltip?: string; +} + +interface ProviderTypeIconStackProps { + items: ProviderTypeIconStackItem[]; + max?: number; + size?: number; + className?: string; +} + +/** + * Icon with a hover tooltip. `TooltipContent` (shadcn) already renders inside a + * Radix portal, so the tooltip is not clipped by the selector trigger and we do + * not need to portal it ourselves. `delayDuration` is set on the tooltip itself + * because shadcn's `Tooltip` wraps each instance in its own `TooltipProvider` + * (delay 0), which would otherwise override an ancestor provider's delay. + */ +const IconWithTooltip = ({ + item, + size, +}: { + item: ProviderTypeIconStackItem; + size: number; +}) => { + const icon = ( + + + + ); + + if (!item.tooltip) return icon; + + return ( + + {icon} + {item.tooltip} + + ); +}; + +/** + * Renders up to `max` provider-type icons followed by a `+N` badge for the + * remainder. Each icon shows its `tooltip` on hover. Items are rendered as + * passed (one per selection) — callers decide whether to dedupe. + */ +export const ProviderTypeIconStack = ({ + items, + max = 3, + size = 18, + className, +}: ProviderTypeIconStackProps) => { + const visible = items.slice(0, max); + const overflow = items.slice(max); + const overflowLabel = overflow + .map((item) => item.tooltip) + .filter(Boolean) + .join(", "); + + return ( + + + {visible.map((item) => ( + + ))} + + {overflow.length > 0 && ( + + + + +{overflow.length} + + + {overflowLabel && ( + + {overflowLabel} + + )} + + )} + + ); +}; diff --git a/ui/components/integrations/s3/s3-integration-form.tsx b/ui/components/integrations/s3/s3-integration-form.tsx index 7b3af4b018..23be644570 100644 --- a/ui/components/integrations/s3/s3-integration-form.tsx +++ b/ui/components/integrations/s3/s3-integration-form.tsx @@ -8,7 +8,10 @@ import { useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; @@ -279,11 +282,14 @@ export const S3IntegrationForm = ({ // Show configuration step (step 0 or editing configuration) if (isEditingConfig || currentStep === 0) { const providerOptions = providers.map((provider) => { - const Icon = PROVIDER_ICONS[provider.attributes.provider]; + const providerType = provider.attributes.provider; return { value: provider.id, label: provider.attributes.alias || provider.attributes.uid, - icon: Icon ? : undefined, + icon: + providerType in PROVIDER_TYPE_DATA ? ( + + ) : undefined, description: provider.attributes.connection.connected ? "Connected" : "Disconnected", diff --git a/ui/components/integrations/security-hub/security-hub-integration-form.tsx b/ui/components/integrations/security-hub/security-hub-integration-form.tsx index 8a62f62457..1c3b436832 100644 --- a/ui/components/integrations/security-hub/security-hub-integration-form.tsx +++ b/ui/components/integrations/security-hub/security-hub-integration-form.tsx @@ -10,7 +10,10 @@ import { useEffect, useState } from "react"; import { Control, useForm } from "react-hook-form"; import { createIntegration, updateIntegration } from "@/actions/integrations"; -import { PROVIDER_ICONS } from "@/components/findings/table/provider-icon-cell"; +import { + PROVIDER_TYPE_DATA, + ProviderTypeIcon, +} from "@/components/icons/providers-badge/provider-type-icon"; import { AWSRoleCredentialsForm } from "@/components/providers/workflow/forms/select-credentials-type/aws/credentials-type/aws-role-credentials-form"; import { EnhancedMultiSelect } from "@/components/shadcn/select/enhanced-multi-select"; import { useToast } from "@/components/ui"; @@ -121,11 +124,14 @@ export const SecurityHubIntegrationForm = ({ ? "Connected" : "Disconnected"; - const Icon = PROVIDER_ICONS[provider.attributes.provider]; + const providerType = provider.attributes.provider; return { value: provider.id, label: provider.attributes.alias || provider.attributes.uid, - icon: Icon ? : undefined, + icon: + providerType in PROVIDER_TYPE_DATA ? ( + + ) : undefined, description: isDisabled ? `${connectionLabel} (Already in use)` : connectionLabel, diff --git a/ui/components/providers/no-providers-added.tsx b/ui/components/providers/no-providers-added.tsx new file mode 100644 index 0000000000..0860f92d80 --- /dev/null +++ b/ui/components/providers/no-providers-added.tsx @@ -0,0 +1,92 @@ +"use client"; + +import Link from "next/link"; + +import { InfoIcon } from "@/components/icons/Icons"; +import { Button, Card, CardContent } from "@/components/shadcn"; +import { cn } from "@/lib/utils"; + +const NO_PROVIDERS_ADDED_ACTION = { + BUTTON: "button", + LINK: "link", +} as const; + +interface NoProvidersAddedBaseProps { + containerClassName?: string; +} + +interface NoProvidersAddedButtonProps extends NoProvidersAddedBaseProps { + action: typeof NO_PROVIDERS_ADDED_ACTION.BUTTON; + onOpenWizard: () => void; + href?: never; +} + +interface NoProvidersAddedLinkProps extends NoProvidersAddedBaseProps { + action: typeof NO_PROVIDERS_ADDED_ACTION.LINK; + href: string; + onOpenWizard?: never; +} + +type NoProvidersAddedProps = + | NoProvidersAddedButtonProps + | NoProvidersAddedLinkProps; + +const renderCta = (props: NoProvidersAddedProps) => { + if (props.action === NO_PROVIDERS_ADDED_ACTION.LINK) { + return ( + + ); + } + + return ( + + ); +}; + +export const NoProvidersAdded = (props: NoProvidersAddedProps) => { + return ( +
+ + +
+ +

+ No Providers Configured +

+
+
+

+ No providers have been configured. Start by setting up a provider. +

+
+ + {renderCta(props)} +
+
+
+ ); +}; diff --git a/ui/components/providers/providers-accounts-view.test.tsx b/ui/components/providers/providers-accounts-view.test.tsx index 9263277ea9..c8b3baf596 100644 --- a/ui/components/providers/providers-accounts-view.test.tsx +++ b/ui/components/providers/providers-accounts-view.test.tsx @@ -1,11 +1,36 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; +const { refreshMock, replaceMock, searchParamsValue } = vi.hoisted(() => ({ + refreshMock: vi.fn(), + replaceMock: vi.fn(), + searchParamsValue: { current: "" }, +})); + +vi.mock("next/navigation", () => ({ + usePathname: () => "/providers", + useRouter: () => ({ + refresh: refreshMock, + replace: replaceMock, + }), + useSearchParams: () => new URLSearchParams(searchParamsValue.current), +})); + +vi.mock("@/components/providers/table", () => ({ + SkeletonTableProviders: () =>
, +})); + vi.mock("@/components/providers/add-provider-button", () => ({ - AddProviderButton: () => , + AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => ( + + ), })); vi.mock("@/components/providers/muted-findings-config-button", () => ({ @@ -15,7 +40,12 @@ vi.mock("@/components/providers/muted-findings-config-button", () => ({ })); vi.mock("@/components/providers/providers-filters", () => ({ - ProvidersFilters: () =>
Filters
, + ProvidersFilters: ({ actions }: { actions: ReactNode }) => ( +
+ Filters + {actions} +
+ ), })); vi.mock("@/components/providers/providers-accounts-table", () => ({ @@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({ })); vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: () =>
, + ProviderWizardModal: ({ + open, + onOpenChange, + }: { + open: boolean; + onOpenChange: (open: boolean) => void; + }) => + open ? ( +
+ Provider wizard + +
+ ) : null, })); import { ProvidersAccountsView } from "./providers-accounts-view"; @@ -36,8 +80,55 @@ const metadata: MetaDataProps = { version: "latest", }; +const disconnectedProviders: ProviderProps[] = [ + { + id: "provider-1", + type: "providers", + attributes: { + provider: "aws", + uid: "123456789012", + alias: "Production", + status: "completed", + resources: 0, + connection: { + connected: false, + last_checked_at: "2026-04-13T00:00:00Z", + }, + scanner_args: { + only_logs: false, + excluded_checks: [], + aws_retries_max_attempts: 3, + }, + inserted_at: "2026-04-13T00:00:00Z", + updated_at: "2026-04-13T00:00:00Z", + created_by: { + object: "user", + id: "user-1", + }, + }, + relationships: { + secret: { + data: null, + }, + provider_groups: { + meta: { + count: 0, + }, + data: [], + }, + }, + }, +]; + describe("ProvidersAccountsView", () => { - it("keeps the same vertical spacing between filters and table as other views", () => { + afterEach(() => { + vi.restoreAllMocks(); + searchParamsValue.current = ""; + window.history.replaceState({}, "", "/"); + }); + + it("shows a full page empty state without filters or table when there are no providers", () => { + // Given/When render( { />, ); + // Then + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: /no providers configured/i }), + ).toHaveClass("min-h-[calc(100dvh-28rem)]"); + expect(screen.queryByTestId("providers-filters")).not.toBeInTheDocument(); + expect(screen.queryByTestId("providers-table")).not.toBeInTheDocument(); + }); + + it("opens the provider wizard from the no providers CTA", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + }); + + it("opens the provider wizard from the URL without immediately clearing the one-shot intent", () => { + // Given + searchParamsValue.current = "tab=connected&addProvider=true"; + window.history.replaceState( + {}, + "", + "/providers?tab=connected&addProvider=true", + ); + // Spy only after the URL setup so we measure what the component does on mount. + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + + render( + , + ); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + expect(replaceStateSpy).not.toHaveBeenCalled(); + }); + + it("cleans the one-shot intent from the URL without refetching when the URL-opened wizard closes", async () => { + // Given + searchParamsValue.current = "tab=connected&addProvider=true"; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + // The URL is cleaned via the History API (no RSC refetch). We must NOT + // refresh/replace here: re-running the /providers Server Component on close + // read as a full page reload. The provider-creation actions already + // revalidatePath("/providers"), so the table is fresh behind the modal. + expect(replaceStateSpy).toHaveBeenCalledWith( + null, + "", + "/providers?tab=connected", + ); + expect(refreshMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("does not touch the URL or refetch when a manually opened wizard closes", async () => { + // Given: no addProvider param in the URL, wizard opened via the CTA. + searchParamsValue.current = ""; + const replaceStateSpy = vi.spyOn(window.history, "replaceState"); + const user = userEvent.setup(); + + render( + , + ); + + // When: open the wizard from the empty-state CTA, then close it. + await user.click( + screen.getByRole("button", { name: /open add provider modal/i }), + ); + await user.click(screen.getByRole("button", { name: /close/i })); + + // Then: nothing to clean and no refresh — the creation actions own the + // data refresh via revalidatePath. + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(replaceStateSpy).not.toHaveBeenCalled(); + expect(refreshMock).not.toHaveBeenCalled(); + expect(replaceMock).not.toHaveBeenCalled(); + }); + + it("keeps filters and table visible when providers are disconnected", () => { + // Given/When + render( + , + ); + + // Then expect(screen.getByTestId("providers-filters").parentElement).toHaveClass( "flex", "flex-col", "gap-6", ); expect(screen.getByTestId("providers-table")).toBeInTheDocument(); + expect( + screen.queryByText("No Providers Configured"), + ).not.toBeInTheDocument(); + }); + + it("opens the provider wizard from the normal Add Provider button", async () => { + // Given + const user = userEvent.setup(); + + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: /add provider/i })); + + // Then + expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); }); }); diff --git a/ui/components/providers/providers-accounts-view.tsx b/ui/components/providers/providers-accounts-view.tsx index b53155d8c0..62322e2ccd 100644 --- a/ui/components/providers/providers-accounts-view.tsx +++ b/ui/components/providers/providers-accounts-view.tsx @@ -1,9 +1,11 @@ "use client"; +import { usePathname, useSearchParams } from "next/navigation"; import { useState } from "react"; import { AddProviderButton } from "@/components/providers/add-provider-button"; import { MutedFindingsConfigButton } from "@/components/providers/muted-findings-config-button"; +import { NoProvidersAdded } from "@/components/providers/no-providers-added"; import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table"; import { ProvidersFilters } from "@/components/providers/providers-filters"; import { ProviderWizardModal } from "@/components/providers/wizard"; @@ -11,6 +13,10 @@ import type { OrgWizardInitialData, ProviderWizardInitialData, } from "@/components/providers/wizard/types"; +import { + ADD_PROVIDER_SEARCH_PARAM, + ADD_PROVIDER_SEARCH_VALUE, +} from "@/lib/providers-navigation"; import type { FilterOption, MetaDataProps, ProviderProps } from "@/types"; import type { ProvidersTableRow } from "@/types/providers-table"; @@ -29,7 +35,14 @@ export function ProvidersAccountsView({ providers, rows, }: ProvidersAccountsViewProps) { - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const hasNoProviders = providers.length === 0; + const shouldOpenProviderWizardFromUrl = + searchParams.get(ADD_PROVIDER_SEARCH_PARAM) === ADD_PROVIDER_SEARCH_VALUE; + const [isProviderWizardOpen, setIsProviderWizardOpen] = useState( + () => shouldOpenProviderWizardFromUrl, + ); const [providerWizardInitialData, setProviderWizardInitialData] = useState< ProviderWizardInitialData | undefined >(undefined); @@ -52,38 +65,64 @@ export function ProvidersAccountsView({ const handleWizardOpenChange = (open: boolean) => { setIsProviderWizardOpen(open); - if (!open) { - setProviderWizardInitialData(undefined); - setOrgWizardInitialData(undefined); + if (open) return; + + setProviderWizardInitialData(undefined); + setOrgWizardInitialData(undefined); + + // Only clean the one-shot ?addProvider intent from the URL bar, via the + // History API so it does NOT trigger an RSC refetch. We must not refresh + // here: the provider-creation actions (addProvider / addCredentialsProvider + // / checkConnectionProvider) already revalidatePath("/providers"), so the + // table updates behind the modal. A router.refresh()/replace() on close + // re-ran the whole /providers Server Component, which read as a full reload. + if (searchParams.has(ADD_PROVIDER_SEARCH_PARAM)) { + const params = new URLSearchParams(searchParams.toString()); + params.delete(ADD_PROVIDER_SEARCH_PARAM); + const query = params.toString(); + window.history.replaceState( + null, + "", + query ? `${pathname}?${query}` : pathname, + ); } }; return ( <> -
- - - openProviderWizard()} /> - - } + {hasNoProviders ? ( + openProviderWizard()} /> - -
+ ) : ( +
+ + + openProviderWizard()} /> + + } + /> + +
+ )} ); diff --git a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts index 40644450c2..dea38e95a9 100644 --- a/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts +++ b/ui/components/providers/wizard/hooks/use-provider-wizard-controller.ts @@ -50,6 +50,10 @@ interface UseProviderWizardControllerProps { onOpenChange: (open: boolean) => void; initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; + // When false, the caller skips the post-close router.refresh() and relies on + // the provider-creation actions' revalidatePath("/providers") to refresh the + // data. Defaults to true so standalone callers keep refreshing. + refreshOnClose?: boolean; } export function useProviderWizardController({ @@ -57,6 +61,7 @@ export function useProviderWizardController({ onOpenChange, initialData, orgInitialData, + refreshOnClose = true, }: UseProviderWizardControllerProps) { const router = useRouter(); const initialProviderId = initialData?.providerId ?? null; @@ -185,7 +190,9 @@ export function useProviderWizardController({ setProviderTypeHint(null); setOrgSetupPhase(ORG_SETUP_PHASE.DETAILS); onOpenChange(false); - router.refresh(); + if (refreshOnClose) { + router.refresh(); + } }; const handleDialogOpenChange = (nextOpen: boolean) => { diff --git a/ui/components/providers/wizard/provider-wizard-modal.tsx b/ui/components/providers/wizard/provider-wizard-modal.tsx index d731ca5786..2d0646d957 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.tsx @@ -38,6 +38,7 @@ interface ProviderWizardModalProps { onOpenChange: (open: boolean) => void; initialData?: ProviderWizardInitialData; orgInitialData?: OrgWizardInitialData; + refreshOnClose?: boolean; } export function ProviderWizardModal({ @@ -45,6 +46,7 @@ export function ProviderWizardModal({ onOpenChange, initialData, orgInitialData, + refreshOnClose, }: ProviderWizardModalProps) { const { backToProviderFlow, @@ -72,6 +74,7 @@ export function ProviderWizardModal({ onOpenChange, initialData, orgInitialData, + refreshOnClose, }); const scrollHintRefreshToken = `${wizardVariant}-${currentStep}-${orgCurrentStep}-${orgSetupPhase}`; const { containerRef, sentinelRef, showScrollHint } = useScrollHint({ diff --git a/ui/components/scans/no-providers-added.tsx b/ui/components/scans/no-providers-added.tsx deleted file mode 100644 index a108009c2d..0000000000 --- a/ui/components/scans/no-providers-added.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { Button, Card, CardContent } from "@/components/shadcn"; - -import { InfoIcon } from "../icons/Icons"; - -interface NoProvidersAddedProps { - onOpenWizard: () => void; -} - -export const NoProvidersAdded = ({ onOpenWizard }: NoProvidersAddedProps) => ( -
- - -
- -

- No Providers Configured -

-
-
-

- No providers have been configured. Start by setting up a provider. -

-
- - -
-
-
-); diff --git a/ui/components/scans/scans-providers-empty-state.test.tsx b/ui/components/scans/scans-providers-empty-state.test.tsx index dc6748a785..deee8318e3 100644 --- a/ui/components/scans/scans-providers-empty-state.test.tsx +++ b/ui/components/scans/scans-providers-empty-state.test.tsx @@ -1,73 +1,43 @@ import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; + +import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation"; import { ScansProvidersEmptyState } from "./scans-providers-empty-state"; -const { replaceMock, searchParamsValue } = vi.hoisted(() => ({ - replaceMock: vi.fn(), - searchParamsValue: { current: "" }, -})); - -vi.mock("next/navigation", () => ({ - usePathname: () => "/scans", - useRouter: () => ({ - replace: replaceMock, - }), - useSearchParams: () => new URLSearchParams(searchParamsValue.current), -})); - -vi.mock("@/components/providers/wizard", () => ({ - ProviderWizardModal: ({ open }: { open: boolean }) => - open ?
Provider wizard
: null, -})); - vi.mock("./no-providers-connected", () => ({ NoProvidersConnected: () =>
No Connected Providers
, })); describe("ScansProvidersEmptyState", () => { - afterEach(() => { - vi.clearAllMocks(); - searchParamsValue.current = ""; - }); - - it("shows the add provider message and opens the provider wizard", async () => { - const user = userEvent.setup(); - + it("shows the add provider message with a providers page CTA", () => { + // Given/When render(); - expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); - - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); - }); - - it("clears the launch scan URL intent before opening the provider wizard", async () => { - // Given - searchParamsValue.current = "tab=completed&launchScan=true"; - const user = userEvent.setup(); - - render(); - - // When - await user.click( - screen.getByRole("button", { name: /open add provider modal/i }), - ); - // Then - expect(replaceMock).toHaveBeenCalledWith("/scans?tab=completed", { - scroll: false, + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + const cta = screen.getByRole("link", { + name: /open add provider modal/i, }); - expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard"); + + expect(cta).toHaveAttribute("href", ADD_PROVIDER_HREF); + expect(cta.tagName).toBe("A"); + }); + + it("does not render the provider wizard in Scans", () => { + // Given/When + render(); + + // Then + expect(screen.getByText("No Providers Configured")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); it("shows the no connected providers message", () => { + // Given/When render(); + // Then expect(screen.getByText("No Connected Providers")).toBeInTheDocument(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); diff --git a/ui/components/scans/scans-providers-empty-state.tsx b/ui/components/scans/scans-providers-empty-state.tsx index 7837dcea2c..09258320f9 100644 --- a/ui/components/scans/scans-providers-empty-state.tsx +++ b/ui/components/scans/scans-providers-empty-state.tsx @@ -1,12 +1,6 @@ -"use client"; +import { NoProvidersAdded } from "@/components/providers/no-providers-added"; +import { ADD_PROVIDER_HREF } from "@/lib/providers-navigation"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useState } from "react"; - -import { ProviderWizardModal } from "@/components/providers/wizard"; -import { LAUNCH_SCAN_SEARCH_PARAM } from "@/lib/scans-navigation"; - -import { NoProvidersAdded } from "./no-providers-added"; import { NoProvidersConnected } from "./no-providers-connected"; interface ScansProvidersEmptyStateProps { @@ -16,35 +10,13 @@ interface ScansProvidersEmptyStateProps { export function ScansProvidersEmptyState({ thereIsNoProviders, }: ScansProvidersEmptyStateProps) { - const pathname = usePathname(); - const router = useRouter(); - const searchParams = useSearchParams(); - const [isProviderWizardOpen, setIsProviderWizardOpen] = useState(false); - - const openProviderWizard = () => { - if (searchParams.has(LAUNCH_SCAN_SEARCH_PARAM)) { - const params = new URLSearchParams(searchParams.toString()); - params.delete(LAUNCH_SCAN_SEARCH_PARAM); - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname, { - scroll: false, - }); - } - - setIsProviderWizardOpen(true); - }; - return ( <> {thereIsNoProviders ? ( - + ) : ( )} - ); } diff --git a/ui/dependency-log.json b/ui/dependency-log.json index e52d786972..76af7f296a 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -778,26 +778,26 @@ { "section": "devDependencies", "name": "@vitest/browser", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", "name": "@vitest/browser-playwright", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", "name": "@vitest/coverage-v8", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", @@ -978,10 +978,10 @@ { "section": "devDependencies", "name": "vitest", - "from": "4.1.6", - "to": "4.0.18", + "from": "4.0.18", + "to": "4.1.8", "strategy": "installed", - "generatedAt": "2026-05-14T10:22:47.378Z" + "generatedAt": "2026-06-02T11:34:46.264Z" }, { "section": "devDependencies", diff --git a/ui/lib/providers-navigation.ts b/ui/lib/providers-navigation.ts new file mode 100644 index 0000000000..7428eed540 --- /dev/null +++ b/ui/lib/providers-navigation.ts @@ -0,0 +1,3 @@ +export const ADD_PROVIDER_SEARCH_PARAM = "addProvider"; +export const ADD_PROVIDER_SEARCH_VALUE = "true"; +export const ADD_PROVIDER_HREF = `/providers?${ADD_PROVIDER_SEARCH_PARAM}=${ADD_PROVIDER_SEARCH_VALUE}`; diff --git a/ui/package.json b/ui/package.json index 8466539961..a5c34094b0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -133,9 +133,9 @@ "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@vitejs/plugin-react": "5.1.2", - "@vitest/browser": "4.0.18", - "@vitest/browser-playwright": "4.0.18", - "@vitest/coverage-v8": "4.0.18", + "@vitest/browser": "4.1.8", + "@vitest/browser-playwright": "4.1.8", + "@vitest/coverage-v8": "4.1.8", "babel-plugin-react-compiler": "1.0.0", "dotenv": "16.6.1", "dotenv-expand": "12.0.3", @@ -158,7 +158,7 @@ "prettier-plugin-tailwindcss": "0.6.14", "tailwindcss": "4.1.18", "typescript": "5.5.4", - "vitest": "4.0.18", + "vitest": "4.1.8", "vitest-browser-react": "2.0.4" }, "packageManager": "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 3996bbb8a8..6d44c180a1 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -325,14 +325,14 @@ importers: specifier: 5.1.2 version: 5.1.2(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) '@vitest/browser': - specifier: 4.0.18 - version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) '@vitest/browser-playwright': - specifier: 4.0.18 - version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) '@vitest/coverage-v8': - specifier: 4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18) + specifier: 4.1.8 + version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -400,11 +400,11 @@ importers: specifier: 5.5.4 version: 5.5.4 vitest: - specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + specifier: 4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) vitest-browser-react: specifier: 2.0.4 - version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18) + version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8) packages: @@ -737,6 +737,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@braintree/sanitize-url@7.1.1': resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==} @@ -4795,54 +4798,54 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/browser-playwright@4.0.18': - resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + '@vitest/browser-playwright@4.1.8': + resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} peerDependencies: playwright: '*' - vitest: 4.0.18 + vitest: 4.1.8 - '@vitest/browser@4.0.18': - resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + '@vitest/browser@4.1.8': + resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} peerDependencies: - vitest: 4.0.18 + vitest: 4.1.8 - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.8': + resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.8 + vitest: 4.1.8 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.8': + resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.8': + resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.8': + resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.8': + resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.8': + resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.8': + resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.8': + resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -5048,8 +5051,8 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.3: + resolution: {integrity: sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -5650,9 +5653,6 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -7246,10 +7246,6 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -7537,6 +7533,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -7829,8 +7826,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} @@ -8347,20 +8344,23 @@ packages: '@types/react-dom': optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.8: + resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.8 + '@vitest/browser-preview': 4.1.8 + '@vitest/browser-webdriverio': 4.1.8 + '@vitest/coverage-istanbul': 4.1.8 + '@vitest/coverage-v8': 4.1.8 + '@vitest/ui': 4.1.8 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -8374,6 +8374,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -9319,6 +9323,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@braintree/sanitize-url@7.1.1': {} '@cfworker/json-schema@4.1.1': {} @@ -14261,29 +14267,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)': + '@vitest/browser-playwright@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)': dependencies: - '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) playwright: 1.56.1 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18)': + '@vitest/browser@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8)': dependencies: - '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) - '@vitest/utils': 4.0.18 + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/utils': 4.1.8 magic-string: 0.30.21 - pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) ws: 8.20.1 transitivePeerDependencies: - bufferutil @@ -14291,60 +14297,62 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.12 + '@vitest/utils': 4.1.8 + ast-v8-to-istanbul: 1.0.3 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 + std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + '@vitest/browser': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.8': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))': + '@vitest/mocker@4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4) vite: 7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.8': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.8': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.8 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.8': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.8 + '@vitest/utils': 4.1.8 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.8': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.8': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.8 + convert-source-map: 2.0.0 tinyrainbow: 3.1.0 '@webassemblyjs/ast@1.14.1': @@ -14604,7 +14612,7 @@ snapshots: ast-types-flow@0.0.8: {} - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.3: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -15273,8 +15281,6 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: @@ -17280,10 +17286,6 @@ snapshots: picomatch@4.0.4: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 - pkce-challenge@5.0.1: {} pkg-types@1.3.1: @@ -17980,7 +17982,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: dependencies: @@ -18487,31 +18489,31 @@ snapshots: terser: 5.47.1 yaml: 2.9.0 - vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.0.18): + vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.8): dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.8 '@types/react-dom': 19.2.3(@types/react@19.2.8) - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.47.1)(yaml@2.9.0): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(jsdom@27.4.0)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.4 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 tinyglobby: 0.2.16 @@ -18521,20 +18523,11 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.8 - '@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.0.18) + '@vitest/browser-playwright': 4.1.8(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.2(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.47.1)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) jsdom: 27.4.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-keyname@2.2.8: {} diff --git a/ui/tests/helpers.ts b/ui/tests/helpers.ts index 2bbbdeaeec..dd4692ed57 100644 --- a/ui/tests/helpers.ts +++ b/ui/tests/helpers.ts @@ -132,6 +132,24 @@ export async function addAWSProvider( await scansPage.verifyPageLoaded(); } +/** + * Waits for the providers page to settle and reports whether the data table is + * present. With zero providers the page renders a full-page empty state + * ("No Providers Configured") instead of the table, so callers must not assume + * the table is always there. + */ +async function providersTableVisibleOrEmptyState( + page: ProvidersPage, +): Promise { + const emptyState = page.page.getByRole("region", { + name: /no providers configured/i, + }); + await expect(page.providersTable.or(emptyState)).toBeVisible({ + timeout: 10000, + }); + return page.providersTable.isVisible().catch(() => false); +} + export async function deleteProviderIfExists( page: ProvidersPage, providerUID: string, @@ -140,7 +158,11 @@ export async function deleteProviderIfExists( // Navigate to providers page await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + // With zero providers the page shows the empty state, not the table, so there + // is nothing to delete. + if (!(await providersTableVisibleOrEmptyState(page))) { + return; + } const allRows = page.providersTable.locator("tbody tr"); @@ -180,7 +202,7 @@ export async function deleteProviderIfExists( // Provider not found, nothing to delete // Navigate back to providers page to ensure clean state await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + await providersTableVisibleOrEmptyState(page); return; } @@ -217,7 +239,8 @@ export async function deleteProviderIfExists( // Wait for modal to close (this indicates deletion was initiated) await expect(modal).not.toBeVisible({ timeout: 10000 }); - // Navigate back to providers page to ensure clean state + // Navigate back to providers page to ensure clean state. Deleting the last + // provider reveals the empty state instead of an empty table. await page.goto(); - await expect(page.providersTable).toBeVisible({ timeout: 10000 }); + await providersTableVisibleOrEmptyState(page); } diff --git a/ui/tests/providers/providers-page.ts b/ui/tests/providers/providers-page.ts index 5b73269062..54545f965d 100644 --- a/ui/tests/providers/providers-page.ts +++ b/ui/tests/providers/providers-page.ts @@ -341,7 +341,10 @@ export class ProvidersPage extends BasePage { name: /Adding A Provider|Update Provider Credentials/i, }); - // Button to add a new provider + // Button to add a new provider. When providers exist this is the filter-bar + // "Add Provider" control; with zero providers the page renders the empty + // state whose CTA is labelled "Open Add Provider modal" (button on + // /providers, link on /scans). Only one of these is ever in the DOM at once. this.addProviderButton = page .getByRole("button", { name: "Add Provider", @@ -352,7 +355,9 @@ export class ProvidersPage extends BasePage { name: "Add Provider", exact: true, }), - ); + ) + .or(page.getByRole("button", { name: "Open Add Provider modal" })) + .or(page.getByRole("link", { name: "Open Add Provider modal" })); // Table displaying existing providers this.providersTable = page.getByRole("table");