From 2e5c3961d1381ce389b485e17bc8bee774adfe2d Mon Sep 17 00:00:00 2001 From: alejandrobailo Date: Mon, 21 Sep 2026 16:45:49 +0200 Subject: [PATCH] feat(ui): offer Add Provider in the sidebar for empty tenants --- .../sidebar-add-provider-action.added.md | 1 + .../app-sidebar/app-sidebar-content.test.tsx | 72 ++++++++++++++++++- .../layout/app-sidebar/launch-scan-action.tsx | 60 +++++++++++++++- .../wizard/provider-wizard-modal.test.tsx | 46 ++++++++++++ .../providers/wizard/steps/connect-step.tsx | 3 + ui/tests/scans/scans-page.ts | 6 +- 6 files changed, 181 insertions(+), 7 deletions(-) create mode 100644 ui/changelog.d/sidebar-add-provider-action.added.md diff --git a/ui/changelog.d/sidebar-add-provider-action.added.md b/ui/changelog.d/sidebar-add-provider-action.added.md new file mode 100644 index 0000000000..29cc8aec45 --- /dev/null +++ b/ui/changelog.d/sidebar-add-provider-action.added.md @@ -0,0 +1 @@ +Sidebar action reads Add Provider while the tenant has no providers diff --git a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx index 8e33a31b86..055d173ae3 100644 --- a/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx +++ b/ui/components/layout/app-sidebar/app-sidebar-content.test.tsx @@ -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 }, 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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"); diff --git a/ui/components/layout/app-sidebar/launch-scan-action.tsx b/ui/components/layout/app-sidebar/launch-scan-action.tsx index 1da429811f..26f0e55002 100644 --- a/ui/components/layout/app-sidebar/launch-scan-action.tsx +++ b/ui/components/layout/app-sidebar/launch-scan-action.tsx @@ -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 ( + + ); + } + + const trackLaunchScan = () => + dispatchProviderFunnel({ + step: PROVIDER_FUNNEL_STEP.SIDEBAR_CTA_CLICKED, + variant: SIDEBAR_CTA_VARIANT.LAUNCH_SCAN, + }); + if (isScansPage) { return ( diff --git a/ui/components/providers/wizard/provider-wizard-modal.test.tsx b/ui/components/providers/wizard/provider-wizard-modal.test.tsx index f77a866831..a925a8903d 100644 --- a/ui/components/providers/wizard/provider-wizard-modal.test.tsx +++ b/ui/components/providers/wizard/provider-wizard-modal.test.tsx @@ -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).detail); + }; + window.addEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal); + const user = userEvent.setup(); + render(); + + 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( diff --git a/ui/components/providers/wizard/steps/connect-step.tsx b/ui/components/providers/wizard/steps/connect-step.tsx index 649f0d1562..7d9945265f 100644 --- a/ui/components/providers/wizard/steps/connect-step.tsx +++ b/ui/components/providers/wizard/steps/connect-step.tsx @@ -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(); }; diff --git a/ui/tests/scans/scans-page.ts b/ui/tests/scans/scans-page.ts index e75c2d3508..9a18d341c0 100644 --- a/ui/tests/scans/scans-page.ts +++ b/ui/tests/scans/scans-page.ts @@ -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