feat(ui): add Local Server Cloud upgrade prompts (#11982)

This commit is contained in:
Alejandro Bailo
2026-07-15 12:11:28 +02:00
committed by GitHub
parent 077dff7f68
commit faa74f4c6c
68 changed files with 1687 additions and 429 deletions
+2 -1
View File
@@ -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({
<Suspense>
<NavigationProgress />
</Suspense>
{children}
<PublicAuthShell>{children}</PublicAuthShell>
<Toaster />
{gtmId && <GoogleTagManager gtmId={gtmId} />}
</Providers>
@@ -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(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
@@ -367,19 +371,34 @@ describe("SeedFromFindingsButton", () => {
// 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(
<SeedFromFindingsButton
filterBag={{ "filter[severity__in]": "critical" }}
isCloudEnabled={false}
/>,
);
// When
await user.tab();
// Then
expect(screen.getByRole("button", { name: /Create Alert/i })).toHaveFocus();
});
});
@@ -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<AlertCondition | null>(
@@ -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}
>
<BellPlusIcon size={14} />
@@ -236,10 +245,10 @@ export const SeedFromFindingsButton = ({
if (!isCloudEnabled) {
return (
<span className="relative inline-flex" tabIndex={0}>
<span className="relative inline-flex">
{button}
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<CloudFeatureBadgeLink />
<span className="pointer-events-none absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<Badge variant="cloud">Cloud</Badge>
</span>
</span>
);
+2 -1
View File
@@ -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("/");
}
@@ -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(
<CompliancePageTabs
activeTab={COMPLIANCE_TAB.PER_SCAN}
@@ -72,12 +80,14 @@ describe("CompliancePageTabs", () => {
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,
);
});
});
@@ -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 = ({
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>Per Scan</TabsTrigger>
<TabsTrigger
value={COMPLIANCE_TAB.CROSS_PROVIDER}
disabled={!crossProviderEnabled}
adornment={!crossProviderEnabled ? <CloudFeatureBadge /> : undefined}
adornment={
!crossProviderEnabled ? (
<Badge variant="cloud">Cloud</Badge>
) : undefined
}
>
Cross-Provider
</TabsTrigger>
+2 -1
View File
@@ -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 (
<ContentLayout
@@ -6,6 +6,7 @@ import { LighthouseV2ConfigPage } from "@/app/(prowler)/lighthouse/_components/c
import {
LighthouseSettings,
LLMProvidersTable,
ManagedLighthouseCallout,
} from "@/components/lighthouse-v1";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
@@ -42,6 +43,8 @@ export default async function LighthouseSettingsPage() {
return (
<ContentLayout title="Settings">
<ManagedLighthouseCallout />
<div className="h-8" aria-hidden="true" />
<LLMProvidersTable />
<div className="h-8" aria-hidden="true" />
<LighthouseSettings />
-55
View File
@@ -1,55 +0,0 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("providers page", () => {
it("does not use unstable Date.now keys for the providers DataTable", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).not.toContain("key={`providers-${Date.now()}`}");
});
it("does not pass non-serializable DataTable callbacks from the server page", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).not.toContain("getSubRows={(row) => row.subRows}");
});
it("keeps expandable providers columns on explicit fixed widths", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const columnsPath = path.join(
currentDir,
"../../../components/providers/table/column-providers.tsx",
);
const source = readFileSync(columnsPath, "utf8");
// Provider is fixed, Provider Groups is fluid (no explicit size)
expect(source).toContain("size: 420");
expect(source).toContain("size: 160");
expect(source).toContain("size: 140");
});
it("keeps the CLI import banner gated by the Cloud environment", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).toContain("NEXT_PUBLIC_IS_CLOUD_ENV");
expect(source).toContain("{isCloudEnvironment && <CliImportBanner");
});
it("does not collapse scan config loading failures into an empty list", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const pagePath = path.join(currentDir, "page.tsx");
const source = readFileSync(pagePath, "utf8");
expect(source).toContain("SCAN_CONFIGURATION_LIST_STATUS.UNAVAILABLE");
expect(source).not.toContain("catch {\n return [];");
});
});
+9 -5
View File
@@ -7,6 +7,7 @@ import { CliImportBanner } from "@/components/scans";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
import { FilterTransitionWrapper } from "@/contexts";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
import {
SCAN_CONFIGURATION_LIST_STATUS,
@@ -25,7 +26,7 @@ export default async function Providers({
}) {
const resolvedSearchParams = await searchParams;
const activeTab = getProviderTab(resolvedSearchParams.tab);
const isCloudEnvironment = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
// Exclude `tab` and `onboarding` from the key: tab switches must not re-suspend,
// and `onboarding` is ephemeral (stripped via history.replaceState) — keeping it
@@ -131,15 +132,18 @@ const ProvidersTabContent = async ({
}: {
searchParams: SearchParamsProps;
}) => {
const isCloud = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({ searchParams, isCloud }),
loadScanConfigs(isCloud),
loadProvidersAccountsViewData({
searchParams,
isCloud: isCloudEnvironment,
}),
loadScanConfigs(isCloudEnvironment),
]);
return (
<ProvidersAccountsView
isCloud={isCloud}
isCloud={isCloudEnvironment}
filters={providersView.filters}
providers={providersView.providers}
providerGroups={providersView.providerGroups}
@@ -0,0 +1 @@
Prowler Local Server branding and contextual Prowler Cloud upgrade prompts across navigation, scans, providers, compliance, findings, alerts, and Lighthouse AI
-6
View File
@@ -1,6 +1,5 @@
import { ReactNode } from "react";
import { ProwlerExtended } from "@/components/icons";
import { ThemeSwitch } from "@/components/ThemeSwitch";
interface AuthLayoutProps {
@@ -21,11 +20,6 @@ export const AuthLayout = ({ title, children }: AuthLayoutProps) => {
}}
></div>
{/* Prowler Logo */}
<div className="relative z-10 mb-8 flex w-full max-w-[300px]">
<ProwlerExtended width={300} className="h-auto w-full" />
</div>
{/* Auth Form Container */}
<div className="border-border-neutral-secondary dark:bg-bg-neutral-primary/85 relative z-10 flex w-full max-w-sm flex-col gap-4 rounded-[14px] border bg-white/90 px-8 py-10 shadow-sm md:max-w-md">
{/* Header with Title and Theme Toggle */}
@@ -0,0 +1,18 @@
import type { ReactNode } from "react";
import { ProwlerBrand } from "@/components/icons";
interface PublicAuthShellProps {
children: ReactNode;
}
export const PublicAuthShell = ({ children }: PublicAuthShellProps) => {
return (
<div className="relative min-h-screen">
<div className="pointer-events-none absolute top-8 left-1/2 z-20 w-[200px] -translate-x-1/2">
<ProwlerBrand className="w-full" />
</div>
{children}
</div>
);
};
@@ -1,7 +1,7 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
ProviderTypeIcon: ({ type }: { type: string }) => (
@@ -50,6 +50,8 @@ beforeAll(() => {
});
import { DOCS_URLS } from "@/lib/external-urls";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
@@ -83,6 +85,10 @@ function makeTriageDetail(
};
}
afterEach(() => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
function renderNoteModal({
triage = makeTriageDetail(),
onTriageUpdateAction = vi.fn(),
@@ -325,8 +331,9 @@ describe("FindingNoteModal", () => {
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
});
it("should disable controls and show the Cloud upsell badge for non-paying users", () => {
it("should keep controls read-only and open the finding triage upgrade", async () => {
// Given
const user = userEvent.setup();
renderNoteModal({
triage: makeTriageDetail({
canEdit: false,
@@ -339,10 +346,16 @@ describe("FindingNoteModal", () => {
screen.getByRole("combobox", { name: "Triage status" }),
).toHaveAttribute("data-disabled", "");
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
const saveUpgrade = screen.getByRole("button", {
name: "Save - available in Prowler Cloud",
});
expect(saveUpgrade).not.toBeDisabled();
await user.click(saveUpgrade);
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE,
);
expect(screen.queryByText(/will be muted/i)).not.toBeInTheDocument();
});
@@ -4,15 +4,22 @@ import { ExternalLink, Info } from "lucide-react";
import { type FormEvent, useRef, useState } from "react";
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn";
import {
Alert,
AlertDescription,
Badge,
Button,
Textarea,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge";
import { DOCS_URLS } from "@/lib/external-urls";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_ORIGIN,
@@ -57,6 +64,9 @@ export function FindingNoteModal({
findingContext,
onTriageUpdateAction,
}: FindingNoteModalProps) {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
// Local state needed: modal edits are buffered until the user chooses Update.
const [selectedStatus, setSelectedStatus] = useState<FindingTriageStatus>(
triage.status,
@@ -248,14 +258,22 @@ export function FindingNoteModal({
</Button>
<span className="relative inline-flex">
{isCloudOnly && (
<span className="absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<CloudFeatureBadgeLink href={triage.billingHref} />
<span className="pointer-events-none absolute top-0 right-0 z-10 translate-x-1/3 -translate-y-1/2">
<Badge variant="cloud">Cloud</Badge>
</span>
)}
<Button
type={canSubmit ? "submit" : "button"}
size="lg"
disabled={!canSubmit}
aria-label={
isCloudOnly ? "Save - available in Prowler Cloud" : undefined
}
disabled={!canSubmit && !isCloudOnly}
onClick={
isCloudOnly
? () => openCloudUpgrade(CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE)
: undefined
}
>
{isSubmitting
? "Saving..."
@@ -1,7 +1,7 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ReactNode } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("@/components/shadcn/modal", () => ({
Modal: ({
@@ -68,6 +68,8 @@ beforeAll(() => {
});
});
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_STATUS,
@@ -98,6 +100,54 @@ function makeTriageSummary(
};
}
afterEach(() => {
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("should open finding triage upgrade from a Cloud-only status cell", async () => {
// Given
const user = userEvent.setup();
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
})}
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: "Change triage status - available in Prowler Cloud",
}),
);
// Then
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE,
);
});
it("should render the Cloud-only triage action with the shared button", () => {
// Given / When
render(
<FindingTriageStatusCell
triage={makeTriageSummary({
canEdit: false,
disabledReason: FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY,
})}
/>,
);
// Then
expect(
screen.getByRole("button", {
name: "Change triage status - available in Prowler Cloud",
}),
).toHaveAttribute("data-slot", "button");
});
describe("finding triage cells", () => {
it("should open the Note modal from the note action with the current status preselected", async () => {
// Given
@@ -373,10 +423,16 @@ describe("finding triage cells", () => {
screen.getByRole("dialog", { name: "Add Triage Note" }),
).toBeVisible();
expect(screen.getByLabelText("Note text")).toBeDisabled();
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(
screen.getByRole("link", { name: "Available in Prowler Cloud" }),
).toHaveAttribute("href", "https://prowler.com/pricing");
const saveUpgrade = screen.getByRole("button", {
name: "Save - available in Prowler Cloud",
});
expect(saveUpgrade).not.toBeDisabled();
await user.click(saveUpgrade);
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE,
);
});
it("should disable Add Triage Note when no update handler is wired", async () => {
@@ -3,6 +3,7 @@
import { MessageSquareText } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/shadcn/button/button";
import { ActionDropdownItem } from "@/components/shadcn/dropdown";
import {
Tooltip,
@@ -10,6 +11,8 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { useCloudUpgradeStore } from "@/store";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
FINDING_TRIAGE_DISABLED_REASON,
FINDING_TRIAGE_NOTE_MAX_LENGTH,
@@ -79,6 +82,9 @@ export function FindingTriageStatusCell({
triage?: FindingTriageSummary;
onTriageUpdateAction?: FindingTriageUpdateHandler;
}) {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const [optimisticStatus, setOptimisticStatus] = useState<{
token: string;
findingId: string;
@@ -166,6 +172,31 @@ export function FindingTriageStatusCell({
return control;
}
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="relative flex">
{control}
<Button
type="button"
variant="bare"
size="link-xs"
aria-label="Change triage status - available in Prowler Cloud"
className="absolute inset-0 h-auto w-auto rounded-lg"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE);
}}
/>
</span>
</TooltipTrigger>
<TooltipContent>{disabledCopy}</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -0,0 +1,49 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProwlerBrand } from "./ProwlerIcons";
describe("ProwlerBrand", () => {
afterEach(() => {
cleanup();
vi.unstubAllEnvs();
});
it("should render the Local Server lockups outside Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
render(<ProwlerBrand />);
// Then
const logo = screen.getByRole("img", { name: "Prowler Local Server" });
const sources = Array.from(logo.querySelectorAll("img"), (image) =>
image.getAttribute("src"),
);
expect(sources).toEqual([
"/logos/prowler-local-server-light.svg",
"/logos/prowler-local-server-dark.svg",
]);
});
it("should render the Prowler Cloud lockups in Cloud", () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
render(<ProwlerBrand />);
// Then
const logo = screen.getByRole("img", { name: "Prowler Cloud" });
const sources = Array.from(logo.querySelectorAll("img"), (image) =>
image.getAttribute("src"),
);
expect(sources).toEqual([
"/logos/prowler-cloud-light.svg",
"/logos/prowler-cloud-dark.svg",
]);
});
});
@@ -1,7 +1,59 @@
import Image from "next/image";
import React from "react";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import { IconSvgProps } from "../../../types/index";
const PROWLER_BRAND = {
CLOUD: {
name: "Prowler Cloud",
lightLogo: "/logos/prowler-cloud-light.svg",
darkLogo: "/logos/prowler-cloud-dark.svg",
},
LOCAL_SERVER: {
name: "Prowler Local Server",
lightLogo: "/logos/prowler-local-server-light.svg",
darkLogo: "/logos/prowler-local-server-dark.svg",
},
} as const;
interface ProwlerBrandProps {
className?: string;
}
export const ProwlerBrand = ({ className }: ProwlerBrandProps) => {
const brand = isCloud() ? PROWLER_BRAND.CLOUD : PROWLER_BRAND.LOCAL_SERVER;
return (
<span
role="img"
aria-label={brand.name}
className={cn("inline-grid", className)}
>
<Image
src={brand.lightLogo}
alt=""
aria-hidden="true"
width={200}
height={63.14}
unoptimized
className="col-start-1 row-start-1 h-auto w-full dark:hidden"
/>
<Image
src={brand.darkLogo}
alt=""
aria-hidden="true"
width={200}
height={63.14}
unoptimized
className="col-start-1 row-start-1 hidden h-auto w-full dark:block"
/>
</span>
);
};
export const ProwlerExtended: React.FC<IconSvgProps> = ({
size,
width = 216,
@@ -18,20 +18,19 @@ vi.mock("../sidebar/sidebar", () => ({
Sidebar: () => <aside data-testid="sidebar" />,
}));
vi.mock("@/components/shared/cloud-upgrade-modal", () => ({
CloudUpgradeModal: () => <div data-testid="cloud-upgrade-modal" />,
}));
describe("MainLayout", () => {
it("renders subdued background glows for side-nav contrast", () => {
it("mounts the shared Cloud upgrade modal with page content", () => {
render(
<MainLayout>
<div>Page content</div>
</MainLayout>,
);
const topGlow =
screen.getByTestId("sidebar").previousElementSibling
?.previousElementSibling;
const bottomGlow = screen.getByTestId("sidebar").previousElementSibling;
expect(topGlow).toHaveClass("h-[120%]", "w-[160%]", "opacity-[7%]");
expect(bottomGlow).toHaveClass("h-[50%]", "w-[50%]", "opacity-[7%]");
expect(screen.getByTestId("cloud-upgrade-modal")).toBeInTheDocument();
expect(screen.getByText("Page content")).toBeVisible();
});
});
@@ -1,5 +1,6 @@
"use client";
import { CloudUpgradeModal } from "@/components/shared/cloud-upgrade-modal";
import { useSidebar } from "@/hooks/use-sidebar";
import { useStore } from "@/hooks/use-store";
import { cn } from "@/lib/utils";
@@ -34,6 +35,7 @@ export default function MainLayout({
/>
<Sidebar />
<CloudUpgradeModal />
<main
className={cn(
"no-scrollbar relative z-10 mb-auto h-full flex-1 flex-col overflow-y-auto transition-[margin-left] duration-300 ease-in-out",
@@ -2,7 +2,7 @@
import { ChevronDown } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { useState } from "react";
import { SubmenuItem } from "@/components/layout/sidebar/submenu-item";
import { Button } from "@/components/shadcn/button/button";
@@ -17,7 +17,12 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { cn } from "@/lib/utils";
import { IconComponent, SubmenuProps } from "@/types";
import {
IconComponent,
type MenuSelectionHandler,
SUBMENU_KIND,
SubmenuProps,
} from "@/types";
interface CollapsibleMenuProps {
icon: IconComponent;
@@ -25,6 +30,7 @@ interface CollapsibleMenuProps {
submenus: SubmenuProps[];
defaultOpen?: boolean;
isOpen: boolean;
onSelect?: MenuSelectionHandler;
}
export const CollapsibleMenu = ({
@@ -33,25 +39,24 @@ export const CollapsibleMenu = ({
submenus,
defaultOpen = false,
isOpen: isSidebarOpen,
onSelect,
}: CollapsibleMenuProps) => {
const pathname = usePathname();
const isSubmenuActive = submenus.some((submenu) =>
submenu.active === undefined ? submenu.href === pathname : submenu.active,
const isSubmenuActive = submenus.some(
(submenu) =>
submenu.kind !== SUBMENU_KIND.CLOUD_UPGRADE &&
(submenu.active === undefined
? submenu.href === pathname
: submenu.active),
);
const [isCollapsed, setIsCollapsed] = useState(
isSubmenuActive || defaultOpen,
);
// Collapse the menu when sidebar is closed
useEffect(() => {
if (!isSidebarOpen) {
setIsCollapsed(false);
}
}, [isSidebarOpen]);
const isOpen = isSidebarOpen && isCollapsed;
return (
<Collapsible
open={isCollapsed}
open={isOpen}
onOpenChange={setIsCollapsed}
defaultOpen={defaultOpen}
className="group mb-1 w-full"
@@ -90,7 +95,7 @@ export const CollapsibleMenu = ({
</Tooltip>
<CollapsibleContent className="data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down flex flex-col items-end overflow-hidden">
{submenus.map((submenu, index) => (
<SubmenuItem key={index} {...submenu} />
<SubmenuItem key={index} {...submenu} onSelect={onSelect} />
))}
</CollapsibleContent>
</Collapsible>
+8 -9
View File
@@ -3,15 +3,15 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
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 { cn } from "@/lib/utils";
import { IconComponent } from "@/types";
import { IconComponent, type MenuSelectionHandler } from "@/types";
interface MenuItemProps {
href: string;
@@ -22,6 +22,7 @@ interface MenuItemProps {
tooltip?: string;
isOpen: boolean;
highlight?: boolean;
onSelect?: MenuSelectionHandler;
}
export const MenuItem = ({
@@ -33,6 +34,7 @@ export const MenuItem = ({
tooltip,
isOpen,
highlight,
onSelect,
}: MenuItemProps) => {
const pathname = usePathname();
// Extract only the pathname from href (without query parameters) for comparison
@@ -53,7 +55,7 @@ export const MenuItem = ({
)}
asChild
>
<Link href={href} target={target}>
<Link href={href} target={target} onClick={onSelect}>
<div className="flex items-center">
<span className={cn(isOpen ? "mr-4" : "")}>
<Icon size={18} />
@@ -62,12 +64,9 @@ export const MenuItem = ({
<p className="flex max-w-[200px] items-center truncate">
<span>{label}</span>
{highlight && (
<MenuFeatureBadge
label="New"
variant="new"
size="sm"
className="ml-2"
/>
<Badge variant="new" size="sm" className="ml-2">
New
</Badge>
)}
</p>
)}
+52 -6
View File
@@ -14,12 +14,14 @@ import { SIDEBAR_NAVIGATION_MODE } from "@/hooks/use-sidebar";
const {
openLaunchScanModalMock,
openCloudUpgradeMock,
pathnameValue,
pushMock,
navigationModeValue,
setNavigationModeMock,
} = vi.hoisted(() => ({
openLaunchScanModalMock: vi.fn(),
openCloudUpgradeMock: vi.fn(),
pathnameValue: { current: "/findings" },
pushMock: vi.fn(),
navigationModeValue: { current: "browse" },
@@ -58,6 +60,11 @@ vi.mock("@/store", () => ({
useScansStore: (
selector: (state: { openLaunchScanModal: () => void }) => unknown,
) => selector({ openLaunchScanModal: openLaunchScanModalMock }),
useCloudUpgradeStore: (
selector: (state: {
openCloudUpgrade: (feature: string) => void;
}) => unknown,
) => selector({ openCloudUpgrade: openCloudUpgradeMock }),
}));
vi.mock("@/hooks/use-sidebar", async (importActual) => {
@@ -80,6 +87,10 @@ vi.mock("@/hooks/use-sidebar", async (importActual) => {
let MenuComponent: typeof import("./menu").Menu;
let SidebarNavigationModeToggleComponent: typeof import("@/components/sidebar/navigation-mode-toggle").SidebarNavigationModeToggle;
const expectLastCloudUpgrade = (feature: string) => {
expect(openCloudUpgradeMock.mock.calls.at(-1)?.[0]).toBe(feature);
};
beforeAll(async () => {
MenuComponent = (await import("./menu")).Menu;
SidebarNavigationModeToggleComponent = (
@@ -90,6 +101,7 @@ beforeAll(async () => {
afterEach(() => {
vi.unstubAllEnvs();
navigationModeValue.current = "browse";
openCloudUpgradeMock.mockClear();
});
describe("Menu", () => {
@@ -163,17 +175,50 @@ describe("Menu", () => {
expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
});
it("shows the mode toggle with Chat disabled outside cloud", () => {
it("opens the managed Lighthouse upgrade from Chat in Local Server", async () => {
pathnameValue.current = "/findings";
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(<MenuComponent isOpen />);
expect(screen.getByRole("button", { name: "Home" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Chat" })).toHaveAttribute(
"aria-disabled",
"true",
);
const chatButton = screen.getByRole("button", { name: "Chat" });
expect(chatButton).not.toHaveAttribute("aria-disabled");
await userEvent.click(chatButton);
expectLastCloudUpgrade("lighthouse_ai");
});
it("shows a persistent Cloud upgrade action only in Local Server", async () => {
pathnameValue.current = "/findings";
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
render(<MenuComponent isOpen />);
const upgradeButton = screen.getByRole("button", {
name: "Explore Prowler Cloud",
});
expect(upgradeButton).toHaveClass("bg-button-primary");
await userEvent.click(upgradeButton);
expectLastCloudUpgrade("general");
});
it("separates the persistent Cloud upgrade action from the navigation", () => {
// Given
pathnameValue.current = "/findings";
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
render(<MenuComponent isOpen />);
// Then
expect(
screen.getByRole("button", { name: "Explore Prowler Cloud" })
.parentElement,
).toHaveClass("pt-4");
});
});
@@ -224,7 +269,7 @@ describe("SidebarNavigationModeToggle", () => {
expect(pushMock).not.toHaveBeenCalled();
});
it("blocks Chat and shows the cloud tooltip when chat is unavailable", async () => {
it("opens the Cloud upgrade and shows the cloud tooltip when chat is unavailable", async () => {
// Given
const user = userEvent.setup();
const onChange = vi.fn();
@@ -252,5 +297,6 @@ describe("SidebarNavigationModeToggle", () => {
// Then
expect(onChange).not.toHaveBeenCalled();
expect(pushMock).not.toHaveBeenCalled();
expectLastCloudUpgrade("lighthouse_ai");
});
});
+60 -8
View File
@@ -1,5 +1,6 @@
"use client";
import { Cloud } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -21,9 +22,11 @@ import { useRuntimeConfig } from "@/hooks/use-runtime-config";
import { SIDEBAR_NAVIGATION_MODE, useSidebar } from "@/hooks/use-sidebar";
import { getMenuList } from "@/lib/menu-list";
import { LAUNCH_SCAN_HREF } from "@/lib/scans-navigation";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import { useScansStore } from "@/store";
import { GroupProps } from "@/types";
import { useCloudUpgradeStore, useScansStore } from "@/store";
import { GroupProps, type MenuSelectionHandler } from "@/types";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { RolePermissionAttributes } from "@/types/users";
interface MenuHideRule {
@@ -58,7 +61,12 @@ const filterMenus = (menuGroups: GroupProps[], labelsToHide: string[]) => {
.filter((group) => group.menus.length > 0);
};
export const Menu = ({ isOpen }: { isOpen: boolean }) => {
interface SidebarMenuProps {
isOpen: boolean;
onSelect?: MenuSelectionHandler;
}
export const Menu = ({ isOpen, onSelect }: SidebarMenuProps) => {
const pathname = usePathname();
const { permissions } = useAuth();
const openLaunchScanModal = useScansStore(
@@ -66,7 +74,10 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
);
const isScansPage = pathname.startsWith("/scans");
const { apiDocsUrl } = useRuntimeConfig();
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
const navigationMode = useSidebar((state) => state.navigationMode);
const setNavigationMode = useSidebar((state) => state.setNavigationMode);
@@ -95,7 +106,10 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
className={cn(isOpen ? "h-14 w-full p-1" : "w-14")}
variant="default"
size="default"
onClick={openLaunchScanModal}
onClick={() => {
openLaunchScanModal();
onSelect?.();
}}
>
<LaunchScanButtonContent isOpen={isOpen} />
</Button>
@@ -106,7 +120,11 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
variant="default"
size="default"
>
<Link href={LAUNCH_SCAN_HREF} aria-label="Launch Scan">
<Link
href={LAUNCH_SCAN_HREF}
aria-label="Launch Scan"
onClick={onSelect}
>
<LaunchScanButtonContent isOpen={isOpen} />
</Link>
</Button>
@@ -121,6 +139,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
value={navigationMode}
onChange={setNavigationMode}
chatEnabled={isCloudEnv}
onSelect={onSelect}
/>
{/* Menu Items */}
@@ -142,6 +161,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
submenus={menu.submenus}
defaultOpen={menu.defaultOpen}
isOpen={isOpen}
onSelect={onSelect}
/>
) : (
<MenuItem
@@ -153,6 +173,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
tooltip={menu.tooltip}
isOpen={isOpen}
highlight={menu.highlight}
onSelect={onSelect}
/>
)}
</div>
@@ -165,12 +186,41 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
)}
</div>
{!isCloudEnv && (
<div className="shrink-0 px-2 pt-4 pb-3">
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<Button
type="button"
variant="default"
aria-label="Explore Prowler Cloud"
className={cn("w-full", isOpen ? "justify-center" : "px-0")}
onClick={() => {
openCloudUpgrade(
CLOUD_UPGRADE_FEATURE.GENERAL,
onSelect?.() ?? undefined,
);
}}
>
<Cloud aria-hidden="true" className="size-4" />
{isOpen && <span>Explore Prowler Cloud</span>}
</Button>
</TooltipTrigger>
{!isOpen && (
<TooltipContent side="right">
Explore Prowler Cloud
</TooltipContent>
)}
</Tooltip>
</div>
)}
{/* Footer */}
<div className="text-muted-foreground border-border-neutral-secondary flex shrink-0 items-center justify-center gap-2 border-t pt-4 pb-2 text-center text-xs">
{isOpen ? (
<>
<span>{process.env.NEXT_PUBLIC_PROWLER_RELEASE_VERSION}</span>
{process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
{isCloudEnv && (
<>
<Separator orientation="vertical" />
<Link
@@ -178,6 +228,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1"
onClick={onSelect}
>
<InfoIcon size={16} />
<span className="text-muted-foreground font-normal opacity-80 transition-opacity hover:font-bold hover:opacity-100">
@@ -188,7 +239,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
)}
</>
) : (
process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true" && (
isCloudEnv && (
<Tooltip>
<TooltipTrigger asChild>
<Link
@@ -196,6 +247,7 @@ export const Menu = ({ isOpen }: { isOpen: boolean }) => {
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
onClick={onSelect}
>
<InfoIcon size={16} />
</Link>
@@ -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: () => <span>Prowler</span>,
}));
vi.mock("@/components/layout/sidebar/menu", () => ({
Menu: ({ onSelect }: { onSelect?: () => void }) => (
<button type="button" onClick={onSelect}>
Alerts
</button>
),
}));
import { SheetMenu } from "./sheet-menu";
describe("SheetMenu", () => {
it("should close after selecting a menu action", async () => {
// Given
const user = userEvent.setup();
render(<SheetMenu />);
// 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();
});
});
+27 -6
View File
@@ -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<HTMLButtonElement>(null);
const handleSelect = () => {
setOpen(false);
return triggerRef.current;
};
return (
<Sheet>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger className="lg:hidden" asChild>
<Button className="h-8" variant="outline" size="icon">
<Button
ref={triggerRef}
aria-label="Open menu"
className="h-8"
variant="outline"
size="icon"
>
<MenuIcon size={20} />
</Button>
</SheetTrigger>
@@ -30,12 +47,16 @@ export function SheetMenu() {
variant="link"
asChild
>
<Link href="/" className="flex items-center gap-2">
<ProwlerExtended />
<Link
href="/"
className="flex items-center justify-center"
onClick={handleSelect}
>
<ProwlerBrand />
</Link>
</Button>
</SheetHeader>
<Menu isOpen />
<Menu isOpen onSelect={handleSelect} />
</SheetContent>
</Sheet>
);
+2 -3
View File
@@ -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,
})}
>
<ProwlerExtended />
<ProwlerBrand />
</div>
</Link>
@@ -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(
<SubmenuItem
href="/alerts"
kind={SUBMENU_KIND.CLOUD_UPGRADE}
label="Alerts"
icon={TestIcon}
disabled
highlight
cloudOnly
cloudUpgradeFeature={CLOUD_UPGRADE_FEATURE.ALERTS}
onSelect={onSelect}
/>,
);
// 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(
<SubmenuItem
href="/scans/config"
label="Scan"
icon={TestIcon}
disabled
highlight
cloudOnly
/>,
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);
});
});
+58 -33
View File
@@ -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<HTMLAnchorElement>) => 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 (
<Button
type="button"
variant="menu-inactive"
className="mt-1 w-[calc(100%-12px)] justify-start px-2 py-1"
onClick={() => {
openCloudUpgrade(cloudUpgradeFeature, onSelect?.() ?? undefined);
}}
>
<span className="mr-2">
<Icon size={16} />
</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{label}</span>
<Badge variant="cloud" size="sm">
Cloud
</Badge>
</span>
</Button>
);
}
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 = ({
<p className="flex min-w-0 items-center gap-2">
<span className="truncate">{label}</span>
{highlight && (
<MenuFeatureBadge label="New" variant="new" size="sm" />
<Badge variant="new" size="sm">
New
</Badge>
)}
</p>
</Button>
@@ -108,7 +136,7 @@ export const SubmenuItem = ({
href={href}
target={target}
className="flex items-center"
onClick={onClick}
onClick={onSelect}
>
<span className="mr-2">
<Icon size={16} />
@@ -116,12 +144,9 @@ export const SubmenuItem = ({
<p className="flex min-w-0 items-center">
<span className="truncate">{label}</span>
{highlight && (
<MenuFeatureBadge
label="New"
variant="new"
size="sm"
className="ml-2"
/>
<Badge variant="new" size="sm" className="ml-2">
New
</Badge>
)}
</p>
</Link>
+1
View File
@@ -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";
@@ -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(<ManagedLighthouseCallout />);
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,
);
});
});
@@ -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 (
<Card variant="inner" padding="lg">
<CardHeader className="gap-3">
<div className="flex items-center gap-2">
<Sparkles
aria-hidden="true"
className="text-text-neutral-primary size-5"
/>
<CardTitle>Skip the setup with Prowler Cloud</CardTitle>
<Badge variant="cloud" size="sm">
Cloud
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col items-start gap-4">
<p className="text-text-neutral-secondary text-sm">
Prowler Cloud includes managed OpenAI access with no API keys to
provision, plus a hosted remote MCP server to automate security
workflows.
</p>
<Button
type="button"
variant="default"
onClick={() => openCloudUpgrade(CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI)}
>
Explore The Agentic Cloud Defender
</Button>
</CardContent>
</Card>
);
};
@@ -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(
<AwsMethodSelector
onSelectSingle={vi.fn()}
onSelectOrganizations={vi.fn()}
onSelectOrganizations={onSelectOrganizations}
/>,
);
// 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,
);
});
});
@@ -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 (
<div className="flex flex-col gap-3">
@@ -29,12 +35,15 @@ export function AwsMethodSelector({
/>
<RadioCard
icon={isCloudEnv ? Boxes : Ban}
icon={Boxes}
title="Add Multiple Accounts With AWS Organizations"
onClick={onSelectOrganizations}
disabled={!isCloudEnv}
onClick={() =>
isCloudEnv
? onSelectOrganizations()
: openCloudUpgrade(CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS)
}
>
{!isCloudEnv && <CloudFeatureBadgeLink />}
{!isCloudEnv && <Badge variant="cloud">Cloud</Badge>}
</RadioCard>
</div>
);
@@ -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(
<LaunchStep
onBack={vi.fn()}
onClose={vi.fn()}
onFooterChange={vi.fn()}
capability={SCAN_SCHEDULE_CAPABILITY.MANUAL_ONLY}
/>,
);
// 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();
@@ -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 ? (
<CloudFeatureBadge label="Requires subscription" size="sm" />
) : (
<CloudFeatureBadgeLink size="sm" />
))}
{isManualOnly && !isBlocked && (
<Badge variant="warning" size="sm">
Requires subscription
</Badge>
)}
</label>
</RadioGroup>
</Field>
+13 -2
View File
@@ -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(
<LaunchScanModal open onOpenChange={vi.fn()} providers={[provider]} />,
);
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 () => {
+25 -9
View File
@@ -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<string>("");
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({
<RadioGroupItem
value={LAUNCH_MODE.SCHEDULE}
aria-label="On a schedule"
disabled={!isAdvanced}
disabled={!canUseScheduleMode}
/>
On a schedule
{!isAdvanced && <CloudFeatureBadgeLink size="sm" />}
{isDailyLegacy && (
<Badge variant="cloud" size="sm">
Cloud
</Badge>
)}
</label>
</RadioGroup>
</Field>
@@ -362,6 +376,8 @@ function LaunchScanForm({
disabled={isSubmitting || !providerId}
showLaunchInitialScan
showNextScheduledCopy
canUseAdvancedSchedule={isAdvanced}
showCloudUpgradeBadge={isDailyLegacy}
/>
)}
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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
@@ -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(
<ScheduleFieldsHarness
canUseAdvancedSchedule={false}
@@ -107,15 +113,27 @@ describe("ScanScheduleFields", () => {
);
// 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,
);
});
});
@@ -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 ? (
<CloudFeatureBadgeLink size="sm" />
<Button
type="button"
variant="bare"
size="link-xs"
aria-label="Explore advanced scheduling in Prowler Cloud"
onClick={() =>
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING)
}
>
<Badge variant="cloud" size="sm">
Cloud
</Badge>
</Button>
) : null;
return (
+33
View File
@@ -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(
<Badge variant="cloud" size="sm">
Cloud
</Badge>,
);
// 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(
<Badge variant="new" size="sm">
New
</Badge>,
);
// Then
expect(screen.getByText("New")).toHaveClass(
"bg-bg-feature-new",
"text-text-feature-new",
"h-5",
);
});
it("merges a custom className", () => {
const { container } = render(
<Badge variant="tag" className="extra-class">
+10 -1
View File
@@ -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 (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
className={cn(badgeVariants({ variant, size }), className)}
{...props}
/>
);
+1 -1
View File
@@ -70,7 +70,7 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className="data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus-visible:bg-bg-neutral-tertiary focus-visible:text-text-neutral-primary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
+3
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
// Radix requires an accessible description; opt out explicitly when none is provided.
{...(description ? {} : { "aria-describedby": undefined })}
className={cn(
@@ -1,90 +0,0 @@
import type { CSSProperties } from "react";
import { cn } from "@/lib/utils";
interface MenuFeatureBadgeProps {
label?: string;
variant?: "cloud" | "new";
size?: "default" | "sm";
className?: string;
}
const FEATURE_BADGE_STYLE: Record<
NonNullable<MenuFeatureBadgeProps["variant"]>,
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<MenuFeatureBadgeProps["variant"]>,
string
> = {
cloud: "text-black",
new: "bg-emerald-500 text-white",
};
const FEATURE_BADGE_SIZE_CLASS: Record<
NonNullable<MenuFeatureBadgeProps["size"]>,
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) => (
<span
className={cn(
"inline-flex shrink-0 items-center justify-center font-bold whitespace-nowrap",
FEATURE_BADGE_VARIANT_CLASS[variant],
FEATURE_BADGE_SIZE_CLASS[size],
className,
)}
style={FEATURE_BADGE_STYLE[variant]}
>
{label}
</span>
);
export const CloudFeatureBadge = ({
label = "Available in Prowler Cloud",
size,
className,
}: Omit<MenuFeatureBadgeProps, "variant">) => (
<MenuFeatureBadge
label={label}
variant="cloud"
size={size}
className={className}
/>
);
interface CloudFeatureBadgeLinkProps
extends Omit<MenuFeatureBadgeProps, "variant"> {
href?: string;
}
export const CloudFeatureBadgeLink = ({
href = "https://prowler.com/pricing",
label,
size,
className,
}: CloudFeatureBadgeLinkProps) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 whitespace-nowrap transition-opacity hover:opacity-90"
>
<CloudFeatureBadge label={label} size={size} className={className} />
</a>
);
@@ -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(<CloudUpgradeModal />);
// 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(<CloudUpgradeModal />);
// 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(
<>
<button
type="button"
onClick={() =>
useCloudUpgradeStore
.getState()
.openCloudUpgrade(CLOUD_UPGRADE_FEATURE.GENERAL)
}
>
Explore Prowler Cloud
</button>
<CloudUpgradeModal />
</>,
);
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(<CloudUpgradeModal />);
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
@@ -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 (
<Modal
open={activeFeature !== null}
onOpenChange={(open) => !open && closeCloudUpgrade()}
onOpenAutoFocus={allowInitialAutoFocus}
onCloseAutoFocus={(event) => {
event.preventDefault();
returnFocusElement?.focus();
}}
title={content.title}
description={content.description}
size="2xl"
>
<div className="min-w-0 space-y-6">
<div className="flex items-center gap-3">
<div className="bg-bg-neutral-tertiary text-text-neutral-primary flex size-10 items-center justify-center rounded-xl">
<Cloud aria-hidden="true" className="size-5" />
</div>
<Badge variant="cloud">Available in Prowler Cloud</Badge>
</div>
<ul className="space-y-3">
{content.benefits.map((benefit) => (
<li
key={benefit}
className="text-text-neutral-secondary flex items-start gap-3 text-sm"
>
<Check
aria-hidden="true"
className="text-text-success mt-0.5 size-4"
/>
<span>{benefit}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-3 md:flex-row">
<Button
asChild
className="h-auto min-h-9 w-full min-w-0 shrink whitespace-normal md:flex-1"
>
<a
href={getCloudUpgradePrimaryUrl(feature)}
target="_blank"
rel="noopener noreferrer"
title={content.primaryCta}
>
{content.primaryCta}
</a>
</Button>
<Button
asChild
variant="outline"
className="h-auto min-h-9 w-full min-w-0 shrink whitespace-normal md:flex-1"
>
<a
href={getCloudUpgradeCompareUrl(feature)}
target="_blank"
rel="noopener noreferrer"
title={CLOUD_UPGRADE_SECONDARY_CTA}
>
{CLOUD_UPGRADE_SECONDARY_CTA}
</a>
</Button>
</div>
<p className="text-text-neutral-tertiary text-center text-xs">
{CLOUD_UPGRADE_FOOTER_NOTE}
</p>
</div>
</Modal>
);
};
@@ -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)}
>
<Icon className="size-4 shrink-0" />
{isOpen && <span className="truncate">{mode.label}</span>}
<Icon aria-hidden="true" className="size-4 shrink-0" />
{isOpen && <span className="shrink-0">{mode.label}</span>}
{isOpen && disabled && (
<Badge variant="cloud" size="sm">
Cloud
</Badge>
)}
</button>
);
+30
View File
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from "vitest";
describe("siteConfig", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it("names the open-source application Prowler Local Server", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "false");
// When
const { siteConfig } = await import("./site");
// Then
expect(siteConfig.name).toBe("Prowler Local Server");
});
it("keeps the Prowler Cloud name in Cloud", async () => {
// Given
vi.stubEnv("NEXT_PUBLIC_IS_CLOUD_ENV", "true");
// When
const { siteConfig } = await import("./site");
// Then
expect(siteConfig.name).toBe("Prowler Cloud");
});
});
+3 -3
View File
@@ -1,9 +1,9 @@
import { isCloud } from "@/lib/shared/env";
export type SiteConfig = typeof siteConfig;
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
export const siteConfig = {
name: isCloudEnv ? "Prowler Cloud" : "Prowler",
name: isCloud() ? "Prowler Cloud" : "Prowler Local Server",
description:
'Prowler is the world\'s most widely used Open-Source Cloud Security Platform that automates security and compliance across any cloud environment. With hundreds of ready-to-use security checks, remediation guidance, and compliance frameworks, Prowler is built to "Secure ANY Cloud at AI Speed". Prowler delivers AI-driven, customizable, and easy-to-use assessments, dashboards, reports, and integrations, making cloud security simple, scalable, and cost-effective for organizations of any size.',
};
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import {
CLOUD_UPGRADE_CONTENT,
CLOUD_UPGRADE_FOOTER_NOTE,
getCloudUpgradeCompareUrl,
getCloudUpgradePrimaryUrl,
} from "./cloud-upgrade";
describe("cloud upgrade content", () => {
it("should use title case for every Cloud modal title", () => {
// Given / When
const titles = Object.values(CLOUD_UPGRADE_CONTENT).map(
(content) => content.title,
);
// Then
expect(titles).toEqual([
"Keep Every Provider Checked Automatically",
"Turn Findings into Alerts",
"Add Your Entire AWS Organization",
"Bring CLI Findings into One Cloud View",
"See Compliance Across Every Provider",
"Coordinate Finding Remediation",
"Use The Agent Cloud Defender",
"Scale Prowler Without Operating It",
"Configure Every Scan Once",
]);
});
it("should explain that Prowler Local Server remains unchanged", () => {
// Given / When / Then
expect(CLOUD_UPGRADE_FOOTER_NOTE).toBe(
"Prowler Cloud opens in a new tab. Your Prowler Local Server remains unchanged.",
);
});
});
describe("cloud upgrade URLs", () => {
it("should attribute the primary Cloud destination", () => {
// Given / When
const url = getCloudUpgradePrimaryUrl(CLOUD_UPGRADE_FEATURE.ALERTS);
// Then
expect(url).toBe(
"https://cloud.prowler.com/sign-up?utm_source=prowler-local-server&utm_content=alerts",
);
});
it("should attribute the plans and pricing destination", () => {
// Given / When
const url = getCloudUpgradeCompareUrl(
CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE,
);
// Then
expect(url).toBe(
"https://prowler.com/pricing?utm_source=prowler-local-server&utm_content=cross-provider-compliance",
);
});
it.each([
[CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING, "advanced-scheduling"],
[CLOUD_UPGRADE_FEATURE.ALERTS, "alerts"],
[CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS, "organization"],
[CLOUD_UPGRADE_FEATURE.CLI_IMPORT, "cli-import"],
[
CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE,
"cross-provider-compliance",
],
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE, "findings"],
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI, "lighthouse-ai"],
[CLOUD_UPGRADE_FEATURE.GENERAL, "general"],
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION, "scan-configuration"],
])("should use the canonical content slug for %s", (feature, contentSlug) => {
// Given / When
const url = new URL(getCloudUpgradePrimaryUrl(feature));
// Then
expect(url.searchParams.get("utm_content")).toBe(contentSlug);
});
});
+152
View File
@@ -0,0 +1,152 @@
import {
CLOUD_UPGRADE_FEATURE,
type CloudUpgradeFeature,
} from "@/types/cloud-upgrade";
interface CloudUpgradeContent {
title: string;
description: string;
benefits: readonly [string, string, string, ...string[]];
primaryCta: string;
}
export const CLOUD_UPGRADE_SECONDARY_CTA = "View Plans & Pricing";
export const CLOUD_UPGRADE_FOOTER_NOTE =
"Prowler Cloud opens in a new tab. Your Prowler Local Server remains unchanged.";
const CLOUD_SIGN_UP_URL = "https://cloud.prowler.com/sign-up";
const PRICING_URL = "https://prowler.com/pricing";
const LOCAL_SERVER_UTM_SOURCE = "prowler-local-server";
const CLOUD_UPGRADE_UTM_CONTENT = {
[CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING]: "advanced-scheduling",
[CLOUD_UPGRADE_FEATURE.ALERTS]: "alerts",
[CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS]: "organization",
[CLOUD_UPGRADE_FEATURE.CLI_IMPORT]: "cli-import",
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]:
"cross-provider-compliance",
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: "findings",
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: "lighthouse-ai",
[CLOUD_UPGRADE_FEATURE.GENERAL]: "general",
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: "scan-configuration",
} as const satisfies Record<CloudUpgradeFeature, string>;
export const CLOUD_UPGRADE_CONTENT = {
[CLOUD_UPGRADE_FEATURE.ADVANCED_SCHEDULING]: {
title: "Keep Every Provider Checked Automatically",
description:
"Run scans on the cadence you choose without maintaining scheduling infrastructure.",
benefits: [
"Choose daily, interval, weekly, or monthly scans",
"Set scan times in your preferred timezone",
"Manage schedules alongside scan history",
],
primaryCta: "Schedule Scans in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.ALERTS]: {
title: "Turn Findings into Alerts",
description:
"Get notified when the findings you care about appear in a scan.",
benefits: [
"Get alerted on what matters most",
"Notify the right people after every scan",
"Manage alert rules from one place",
],
primaryCta: "Create Alerts in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.AWS_ORGANIZATIONS]: {
title: "Add Your Entire AWS Organization",
description:
"Discover accounts and organizational units, then manage them from one place.",
benefits: [
"Discover accounts and organizational units automatically",
"Choose exactly which accounts to onboard",
"Apply schedules across the selected accounts",
],
primaryCta: "Set Up AWS Organizations in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.CLI_IMPORT]: {
title: "Bring CLI Findings into One Cloud View",
description:
"Send Prowler CLI scan results to Prowler Cloud for centralized analysis and collaboration.",
benefits: [
"Push results directly with --push-to-cloud",
"Track CLI and managed scans in one place",
"Automate findings ingestion from CI/CD pipelines",
],
primaryCta: "Import CLI Findings in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.CROSS_PROVIDER_COMPLIANCE]: {
title: "See Compliance Across Every Provider",
description:
"Replace separate scan reports with a consolidated compliance view.",
benefits: [
"Compare framework posture across providers",
"Find coverage gaps without switching scans",
"Generate a consolidated compliance report",
],
primaryCta: "Consolidate Compliance in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.FINDING_TRIAGE]: {
title: "Coordinate Finding Remediation",
description:
"Add investigation notes and move findings through a shared remediation workflow.",
benefits: [
"Preserve investigation context on each finding",
"Track review and remediation status",
"Keep triage history with future scans",
],
primaryCta: "Triage Findings in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.LIGHTHOUSE_AI]: {
title: "Use The Agent Cloud Defender",
description:
"Investigate and act on your security posture without operating an AI stack.",
benefits: [
"Start without provisioning or managing OpenAI API keys",
"Automate security workflows through the hosted remote MCP server",
"Keep Lighthouse actions grounded in your Prowler Cloud data",
],
primaryCta: "Open Lighthouse AI in Prowler Cloud",
},
[CLOUD_UPGRADE_FEATURE.GENERAL]: {
title: "Scale Prowler Without Operating It",
description:
"Add managed automation and collaboration while Prowler operates the platform.",
benefits: [
"Onboard AWS Organizations and automate scans and alerts",
"Triage findings and consolidate compliance across providers",
"Investigate and remediate with Lighthouse AI and Agentic View",
"Use managed infrastructure, support, and backups",
],
primaryCta: "Start a Prowler Cloud Trial",
},
[CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION]: {
title: "Configure Every Scan Once",
description:
"Create reusable scan configurations instead of rebuilding options for each run.",
benefits: [
"Reduce noise by fine-tuning scan configurations",
"Apply consistent configurations to providers",
"Manage scan behavior from one place",
],
primaryCta: "Configure Scans in Prowler Cloud",
},
} as const satisfies Record<CloudUpgradeFeature, CloudUpgradeContent>;
const buildCloudUpgradeUrl = (
baseUrl: string,
feature: CloudUpgradeFeature,
) => {
const url = new URL(baseUrl);
url.searchParams.set("utm_source", LOCAL_SERVER_UTM_SOURCE);
url.searchParams.set("utm_content", CLOUD_UPGRADE_UTM_CONTENT[feature]);
return url.toString();
};
export const getCloudUpgradePrimaryUrl = (feature: CloudUpgradeFeature) =>
buildCloudUpgradeUrl(CLOUD_SIGN_UP_URL, feature);
export const getCloudUpgradeCompareUrl = (feature: CloudUpgradeFeature) =>
buildCloudUpgradeUrl(PRICING_URL, feature);
+2 -2
View File
@@ -13,7 +13,7 @@ You are an Autonomous Cloud Security Analyst, the best cloud security chatbot po
Your goal is to help users solve their cloud security problems effectively.
You have access to tools from multiple sources:
- **Prowler App**: User's Prowler providers data, configurations and security overview
- **Prowler Local Server**: User's Prowler providers data, configurations and security overview
- **Prowler Hub**: Generic automatic detections, remediations and compliance framework that are available for Prowler
- **Prowler Docs**: Documentation and knowledge base. Here you can find information about Prowler capabilities, configuration tutorials, guides, and more
@@ -233,7 +233,7 @@ When providing proactive recommendations to secure users' cloud accounts, follow
## Sources and Domain Knowledge
- Prowler website: https://prowler.com/
- Prowler App: https://cloud.prowler.com/
- Prowler Cloud: https://cloud.prowler.com/
- Prowler GitHub repository: https://github.com/prowler-cloud/prowler
- Prowler Documentation: https://docs.prowler.com/
`;
+36 -12
View File
@@ -1,5 +1,8 @@
import { afterEach, describe, expect, it } from "vitest";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { SUBMENU_KIND } from "@/types/components";
import { getMenuList } from "./menu-list";
const findMenu = (label: string) =>
@@ -76,20 +79,18 @@ describe("getMenuList", () => {
});
});
it("should show Alerts as disabled Cloud-only in OSS when Cloud is disabled", () => {
it("should show Alerts as a contextual Cloud upgrade in Local Server", () => {
// Given / When
const alerts = findSubmenu("Alerts");
// Then
expect(alerts).toEqual(
expect.objectContaining({
href: "/alerts",
disabled: true,
cloudOnly: true,
highlight: true,
active: false,
kind: SUBMENU_KIND.CLOUD_UPGRADE,
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.ALERTS,
}),
);
expect(alerts).not.toHaveProperty("href");
});
it("should show Alerts as new under Configuration when Cloud is enabled", () => {
@@ -109,20 +110,43 @@ describe("getMenuList", () => {
);
});
it("should show Scan as disabled Cloud-only in OSS when Cloud is disabled", () => {
it("should show Scan as a contextual Cloud upgrade in Local Server", () => {
// Given / When
const scanConfig = findSubmenu("Scan");
// Then
expect(scanConfig).toEqual(
expect.objectContaining({
href: "/scans/config",
disabled: true,
cloudOnly: true,
highlight: true,
active: false,
kind: SUBMENU_KIND.CLOUD_UPGRADE,
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
}),
);
expect(scanConfig).not.toHaveProperty("href");
});
it("should expose CLI Import as a contextual Cloud upgrade in Local Server", () => {
// Given / When
const cliImport = findSubmenu("CLI Import");
// Then
expect(cliImport).toEqual(
expect.objectContaining({
kind: SUBMENU_KIND.CLOUD_UPGRADE,
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
}),
);
expect(cliImport).not.toHaveProperty("href");
});
it("should omit CLI Import from the Cloud menu", () => {
// Given
process.env.NEXT_PUBLIC_IS_CLOUD_ENV = "true";
// When
const cliImport = findSubmenu("CLI Import");
// Then
expect(cliImport).toBeUndefined();
});
it("should show Scan as new under Configuration when Cloud is enabled", () => {
+67 -14
View File
@@ -12,6 +12,7 @@ import {
SquareChartGantt,
Tag,
Timer,
Upload,
User,
UserCog,
Users,
@@ -27,7 +28,15 @@ import {
LighthouseIcon,
SupportIcon,
} from "@/components/icons/Icons";
import { GroupProps } from "@/types";
import { isCloud } from "@/lib/shared/env";
import {
type CloudUpgradeFeature,
type GroupProps,
type IconComponent,
SUBMENU_KIND,
type SubmenuProps,
} from "@/types";
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
interface MenuListOptions {
pathname: string;
@@ -36,11 +45,47 @@ interface MenuListOptions {
apiDocsUrl?: string | null;
}
interface CloudFeatureSubmenuOptions {
isCloudEnvironment: boolean;
href: string;
label: string;
icon: IconComponent;
active: boolean;
feature: CloudUpgradeFeature;
}
const getCloudFeatureSubmenu = ({
isCloudEnvironment,
href,
label,
icon,
active,
feature,
}: CloudFeatureSubmenuOptions): SubmenuProps => {
if (!isCloudEnvironment) {
return {
kind: SUBMENU_KIND.CLOUD_UPGRADE,
label,
icon,
cloudUpgradeFeature: feature,
};
}
return {
kind: SUBMENU_KIND.LINK,
href,
label,
icon,
active,
highlight: true,
};
};
export const getMenuList = ({
pathname,
apiDocsUrl = null,
}: MenuListOptions): GroupProps[] => {
const isCloudEnv = process.env.NEXT_PUBLIC_IS_CLOUD_ENV === "true";
const isCloudEnv = isCloud();
return [
{
@@ -134,30 +179,38 @@ export const getMenuList = ({
icon: Settings,
submenus: [
{ href: "/providers", label: "Providers", icon: CloudCog },
{
getCloudFeatureSubmenu({
isCloudEnvironment: isCloudEnv,
href: "/alerts",
label: "Alerts",
icon: BellRing,
active: isCloudEnv && pathname.startsWith("/alerts"),
highlight: true,
disabled: !isCloudEnv,
cloudOnly: !isCloudEnv,
},
active: pathname.startsWith("/alerts"),
feature: CLOUD_UPGRADE_FEATURE.ALERTS,
}),
{
href: "/mutelist",
label: "Mutelist",
icon: VolumeX,
active: pathname === "/mutelist",
},
{
getCloudFeatureSubmenu({
isCloudEnvironment: isCloudEnv,
href: "/scans/config",
label: "Scan",
icon: SlidersHorizontal,
active: isCloudEnv && pathname.startsWith("/scans/config"),
highlight: true,
disabled: !isCloudEnv,
cloudOnly: !isCloudEnv,
},
active: pathname.startsWith("/scans/config"),
feature: CLOUD_UPGRADE_FEATURE.SCAN_CONFIGURATION,
}),
...(!isCloudEnv
? [
{
kind: SUBMENU_KIND.CLOUD_UPGRADE,
label: "CLI Import",
icon: Upload,
cloudUpgradeFeature: CLOUD_UPGRADE_FEATURE.CLI_IMPORT,
},
]
: []),
{ href: "/integrations", label: "Integrations", icon: Puzzle },
{ href: "/lighthouse/settings", label: "Lighthouse AI", icon: Cog },
],
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="63.14" viewBox="0 0 200 63.14"><defs><linearGradient id="lh" x1="0" y1="0" x2="1" y2="0.14"><stop offset="0" stop-color="#2EE59B"></stop><stop offset="1" stop-color="#62DFF0"></stop></linearGradient></defs><g transform="scale(0.1621179084)"><path d="M1169.38,132.04c20.76-12.21,34.44-34.9,34.44-59.79,0-38.18-31.06-69.25-69.25-69.25l-216.9.23v145.17h-64.8V3h-79.95s-47.14,95.97-47.14,95.97V3h-52.09s-47.14,95.97-47.14,95.97V3h-53.48v69.6C560.37,30.64,521.34,0,475.28,0c-42.63,0-79.24,26.25-94.54,63.43-4.35-34.03-33.48-60.43-68.67-60.43h-100.01v47.43C202.9,22.91,176.91,3,146.35,3H0s46.34,46.33,46.34,46.33v151.64h53.47v-76.68l17.21,17.21h29.33c30.56,0,56.54-19.91,65.71-47.43v106.91h53.48v-81.51l76.01,81.51h69.62l-64.29-68.94c11.14-6.56,20.22-16.15,26.26-27.46,1.27,55.26,46.58,99.82,102.14,99.82,46.06,0,85.09-30.64,97.81-72.6v69.18h60.88l38.34-78.06v78.06h60.88l66.2-134.78v135.69h95.41l22.86-22.86v22.86h95.05l21.84-21.84v20.93h53.48v-81.5l76.01,81.5h69.62l-64.29-68.94ZM146.35,88.02h-46.54v-31.54h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77ZM312.07,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.08,15.77-15.77,15.77ZM475.28,150.92c-26.86,0-48.72-21.86-48.72-48.72s21.86-48.72,48.72-48.72,48.72,21.86,48.72,48.72-21.86,48.72-48.72,48.72ZM1034.56,148.41h-63.41v-20.35h42.91v-50.88h-42.91v-20.46h63.41v91.69ZM1134.57,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77Z" fill="#ffffff"></path></g><text x="200" y="59.14" text-anchor="end" font-family="ProwlerInter, Inter, sans-serif" font-weight="600" font-size="19" letter-spacing="0.7" fill="url(#lh)">CLOUD</text></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="63.14" viewBox="0 0 200 63.14"><defs><linearGradient id="lh" x1="0" y1="0" x2="1" y2="0.14"><stop offset="0" stop-color="#2EE59B"></stop><stop offset="1" stop-color="#62DFF0"></stop></linearGradient></defs><g transform="scale(0.1621179084)"><path d="M1169.38,132.04c20.76-12.21,34.44-34.9,34.44-59.79,0-38.18-31.06-69.25-69.25-69.25l-216.9.23v145.17h-64.8V3h-79.95s-47.14,95.97-47.14,95.97V3h-52.09s-47.14,95.97-47.14,95.97V3h-53.48v69.6C560.37,30.64,521.34,0,475.28,0c-42.63,0-79.24,26.25-94.54,63.43-4.35-34.03-33.48-60.43-68.67-60.43h-100.01v47.43C202.9,22.91,176.91,3,146.35,3H0s46.34,46.33,46.34,46.33v151.64h53.47v-76.68l17.21,17.21h29.33c30.56,0,56.54-19.91,65.71-47.43v106.91h53.48v-81.51l76.01,81.51h69.62l-64.29-68.94c11.14-6.56,20.22-16.15,26.26-27.46,1.27,55.26,46.58,99.82,102.14,99.82,46.06,0,85.09-30.64,97.81-72.6v69.18h60.88l38.34-78.06v78.06h60.88l66.2-134.78v135.69h95.41l22.86-22.86v22.86h95.05l21.84-21.84v20.93h53.48v-81.5l76.01,81.5h69.62l-64.29-68.94ZM146.35,88.02h-46.54v-31.54h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77ZM312.07,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.08,15.77-15.77,15.77ZM475.28,150.92c-26.86,0-48.72-21.86-48.72-48.72s21.86-48.72,48.72-48.72,48.72,21.86,48.72,48.72-21.86,48.72-48.72,48.72ZM1034.56,148.41h-63.41v-20.35h42.91v-50.88h-42.91v-20.46h63.41v91.69ZM1134.57,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77Z" fill="#0C0A09"></path></g><text x="200" y="59.14" text-anchor="end" font-family="ProwlerInter, Inter, sans-serif" font-weight="600" font-size="19" letter-spacing="0.7" fill="url(#lh)">CLOUD</text></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="63.14" viewBox="0 0 200 63.14"><defs><linearGradient id="lh" x1="0" y1="0" x2="1" y2="0.14"><stop offset="0" stop-color="#2EE59B"></stop><stop offset="1" stop-color="#62DFF0"></stop></linearGradient></defs><g transform="scale(0.1621179084)"><path d="M1169.38,132.04c20.76-12.21,34.44-34.9,34.44-59.79,0-38.18-31.06-69.25-69.25-69.25l-216.9.23v145.17h-64.8V3h-79.95s-47.14,95.97-47.14,95.97V3h-52.09s-47.14,95.97-47.14,95.97V3h-53.48v69.6C560.37,30.64,521.34,0,475.28,0c-42.63,0-79.24,26.25-94.54,63.43-4.35-34.03-33.48-60.43-68.67-60.43h-100.01v47.43C202.9,22.91,176.91,3,146.35,3H0s46.34,46.33,46.34,46.33v151.64h53.47v-76.68l17.21,17.21h29.33c30.56,0,56.54-19.91,65.71-47.43v106.91h53.48v-81.51l76.01,81.51h69.62l-64.29-68.94c11.14-6.56,20.22-16.15,26.26-27.46,1.27,55.26,46.58,99.82,102.14,99.82,46.06,0,85.09-30.64,97.81-72.6v69.18h60.88l38.34-78.06v78.06h60.88l66.2-134.78v135.69h95.41l22.86-22.86v22.86h95.05l21.84-21.84v20.93h53.48v-81.5l76.01,81.5h69.62l-64.29-68.94ZM146.35,88.02h-46.54v-31.54h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77ZM312.07,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.08,15.77-15.77,15.77ZM475.28,150.92c-26.86,0-48.72-21.86-48.72-48.72s21.86-48.72,48.72-48.72,48.72,21.86,48.72,48.72-21.86,48.72-48.72,48.72ZM1034.56,148.41h-63.41v-20.35h42.91v-50.88h-42.91v-20.46h63.41v91.69ZM1134.57,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77Z" fill="#ffffff"></path></g><text x="200" y="59.14" text-anchor="end" font-family="ProwlerInter, Inter, sans-serif" font-weight="600" font-size="19" letter-spacing="0.7" fill="url(#lh)">LOCAL SERVER</text></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="63.14" viewBox="0 0 200 63.14"><defs><linearGradient id="lh" x1="0" y1="0" x2="1" y2="0.14"><stop offset="0" stop-color="#2EE59B"></stop><stop offset="1" stop-color="#62DFF0"></stop></linearGradient></defs><g transform="scale(0.1621179084)"><path d="M1169.38,132.04c20.76-12.21,34.44-34.9,34.44-59.79,0-38.18-31.06-69.25-69.25-69.25l-216.9.23v145.17h-64.8V3h-79.95s-47.14,95.97-47.14,95.97V3h-52.09s-47.14,95.97-47.14,95.97V3h-53.48v69.6C560.37,30.64,521.34,0,475.28,0c-42.63,0-79.24,26.25-94.54,63.43-4.35-34.03-33.48-60.43-68.67-60.43h-100.01v47.43C202.9,22.91,176.91,3,146.35,3H0s46.34,46.33,46.34,46.33v151.64h53.47v-76.68l17.21,17.21h29.33c30.56,0,56.54-19.91,65.71-47.43v106.91h53.48v-81.51l76.01,81.51h69.62l-64.29-68.94c11.14-6.56,20.22-16.15,26.26-27.46,1.27,55.26,46.58,99.82,102.14,99.82,46.06,0,85.09-30.64,97.81-72.6v69.18h60.88l38.34-78.06v78.06h60.88l66.2-134.78v135.69h95.41l22.86-22.86v22.86h95.05l21.84-21.84v20.93h53.48v-81.5l76.01,81.5h69.62l-64.29-68.94ZM146.35,88.02h-46.54v-31.54h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77ZM312.07,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.08,15.77-15.77,15.77ZM475.28,150.92c-26.86,0-48.72-21.86-48.72-48.72s21.86-48.72,48.72-48.72,48.72,21.86,48.72,48.72-21.86,48.72-48.72,48.72ZM1034.56,148.41h-63.41v-20.35h42.91v-50.88h-42.91v-20.46h63.41v91.69ZM1134.57,88.02l-46.54-.18v-31.36h46.54c8.7,0,15.77,7.07,15.77,15.77s-7.07,15.77-15.77,15.77Z" fill="#0C0A09"></path></g><text x="200" y="59.14" text-anchor="end" font-family="ProwlerInter, Inter, sans-serif" font-weight="600" font-size="19" letter-spacing="0.7" fill="url(#lh)">LOCAL SERVER</text></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import type { CloudUpgradeFeature } from "@/types/cloud-upgrade";
interface CloudUpgradeStoreState {
activeFeature: CloudUpgradeFeature | null;
returnFocusElement: HTMLElement | null;
openCloudUpgrade: (
feature: CloudUpgradeFeature,
returnFocusElement?: HTMLElement,
) => void;
closeCloudUpgrade: () => void;
}
// Upgrade prompts are ephemeral and shared so only one modal can be open.
export const useCloudUpgradeStore = create<CloudUpgradeStoreState>((set) => ({
activeFeature: null,
returnFocusElement: null,
openCloudUpgrade: (activeFeature, requestedReturnFocusElement) =>
set({
activeFeature,
returnFocusElement:
requestedReturnFocusElement ??
(document.activeElement instanceof HTMLElement
? document.activeElement
: null),
}),
closeCloudUpgrade: () => set({ activeFeature: null }),
}));
+1
View File
@@ -1,3 +1,4 @@
export * from "./cloud-upgrade/store";
export * from "./organizations/store";
export * from "./provider-wizard/store";
export * from "./scans/store";
+14
View File
@@ -95,6 +95,15 @@
/* Lighthouse AI */
--gradient-lighthouse: linear-gradient(96deg, #2ee59b 3.55%, #62dff0 98.85%);
/* Cloud feature badge */
--gradient-feature-cloud: linear-gradient(
112deg,
rgb(46, 229, 155) 3.5%,
rgb(98, 223, 240) 98.8%
);
--bg-feature-new: var(--color-emerald-500);
--text-feature-new: var(--color-white);
}
/* ===== DARK THEME =====
@@ -242,10 +251,15 @@
--color-bg-warning-secondary: var(--bg-warning-secondary);
--color-bg-fail: var(--bg-fail-primary);
--color-bg-fail-secondary: var(--bg-fail-secondary);
--color-bg-feature-new: var(--bg-feature-new);
--color-text-feature-new: var(--text-feature-new);
/* Shadows */
--shadow-progress-glow: var(--shadow-progress-glow);
/* Background images */
--background-image-feature-cloud: var(--gradient-feature-cloud);
/* Breakpoints */
--breakpoint-3xl: 1920px;
+14
View File
@@ -0,0 +1,14 @@
export const CLOUD_UPGRADE_FEATURE = {
ADVANCED_SCHEDULING: "advanced_scheduling",
ALERTS: "alerts",
AWS_ORGANIZATIONS: "aws_organizations",
CLI_IMPORT: "cli_import",
CROSS_PROVIDER_COMPLIANCE: "cross_provider_compliance",
FINDING_TRIAGE: "finding_triage",
LIGHTHOUSE_AI: "lighthouse_ai",
GENERAL: "general",
SCAN_CONFIGURATION: "scan_configuration",
} as const;
export type CloudUpgradeFeature =
(typeof CLOUD_UPGRADE_FEATURE)[keyof typeof CLOUD_UPGRADE_FEATURE];
+31 -6
View File
@@ -1,8 +1,9 @@
import { LucideIcon } from "lucide-react";
import { MouseEvent, SVGProps } from "react";
import { SVGProps } from "react";
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
import type { CloudUpgradeFeature } from "./cloud-upgrade";
import type { FindingTriageSummary } from "./findings-triage";
export type IconSvgProps = SVGProps<SVGSVGElement> & {
@@ -16,17 +17,41 @@ export type IconProps = {
export type IconComponent = LucideIcon | React.FC<IconSvgProps>;
export type SubmenuProps = {
export const SUBMENU_KIND = {
LINK: "link",
CLOUD_UPGRADE: "cloud_upgrade",
} as const;
interface SubmenuBaseProps {
label: string;
icon: IconComponent;
}
export interface SubmenuLinkProps extends SubmenuBaseProps {
kind?: typeof SUBMENU_KIND.LINK;
href: string;
target?: string;
label: string;
active?: boolean;
icon: IconComponent;
disabled?: boolean;
highlight?: boolean;
cloudOnly?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
cloudUpgradeFeature?: never;
}
export interface SubmenuCloudUpgradeProps extends SubmenuBaseProps {
kind: typeof SUBMENU_KIND.CLOUD_UPGRADE;
cloudUpgradeFeature: CloudUpgradeFeature;
href?: never;
target?: never;
active?: never;
disabled?: never;
highlight?: never;
cloudOnly?: never;
}
export type SubmenuProps = SubmenuLinkProps | SubmenuCloudUpgradeProps;
export type MenuSelectionHandler = () => HTMLElement | null;
export type MenuProps = {
href: string;
+1
View File
@@ -1,4 +1,5 @@
export * from "./authFormSchema";
export * from "./cloud-upgrade";
export * from "./components";
export * from "./filters";
export * from "./findings-table";