mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): offer Add Provider in the sidebar for empty tenants
This commit is contained in:
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user