fix(ui): refine add-provider wizard flow between scans and providers (#11424)

This commit is contained in:
Alejandro Bailo
2026-06-03 16:08:06 +02:00
committed by GitHub
parent f7f8747512
commit e60a4462e5
25 changed files with 1198 additions and 550 deletions
+16
View File
@@ -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
@@ -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 }) => (
<div>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -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(
<AccountsSelector
providers={providers}
onBatchChange={vi.fn()}
selectedValues={["provider-1"]}
/>,
);
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(
<AccountsSelector
providers={[providers[0], secondAws]}
onBatchChange={vi.fn()}
selectedValues={["provider-1", "provider-2"]}
/>,
);
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();
});
});
@@ -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<ProviderType, ReactNode> = {
aws: <AWSProviderBadge width={18} height={18} />,
azure: <AzureProviderBadge width={18} height={18} />,
gcp: <GCPProviderBadge width={18} height={18} />,
kubernetes: <KS8ProviderBadge width={18} height={18} />,
m365: <M365ProviderBadge width={18} height={18} />,
github: <GitHubProviderBadge width={18} height={18} />,
googleworkspace: <GoogleWorkspaceProviderBadge width={18} height={18} />,
iac: <IacProviderBadge width={18} height={18} />,
image: <ImageProviderBadge width={18} height={18} />,
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
cloudflare: <CloudflareProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
vercel: <VercelProviderBadge width={18} height={18} />,
okta: <OktaProviderBadge width={18} height={18} />,
};
/** 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 <span className="truncate">{name}</span>;
return (
<span className="flex min-w-0 items-center gap-2">
{p && (
<span aria-hidden="true">
<ProviderTypeIcon type={p.attributes.provider} />
</span>
)}
<span className="truncate">{name}</span>
</span>
);
}
// 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 (
<span className="truncate">{selectedIds.length} Providers selected</span>
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack items={items} />
<span className="truncate">
{selectedIds.length} Providers selected
</span>
</span>
);
};
@@ -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);
}}
>
<span aria-hidden="true">{icon}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="flex min-w-0 flex-1 items-center gap-2">
<span className="truncate">{displayName}</span>
{isDisabled && <Badge variant="tag">Disconnected</Badge>}
@@ -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", () => ({
<div>{children}</div>
),
MultiSelectTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
<div data-testid="trigger">{children}</div>
),
MultiSelectValue: ({ placeholder }: { placeholder: string }) => (
<span>{placeholder}</span>
@@ -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(
<ProviderTypeSelector
providers={[providers[0], azure]}
onBatchChange={vi.fn()}
selectedValues={["aws", "azure"]}
/>,
);
const trigger = screen.getByTestId("trigger");
expect(await within(trigger).findByText("AWS")).toBeInTheDocument();
expect(
within(trigger).getByText("2 Provider Types selected"),
).toBeInTheDocument();
});
});
@@ -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) => (
<div style={{ width, height }} />
);
const PROVIDER_DATA: Record<
ProviderType,
{ label: string; icon: ComponentType<IconProps> }
> = {
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 (
<Suspense fallback={<IconPlaceholder width={24} height={24} />}>
<IconComponent width={24} height={24} />
</Suspense>
);
};
const selectedLabel = () => {
if (selectedTypes.length === 0) return null;
if (selectedTypes.length === 1) {
const providerType = selectedTypes[0] as ProviderType;
return (
<span className="flex min-w-0 items-center gap-2">
{renderIcon(providerType)}
<span className="truncate">{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} />
</span>
<span className="truncate">
{PROVIDER_TYPE_DATA[providerType].label}
</span>
</span>
);
}
return (
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
<span className="flex min-w-0 items-center gap-2">
<ProviderTypeIconStack
items={(selectedTypes as ProviderType[]).map((type) => ({
key: type,
type,
tooltip: PROVIDER_TYPE_DATA[type].label,
}))}
/>
<span className="min-w-0 truncate">
{selectedTypes.length} Provider Types selected
</span>
</span>
);
};
@@ -329,12 +180,17 @@ export const ProviderTypeSelector = ({
<MultiSelectItem
key={providerType}
value={providerType}
badgeLabel={PROVIDER_DATA[providerType].label}
keywords={[providerType, PROVIDER_DATA[providerType].label]}
aria-label={`${PROVIDER_DATA[providerType].label} Provider Type`}
badgeLabel={PROVIDER_TYPE_DATA[providerType].label}
keywords={[
providerType,
PROVIDER_TYPE_DATA[providerType].label,
]}
aria-label={`${PROVIDER_TYPE_DATA[providerType].label} Provider Type`}
>
<span aria-hidden="true">{renderIcon(providerType)}</span>
<span>{PROVIDER_DATA[providerType].label}</span>
<span aria-hidden="true">
<ProviderTypeIcon type={providerType} size={24} />
</span>
<span>{PROVIDER_TYPE_DATA[providerType].label}</span>
</MultiSelectItem>
))}
</>
@@ -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: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
describe("ProviderIconCell", () => {
it("renders the shared provider-type icon for a known provider", async () => {
render(<ProviderIconCell provider="aws" />);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
it("renders a '?' placeholder for a provider type missing from the map", () => {
render(
<ProviderIconCell
provider={"future-provider" as unknown as ProviderType}
/>,
);
expect(screen.getByText("?")).toBeInTheDocument();
});
});
@@ -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 (
<div className={cn("flex items-center justify-center", className)}>
<span className="text-text-neutral-secondary text-xs">?</span>
@@ -66,7 +33,7 @@ export const ProviderIconCell = ({
className,
)}
>
<IconComponent width={size} height={size} />
<ProviderTypeIcon type={provider} size={size} />
</div>
);
};
@@ -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: () => <span>AWS</span>,
AzureProviderBadge: () => <span>Azure</span>,
GCPProviderBadge: () => <span>GCP</span>,
KS8ProviderBadge: () => <span>Kubernetes</span>,
M365ProviderBadge: () => <span>M365</span>,
GitHubProviderBadge: () => <span>GitHub</span>,
GoogleWorkspaceProviderBadge: () => <span>Google Workspace</span>,
IacProviderBadge: () => <span>IaC</span>,
ImageProviderBadge: () => <span>Image</span>,
OracleCloudProviderBadge: () => <span>Oracle Cloud</span>,
MongoDBAtlasProviderBadge: () => <span>MongoDB Atlas</span>,
AlibabaCloudProviderBadge: () => <span>Alibaba Cloud</span>,
CloudflareProviderBadge: () => <span>Cloudflare</span>,
OpenStackProviderBadge: () => <span>OpenStack</span>,
VercelProviderBadge: () => <span>Vercel</span>,
OktaProviderBadge: () => <span>Okta</span>,
}));
// Render the tooltip pieces inline so the hover content is queryable in jsdom.
vi.mock("@/components/shadcn", () => ({
Badge: ({ children }: { children: React.ReactNode }) => (
<span data-testid="badge">{children}</span>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<span data-testid="tooltip">{children}</span>
),
}));
describe("ProviderTypeIcon", () => {
it("renders the badge for the given provider type", async () => {
render(<ProviderTypeIcon type="aws" />);
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(
<ProviderTypeIcon type={UNKNOWN_TYPE} size={24} />,
);
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(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: "aws", tooltip: "222" },
]}
/>,
);
// 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(
<ProviderTypeIconStack
items={[{ key: "a", type: "aws", tooltip: "account-uid-123" }]}
/>,
);
expect(await screen.findByTestId("tooltip")).toHaveTextContent(
"account-uid-123",
);
});
it("collapses items beyond `max` into a +N badge", async () => {
render(
<ProviderTypeIconStack
max={3}
items={[
{ key: "a", type: "aws", tooltip: "1" },
{ key: "b", type: "azure", tooltip: "2" },
{ key: "c", type: "gcp", tooltip: "3" },
{ key: "d", type: "github", tooltip: "4" },
{ key: "e", type: "okta", tooltip: "5" },
]}
/>,
);
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(
<ProviderTypeIconStack
items={[
{ key: "a", type: "aws", tooltip: "111" },
{ key: "b", type: UNKNOWN_TYPE, tooltip: "222" },
]}
/>,
);
expect(await screen.findByText("AWS")).toBeInTheDocument();
});
});
@@ -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) => (
<div style={{ width, height }} />
);
// 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<IconProps> }
> = {
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 <IconPlaceholder width={size} height={size} />;
const Icon = data.icon;
return (
<Suspense fallback={<IconPlaceholder width={size} height={size} />}>
<Icon width={size} height={size} />
</Suspense>
);
};
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 = (
<span className="inline-flex shrink-0">
<ProviderTypeIcon type={item.type} size={size} />
</span>
);
if (!item.tooltip) return icon;
return (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent side="top">{item.tooltip}</TooltipContent>
</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 (
<span className={cn("flex shrink-0 items-center gap-1", className)}>
<span className="flex items-center gap-1">
{visible.map((item) => (
<IconWithTooltip key={item.key} item={item} size={size} />
))}
</span>
{overflow.length > 0 && (
<Tooltip delayDuration={150}>
<TooltipTrigger asChild>
<Badge variant="tag" className="px-1.5 py-0.5 text-xs font-medium">
+{overflow.length}
</Badge>
</TooltipTrigger>
{overflowLabel && (
<TooltipContent side="top" className="max-w-xs">
{overflowLabel}
</TooltipContent>
)}
</Tooltip>
)}
</span>
);
};
@@ -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 ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: provider.attributes.connection.connected
? "Connected"
: "Disconnected",
@@ -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 ? <Icon width={20} height={20} /> : undefined,
icon:
providerType in PROVIDER_TYPE_DATA ? (
<ProviderTypeIcon type={providerType} size={20} />
) : undefined,
description: isDisabled
? `${connectionLabel} (Already in use)`
: connectionLabel,
@@ -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 (
<Button
asChild
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
>
<Link href={props.href}>Get Started</Link>
</Button>
);
}
return (
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={props.onOpenWizard}
>
Get Started
</Button>
);
};
export const NoProvidersAdded = (props: NoProvidersAddedProps) => {
return (
<div
role="region"
aria-labelledby="no-providers-added-title"
className={cn(
"flex min-h-[calc(100dvh-10rem)] items-center justify-center",
props.containerClassName,
)}
>
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2
id="no-providers-added-title"
className="text-2xl font-bold text-gray-800 dark:text-white"
>
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
{renderCta(props)}
</CardContent>
</Card>
</div>
);
};
@@ -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: () => <div data-testid="providers-skeleton" />,
}));
vi.mock("@/components/providers/add-provider-button", () => ({
AddProviderButton: () => <button type="button">Add provider</button>,
AddProviderButton: ({ onOpenWizard }: { onOpenWizard: () => void }) => (
<button type="button" onClick={onOpenWizard}>
Add Provider
</button>
),
}));
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: () => <div data-testid="providers-filters">Filters</div>,
ProvidersFilters: ({ actions }: { actions: ReactNode }) => (
<div data-testid="providers-filters">
Filters
{actions}
</div>
),
}));
vi.mock("@/components/providers/providers-accounts-table", () => ({
@@ -23,7 +53,21 @@ vi.mock("@/components/providers/providers-accounts-table", () => ({
}));
vi.mock("@/components/providers/wizard", () => ({
ProviderWizardModal: () => <div data-testid="provider-wizard-modal" />,
ProviderWizardModal: ({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) =>
open ? (
<div role="dialog">
Provider wizard
<button type="button" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : 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(
<ProvidersAccountsView
isCloud={false}
@@ -48,11 +139,170 @@ describe("ProvidersAccountsView", () => {
/>,
);
// 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(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// 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(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// 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(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// 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(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={providers}
rows={rows}
/>,
);
// 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(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// 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(
<ProvidersAccountsView
isCloud={false}
filters={filters}
metadata={metadata}
providers={disconnectedProviders}
rows={rows}
/>,
);
// When
await user.click(screen.getByRole("button", { name: /add provider/i }));
// Then
expect(screen.getByRole("dialog")).toHaveTextContent("Provider wizard");
});
});
@@ -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 (
<>
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
{hasNoProviders ? (
<NoProvidersAdded
action="button"
containerClassName="min-h-[calc(100dvh-28rem)]"
onOpenWizard={() => openProviderWizard()}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
) : (
<div className="flex flex-col gap-6">
<ProvidersFilters
filters={filters}
providers={providers}
actions={
<>
<MutedFindingsConfigButton />
<AddProviderButton onOpenWizard={() => openProviderWizard()} />
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
onOpenProviderWizard={openProviderWizard}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</div>
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={handleWizardOpenChange}
initialData={providerWizardInitialData}
orgInitialData={orgWizardInitialData}
refreshOnClose={false}
/>
</>
);
@@ -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) => {
@@ -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({
@@ -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) => (
<div className="flex min-h-screen items-center justify-center">
<Card variant="base" className="mx-auto w-full max-w-3xl">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center sm:p-8">
<div className="flex flex-col items-center gap-4">
<InfoIcon className="h-10 w-10 text-gray-800 dark:text-white" />
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
No Providers Configured
</h2>
</div>
<div className="flex flex-col items-center gap-3">
<p className="text-md leading-relaxed text-gray-600 dark:text-gray-300">
No providers have been configured. Start by setting up a provider.
</p>
</div>
<Button
aria-label="Open Add Provider modal"
className="w-full max-w-xs justify-center"
size="lg"
onClick={onOpenWizard}
>
Get Started
</Button>
</CardContent>
</Card>
</div>
);
@@ -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 ? <div role="dialog">Provider wizard</div> : null,
}));
vi.mock("./no-providers-connected", () => ({
NoProvidersConnected: () => <div>No Connected Providers</div>,
}));
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(<ScansProvidersEmptyState thereIsNoProviders />);
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(<ScansProvidersEmptyState thereIsNoProviders />);
// 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(<ScansProvidersEmptyState thereIsNoProviders />);
// Then
expect(screen.getByText("No Providers Configured")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("shows the no connected providers message", () => {
// Given/When
render(<ScansProvidersEmptyState thereIsNoProviders={false} />);
// Then
expect(screen.getByText("No Connected Providers")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
@@ -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 ? (
<NoProvidersAdded onOpenWizard={openProviderWizard} />
<NoProvidersAdded action="link" href={ADD_PROVIDER_HREF} />
) : (
<NoProvidersConnected />
)}
<ProviderWizardModal
open={isProviderWizardOpen}
onOpenChange={setIsProviderWizardOpen}
/>
</>
);
}
+12 -12
View File
@@ -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",
+3
View File
@@ -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}`;
+4 -4
View File
@@ -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",
+103 -110
View File
@@ -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: {}
+27 -4
View File
@@ -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<boolean> {
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);
}
+7 -2
View File
@@ -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");