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);
}}
>
- {icon}
+
+
+
{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 = ({
- {renderIcon(providerType)}
- {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");