feat(ui): offer Add Provider in the sidebar for empty tenants

This commit is contained in:
alejandrobailo
2026-09-21 16:45:49 +02:00
parent 8da79bb47e
commit 2e5c3961d1
6 changed files with 181 additions and 7 deletions
@@ -0,0 +1 @@
Sidebar action reads Add Provider while the tenant has no providers
@@ -13,11 +13,13 @@ const {
openCloudUpgradeMock,
openLaunchScanModalMock,
pathnameValue,
permissionsValue,
pushMock,
} = vi.hoisted(() => ({
openCloudUpgradeMock: vi.fn(),
openLaunchScanModalMock: vi.fn(),
pathnameValue: { current: "/findings" },
permissionsValue: { current: {} as Record<string, boolean> },
pushMock: vi.fn(),
}));
@@ -27,7 +29,7 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/hooks", () => ({
useAuth: () => ({ permissions: {} }),
useAuth: () => ({ permissions: permissionsValue.current }),
}));
vi.mock("@/hooks/use-runtime-config", () => ({
@@ -54,11 +56,16 @@ vi.mock("@/app/(prowler)/lighthouse/_components/navigation", () => ({
describe("AppSidebarContent", () => {
beforeEach(() => {
pathnameValue.current = "/findings";
permissionsValue.current = { manage_providers: true };
pushMock.mockClear();
openCloudUpgradeMock.mockClear();
openLaunchScanModalMock.mockClear();
useAppSidebarMode.setState({ mode: APP_SIDEBAR_MODE.BROWSE });
useUIStore.setState({ registryEligible: false });
useUIStore.setState({
registryEligible: false,
hasProviders: false,
hasProvidersResolved: false,
});
});
afterEach(() => {
@@ -163,6 +170,67 @@ describe("AppSidebarContent", () => {
expect(pushMock).toHaveBeenCalledWith("/lighthouse");
});
it("offers Add Provider instead of Launch Scan once the tenant is known to have no providers", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useUIStore.setState({ hasProviders: false, hasProvidersResolved: true });
// When
render(<AppSidebarContent />);
// Then
expect(screen.getByRole("link", { name: "Add Provider" })).toHaveAttribute(
"href",
"/providers?addProvider=true&addProviderSource=sidebar_cta",
);
expect(
screen.queryByRole("link", { name: "Launch Scan" }),
).not.toBeInTheDocument();
});
it("keeps Launch Scan for a user who cannot add providers", () => {
// Given: an empty list may only mean limited visibility.
vi.stubEnv("UI_CLOUD_ENABLED", "false");
permissionsValue.current = { manage_providers: false };
useUIStore.setState({ hasProviders: false, hasProvidersResolved: true });
// When
render(<AppSidebarContent />);
// Then
expect(screen.getByRole("link", { name: "Launch Scan" })).toBeVisible();
expect(
screen.queryByRole("link", { name: "Add Provider" }),
).not.toBeInTheDocument();
});
it("keeps Launch Scan while the provider count is still unresolved", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useUIStore.setState({ hasProviders: false, hasProvidersResolved: false });
// When
render(<AppSidebarContent />);
// Then
expect(screen.getByRole("link", { name: "Launch Scan" })).toBeVisible();
expect(
screen.queryByRole("link", { name: "Add Provider" }),
).not.toBeInTheDocument();
});
it("keeps Launch Scan for a tenant that already has providers", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useUIStore.setState({ hasProviders: true, hasProvidersResolved: true });
// When
render(<AppSidebarContent />);
// Then
expect(screen.getByRole("link", { name: "Launch Scan" })).toBeVisible();
});
it("opens the current scan modal instead of navigating from the scans route", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
@@ -1,15 +1,28 @@
"use client";
import { ScanLine } from "lucide-react";
import { CloudCog, ScanLine } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Button } from "@/components/shadcn/button/button";
import { useAuth } from "@/hooks";
import {
dispatchProviderFunnel,
PROVIDER_FUNNEL_STEP,
SIDEBAR_CTA_VARIANT,
WIZARD_OPEN_SOURCE,
} from "@/lib/provider-funnel/provider-funnel-events";
import { buildAddProviderHref } from "@/lib/providers-navigation";
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
import { useScansStore } from "@/store";
import { useUIStore } from "@/store/ui/store";
import type { AppSidebarSelectionHandler } from "./types";
const ADD_PROVIDER_FROM_SIDEBAR_HREF = buildAddProviderHref(
WIZARD_OPEN_SOURCE.SIDEBAR_CTA,
);
interface LaunchScanActionProps {
onSelect?: AppSidebarSelectionHandler;
}
@@ -28,8 +41,43 @@ export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
const openLaunchScanModal = useScansStore(
(state) => state.openLaunchScanModal,
);
const { permissions } = useAuth();
// Only a confirmed empty tenant swaps the action; an unresolved count keeps Launch Scan.
const hasNoProviders = useUIStore(
(state) => state.hasProvidersResolved && !state.hasProviders,
);
// Without the permission an empty list may just be limited visibility.
const needsFirstProvider =
hasNoProviders && permissions.manage_providers === true;
const isScansPage = pathname.startsWith("/scans");
if (needsFirstProvider) {
return (
<Button asChild size="lg" className="w-full">
<Link
href={ADD_PROVIDER_FROM_SIDEBAR_HREF}
aria-label="Add Provider"
onClick={() => {
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED,
variant: SIDEBAR_CTA_VARIANT.ADD_PROVIDER,
});
onSelect?.();
}}
>
<CloudCog aria-hidden="true" className="size-5" />
<span>Add Provider</span>
</Link>
</Button>
);
}
const trackLaunchScan = () =>
dispatchProviderFunnel({
step: PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED,
variant: SIDEBAR_CTA_VARIANT.LAUNCH_SCAN,
});
if (isScansPage) {
return (
<Button
@@ -38,6 +86,7 @@ export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
className="w-full"
aria-label="Launch Scan"
onClick={() => {
trackLaunchScan();
openLaunchScanModal();
onSelect?.();
}}
@@ -49,7 +98,14 @@ export function LaunchScanAction({ onSelect }: LaunchScanActionProps) {
return (
<Button asChild size="lg" className="w-full">
<Link href={LAUNCH_SCAN_HREF} aria-label="Launch Scan" onClick={onSelect}>
<Link
href={LAUNCH_SCAN_HREF}
aria-label="Launch Scan"
onClick={() => {
trackLaunchScan();
onSelect?.();
}}
>
<LaunchScanContent />
</Link>
</Button>
@@ -4,7 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Toaster } from "@/components/shadcn/toast/Toaster";
import { resetToasts } from "@/components/shadcn/toast/use-toast";
import {
PROVIDER_FUNNEL_EVENT,
type ProviderFunnelDetail,
} from "@/lib/provider-funnel/provider-funnel-events";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { useUIStore } from "@/store/ui/store";
import { ProviderWizardModal } from "./provider-wizard-modal";
@@ -132,6 +137,20 @@ describe("provider wizard account creation", () => {
expect(await screen.findByText("Credential details")).toBeVisible();
});
it("tells the rest of the app the tenant now has a provider", async () => {
// Given
useUIStore.setState({ hasProviders: false, hasProvidersResolved: true });
addRegistryProvider.mockResolvedValueOnce(createdAccount);
const user = await enterAccountDetails();
// When
await user.click(screen.getByRole("button", { name: "Next" }));
await screen.findByText("Credential details");
// Then: the sidebar stops offering Add Provider without waiting for a reload.
expect(useUIStore.getState().hasProviders).toBe(true);
});
it("restores Next after a failed creation and retries the same account", async () => {
// Given
const failure = { errors: [{ detail: "Creation failed. Try again." }] };
@@ -206,6 +225,33 @@ describe("provider wizard account creation", () => {
expect(addRegistryProvider).toHaveBeenCalledTimes(2);
});
it("signals the provider type the user picked, once", async () => {
// Given
const funnelSignals: ProviderFunnelDetail[] = [];
const recordFunnelSignal: EventListener = (event) => {
funnelSignals.push((event as CustomEvent<ProviderFunnelDetail>).detail);
};
window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
const user = userEvent.setup();
render(<ProviderWizardModal open onOpenChange={vi.fn()} />);
await screen.findByRole("option", { name: "Acme Cloud Registry" });
// When
await user.click(
screen.getByRole("option", { name: /Amazon Web Services/ }),
);
await screen.findByRole("radio", {
name: "Add A Single AWS Cloud Account",
});
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
// Then
expect(funnelSignals).toEqual([
{ step: "provider_type_selected", providerType: "aws" },
]);
});
it("keeps native providers available during a Registry discovery error and retries", async () => {
// Given
getInstalledRegistryProviderOptions.mockRejectedValueOnce(
@@ -7,6 +7,7 @@ import {
ConnectAccountSuccessData,
} from "@/components/providers/workflow/forms";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import { useUIStore } from "@/store/ui/store";
import { OrgFlowType } from "@/types/organizations";
import { PROVIDER_WIZARD_MODE } from "@/types/provider-wizard";
import { ProviderType } from "@/types/providers";
@@ -52,6 +53,8 @@ export function ConnectStep({
setVia(null);
setSecretId(null);
setMode(PROVIDER_WIZARD_MODE.ADD);
// The layout only re-counts providers on a server render; flip the shared flag now.
useUIStore.getState().setHasProviders(true);
onNext();
};
+3 -3
View File
@@ -24,9 +24,9 @@ export class ScansPage extends BasePage {
super(page);
// Scan provider selection elements
// The sidebar exposes its own icon-button labeled "Launch Scan"
// (aria-label, wrapped in a Tooltip), so scoping by accessible name
// alone hits a strict-mode duplicate. Scope to the page-shell's
// The sidebar exposes its own action labeled "Launch Scan" (it reads
// "Add Provider" only while the tenant has no providers), so scoping by
// accessible name alone hits a strict-mode duplicate. Scope to the page-shell's
// tabs-and-actions group, which only contains the visible-text
// Launch Scan button.
this.launchScanButton = page