From faa74f4c6cda8a834f5fea7e0793258cb4d6c7cb Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:11:28 +0200 Subject: [PATCH] feat(ui): add Local Server Cloud upgrade prompts (#11982) --- ui/app/(auth)/layout.tsx | 3 +- .../seed-from-findings-button.test.tsx | 39 +++-- .../_components/seed-from-findings-button.tsx | 21 ++- ui/app/(prowler)/alerts/page.tsx | 3 +- .../_components/compliance-page-tabs.test.tsx | 26 ++- .../_components/compliance-page-tabs.tsx | 26 ++- ui/app/(prowler)/findings/page.tsx | 3 +- ui/app/(prowler)/lighthouse/settings/page.tsx | 3 + ui/app/(prowler)/providers/page.test.ts | 55 ------- ui/app/(prowler)/providers/page.tsx | 14 +- .../local-server-cloud-upgrades.added.md | 1 + ui/components/auth/oss/auth-layout.tsx | 6 - ui/components/auth/oss/public-auth-shell.tsx | 18 +++ .../table/finding-note-modal.test.tsx | 25 ++- .../findings/table/finding-note-modal.tsx | 28 +++- .../table/finding-triage-cells.test.tsx | 66 +++++++- .../findings/table/finding-triage-cells.tsx | 31 ++++ .../icons/prowler/ProwlerIcons.test.tsx | 49 ++++++ ui/components/icons/prowler/ProwlerIcons.tsx | 52 ++++++ .../layout/main-layout/main-layout.test.tsx | 15 +- .../layout/main-layout/main-layout.tsx | 2 + .../layout/sidebar/collapsible-menu.tsx | 31 ++-- ui/components/layout/sidebar/menu-item.tsx | 17 +- ui/components/layout/sidebar/menu.test.tsx | 58 ++++++- ui/components/layout/sidebar/menu.tsx | 68 +++++++- .../layout/sidebar/sheet-menu.test.tsx | 35 ++++ ui/components/layout/sidebar/sheet-menu.tsx | 33 +++- ui/components/layout/sidebar/sidebar.tsx | 5 +- .../layout/sidebar/submenu-item.test.tsx | 69 +++----- ui/components/layout/sidebar/submenu-item.tsx | 91 +++++++---- ui/components/lighthouse-v1/index.ts | 1 + .../managed-lighthouse-callout.test.tsx | 33 ++++ .../managed-lighthouse-callout.tsx | 51 ++++++ .../aws-method-selector.test.tsx | 25 ++- .../organizations/aws-method-selector.tsx | 23 ++- .../wizard/steps/launch-step.test.tsx | 25 +++ .../providers/wizard/steps/launch-step.tsx | 17 +- .../scans/launch-scan-modal.test.tsx | 15 +- ui/components/scans/launch-scan-modal.tsx | 34 ++-- ui/components/scans/scans-filter-bar.tsx | 3 +- ui/components/scans/scans-page-shell.tsx | 3 +- .../schedule/scan-schedule-fields.test.tsx | 30 +++- .../scans/schedule/scan-schedule-fields.tsx | 22 ++- ui/components/shadcn/badge/badge.test.tsx | 33 ++++ ui/components/shadcn/badge/badge.tsx | 11 +- ui/components/shadcn/dialog.tsx | 2 +- ui/components/shadcn/modal/modal.tsx | 3 + ui/components/shared/cloud-feature-badge.tsx | 90 ----------- .../shared/cloud-upgrade-modal.test.tsx | 136 ++++++++++++++++ ui/components/shared/cloud-upgrade-modal.tsx | 107 ++++++++++++ .../sidebar/navigation-mode-toggle.tsx | 54 +++++-- ui/config/site.test.ts | 30 ++++ ui/config/site.ts | 6 +- ui/lib/cloud-upgrade.test.ts | 84 ++++++++++ ui/lib/cloud-upgrade.ts | 152 ++++++++++++++++++ ui/lib/lighthouse-v1/system-prompt.ts | 4 +- ui/lib/menu-list.test.ts | 48 ++++-- ui/lib/menu-list.ts | 81 ++++++++-- ui/public/logos/prowler-cloud-dark.svg | 1 + ui/public/logos/prowler-cloud-light.svg | 1 + ui/public/logos/prowler-local-server-dark.svg | 1 + .../logos/prowler-local-server-light.svg | 1 + ui/store/cloud-upgrade/store.ts | 29 ++++ ui/store/index.ts | 1 + ui/styles/globals.css | 14 ++ ui/types/cloud-upgrade.ts | 14 ++ ui/types/components.ts | 37 ++++- ui/types/index.ts | 1 + 68 files changed, 1687 insertions(+), 429 deletions(-) delete mode 100644 ui/app/(prowler)/providers/page.test.ts create mode 100644 ui/changelog.d/local-server-cloud-upgrades.added.md create mode 100644 ui/components/auth/oss/public-auth-shell.tsx create mode 100644 ui/components/icons/prowler/ProwlerIcons.test.tsx create mode 100644 ui/components/layout/sidebar/sheet-menu.test.tsx create mode 100644 ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx create mode 100644 ui/components/lighthouse-v1/managed-lighthouse-callout.tsx delete mode 100644 ui/components/shared/cloud-feature-badge.tsx create mode 100644 ui/components/shared/cloud-upgrade-modal.test.tsx create mode 100644 ui/components/shared/cloud-upgrade-modal.tsx create mode 100644 ui/config/site.test.ts create mode 100644 ui/lib/cloud-upgrade.test.ts create mode 100644 ui/lib/cloud-upgrade.ts create mode 100644 ui/public/logos/prowler-cloud-dark.svg create mode 100644 ui/public/logos/prowler-cloud-light.svg create mode 100644 ui/public/logos/prowler-local-server-dark.svg create mode 100644 ui/public/logos/prowler-local-server-light.svg create mode 100644 ui/store/cloud-upgrade/store.ts create mode 100644 ui/types/cloud-upgrade.ts diff --git a/ui/app/(auth)/layout.tsx b/ui/app/(auth)/layout.tsx index 27fff93fe5..b0a9bc2061 100644 --- a/ui/app/(auth)/layout.tsx +++ b/ui/app/(auth)/layout.tsx @@ -5,6 +5,7 @@ import { Metadata, Viewport } from "next"; import { connection } from "next/server"; import { ReactNode, Suspense } from "react"; +import { PublicAuthShell } from "@/components/auth/oss/public-auth-shell"; import { RuntimePublicConfig } from "@/components/runtime-config/runtime-public-config"; import { NavigationProgress, Toaster } from "@/components/shadcn"; import { fontMono, fontSans } from "@/config/fonts"; @@ -65,7 +66,7 @@ export default async function AuthLayout({ - {children} + {children} {gtmId && } diff --git a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx index 074304e58c..4250a9c579 100644 --- a/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx +++ b/ui/app/(prowler)/alerts/_components/__tests__/seed-from-findings-button.test.tsx @@ -8,6 +8,8 @@ import type { AlertFormSubmitResult, AlertFormValues, } from "@/app/(prowler)/alerts/_types/alert-form"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; const routerMocks = vi.hoisted(() => ({ push: vi.fn(), @@ -99,6 +101,7 @@ import { SeedFromFindingsButton } from "../seed-from-findings-button"; describe("SeedFromFindingsButton", () => { afterEach(() => { vi.clearAllMocks(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); it("should explain why creating an alert is disabled when no real filters are applied", async () => { @@ -356,8 +359,9 @@ describe("SeedFromFindingsButton", () => { ).not.toBeInTheDocument(); }); - it("should render disabled as a Cloud-only feature in OSS", () => { + it("should open the Alerts upgrade in Local Server", async () => { // Given + const user = userEvent.setup(); render( { // When const button = screen.getByRole("button", { name: /Create Alert/i }); + await user.click(button); // Then - expect(button).toBeDisabled(); + expect(button).not.toBeDisabled(); expect(button.className).not.toContain("min-w"); expect(button).not.toHaveClass("justify-start"); - const pricingLink = screen.getByRole("link", { - name: /available in prowler cloud/i, - }); - expect(pricingLink).toHaveAttribute("href", "https://prowler.com/pricing"); - expect(pricingLink).toHaveClass("whitespace-nowrap"); - expect(pricingLink).toHaveTextContent("Available in Prowler Cloud"); - expect(pricingLink.closest("button")).toBeNull(); - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ALERTS, + ); expect(actionMocks.seedAlertRule).not.toHaveBeenCalled(); }); + + it("should expose a single keyboard stop for the Local Server upgrade", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.tab(); + + // Then + expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveFocus(); + }); }); diff --git a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx index e30ed9eee7..6b94549a9e 100644 --- a/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx +++ b/ui/app/(prowler)/alerts/_components/seed-from-findings-button.tsx @@ -23,14 +23,16 @@ import type { } from "@/app/(prowler)/alerts/_types/alert-form"; import { buildFindingsFilterChips } from "@/components/findings/findings-filters.utils"; import { + Badge, Button, Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn"; import { ToastAction, useToast } from "@/components/shadcn"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { useCloudUpgradeStore } from "@/store"; import type { ScanEntity } from "@/types"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { ProviderProps } from "@/types/providers"; const DISABLED_FILTER_TOOLTIP = @@ -138,6 +140,9 @@ export const SeedFromFindingsButton = ({ }: SeedFromFindingsButtonProps) => { const router = useRouter(); const { toast } = useToast(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const [modalOpen, setModalOpen] = useState(false); const [seeding, setSeeding] = useState(false); const [seededCondition, setSeededCondition] = useState( @@ -154,7 +159,11 @@ export const SeedFromFindingsButton = ({ const canSeedFromFilters = hasFindingFilterValue(filterBag); const handleClick = async () => { - if (!isCloudEnabled || !canSeedFromFilters) return; + if (!isCloudEnabled) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + return; + } + if (!canSeedFromFilters) return; setSeeding(true); const result = await seedAlertRule(withDefaultAlertSeedFilters(filterBag)); setSeeding(false); @@ -201,7 +210,7 @@ export const SeedFromFindingsButton = ({ size={size} variant="default" onClick={handleClick} - disabled={!isCloudEnabled || !canSeedFromFilters || seeding} + disabled={(isCloudEnabled && !canSeedFromFilters) || seeding} className={className} > @@ -236,10 +245,10 @@ export const SeedFromFindingsButton = ({ if (!isCloudEnabled) { return ( - + {button} - - + + Cloud ); diff --git a/ui/app/(prowler)/alerts/page.tsx b/ui/app/(prowler)/alerts/page.tsx index 438313a262..2b7e74336b 100644 --- a/ui/app/(prowler)/alerts/page.tsx +++ b/ui/app/(prowler)/alerts/page.tsx @@ -7,6 +7,7 @@ import { getAlert, listAlerts } from "@/app/(prowler)/alerts/_actions"; import { AlertsManager } from "@/app/(prowler)/alerts/_components/alerts-manager"; import { ContentLayout } from "@/components/shadcn/content-layout"; import { createScanDetailsMapping } from "@/lib"; +import { isCloud } from "@/lib/shared/env"; import type { MetaDataProps, ScanEntity, ScanProps } from "@/types"; interface AlertsPageProps { @@ -49,7 +50,7 @@ const toAlertsSearchParams = ( }; export default async function AlertsPage({ searchParams }: AlertsPageProps) { - if (process.env.NEXT_PUBLIC_IS_CLOUD_ENV !== "true") { + if (!isCloud()) { redirect("/"); } diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx index b5f25adfe7..617d943030 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.test.tsx @@ -1,6 +1,9 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB } from "../_types"; import { CompliancePageTabs } from "./compliance-page-tabs"; @@ -32,6 +35,10 @@ describe("CompliancePageTabs", () => { pushMock.mockClear(); }); + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + it("navigates with ?tab=cross-provider and back to the bare route", async () => { const user = userEvent.setup(); const { rerender } = render( @@ -59,7 +66,8 @@ describe("CompliancePageTabs", () => { expect(pushMock).toHaveBeenCalledWith("/compliance"); }); - it("disables the cross-provider tab with the cloud upsell badge in OSS", () => { + it("opens the cross-provider upgrade without changing tabs in Local Server", async () => { + const user = userEvent.setup(); render( { const crossProviderTab = screen.getByRole("tab", { name: /cross-provider/i, }); - const tabLabel = screen.getByText("Cross-Provider", { exact: true }); - const cloudBadge = screen.getByText("Available in Prowler Cloud"); + await user.click(crossProviderTab); - expect(crossProviderTab).toBeDisabled(); - expect(crossProviderTab).not.toHaveClass("disabled:opacity-50"); - expect(tabLabel).toHaveClass("opacity-50"); - expect(cloudBadge.parentElement).toHaveClass("gap-2"); + expect(crossProviderTab).not.toBeDisabled(); + expect(crossProviderTab).toHaveAttribute("aria-selected", "false"); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(pushMock).not.toHaveBeenCalled(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE, + ); }); }); diff --git a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx index fa6ab6ea70..79e5d8af00 100644 --- a/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx +++ b/ui/app/(prowler)/compliance/_components/compliance-page-tabs.tsx @@ -3,8 +3,15 @@ import { useRouter } from "next/navigation"; import { ReactNode } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn"; -import { CloudFeatureBadge } from "@/components/shared/cloud-feature-badge"; +import { + Badge, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/shadcn"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { COMPLIANCE_TAB, type ComplianceTab } from "../_types"; @@ -24,10 +31,18 @@ export const CompliancePageTabs = ({ crossProviderContent, }: CompliancePageTabsProps) => { const router = useRouter(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const handleTabChange = (tab: string) => { const typedTab = tab as ComplianceTab; + if (typedTab === COMPLIANCE_TAB.CROSS_PROVIDER && !crossProviderEnabled) { + openCloudUpgrade(CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE); + return; + } + if (typedTab === activeTab) { return; } @@ -52,8 +67,11 @@ export const CompliancePageTabs = ({ Per Scan : undefined} + adornment={ + !crossProviderEnabled ? ( + Cloud + ) : undefined + } > Cross-Provider diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index a93f799f62..77cb1d8d0e 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -25,6 +25,7 @@ import { hasDateOrScanFilter, } from "@/lib"; import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; +import { isCloud } from "@/lib/shared/env"; import { ScanEntity, ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; @@ -89,7 +90,7 @@ export default async function Findings({ completedScans || [], providersData, ) as { [uid: string]: ScanEntity }[]; - const alertsEnabled = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const alertsEnabled = isCloud(); return ( + + + {!isCloudEnv && ( +
+ + + + + {!isOpen && ( + + Explore Prowler Cloud + + )} + +
+ )} + {/* Footer */}
{isOpen ? ( <> {process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION} - {process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + {isCloudEnv && ( <> { target="_blank" rel="noopener noreferrer" className="flex items-center gap-1" + onClick={onSelect} > @@ -188,7 +239,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => { )} ) : ( - process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && ( + isCloudEnv && ( { target="_blank" rel="noopener noreferrer" className="flex items-center" + onClick={onSelect} > diff --git a/ui/components/layout/sidebar/sheet-menu.test.tsx b/ui/components/layout/sidebar/sheet-menu.test.tsx new file mode 100644 index 0000000000..c57e7ecb54 --- /dev/null +++ b/ui/components/layout/sidebar/sheet-menu.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/icons", () => ({ + ProwlerBrand: () => Prowler, +})); + +vi.mock("@/components/layout/sidebar/menu", () => ({ + Menu: ({ onSelect }: { onSelect?: () => void }) => ( + + ), +})); + +import { SheetMenu } from "./sheet-menu"; + +describe("SheetMenu", () => { + it("should close after selecting a menu action", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click(screen.getByRole("button", { name: "Open menu" })); + expect(screen.getByRole("dialog", { name: "Sidebar" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Alerts" })); + + // Then + expect( + screen.queryByRole("dialog", { name: "Sidebar" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/layout/sidebar/sheet-menu.tsx b/ui/components/layout/sidebar/sheet-menu.tsx index 8fef80c3fa..842986f8eb 100644 --- a/ui/components/layout/sidebar/sheet-menu.tsx +++ b/ui/components/layout/sidebar/sheet-menu.tsx @@ -1,7 +1,10 @@ +"use client"; + import { MenuIcon } from "lucide-react"; import Link from "next/link"; +import { useRef, useState } from "react"; -import { ProwlerExtended } from "@/components/icons"; +import { ProwlerBrand } from "@/components/icons"; import { Menu } from "@/components/layout/sidebar/menu"; import { Button } from "@/components/shadcn/button/button"; import { @@ -14,10 +17,24 @@ import { } from "@/components/shadcn/sheet"; export function SheetMenu() { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + + const handleSelect = () => { + setOpen(false); + return triggerRef.current; + }; + return ( - + - @@ -30,12 +47,16 @@ export function SheetMenu() { variant="link" asChild > - - + + - + ); diff --git a/ui/components/layout/sidebar/sidebar.tsx b/ui/components/layout/sidebar/sidebar.tsx index 79052d3551..f6f788563d 100644 --- a/ui/components/layout/sidebar/sidebar.tsx +++ b/ui/components/layout/sidebar/sidebar.tsx @@ -3,8 +3,7 @@ import clsx from "clsx"; import Link from "next/link"; -import { ProwlerShort } from "@/components/icons"; -import { ProwlerExtended } from "@/components/icons"; +import { ProwlerBrand, ProwlerShort } from "@/components/icons"; import { useSidebar } from "@/hooks/use-sidebar"; import { useStore } from "@/hooks/use-store"; import { cn } from "@/lib/utils"; @@ -49,7 +48,7 @@ export function Sidebar() { "mt-0!": isOpen, })} > - +
diff --git a/ui/components/layout/sidebar/submenu-item.test.tsx b/ui/components/layout/sidebar/submenu-item.test.tsx index 02726b8e46..053a50f3fc 100644 --- a/ui/components/layout/sidebar/submenu-item.test.tsx +++ b/ui/components/layout/sidebar/submenu-item.test.tsx @@ -1,6 +1,10 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { SUBMENU_KIND } from "@/types/components"; import { SubmenuItem } from "./submenu-item"; @@ -13,64 +17,41 @@ const TestIcon = ({ size = 16 }: { size?: number }) => ( ); describe("SubmenuItem", () => { - it("should show the cloud-only tooltip for disabled cloud menu items", async () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("should open the Alerts upgrade modal from a Local Server menu item", async () => { // Given const user = userEvent.setup(); + const returnFocusElement = document.createElement("button"); + const onSelect = vi.fn(() => returnFocusElement); render( , ); // When const button = screen.getByRole("button", { name: /alerts/i }); - expect(button).toHaveAttribute("aria-disabled", "true"); - expect(button).toHaveClass( - "cursor-not-allowed", - "text-text-neutral-tertiary", - ); - await user.hover(button.parentElement as HTMLElement); + await user.click(button); // Then - expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]"); - expect(screen.queryByText("Cloud")).not.toBeInTheDocument(); + expect(button).not.toHaveAttribute("aria-disabled"); + expect(screen.getByText("Cloud")).toBeVisible(); expect( - await screen.findAllByText("Available in Prowler Cloud"), - ).not.toHaveLength(0); - }); - - it("should render disabled Scan config menu items like disabled Alerts", async () => { - // Given - const user = userEvent.setup(); - render( - , + screen.queryByRole("link", { name: /alerts/i }), + ).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ALERTS, ); - - // When - const button = screen.getByRole("button", { name: /scan/i }); - await user.hover(button.parentElement as HTMLElement); - - // Then - expect(button).toHaveAttribute("aria-disabled", "true"); - expect(button).toHaveClass( - "cursor-not-allowed", - "text-text-neutral-tertiary", + expect(onSelect).toHaveBeenCalledOnce(); + expect(useCloudUpgradeStore.getState().returnFocusElement).toBe( + returnFocusElement, ); - expect(screen.getByText("New")).toHaveClass("h-5", "text-[10px]"); - expect( - await screen.findAllByText("Available in Prowler Cloud"), - ).not.toHaveLength(0); }); }); diff --git a/ui/components/layout/sidebar/submenu-item.tsx b/ui/components/layout/sidebar/submenu-item.tsx index 2767381ff0..e481de6f5d 100644 --- a/ui/components/layout/sidebar/submenu-item.tsx +++ b/ui/components/layout/sidebar/submenu-item.tsx @@ -2,41 +2,67 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { type MouseEvent } from "react"; +import { Badge } from "@/components/shadcn/badge/badge"; import { Button } from "@/components/shadcn/button/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; -import { MenuFeatureBadge } from "@/components/shared/cloud-feature-badge"; -import { IconComponent } from "@/types"; +import { useCloudUpgradeStore } from "@/store"; +import { + type MenuSelectionHandler, + SUBMENU_KIND, + type SubmenuProps, +} from "@/types"; -interface SubmenuItemProps { - href: string; - label: string; - icon: IconComponent; - active?: boolean; - target?: string; - disabled?: boolean; - highlight?: boolean; - cloudOnly?: boolean; - onClick?: (event: MouseEvent) => void; -} +type SubmenuItemProps = SubmenuProps & { + onSelect?: MenuSelectionHandler; +}; -export const SubmenuItem = ({ - href, - label, - icon: Icon, - active, - target, - disabled, - highlight, - cloudOnly, - onClick, -}: SubmenuItemProps) => { +export const SubmenuItem = (props: SubmenuItemProps) => { const pathname = usePathname(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + if (props.kind === SUBMENU_KIND.CLOUD_UPGRADE) { + const { cloudUpgradeFeature, icon: Icon, label, onSelect } = props; + + return ( + + ); + } + + const { + active, + cloudOnly, + disabled, + highlight, + href, + icon: Icon, + label, + onSelect, + target, + } = props; const isActive = active !== undefined ? active : pathname === href; // Special case: Mutelist with tooltip when disabled @@ -86,7 +112,9 @@ export const SubmenuItem = ({

{label} {highlight && ( - + + New + )}

@@ -108,7 +136,7 @@ export const SubmenuItem = ({ href={href} target={target} className="flex items-center" - onClick={onClick} + onClick={onSelect} > @@ -116,12 +144,9 @@ export const SubmenuItem = ({

{label} {highlight && ( - + + New + )}

diff --git a/ui/components/lighthouse-v1/index.ts b/ui/components/lighthouse-v1/index.ts index 5f1281f40e..9bdcd76a06 100644 --- a/ui/components/lighthouse-v1/index.ts +++ b/ui/components/lighthouse-v1/index.ts @@ -4,4 +4,5 @@ export * from "./lighthouse-settings"; export * from "./llm-provider-registry"; export * from "./llm-provider-utils"; export * from "./llm-providers-table"; +export * from "./managed-lighthouse-callout"; export * from "./select-model"; diff --git a/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx b/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx new file mode 100644 index 0000000000..43ce9ac993 --- /dev/null +++ b/ui/components/lighthouse-v1/managed-lighthouse-callout.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { ManagedLighthouseCallout } from "./managed-lighthouse-callout"; + +describe("ManagedLighthouseCallout", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("opens the managed Lighthouse Cloud upgrade", async () => { + // Given + const user = userEvent.setup(); + render(); + + const upgradeButton = screen.getByRole("button", { + name: "Explore The Agentic Cloud Defender", + }); + + // When + await user.click(upgradeButton); + + // Then + expect(upgradeButton).toHaveClass("bg-button-primary"); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + ); + }); +}); diff --git a/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx b/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx new file mode 100644 index 0000000000..80e38f5ccc --- /dev/null +++ b/ui/components/lighthouse-v1/managed-lighthouse-callout.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { Sparkles } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/shadcn/card/card"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +export const ManagedLighthouseCallout = () => { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + + return ( + + +
+
+
+ +

+ Prowler Cloud includes managed OpenAI access with no API keys to + provision, plus a hosted remote MCP server to automate security + workflows. +

+ +
+
+ ); +}; diff --git a/ui/components/providers/organizations/aws-method-selector.test.tsx b/ui/components/providers/organizations/aws-method-selector.test.tsx index 62b74a67f4..9279e8dfb2 100644 --- a/ui/components/providers/organizations/aws-method-selector.test.tsx +++ b/ui/components/providers/organizations/aws-method-selector.test.tsx @@ -1,28 +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 { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + import { AwsMethodSelector } from "./aws-method-selector"; describe("AwsMethodSelector", () => { afterEach(() => { vi.unstubAllEnvs(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); }); - it("links the OSS AWS Organizations badge to pricing", () => { + it("opens the AWS Organizations upgrade in Local Server", async () => { // Given vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + const onSelectOrganizations = vi.fn(); // When render( , ); // Then - expect( - screen.getByRole("link", { name: /available in prowler cloud/i }), - ).toHaveAttribute("href", "https://prowler.com/pricing"); + await user.click( + screen.getByRole("radio", { + name: /add multiple accounts with aws organizations/i, + }), + ); + + expect(onSelectOrganizations).not.toHaveBeenCalled(); + expect(screen.getByText("Cloud")).toBeVisible(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, + ); }); }); diff --git a/ui/components/providers/organizations/aws-method-selector.tsx b/ui/components/providers/organizations/aws-method-selector.tsx index ca36407508..882adef1b2 100644 --- a/ui/components/providers/organizations/aws-method-selector.tsx +++ b/ui/components/providers/organizations/aws-method-selector.tsx @@ -1,9 +1,12 @@ "use client"; -import { Ban, Box, Boxes } from "lucide-react"; +import { Box, Boxes } from "lucide-react"; import { RadioCard } from "@/components/providers/radio-card"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { Badge } from "@/components/shadcn/badge/badge"; +import { isCloud } from "@/lib/shared/env"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; interface AwsMethodSelectorProps { onSelectSingle: () => void; @@ -14,7 +17,10 @@ export function AwsMethodSelector({ onSelectSingle, onSelectOrganizations, }: AwsMethodSelectorProps) { - const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnv = isCloud(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); return (
@@ -29,12 +35,15 @@ export function AwsMethodSelector({ /> + isCloudEnv + ? onSelectOrganizations() + : openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS) + } > - {!isCloudEnv && } + {!isCloudEnv && Cloud}
); diff --git a/ui/components/providers/wizard/steps/launch-step.test.tsx b/ui/components/providers/wizard/steps/launch-step.test.tsx index caeef23385..dbc31771d9 100644 --- a/ui/components/providers/wizard/steps/launch-step.test.tsx +++ b/ui/components/providers/wizard/steps/launch-step.test.tsx @@ -374,6 +374,31 @@ describe("LaunchStep", () => { scanOnDemandMock.mockResolvedValue({ data: { id: "scan-1" } }); }); + it("uses a warning badge for the subscription requirement", () => { + // Given + seedConnectedProvider(); + + // When + render( + , + ); + + // Then + expect(screen.getByText("Requires subscription")).toHaveClass( + "bg-bg-warning-secondary/20", + "text-text-warning-primary", + ); + expect(screen.getByText("Requires subscription")).toHaveAttribute( + "data-slot", + "badge", + ); + }); + it("defaults to run now, locks schedule mode, and only launches a manual scan", async () => { // Given const onClose = vi.fn(); diff --git a/ui/components/providers/wizard/steps/launch-step.tsx b/ui/components/providers/wizard/steps/launch-step.tsx index 8c3e96b28b..1c24f2b893 100644 --- a/ui/components/providers/wizard/steps/launch-step.tsx +++ b/ui/components/providers/wizard/steps/launch-step.tsx @@ -13,6 +13,7 @@ import { import { ScanScheduleFields } from "@/components/scans/schedule/scan-schedule-fields"; import { Field, FieldLabel } from "@/components/shadcn"; import { ToastAction, useToast } from "@/components/shadcn"; +import { Badge } from "@/components/shadcn/badge/badge"; import { EntityInfo } from "@/components/shadcn/entities"; import { RadioGroup, @@ -20,10 +21,6 @@ import { } from "@/components/shadcn/radio-group/radio-group"; import { Spinner } from "@/components/shadcn/spinner/spinner"; import { TreeStatusIcon } from "@/components/shadcn/tree-view/tree-status-icon"; -import { - CloudFeatureBadge, - CloudFeatureBadgeLink, -} from "@/components/shared/cloud-feature-badge"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { type ActionErrorResult, @@ -333,13 +330,11 @@ export function LaunchStep({ disabled={!canUseScheduleMode} /> On a schedule - {!canUseScheduleMode && - !isBlocked && - (isManualOnly ? ( - - ) : ( - - ))} + {isManualOnly && !isBlocked && ( + + Requires subscription + + )} diff --git a/ui/components/scans/launch-scan-modal.test.tsx b/ui/components/scans/launch-scan-modal.test.tsx index 96272e09cd..706b704425 100644 --- a/ui/components/scans/launch-scan-modal.test.tsx +++ b/ui/components/scans/launch-scan-modal.test.tsx @@ -548,15 +548,26 @@ describe("LaunchScanModal", () => { expect(getScheduleMock).not.toHaveBeenCalled(); }); - it("locks schedule mode outside ADVANCED (OSS default)", () => { + it("preserves legacy daily scheduling outside Cloud", async () => { + const user = userEvent.setup(); + scheduleDailyMock.mockResolvedValue({ data: { id: provider.id } }); render( , ); expect( screen.getByRole("radio", { name: "On a schedule" }), - ).toBeDisabled(); + ).toBeEnabled(); + + await user.selectOptions(screen.getByLabelText("Providers"), provider.id); + await user.click(screen.getByRole("radio", { name: "On a schedule" })); + expect(screen.getByRole("combobox", { name: "Repeats" })).toBeDisabled(); + + await user.click(screen.getByRole("button", { name: /save schedule/i })); + + await waitFor(() => expect(scheduleDailyMock).toHaveBeenCalledTimes(1)); expect(getScheduleMock).not.toHaveBeenCalled(); + expect(updateScheduleMock).not.toHaveBeenCalled(); }); it("hides schedule mode but allows manual scans in MANUAL_ONLY", async () => { diff --git a/ui/components/scans/launch-scan-modal.tsx b/ui/components/scans/launch-scan-modal.tsx index b9c469ce6f..465321dc1a 100644 --- a/ui/components/scans/launch-scan-modal.tsx +++ b/ui/components/scans/launch-scan-modal.tsx @@ -11,7 +11,13 @@ import { z } from "zod"; import { scanOnDemand } from "@/actions/scans"; import { getSchedule } from "@/actions/schedules"; import { AccountsSelector } from "@/app/(prowler)/_overview/_components/accounts-selector"; -import { Field, FieldError, FieldLabel, Input } from "@/components/shadcn"; +import { + Badge, + Field, + FieldError, + FieldLabel, + Input, +} from "@/components/shadcn"; import { FormButtons } from "@/components/shadcn/form"; import { Modal } from "@/components/shadcn/modal"; import { @@ -19,7 +25,6 @@ import { RadioGroupItem, } from "@/components/shadcn/radio-group/radio-group"; import { toast, ToastAction } from "@/components/shadcn/toast"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { UsageLimitMessage } from "@/components/shared/usage-limit-message"; import { getActionErrorMessage, hasActionError } from "@/lib/action-errors"; import { @@ -111,11 +116,13 @@ function LaunchScanForm({ const requestedProviderRef = useRef(""); const isAdvanced = capability === SCAN_SCHEDULE_CAPABILITY.ADVANCED; + const isDailyLegacy = capability === SCAN_SCHEDULE_CAPABILITY.DAILY_LEGACY; + const canUseScheduleMode = isAdvanced || isDailyLegacy; const isManualOnly = capability === SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY; const isBlocked = capability === SCAN_SCHEDULE_CAPABILITY.BLOCKED || (isManualOnly && isScanLimitReached); - const isScheduleMode = isAdvanced && mode === LAUNCH_MODE.SCHEDULE; + const isScheduleMode = canUseScheduleMode && mode === LAUNCH_MODE.SCHEDULE; // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const providerId = useWatch({ control: form.control, name: "providerId" }); @@ -169,13 +176,15 @@ function LaunchScanForm({ const handleProviderChange = (id: string) => { form.setValue("providerId", id, { shouldValidate: true }); - if (isScheduleMode) void loadSchedule(id); + if (isScheduleMode && isAdvanced) void loadSchedule(id); }; const handleModeChange = (nextMode: string) => { - if (nextMode === LAUNCH_MODE.SCHEDULE && !isAdvanced) return; + if (nextMode === LAUNCH_MODE.SCHEDULE && !canUseScheduleMode) return; setMode(nextMode as LaunchMode); - if (nextMode === LAUNCH_MODE.SCHEDULE) void loadSchedule(providerId); + if (nextMode === LAUNCH_MODE.SCHEDULE && isAdvanced) { + void loadSchedule(providerId); + } }; const launchNow = form.handleSubmit(async ({ providerId, scanAlias }) => { @@ -216,7 +225,7 @@ function LaunchScanForm({ }); const saveSchedule = async () => { - if (isBlocked || !isAdvanced) return; + if (isBlocked || !canUseScheduleMode) return; const providerValid = await form.trigger("providerId"); if (!providerValid) return; @@ -225,6 +234,7 @@ function LaunchScanForm({ const result = await saveScheduleWithInitialScan({ providerId: form.getValues("providerId"), values, + useLegacyDaily: isDailyLegacy, }); if (result.status === SAVE_SCHEDULE_STATUS.ERROR) { @@ -320,10 +330,14 @@ function LaunchScanForm({ On a schedule - {!isAdvanced && } + {isDailyLegacy && ( + + Cloud + + )} @@ -362,6 +376,8 @@ function LaunchScanForm({ disabled={isSubmitting || !providerId} showLaunchInitialScan showNextScheduledCopy + canUseAdvancedSchedule={isAdvanced} + showCloudUpgradeBadge={isDailyLegacy} /> )} diff --git a/ui/components/scans/scans-filter-bar.tsx b/ui/components/scans/scans-filter-bar.tsx index df635514e6..de4539651f 100644 --- a/ui/components/scans/scans-filter-bar.tsx +++ b/ui/components/scans/scans-filter-bar.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn"; +import { isCloud } from "@/lib/shared/env"; import { SCAN_JOBS_TAB, type ScanJobsTab } from "@/types"; import type { ProviderGroup } from "@/types/components"; import { FILTER_FIELD } from "@/types/filters"; @@ -42,7 +43,7 @@ export function ScansFilterBar({ onScheduleTypeChange, onScanStatusChange, }: ScansFilterBarProps) { - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); const triggerFilterOptions = getScanTriggerFilterOptions(isCloudEnvironment); const statusFilterOptions = getScanStatusFilterOptions(activeTab); const showScheduleTypeFilter = activeTab !== SCAN_JOBS_TAB.SCHEDULED; diff --git a/ui/components/scans/scans-page-shell.tsx b/ui/components/scans/scans-page-shell.tsx index 0333570db3..b553d224b5 100644 --- a/ui/components/scans/scans-page-shell.tsx +++ b/ui/components/scans/scans-page-shell.tsx @@ -17,6 +17,7 @@ import { LAUNCH_SCAN_SEARCH_PARAM, LAUNCH_SCAN_SEARCH_VALUE, } from "@/lib/scans-navigation"; +import { isCloud } from "@/lib/shared/env"; import { buildViewFirstScanTour } from "@/lib/tours/view-first-scan.tour"; import { useScansStore } from "@/store"; import { SCAN_JOBS_TAB, SCAN_TAB_LABELS, type ScanJobsTab } from "@/types"; @@ -67,7 +68,7 @@ export function ScansPageShell({ const hasConnectedProviders = providers.some( (provider) => provider.attributes.connection.connected === true, ); - const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true"; + const isCloudEnvironment = isCloud(); const launchDisabled = !hasManageScansPermission || !hasConnectedProviders; const launchOpen = isLaunchScanModalOpen || urlLaunchOpen; // When a scan is already running, the tour highlights its row (anchored in diff --git a/ui/components/scans/schedule/scan-schedule-fields.test.tsx b/ui/components/scans/schedule/scan-schedule-fields.test.tsx index dd311d880e..2beadf68ad 100644 --- a/ui/components/scans/schedule/scan-schedule-fields.test.tsx +++ b/ui/components/scans/schedule/scan-schedule-fields.test.tsx @@ -1,9 +1,11 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; -import { beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { getScheduleFormDefaults } from "@/lib/schedules"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import type { ScheduleFormValues } from "@/types/schedules"; import { ScanScheduleFields } from "./scan-schedule-fields"; @@ -58,6 +60,9 @@ function getHelperCopy(text: RegExp) { } describe("ScanScheduleFields", () => { + afterEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); it("updates the helper copy when the cadence changes to interval", async () => { // Given const user = userEvent.setup(); @@ -97,8 +102,9 @@ describe("ScanScheduleFields", () => { ); }); - it("shows a single cloud badge beside the Scan Schedule title when advanced controls are locked", () => { + it("opens advanced scheduling from the Cloud badge when controls are locked", async () => { // Given + const user = userEvent.setup(); render( { ); // Then - expect(screen.getAllByText("Available in Prowler Cloud")).toHaveLength(1); + expect(screen.getAllByText("Cloud")).toHaveLength(1); expect(screen.getByText("Scan Schedule").parentElement).toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", ); expect(screen.getByText("Scan Time").parentElement).not.toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", ); expect(screen.getByText("Repeats").parentElement).not.toHaveTextContent( - "Available in Prowler Cloud", + "Cloud", + ); + + // When + await user.click( + screen.getByRole("button", { + name: "Explore advanced scheduling in Prowler Cloud", + }), + ); + + // Then + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING, ); }); }); diff --git a/ui/components/scans/schedule/scan-schedule-fields.tsx b/ui/components/scans/schedule/scan-schedule-fields.tsx index 9351ca5336..a83d8d3c0b 100644 --- a/ui/components/scans/schedule/scan-schedule-fields.tsx +++ b/ui/components/scans/schedule/scan-schedule-fields.tsx @@ -6,6 +6,8 @@ import type { ReactNode } from "react"; import { Controller, type UseFormReturn, useWatch } from "react-hook-form"; import { + Badge, + Button, Checkbox, Field, FieldLabel, @@ -15,13 +17,14 @@ import { SelectTrigger, SelectValue, } from "@/components/shadcn"; -import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; import { formatDayOfMonth, formatScheduleHour, getBrowserTimezone, getNextScheduledRun, } from "@/lib/schedules"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { SCHEDULE_FREQUENCY, SCHEDULE_WEEKDAY_LABELS, @@ -130,6 +133,9 @@ export function ScanScheduleFields({ canUseAdvancedSchedule = true, showCloudUpgradeBadge = false, }: ScanScheduleFieldsProps) { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); // useWatch, not form.watch: form.watch re-renders are dropped by React Compiler memoization. const control = form.control; const [frequency, hour, dayOfWeek, dayOfMonth, intervalHours] = useWatch({ @@ -149,7 +155,19 @@ export function ScanScheduleFields({ // ignores them, so they are display-only with a Cloud upsell. const advancedDisabled = disabled || !canUseAdvancedSchedule; const cloudUpgradeBadge = showCloudUpgradeBadge ? ( - + ) : null; return ( diff --git a/ui/components/shadcn/badge/badge.test.tsx b/ui/components/shadcn/badge/badge.test.tsx index adae2f54b8..51f68d4610 100644 --- a/ui/components/shadcn/badge/badge.test.tsx +++ b/ui/components/shadcn/badge/badge.test.tsx @@ -18,6 +18,39 @@ describe("Badge", () => { expect(badge?.className).toContain("text-bg-data-info"); }); + it("applies the Cloud variant and compact size", () => { + // Given / When + render( + + Cloud + , + ); + + // Then + expect(screen.getByText("Cloud")).toHaveClass( + "bg-feature-cloud", + "h-5", + "rounded-md", + "text-[10px]", + ); + }); + + it("applies the New feature variant tokens", () => { + // Given / When + render( + + New + , + ); + + // Then + expect(screen.getByText("New")).toHaveClass( + "bg-bg-feature-new", + "text-text-feature-new", + "h-5", + ); + }); + it("merges a custom className", () => { const { container } = render( diff --git a/ui/components/shadcn/badge/badge.tsx b/ui/components/shadcn/badge/badge.tsx index 57674c4610..7e727e1a33 100644 --- a/ui/components/shadcn/badge/badge.tsx +++ b/ui/components/shadcn/badge/badge.tsx @@ -25,10 +25,18 @@ const badgeVariants = cva( error: "border-transparent bg-bg-fail-secondary text-text-error-primary", info: "border-transparent bg-bg-data-info/15 text-bg-data-info", + cloud: + "bg-feature-cloud h-6 rounded-lg border-0 px-2 py-0 text-xs leading-5 font-bold text-black", + new: "bg-bg-feature-new text-text-feature-new border-0 font-bold", + }, + size: { + default: "", + sm: "h-5 rounded-md px-1.5 py-0 text-[10px] leading-4", }, }, defaultVariants: { variant: "default", + size: "default", }, }, ); @@ -36,6 +44,7 @@ const badgeVariants = cva( function Badge({ className, variant, + size, asChild = false, ...props }: ComponentProps<"span"> & @@ -45,7 +54,7 @@ function Badge({ return ( ); diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index d4ae96d2ed..2b4669190c 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -70,7 +70,7 @@ function DialogContent({ {showCloseButton && ( Close diff --git a/ui/components/shadcn/modal/modal.tsx b/ui/components/shadcn/modal/modal.tsx index d47f0f3055..058cce3e7d 100644 --- a/ui/components/shadcn/modal/modal.tsx +++ b/ui/components/shadcn/modal/modal.tsx @@ -33,6 +33,7 @@ interface ModalProps { size?: ModalSize; className?: string; onOpenAutoFocus?: (event: Event) => void; + onCloseAutoFocus?: (event: Event) => void; /** * Cap the dialog at 90dvh and scroll overflowing content, instead of * letting it grow past the viewport. Opt-in per modal (e.g. for content @@ -51,12 +52,14 @@ export const Modal = ({ size = "xl", className, onOpenAutoFocus = preventInitialAutoFocus, + onCloseAutoFocus, scrollable = false, }: ModalProps) => { return ( , - CSSProperties | undefined -> = { - cloud: { - backgroundImage: - "linear-gradient(112deg, rgb(46, 229, 155) 3.5%, rgb(98, 223, 240) 98.8%)", - }, - new: undefined, -}; - -const FEATURE_BADGE_VARIANT_CLASS: Record< - NonNullable, - string -> = { - cloud: "text-black", - new: "bg-emerald-500 text-white", -}; - -const FEATURE_BADGE_SIZE_CLASS: Record< - NonNullable, - string -> = { - default: "h-6 rounded-lg px-2 text-xs leading-5", - sm: "h-5 rounded-md px-1.5 text-[10px] leading-4", -}; - -export const MenuFeatureBadge = ({ - label, - variant = "cloud", - size = "default", - className, -}: MenuFeatureBadgeProps) => ( - - {label} - -); - -export const CloudFeatureBadge = ({ - label = "Available in Prowler Cloud", - size, - className, -}: Omit) => ( - -); - -interface CloudFeatureBadgeLinkProps - extends Omit { - href?: string; -} - -export const CloudFeatureBadgeLink = ({ - href = "https://prowler.com/pricing", - label, - size, - className, -}: CloudFeatureBadgeLinkProps) => ( - - - -); diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx new file mode 100644 index 0000000000..318c03740c --- /dev/null +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -0,0 +1,136 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { CloudUpgradeModal } from "./cloud-upgrade-modal"; + +describe("CloudUpgradeModal", () => { + afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("renders the active contextual upgrade in Local Server", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + + // When + render(); + + // Then + expect( + await screen.findByRole("dialog", { name: "Turn Findings into Alerts" }), + ).toBeVisible(); + expect(screen.getByText("Available in Prowler Cloud")).toBeVisible(); + expect( + screen.getByRole("link", { name: "Create Alerts in Prowler Cloud" }), + ).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=alerts", + ); + expect( + screen.getByRole("link", { name: "View Plans & Pricing" }), + ).toHaveAttribute( + "href", + "https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=alerts", + ); + }); + + it("uses the standard equal-width CTA layout", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS); + + // When + render(); + + // Then + const dialog = await screen.findByRole("dialog", { + name: "Add Your Entire AWS Organization", + }); + const primaryCta = screen.getByRole("link", { + name: "Set Up AWS Organizations in Prowler Cloud", + }); + const secondaryCta = screen.getByRole("link", { + name: "View Plans & Pricing", + }); + + expect(dialog).toHaveClass("sm:max-w-2xl"); + expect(primaryCta.parentElement).toHaveClass("gap-3", "md:flex-row"); + expect(primaryCta).toHaveClass( + "h-auto", + "min-h-9", + "whitespace-normal", + "md:flex-1", + ); + expect(secondaryCta).toHaveClass( + "h-auto", + "min-h-9", + "whitespace-normal", + "md:flex-1", + ); + expect(primaryCta.querySelector(".truncate")).not.toBeInTheDocument(); + expect(primaryCta).toHaveAttribute( + "href", + "https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=organization", + ); + }); + + it("closes the active upgrade and returns focus to its trigger", async () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false"); + const user = userEvent.setup(); + + render( + <> + + + , + ); + + const trigger = screen.getByRole("button", { + name: "Explore Prowler Cloud", + }); + await user.click(trigger); + + // When + await user.click(screen.getByRole("button", { name: "Close" })); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect(useCloudUpgradeStore.getState().activeFeature).toBeNull(); + }); + + it("does not render upgrade UI in Prowler Cloud", () => { + // Given + vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true"); + useCloudUpgradeStore + .getState() + .openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ALERTS); + + // When + render(); + + // Then + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/components/shared/cloud-upgrade-modal.tsx b/ui/components/shared/cloud-upgrade-modal.tsx new file mode 100644 index 0000000000..9d7617d941 --- /dev/null +++ b/ui/components/shared/cloud-upgrade-modal.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Check, Cloud } from "lucide-react"; + +import { Badge } from "@/components/shadcn/badge/badge"; +import { Button } from "@/components/shadcn/button/button"; +import { Modal } from "@/components/shadcn/modal"; +import { + CLOUD_UPGRADE_CONTENT, + CLOUD_UPGRADE_FOOTER_NOTE, + CLOUD_UPGRADE_SECONDARY_CTA, + getCloudUpgradeCompareUrl, + getCloudUpgradePrimaryUrl, +} from "@/lib/cloud-upgrade"; +import { isCloud } from "@/lib/shared/env"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +const allowInitialAutoFocus = () => {}; + +export const CloudUpgradeModal = () => { + const activeFeature = useCloudUpgradeStore((state) => state.activeFeature); + const closeCloudUpgrade = useCloudUpgradeStore( + (state) => state.closeCloudUpgrade, + ); + const returnFocusElement = useCloudUpgradeStore( + (state) => state.returnFocusElement, + ); + + if (isCloud()) return null; + + const feature = activeFeature ?? CLOUD_UPGRADE_FEATURE.GENERAL; + const content = CLOUD_UPGRADE_CONTENT[feature]; + + return ( + !open && closeCloudUpgrade()} + onOpenAutoFocus={allowInitialAutoFocus} + onCloseAutoFocus={(event) => { + event.preventDefault(); + returnFocusElement?.focus(); + }} + title={content.title} + description={content.description} + size="2xl" + > +
+
+
+
+ Available in Prowler Cloud +
+ +
    + {content.benefits.map((benefit) => ( +
  • +
  • + ))} +
+ + + +

+ {CLOUD_UPGRADE_FOOTER_NOTE} +

+
+
+ ); +}; diff --git a/ui/components/sidebar/navigation-mode-toggle.tsx b/ui/components/sidebar/navigation-mode-toggle.tsx index 548c55dbef..73954e48ab 100644 --- a/ui/components/sidebar/navigation-mode-toggle.tsx +++ b/ui/components/sidebar/navigation-mode-toggle.tsx @@ -4,6 +4,7 @@ import { Home } from "lucide-react"; import { useRouter } from "next/navigation"; import { LighthouseIcon } from "@/components/icons/Icons"; +import { Badge } from "@/components/shadcn/badge/badge"; import { Tooltip, TooltipContent, @@ -14,19 +15,27 @@ import { type SidebarNavigationMode, } from "@/hooks/use-sidebar"; import { cn } from "@/lib/utils"; +import { useCloudUpgradeStore } from "@/store"; +import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import type { MenuSelectionHandler } from "@/types/components"; export function SidebarNavigationModeToggle({ isOpen, value, onChange, chatEnabled = true, + onSelect, }: { isOpen: boolean; value: SidebarNavigationMode; onChange: (value: SidebarNavigationMode) => void; chatEnabled?: boolean; + onSelect?: MenuSelectionHandler; }) { const router = useRouter(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const modes = [ { value: SIDEBAR_NAVIGATION_MODE.BROWSE, @@ -41,8 +50,15 @@ export function SidebarNavigationModeToggle({ ] as const; const handleModeChange = (mode: SidebarNavigationMode, disabled: boolean) => { - if (disabled) return; + if (disabled) { + openCloudUpgrade( + CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, + onSelect?.() ?? undefined, + ); + return; + } onChange(mode); + onSelect?.(); if (mode === SIDEBAR_NAVIGATION_MODE.CHAT) { router.push("/lighthouse"); } @@ -66,25 +82,37 @@ export function SidebarNavigationModeToggle({ key={mode.value} type="button" aria-label={mode.label} - // aria-disabled (not disabled) keeps the button hoverable and - // focusable so the availability tooltip can fire. - aria-disabled={disabled || undefined} className={cn( - "flex h-8 items-center justify-center rounded-[6px] border px-2 text-sm transition-all duration-200 ease-out", - isOpen ? "min-w-0 gap-2" : "w-8", - // The active segment grows (~55%) and gains a bordered, shadowed - // "thumb"; the inactive one shrinks (~45%) and stays flat. + "flex h-8 items-center justify-center rounded-[6px] border text-sm transition-all duration-200 ease-out", + isOpen + ? disabled + ? "min-w-0 gap-1 px-1.5" + : "min-w-0 gap-2 px-2" + : "w-8 px-2", active ? "border-border-input-primary bg-bg-neutral-primary text-text-neutral-primary shadow-md" : "text-text-neutral-secondary hover:text-text-neutral-primary border-transparent", - isOpen && (active ? "flex-[11]" : "flex-[9]"), - disabled && - "hover:text-text-neutral-secondary cursor-not-allowed opacity-50", + // Local Server gives the Chat upsell enough room to keep both + // the feature name and its Cloud badge visible. + isOpen && + (!chatEnabled + ? mode.value === SIDEBAR_NAVIGATION_MODE.CHAT + ? "flex-[3]" + : "flex-[2]" + : active + ? "flex-[11]" + : "flex-[9]"), + disabled && "text-text-neutral-secondary", )} onClick={() => handleModeChange(mode.value, disabled)} > - - {isOpen && {mode.label}} +