mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 04:21:52 +00:00
feat(ui): add Local Server Cloud upgrade prompts (#11982)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 [];");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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,3 +1,4 @@
|
||||
export * from "./cloud-upgrade/store";
|
||||
export * from "./organizations/store";
|
||||
export * from "./provider-wizard/store";
|
||||
export * from "./scans/store";
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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,4 +1,5 @@
|
||||
export * from "./authFormSchema";
|
||||
export * from "./cloud-upgrade";
|
||||
export * from "./components";
|
||||
export * from "./filters";
|
||||
export * from "./findings-table";
|
||||
|
||||
Reference in New Issue
Block a user