feat(ui): open the paid plan upgrade modal from report downloads (#12875)

This commit is contained in:
Alejandro Bailo
2026-09-23 13:08:49 +02:00
committed by GitHub
parent 859421b0ec
commit 69e1d19abe
30 changed files with 887 additions and 173 deletions
+46
View File
@@ -6,12 +6,14 @@ const {
getAuthHeadersMock,
handleApiErrorMock,
handleApiResponseMock,
isReportDownloadLockedMock,
} = vi.hoisted(() => ({
addScanOperationMock: vi.fn(),
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiErrorMock: vi.fn(),
handleApiResponseMock: vi.fn(),
isReportDownloadLockedMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
@@ -32,7 +34,16 @@ vi.mock("@/lib/sentry-breadcrumbs", () => ({
addScanOperation: addScanOperationMock,
}));
vi.mock("@/lib/report-download-access", () => ({
REPORT_DOWNLOAD_LOCKED_ERROR:
"Report downloads require an active subscription.",
isReportDownloadLocked: isReportDownloadLockedMock,
}));
import {
getComplianceCsv,
getComplianceOcsf,
getCompliancePdfReport,
getExportsZip,
launchOrganizationScans,
scheduleOrganizationDailyScans,
@@ -156,6 +167,7 @@ describe("getExportsZip", () => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
isReportDownloadLockedMock.mockResolvedValue(false);
});
it("returns a generic server error when the report endpoint returns HTML", async () => {
@@ -181,3 +193,37 @@ describe("getExportsZip", () => {
});
});
});
describe("report downloads for subscription-only tenants", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
isReportDownloadLockedMock.mockResolvedValue(true);
});
it.each([
{ name: "scan ZIP", download: () => getExportsZip("scan-123") },
{
name: "compliance CSV",
download: () => getComplianceCsv("scan-123", "cis_2.0_aws"),
},
{
name: "compliance OCSF",
download: () => getComplianceOcsf("scan-123", "dora_aws"),
},
{
name: "compliance PDF",
download: () => getCompliancePdfReport("scan-123", "threatscore"),
},
])("rejects the $name without calling the API", async ({ download }) => {
// When
const result = await download();
// Then
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({
error: "Report downloads require an active subscription.",
});
});
});
+12
View File
@@ -15,6 +15,10 @@ import {
} from "@/lib/compliance/compliance-report-types";
import { runWithConcurrencyLimit } from "@/lib/concurrency";
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isReportDownloadLocked,
REPORT_DOWNLOAD_LOCKED_ERROR,
} from "@/lib/report-download-access";
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { SCAN_STATES } from "@/types/attack-paths";
@@ -377,6 +381,10 @@ export const updateScan = async (formData: FormData) => {
};
export const getExportsZip = async (scanId: string) => {
if (await isReportDownloadLocked()) {
return { error: REPORT_DOWNLOAD_LOCKED_ERROR };
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scans/${scanId}/report`);
@@ -457,6 +465,10 @@ const _fetchScanBinary = async (
filename: string,
errorLabel: string,
): Promise<ScanBinaryResult> => {
if (await isReportDownloadLocked()) {
return { error: REPORT_DOWNLOAD_LOCKED_ERROR };
}
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/scans/${scanId}/${urlPath}`);
@@ -38,6 +38,7 @@ import {
} from "@/lib/compliance/compliance-report-types";
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import type { SearchParamsProps } from "@/types";
@@ -77,6 +78,8 @@ export default async function ComplianceDetail({
notFound();
}
const subscriptionOnlyPromise = isReportDownloadLocked();
// Cross-provider mode replaces the per-scan pipeline with the universal
// roll-up view. Prowler Cloud-only: the OSS API has no such endpoint, so
// the route is blocked in OSS the same way the compliance tab is.
@@ -85,6 +88,7 @@ export default async function ComplianceDetail({
redirect("/compliance");
}
const subscriptionOnly = await subscriptionOnlyPromise;
return (
<Suspense
key={buildSearchParamsKey(resolvedSearchParams)}
@@ -105,6 +109,7 @@ export default async function ComplianceDetail({
complianceId={complianceId}
searchParams={resolvedSearchParams}
targetSection={section}
subscriptionOnly={subscriptionOnly}
/>
</Suspense>
);
@@ -124,6 +129,7 @@ export default async function ComplianceDetail({
}
const crossAccountTitle = compliancetitle.split("-").join(" ");
const subscriptionOnly = await subscriptionOnlyPromise;
return (
<ContentLayout
title={
@@ -148,6 +154,7 @@ export default async function ComplianceDetail({
providerType={providerType}
searchParams={resolvedSearchParams}
targetSection={section}
subscriptionOnly={subscriptionOnly}
/>
</Suspense>
</ContentLayout>
@@ -172,18 +179,23 @@ export default async function ComplianceDetail({
let selectedScan: ScanEntity | null = null;
const selectedScanId = scanId || null;
const [metadataInfoData, attributesData, selectedScanResponse] =
await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId ?? undefined,
},
}),
getComplianceAttributes(complianceId, selectedScanId ?? undefined),
selectedScanId
? getScan(selectedScanId, { include: "provider" })
: Promise.resolve(null),
]);
const [
metadataInfoData,
attributesData,
selectedScanResponse,
subscriptionOnly,
] = await Promise.all([
getComplianceOverviewMetadataInfo({
filters: {
"filter[scan_id]": selectedScanId ?? undefined,
},
}),
getComplianceAttributes(complianceId, selectedScanId ?? undefined),
selectedScanId
? getScan(selectedScanId, { include: "provider" })
: Promise.resolve(null),
subscriptionOnlyPromise,
]);
// The compliance catalog is still warming after a deploy/restart. Show the
// "still loading" state with a Try Again instead of rendering an empty page.
@@ -309,6 +321,7 @@ export default async function ComplianceDetail({
complianceId,
latestCisIds.has(complianceId),
)}
subscriptionOnly={subscriptionOnly}
/>
</div>
)}
@@ -5,11 +5,13 @@ const {
getAuthHeadersMock,
handleApiResponseMock,
captureExceptionMock,
isReportDownloadLockedMock,
} = vi.hoisted(() => ({
fetchMock: vi.fn(),
getAuthHeadersMock: vi.fn(),
handleApiResponseMock: vi.fn(),
captureExceptionMock: vi.fn(),
isReportDownloadLockedMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
@@ -28,6 +30,12 @@ vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
}));
vi.mock("@/lib/report-download-access", () => ({
REPORT_DOWNLOAD_LOCKED_ERROR:
"Report downloads require an active subscription.",
isReportDownloadLocked: isReportDownloadLockedMock,
}));
import {
generateCrossProviderPdf,
getCrossProviderComplianceOverview,
@@ -56,6 +64,29 @@ beforeEach(() => {
Authorization: "Bearer test-token",
});
handleApiResponseMock.mockResolvedValue({ data: null });
isReportDownloadLockedMock.mockResolvedValue(false);
});
describe("cross-provider PDF reports for subscription-only tenants", () => {
it.each([
{
name: "generation",
run: () => generateCrossProviderPdf({ complianceId: "csa_ccm_4.0" }),
},
{ name: "download", run: () => getCrossProviderPdfBinary("task-1") },
])("rejects the $name without calling the API", async ({ run }) => {
// Given
isReportDownloadLockedMock.mockResolvedValue(true);
// When
const result = await run();
// Then
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({
error: "Report downloads require an active subscription.",
});
});
});
describe("getCrossProviderComplianceOverview", () => {
@@ -48,6 +48,7 @@ interface CrossAccountDetailProps {
providerType: KnownProviderType;
searchParams: Record<string, string | string[] | undefined>;
targetSection?: string;
subscriptionOnly?: boolean;
}
/**
@@ -63,6 +64,7 @@ export const CrossAccountDetail = async ({
providerType,
searchParams,
targetSection,
subscriptionOnly = false,
}: CrossAccountDetailProps) => {
const filters = parseCrossAccountFilters(searchParams);
@@ -205,6 +207,7 @@ export const CrossAccountDetail = async ({
providerType={providerType}
filters={{ ...filters, scanIds: attrs.scan_ids }}
latestPdf={latestPdf}
subscriptionOnly={subscriptionOnly}
/>
}
filters={
@@ -44,6 +44,7 @@ interface CrossProviderDetailProps {
complianceId: string;
searchParams: Record<string, string | string[] | undefined>;
targetSection?: string;
subscriptionOnly?: boolean;
}
/**
@@ -57,6 +58,7 @@ export const CrossProviderDetail = async ({
complianceId,
searchParams,
targetSection,
subscriptionOnly = false,
}: CrossProviderDetailProps) => {
const filters = parseCrossProviderFilters(searchParams);
@@ -206,6 +208,7 @@ export const CrossProviderDetail = async ({
complianceId={complianceId}
filters={{ ...filters, scanIds: attrs.scan_ids }}
latestPdf={latestPdf}
subscriptionOnly={subscriptionOnly}
/>
}
filters={
@@ -2,6 +2,9 @@ import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { CrossProviderPdfButton } from "./cross-provider-pdf-button";
// Radix dialogs/dropdowns rely on pointer-capture and scrollIntoView, which
@@ -99,6 +102,7 @@ describe("CrossProviderPdfButton", () => {
vi.clearAllMocks();
storeState.tasks = {};
generatePdfMock.mockResolvedValue({ taskId: "task-1" });
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
const openGenerateModal = async (
@@ -192,6 +196,34 @@ describe("CrossProviderPdfButton", () => {
await waitFor(() => expect(downloadPdfMock).toHaveBeenCalledWith("task-7"));
});
it.each([/download latest/i, /generate new report/i])(
"opens the paid plan upgrade instead of %s for subscription-only tenants",
async (label) => {
// Given
const user = userEvent.setup();
render(
<CrossProviderPdfButton
{...props}
latestPdf={{ taskId: "task-7", filename: "csa-latest.pdf" }}
subscriptionOnly
/>,
);
// When
await user.click(screen.getByRole("button", { name: /report/i }));
await user.click(await screen.findByRole("menuitem", { name: label }));
// Then
expect(downloadPdfMock).not.toHaveBeenCalled();
expect(
screen.queryByRole("dialog", { name: /generate/i }),
).not.toBeInTheDocument();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD,
);
},
);
it("keeps a completed report downloadable after the ready toast closes", async () => {
// Given
storeState.tasks = {
@@ -12,6 +12,7 @@ import { FormButtons } from "@/components/shadcn/form";
import { Input } from "@/components/shadcn/input/input";
import { Modal } from "@/components/shadcn/modal";
import { toast } from "@/components/shadcn/toast";
import { useReportDownload } from "@/hooks/use-report-download";
import {
TASK_WATCHER_STATUS,
trackAndPollTask,
@@ -47,6 +48,8 @@ interface CrossProviderPdfButtonProps {
/** Already-generated report matching these filters, if any — offered as an
* instant download instead of forcing a re-generate. */
latestPdf: LatestCrossProviderPdf | null;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
}
export const CrossProviderPdfButton = ({
@@ -54,7 +57,9 @@ export const CrossProviderPdfButton = ({
providerType,
filters,
latestPdf,
subscriptionOnly = false,
}: CrossProviderPdfButtonProps) => {
const runReportDownload = useReportDownload(subscriptionOnly);
const [dialogOpen, setDialogOpen] = useState(false);
const [reportName, setReportName] = useState("");
const [submitting, setSubmitting] = useState(false);
@@ -185,13 +190,15 @@ export const CrossProviderPdfButton = ({
icon={<DownloadIcon />}
label={`Download latest${formatGeneratedAt(availablePdf.completedAt)}`}
description={availablePdf.filename}
onSelect={() => downloadPdf(availablePdf.taskId)}
onSelect={() =>
runReportDownload(() => downloadPdf(availablePdf.taskId))
}
/>
)}
<ActionDropdownItem
icon={<FileTextIcon />}
label="Generate new report…"
onSelect={() => setDialogOpen(true)}
onSelect={() => runReportDownload(() => setDialogOpen(true))}
/>
</ActionDropdown>
)}
@@ -7,6 +7,10 @@ import {
getErrorMessage,
} from "@/lib";
import { hasActionError, type ActionErrorResult } from "@/lib/action-errors";
import {
isReportDownloadLocked,
REPORT_DOWNLOAD_LOCKED_ERROR,
} from "@/lib/report-download-access";
import { handleApiResponse } from "@/lib/server-actions-helper";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
@@ -171,6 +175,10 @@ export const generateAggregatedCompliancePdf = async (
url: URL,
operation: string,
): Promise<{ taskId: string } | { error: string }> => {
if (await isReportDownloadLocked()) {
return { error: REPORT_DOWNLOAD_LOCKED_ERROR };
}
const headers = await getAuthHeaders({ contentType: false });
try {
@@ -211,6 +219,10 @@ export const getAggregatedCompliancePdfBinary = async ({
operation: string;
defaultFilename: string;
}): Promise<ScanBinaryResult> => {
if (await isReportDownloadLocked()) {
return { error: REPORT_DOWNLOAD_LOCKED_ERROR };
}
const headers = await getAuthHeaders({ contentType: false });
try {
+19 -10
View File
@@ -20,6 +20,7 @@ import { Alert, AlertDescription } from "@/components/shadcn/alert";
import { Card, CardContent } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
@@ -124,16 +125,19 @@ export default async function Compliance({
);
}
const scansData = await getScans({
filters: {
"filter[state]": "completed",
},
pageSize: 50,
fields: {
scans: "name,completed_at,provider",
},
include: "provider",
});
const [subscriptionOnly, scansData] = await Promise.all([
isReportDownloadLocked(),
getScans({
filters: {
"filter[state]": "completed",
},
pageSize: 50,
fields: {
scans: "name,completed_at,provider",
},
include: "provider",
}),
]);
if (!scansData?.data) {
return (
@@ -256,6 +260,7 @@ export default async function Compliance({
provider={selectedScan.providerInfo.provider}
selectedScan={selectedScanData}
sectionScores={threatScoreData.sectionScores}
subscriptionOnly={subscriptionOnly}
/>
</div>
)}
@@ -273,6 +278,7 @@ export default async function Compliance({
scanId={selectedScanId}
selectedScan={selectedScanData}
watchlistPromise={watchlistPromise}
subscriptionOnly={subscriptionOnly}
/>
</Suspense>
</>
@@ -302,11 +308,13 @@ const SSRComplianceGrid = async ({
scanId,
selectedScan,
watchlistPromise,
subscriptionOnly,
}: {
searchParams: SearchParamsProps;
scanId: string | null;
selectedScan?: ScanEntity;
watchlistPromise: Promise<ComplianceWatchlistContext>;
subscriptionOnly: boolean;
}) => {
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
@@ -388,6 +396,7 @@ const SSRComplianceGrid = async ({
catalogEntries={watchlist.entries}
providerType={providerType}
canManageWatchlist={watchlist.canManage}
subscriptionOnly={subscriptionOnly}
/>
</ComplianceOverviewPanel>
);
+10 -1
View File
@@ -22,6 +22,7 @@ import {
import { SkeletonTableScans } from "@/components/scans/table";
import { ScanJobsTable } from "@/components/scans/table/scan-jobs-table";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import {
buildProviderScheduleSummary,
buildSchedulesByProviderId,
@@ -196,7 +197,10 @@ export default async function Scans({
const hasManageIngestionsPermission = Boolean(
session?.user?.permissions?.manage_ingestions,
);
const activeScanCount = await getActiveScanCount(resolvedSearchParams);
const [activeScanCount, reportDownloadLocked] = await Promise.all([
getActiveScanCount(resolvedSearchParams),
isReportDownloadLocked(),
]);
// Mirrors ScansPageShell's launch gate: it only mounts the view-first-scan trigger
// when Launch Scan is usable (manage_scans + a connected provider). Without the
// permission nothing can consume the navbar action, so offer none rather than an
@@ -234,6 +238,7 @@ export default async function Scans({
<SSRDataTableScans
searchParams={resolvedSearchParams}
providers={providers}
subscriptionOnly={reportDownloadLocked}
/>
</Suspense>
</ScansPageShell>
@@ -245,10 +250,12 @@ const SSRDataTableScans = async ({
searchParams,
providers,
scanScheduleCapability,
subscriptionOnly,
}: {
searchParams: SearchParamsProps;
providers: ProviderProps[];
scanScheduleCapability?: ScanScheduleCapability;
subscriptionOnly: boolean;
}) => {
const tab = getScanJobsTab(searchParams.tab);
@@ -294,6 +301,7 @@ const SSRDataTableScans = async ({
tab={tab}
hasFilters={hasUserFilters}
scanScheduleCapability={capability}
subscriptionOnly={subscriptionOnly}
/>
);
}
@@ -389,6 +397,7 @@ const SSRDataTableScans = async ({
tab={tab}
hasFilters={hasUserFilters}
scanScheduleCapability={scanScheduleCapability}
subscriptionOnly={subscriptionOnly}
/>
);
};
+39 -2
View File
@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GET } from "./route";
const { getAuthHeadersMock } = vi.hoisted(() => ({
const { getAuthHeadersMock, isReportDownloadLockedMock } = vi.hoisted(() => ({
getAuthHeadersMock: vi.fn(),
isReportDownloadLockedMock: vi.fn(),
}));
vi.mock("@/lib", () => ({
@@ -11,12 +12,48 @@ vi.mock("@/lib", () => ({
getAuthHeaders: getAuthHeadersMock,
}));
vi.mock("@/lib/report-download-access", () => ({
REPORT_DOWNLOAD_LOCKED_ERROR:
"Report downloads require an active subscription.",
isReportDownloadLocked: isReportDownloadLockedMock,
}));
describe("GET /api/scans/[scanId]/report", () => {
beforeEach(() => {
isReportDownloadLockedMock.mockResolvedValue(false);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it.each([
{ label: "download", url: "http://localhost/api" },
{ label: "preflight", url: "http://localhost/api?preflight=1" },
])(
"rejects the $label without reaching the API when downloads are locked",
async ({ url }) => {
// Given
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
isReportDownloadLockedMock.mockResolvedValue(true);
// When
const response = await GET(new Request(url), {
params: Promise.resolve({ scanId: "scan-123" }),
});
// Then
expect(fetchMock).not.toHaveBeenCalled();
expect(response.status).toBe(403);
expect(response.headers.get("cache-control")).toBe("no-store");
await expect(response.text()).resolves.toBe(
"Report downloads require an active subscription.",
);
},
);
it("streams the upstream report body without buffering it", async () => {
const upstreamBody = new ReadableStream({
start(controller) {
+12
View File
@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import {
isReportDownloadLocked,
REPORT_DOWNLOAD_LOCKED_ERROR,
} from "@/lib/report-download-access";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -63,6 +67,14 @@ export async function GET(
{ params }: ScanReportRouteContext,
) {
const { scanId } = await params;
if (await isReportDownloadLocked()) {
return new Response(REPORT_DOWNLOAD_LOCKED_ERROR, {
status: 403,
headers: { "Cache-Control": "no-store", "Content-Type": "text/plain" },
});
}
const headers = await getAuthHeaders({ contentType: false });
const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`;
const isPreflight =
@@ -50,6 +50,8 @@ interface ComplianceCardProps {
* viewer cannot curate the organization's watchlist.
*/
watchlistAction?: ReactNode;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
@@ -62,6 +64,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
id,
isLatestCisForProvider = false,
watchlistAction,
subscriptionOnly = false,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -174,6 +177,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
@@ -27,6 +27,9 @@ vi.mock("@/components/shadcn", async (importOriginal) => ({
toast: {},
}));
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { ComplianceDownloadContainer } from "./compliance-download-container";
describe("ComplianceDownloadContainer", () => {
@@ -36,6 +39,7 @@ describe("ComplianceDownloadContainer", () => {
beforeEach(() => {
vi.clearAllMocks();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("uses the shared action dropdown for the card actions mode", () => {
@@ -136,6 +140,42 @@ describe("ComplianceDownloadContainer", () => {
);
});
it.each([
{ label: /Download CSV report/i, complianceId: "compliance-1" },
{ label: /Download OCSF report/i, complianceId: "dora_2022_2554" },
{ label: /Download PDF report/i, complianceId: "compliance-1" },
])(
"should open the paid plan upgrade instead of $label for subscription-only tenants",
async ({ label, complianceId }) => {
// Given
const user = userEvent.setup();
render(
<ComplianceDownloadContainer
compact
presentation="dropdown"
scanId="scan-1"
complianceId={complianceId}
reportType="threatscore"
subscriptionOnly
/>,
);
// When
await user.click(
screen.getByRole("button", { name: "Open compliance export actions" }),
);
await user.click(screen.getByRole("menuitem", { name: label }));
// Then
expect(downloadComplianceCsvMock).not.toHaveBeenCalled();
expect(downloadComplianceOcsfMock).not.toHaveBeenCalled();
expect(downloadCompliancePdfMock).not.toHaveBeenCalled();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD,
);
},
);
it("should hide the OCSF action for frameworks without OCSF support", async () => {
const user = userEvent.setup();
@@ -14,6 +14,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { useReportDownload } from "@/hooks/use-report-download";
import {
type ComplianceReportType,
isOcsfSupported,
@@ -37,6 +38,8 @@ interface ComplianceDownloadContainerProps {
/** Custom dropdown trigger (e.g. an outline "Report" button); only used
* when presentation is "dropdown". Defaults to the dots icon. */
dropdownTrigger?: React.ReactNode;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
}
export const ComplianceDownloadContainer = ({
@@ -49,7 +52,9 @@ export const ComplianceDownloadContainer = ({
buttonWidth = "auto",
presentation = "buttons",
dropdownTrigger,
subscriptionOnly = false,
}: ComplianceDownloadContainerProps) => {
const runReportDownload = useReportDownload(subscriptionOnly);
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isDownloadingOcsf, setIsDownloadingOcsf] = useState(false);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
@@ -60,35 +65,38 @@ export const ComplianceDownloadContainer = ({
// action everywhere else so the user never hits a guaranteed 404.
const ocsfAvailable = isOcsfSupported(complianceId);
const handleDownloadCsv = async () => {
if (isDownloadingCsv) return;
setIsDownloadingCsv(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloadingCsv(false);
}
};
const handleDownloadCsv = () =>
runReportDownload(async () => {
if (isDownloadingCsv) return;
setIsDownloadingCsv(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloadingCsv(false);
}
});
const handleDownloadOcsf = async () => {
if (!ocsfAvailable || isDownloadingOcsf) return;
setIsDownloadingOcsf(true);
try {
await downloadComplianceOcsf(scanId, complianceId, toast);
} finally {
setIsDownloadingOcsf(false);
}
};
const handleDownloadOcsf = () =>
runReportDownload(async () => {
if (!ocsfAvailable || isDownloadingOcsf) return;
setIsDownloadingOcsf(true);
try {
await downloadComplianceOcsf(scanId, complianceId, toast);
} finally {
setIsDownloadingOcsf(false);
}
});
const handleDownloadPdf = async () => {
if (!reportType || isDownloadingPdf) return;
setIsDownloadingPdf(true);
try {
await downloadCompliancePdf(scanId, reportType, toast);
} finally {
setIsDownloadingPdf(false);
}
};
const handleDownloadPdf = () =>
runReportDownload(async () => {
if (!reportType || isDownloadingPdf) return;
setIsDownloadingPdf(true);
try {
await downloadCompliancePdf(scanId, reportType, toast);
} finally {
setIsDownloadingPdf(false);
}
});
const buttonClassName = cn(
"border-button-primary text-button-primary hover:bg-button-primary/10",
@@ -47,6 +47,7 @@ interface ComplianceOverviewGridProps {
catalogEntries?: ComplianceCatalogEntry[];
providerType?: string;
canManageWatchlist?: boolean;
subscriptionOnly?: boolean;
}
export const ComplianceOverviewGrid = ({
@@ -57,6 +58,7 @@ export const ComplianceOverviewGrid = ({
catalogEntries,
providerType,
canManageWatchlist = false,
subscriptionOnly = false,
}: ComplianceOverviewGridProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -146,6 +148,7 @@ export const ComplianceOverviewGrid = ({
id={id}
selectedScan={selectedScan}
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
subscriptionOnly={subscriptionOnly}
watchlistAction={
watchlistEnabled && canManageWatchlist ? (
<WatchlistToggle
@@ -2,7 +2,30 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { ThreatScoreBadge } from "./threatscore-badge";
const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } =
vi.hoisted(() => ({
downloadComplianceCsvMock: vi.fn(),
downloadComplianceReportPdfMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/lib/helper", () => ({
downloadComplianceCsv: downloadComplianceCsvMock,
downloadComplianceReportPdf: downloadComplianceReportPdfMock,
}));
describe("ThreatScoreBadge", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
@@ -17,6 +40,44 @@ describe("ThreatScoreBadge", () => {
expect(source).not.toContain("ComplianceDownloadContainer");
});
describe("for subscription-only tenants", () => {
beforeEach(() => {
vi.clearAllMocks();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it.each([/Download CSV report/i, /Download PDF report/i])(
"opens the paid plan upgrade instead of %s",
async (label) => {
// Given
const user = userEvent.setup();
render(
<ThreatScoreBadge
score={80}
scanId="scan-1"
provider="aws"
subscriptionOnly
/>,
);
// When
await user.click(
screen.getByRole("button", {
name: "Open compliance export actions",
}),
);
await user.click(screen.getByRole("menuitem", { name: label }));
// Then
expect(downloadComplianceCsvMock).not.toHaveBeenCalled();
expect(downloadComplianceReportPdfMock).not.toHaveBeenCalled();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD,
);
},
);
});
it("does not use Collapsible components", () => {
expect(source).not.toContain("Collapsible");
expect(source).not.toContain("CollapsibleTrigger");
+29 -22
View File
@@ -13,6 +13,7 @@ import {
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { Progress } from "@/components/shadcn/progress";
import { useReportDownload } from "@/hooks/use-report-download";
import { COMPLIANCE_REPORT_TYPES } from "@/lib/compliance/compliance-report-types";
import {
getScoreColor,
@@ -35,6 +36,8 @@ interface ThreatScoreBadgeProps {
provider: string;
selectedScan?: ScanEntity;
sectionScores?: SectionScores;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
}
export const ThreatScoreBadge = ({
@@ -42,8 +45,10 @@ export const ThreatScoreBadge = ({
scanId,
provider,
sectionScores,
subscriptionOnly = false,
}: ThreatScoreBadgeProps) => {
const router = useRouter();
const runReportDownload = useReportDownload(subscriptionOnly);
const searchParams = useSearchParams();
const [isDownloadingCsv, setIsDownloadingCsv] = useState(false);
const [isDownloadingPdf, setIsDownloadingPdf] = useState(false);
@@ -83,29 +88,31 @@ export const ThreatScoreBadge = ({
const pillars = getOrderedPillars(sectionScores);
const handleDownloadCsv = async () => {
if (isDownloadingCsv) return;
setIsDownloadingCsv(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloadingCsv(false);
}
};
const handleDownloadCsv = () =>
runReportDownload(async () => {
if (isDownloadingCsv) return;
setIsDownloadingCsv(true);
try {
await downloadComplianceCsv(scanId, complianceId, toast);
} finally {
setIsDownloadingCsv(false);
}
});
const handleDownloadPdf = async () => {
if (isDownloadingPdf) return;
setIsDownloadingPdf(true);
try {
await downloadComplianceReportPdf(
scanId,
COMPLIANCE_REPORT_TYPES.THREATSCORE,
toast,
);
} finally {
setIsDownloadingPdf(false);
}
};
const handleDownloadPdf = () =>
runReportDownload(async () => {
if (isDownloadingPdf) return;
setIsDownloadingPdf(true);
try {
await downloadComplianceReportPdf(
scanId,
COMPLIANCE_REPORT_TYPES.THREATSCORE,
toast,
);
} finally {
setIsDownloadingPdf(false);
}
});
return (
<Card variant="base" padding="md" className="relative gap-4">
+26 -15
View File
@@ -20,9 +20,13 @@ import {
} from "./cells";
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
interface GetScanJobsColumnsOptions {
tab: ScanJobsTab;
interface ScanJobsRowActionOptions {
capability?: ScanScheduleCapability;
subscriptionOnly?: boolean;
}
interface GetScanJobsColumnsOptions extends ScanJobsRowActionOptions {
tab: ScanJobsTab;
}
const accountColumn: ColumnDef<ScanProps> = {
@@ -121,12 +125,17 @@ const resourcesColumn: ColumnDef<ScanProps> = {
const actionsColumn = (
tab: ScanJobsTab,
capability?: ScanScheduleCapability,
{ capability, subscriptionOnly }: ScanJobsRowActionOptions,
): ColumnDef<ScanProps> => ({
id: "actions",
header: ({ column }) => <DataTableColumnHeader column={column} title="" />,
cell: ({ row }) => (
<ScanJobsRowActions scan={row.original} tab={tab} capability={capability} />
<ScanJobsRowActions
scan={row.original}
tab={tab}
capability={capability}
subscriptionOnly={subscriptionOnly}
/>
),
enableSorting: false,
});
@@ -141,7 +150,7 @@ const durationColumn: ColumnDef<ScanProps> = {
};
const activeColumns = (
capability?: ScanScheduleCapability,
rowActionOptions: ScanJobsRowActionOptions,
): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
@@ -166,11 +175,11 @@ const activeColumns = (
),
enableSorting: false,
},
actionsColumn(SCAN_JOBS_TAB.ACTIVE, capability),
actionsColumn(SCAN_JOBS_TAB.ACTIVE, rowActionOptions),
];
const completedColumns = (
capability?: ScanScheduleCapability,
rowActionOptions: ScanJobsRowActionOptions,
): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
@@ -197,28 +206,30 @@ const completedColumns = (
),
cell: ({ row }) => renderDateCell(row.original.attributes.completed_at),
},
actionsColumn(SCAN_JOBS_TAB.COMPLETED, capability),
actionsColumn(SCAN_JOBS_TAB.COMPLETED, rowActionOptions),
];
const scheduledColumns = (
capability?: ScanScheduleCapability,
rowActionOptions: ScanJobsRowActionOptions,
): ColumnDef<ScanProps>[] => [
accountColumn,
scanInfoColumn,
scheduledScanScheduleColumn,
nextScanColumn,
lastScanColumn,
actionsColumn(SCAN_JOBS_TAB.SCHEDULED, capability),
actionsColumn(SCAN_JOBS_TAB.SCHEDULED, rowActionOptions),
];
export function getScanJobsColumns(
options: GetScanJobsColumnsOptions,
): ColumnDef<ScanProps>[] {
if (options.tab === SCAN_JOBS_TAB.SCHEDULED) {
return scheduledColumns(options.capability);
const { tab, ...rowActionOptions } = options;
if (tab === SCAN_JOBS_TAB.SCHEDULED) {
return scheduledColumns(rowActionOptions);
}
if (options.tab === SCAN_JOBS_TAB.ACTIVE) {
return activeColumns(options.capability);
if (tab === SCAN_JOBS_TAB.ACTIVE) {
return activeColumns(rowActionOptions);
}
return completedColumns(options.capability);
return completedColumns(rowActionOptions);
}
@@ -2,7 +2,9 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useCloudUpgradeStore } from "@/store/cloud-upgrade/store";
import type { ScanProps } from "@/types";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
import { SCAN_SCHEDULE_CAPABILITY } from "@/types/schedules";
import { ScanJobsRowActions } from "./scan-jobs-row-actions";
@@ -122,6 +124,7 @@ describe("ScanJobsRowActions", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.clearAllMocks();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
it("opens the Edit modal seeded with the current scan name", async () => {
@@ -384,6 +387,35 @@ describe("ScanJobsRowActions", () => {
expect(downloadScanZipMock).toHaveBeenCalledWith("scan-1", toastMock);
});
it("opens the paid plan upgrade instead of downloading subscription-only reports", async () => {
// Given
const user = userEvent.setup();
render(
<ScanJobsRowActions
scan={makeScan({
state: "completed",
completed_at: "2026-01-01T10:05:00Z",
})}
tab="completed"
subscriptionOnly
/>,
);
// When
await user.click(
screen.getByRole("button", { name: /open actions menu/i }),
);
await user.click(
screen.getByRole("menuitem", { name: /download scan reports/i }),
);
// Then
expect(downloadScanZipMock).not.toHaveBeenCalled();
expect(useCloudUpgradeStore.getState().activeFeature).toBe(
PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD,
);
});
it("opens failed scan error details from the actions menu", async () => {
// Given
const user = userEvent.setup();
@@ -29,6 +29,7 @@ import {
ActionDropdown,
ActionDropdownItem,
} from "@/components/shadcn/dropdown";
import { useReportDownload } from "@/hooks/use-report-download";
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
import { downloadScanZip } from "@/lib/helper";
import { getScanScheduleCapability } from "@/lib/schedules";
@@ -54,14 +55,18 @@ interface ScanJobsRowActionsProps {
* Schedule capability override. Only for Prowler Cloud.
*/
capability?: ScanScheduleCapability;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
}
export function ScanJobsRowActions({
scan,
tab,
capability,
subscriptionOnly = false,
}: ScanJobsRowActionsProps) {
const router = useRouter();
const runReportDownload = useReportDownload(subscriptionOnly);
const canEditSchedule =
(capability ?? getScanScheduleCapability(isCloud())) ===
SCAN_SCHEDULE_CAPABILITY.ADVANCED;
@@ -213,7 +218,9 @@ export function ScanJobsRowActions({
<ActionDropdownItem
icon={<Download />}
label="Download Scan Reports"
onSelect={() => downloadScanZip(scan.id, toast)}
onSelect={() =>
runReportDownload(() => downloadScanZip(scan.id, toast))
}
/>
</>
)}
@@ -23,6 +23,7 @@ interface ScanJobsTableProps {
tab: ScanJobsTab;
hasFilters?: boolean;
scanScheduleCapability?: ScanScheduleCapability;
subscriptionOnly?: boolean;
}
const REFRESHING_STATES = ["available", "executing"] as const;
@@ -33,6 +34,7 @@ export function ScanJobsTable({
tab,
hasFilters = false,
scanScheduleCapability,
subscriptionOnly,
}: ScanJobsTableProps) {
const searchParams = useSearchParams();
const hasRefreshingScan = data.some((scan) =>
@@ -43,6 +45,7 @@ export function ScanJobsTable({
const columns = getScanJobsColumns({
tab,
capability: scanScheduleCapability,
subscriptionOnly,
});
const showEmptyState = data.length === 0 && !hasFilters;
const selectedScanId = searchParams?.get("scanId");
@@ -3,7 +3,10 @@ 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 {
CLOUD_UPGRADE_FEATURE,
PAID_PLAN_UPGRADE_FEATURE,
} from "@/types/cloud-upgrade";
import { CloudUpgradeModal } from "./cloud-upgrade-modal";
@@ -11,6 +14,16 @@ const modalTestState = vi.hoisted(() => ({
keepContentMounted: false,
}));
const authState = vi.hoisted(() => ({
canManageBilling: true,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
permissions: { manage_billing: authState.canManageBilling },
}),
}));
vi.mock("@/components/shadcn/modal", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/components/shadcn/modal")>();
@@ -41,6 +54,7 @@ describe("CloudUpgradeModal", () => {
afterEach(() => {
cleanup();
modalTestState.keepContentMounted = false;
authState.canManageBilling = true;
vi.unstubAllEnvs();
useCloudUpgradeStore.getState().closeCloudUpgrade();
});
@@ -219,7 +233,7 @@ describe("CloudUpgradeModal", () => {
},
);
it("does not render upgrade UI in Prowler Cloud", () => {
it("does not render Local Server upgrades in Prowler Cloud", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useCloudUpgradeStore
@@ -232,4 +246,83 @@ describe("CloudUpgradeModal", () => {
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("does not render paid plan upgrades in Local Server", () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "false");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD);
// When
render(<CloudUpgradeModal />);
// Then
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders the report download upgrade in Prowler Cloud", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
useCloudUpgradeStore
.getState()
.openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD);
// When
render(<CloudUpgradeModal />);
// Then
expect(
await screen.findByRole("dialog", { name: "Download Your Scan Reports" }),
).toBeVisible();
expect(screen.getByText("Available on paid plans")).toBeVisible();
expect(
screen.queryByText("Available in Prowler Cloud"),
).not.toBeInTheDocument();
const upgradeLink = screen.getByRole("link", {
name: "Upgrade to Download",
});
expect(upgradeLink).toHaveAttribute(
"href",
"/billing?feature=report_download",
);
expect(upgradeLink).not.toHaveAttribute("target");
const pricingLink = screen.getByRole("link", {
name: "View Plans & Pricing",
});
expect(pricingLink).toHaveAttribute(
"href",
"https://prowler.com/pricing?utm_source=prowler-cloud&utm_content=report-download",
);
expect(pricingLink).toHaveAttribute("target", "_blank");
expect(
screen.queryByText(/Your Prowler Local Server remains unchanged/),
).not.toBeInTheDocument();
});
it("asks users without billing access to contact an admin", async () => {
// Given
vi.stubEnv("UI_CLOUD_ENABLED", "true");
authState.canManageBilling = false;
useCloudUpgradeStore
.getState()
.openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD);
// When
render(<CloudUpgradeModal />);
// Then
expect(
await screen.findByRole("dialog", { name: "Download Your Scan Reports" }),
).toBeVisible();
expect(
screen.queryByRole("link", { name: "Upgrade to Download" }),
).not.toBeInTheDocument();
expect(
screen.getByText("Ask an account admin to upgrade your plan."),
).toBeVisible();
expect(
screen.getByRole("link", { name: "View Plans & Pricing" }),
).toBeVisible();
});
});
+207 -74
View File
@@ -5,18 +5,211 @@ 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 { useAuth } from "@/hooks/use-auth";
import {
CLOUD_UPGRADE_CONTENT,
CLOUD_UPGRADE_FOOTER_NOTE,
CLOUD_UPGRADE_SECONDARY_CTA,
getCloudUpgradeCompareUrl,
getCloudUpgradePrimaryUrl,
getPaidPlanUpgradeBillingHref,
getPaidPlanUpgradeCompareUrl,
isCloudUpgradeFeature,
isPaidPlanUpgradeFeature,
PAID_PLAN_UPGRADE_ADMIN_NOTE,
PAID_PLAN_UPGRADE_BADGE,
PAID_PLAN_UPGRADE_CONTENT,
} from "@/lib/cloud-upgrade";
import { isCloud } from "@/lib/shared/env";
import { useCloudUpgradeStore } from "@/store";
import type {
CloudUpgradeFeature,
PaidPlanUpgradeFeature,
} from "@/types/cloud-upgrade";
const allowInitialAutoFocus = () => {};
const CTA_CLASS_NAME =
"h-auto min-h-9 w-full min-w-0 shrink whitespace-normal md:flex-1";
interface UpgradeModalCta {
label: string;
href: string;
opensInNewTab: boolean;
}
interface UpgradeModalVariantProps {
open: boolean;
onClose: () => void;
returnFocusElement: HTMLElement | null;
}
interface UpgradeModalLayoutProps extends UpgradeModalVariantProps {
title: string;
description: string;
badge: string;
benefits: readonly string[];
primaryCta?: UpgradeModalCta;
secondaryCta: UpgradeModalCta;
footerNote?: string;
}
interface UpgradeModalLinkProps {
cta: UpgradeModalCta;
isSecondary?: boolean;
}
const UpgradeModalLink = ({ cta, isSecondary }: UpgradeModalLinkProps) => (
<Button
asChild
variant={isSecondary ? "outline" : undefined}
className={CTA_CLASS_NAME}
>
<a
href={cta.href}
title={cta.label}
{...(cta.opensInNewTab && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{cta.label}
</a>
</Button>
);
const UpgradeModalLayout = ({
open,
onClose,
returnFocusElement,
title,
description,
badge,
benefits,
primaryCta,
secondaryCta,
footerNote,
}: UpgradeModalLayoutProps) => (
<Modal
open={open}
onOpenChange={(nextOpen) => !nextOpen && onClose()}
onOpenAutoFocus={allowInitialAutoFocus}
onCloseAutoFocus={(event) => {
event.preventDefault();
returnFocusElement?.focus();
}}
title={title}
description={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">{badge}</Badge>
</div>
<ul className="space-y-3">
{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">
{primaryCta && <UpgradeModalLink cta={primaryCta} />}
<UpgradeModalLink cta={secondaryCta} isSecondary />
</div>
{footerNote && (
<p className="text-text-neutral-tertiary text-center text-xs">
{footerNote}
</p>
)}
</div>
</Modal>
);
interface LocalServerUpgradeModalProps extends UpgradeModalVariantProps {
feature: CloudUpgradeFeature;
}
const LocalServerUpgradeModal = ({
feature,
...modalProps
}: LocalServerUpgradeModalProps) => {
const content = CLOUD_UPGRADE_CONTENT[feature];
return (
<UpgradeModalLayout
{...modalProps}
title={content.title}
description={content.description}
badge="Available in Prowler Cloud"
benefits={content.benefits}
primaryCta={{
label: content.primaryCta,
href: getCloudUpgradePrimaryUrl(feature),
opensInNewTab: true,
}}
secondaryCta={{
label: CLOUD_UPGRADE_SECONDARY_CTA,
href: getCloudUpgradeCompareUrl(feature),
opensInNewTab: true,
}}
footerNote={CLOUD_UPGRADE_FOOTER_NOTE}
/>
);
};
interface PaidPlanUpgradeModalProps extends UpgradeModalVariantProps {
feature: PaidPlanUpgradeFeature;
}
const PaidPlanUpgradeModal = ({
feature,
...modalProps
}: PaidPlanUpgradeModalProps) => {
const { permissions } = useAuth();
const content = PAID_PLAN_UPGRADE_CONTENT[feature];
// The /billing route redirects users without billing access to /profile.
const canManageBilling = permissions.manage_billing === true;
return (
<UpgradeModalLayout
{...modalProps}
title={content.title}
description={content.description}
badge={PAID_PLAN_UPGRADE_BADGE}
benefits={content.benefits}
primaryCta={
canManageBilling
? {
label: content.primaryCta,
href: getPaidPlanUpgradeBillingHref(feature),
opensInNewTab: false,
}
: undefined
}
secondaryCta={{
label: CLOUD_UPGRADE_SECONDARY_CTA,
href: getPaidPlanUpgradeCompareUrl(feature),
opensInNewTab: true,
}}
footerNote={canManageBilling ? undefined : PAID_PLAN_UPGRADE_ADMIN_NOTE}
/>
);
};
export const CloudUpgradeModal = () => {
const activeFeature = useCloudUpgradeStore((state) => state.activeFeature);
const retainedFeature = useCloudUpgradeStore(
@@ -29,81 +222,21 @@ export const CloudUpgradeModal = () => {
(state) => state.returnFocusElement,
);
if (isCloud()) return null;
const feature = activeFeature ?? retainedFeature;
const content = CLOUD_UPGRADE_CONTENT[feature];
const modalProps = {
open: activeFeature !== null,
onClose: closeCloudUpgrade,
returnFocusElement,
};
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>
// Cloud only upsells paid plans; Local Server only upsells Prowler Cloud.
if (isCloud()) {
return isPaidPlanUpgradeFeature(feature) ? (
<PaidPlanUpgradeModal feature={feature} {...modalProps} />
) : null;
}
<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>
);
return isCloudUpgradeFeature(feature) ? (
<LocalServerUpgradeModal feature={feature} {...modalProps} />
) : null;
};
+18
View File
@@ -0,0 +1,18 @@
import { useCloudUpgradeStore } from "@/store";
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
/** Wraps report downloads so subscription-only tenants get the paid plan upgrade instead. */
export const useReportDownload = (subscriptionOnly = false) => {
const openCloudUpgrade = useCloudUpgradeStore(
(state) => state.openCloudUpgrade,
);
return (download: () => void | Promise<void>) => {
if (subscriptionOnly) {
openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD);
return;
}
return download();
};
};
+48
View File
@@ -1,6 +1,9 @@
import {
CLOUD_UPGRADE_FEATURE,
type CloudUpgradeFeature,
PAID_PLAN_UPGRADE_FEATURE,
type PaidPlanUpgradeFeature,
type UpgradeFeature,
} from "@/types/cloud-upgrade";
import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml";
@@ -200,3 +203,48 @@ export const getCloudUpgradePrimaryUrl = (feature: CloudUpgradeFeature) =>
export const getCloudUpgradeCompareUrl = (feature: CloudUpgradeFeature) =>
buildCloudUpgradeUrl(PRICING_URL, feature);
export const isCloudUpgradeFeature = (
feature: UpgradeFeature,
): feature is CloudUpgradeFeature => feature in CLOUD_UPGRADE_CONTENT;
export const PAID_PLAN_UPGRADE_BADGE = "Available on paid plans";
export const PAID_PLAN_UPGRADE_ADMIN_NOTE =
"Ask an account admin to upgrade your plan.";
const CLOUD_UTM_SOURCE = "prowler-cloud";
const PAID_PLAN_UPGRADE_UTM_CONTENT = {
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download",
} as const satisfies Record<PaidPlanUpgradeFeature, string>;
export const PAID_PLAN_UPGRADE_CONTENT = {
[PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: {
title: "Download Your Scan Reports",
description: "Report downloads are included in Prowler Cloud paid plans.",
benefits: [
"Download the full scan output in CSV, JSON-OCSF, and HTML",
"Export compliance reports as CSV, OCSF, and PDF",
"Share evidence with auditors and your team",
],
primaryCta: "Upgrade to Download",
},
} as const satisfies Record<PaidPlanUpgradeFeature, CloudUpgradeContent>;
export const isPaidPlanUpgradeFeature = (
feature: UpgradeFeature,
): feature is PaidPlanUpgradeFeature => feature in PAID_PLAN_UPGRADE_CONTENT;
export const getPaidPlanUpgradeBillingHref = (
feature: PaidPlanUpgradeFeature,
) => `/billing?${new URLSearchParams({ feature })}`;
export const getPaidPlanUpgradeCompareUrl = (
feature: PaidPlanUpgradeFeature,
) => {
const url = new URL(PRICING_URL);
url.searchParams.set("utm_source", CLOUD_UTM_SOURCE);
url.searchParams.set("utm_content", PAID_PLAN_UPGRADE_UTM_CONTENT[feature]);
return url.toString();
};
+10
View File
@@ -0,0 +1,10 @@
export const REPORT_DOWNLOAD_LOCKED_ERROR =
"Report downloads require an active subscription.";
/**
* Whether the current tenant must upgrade before downloading reports.
* Self-hosted deployments never lock downloads; the Prowler Cloud overlay
* replaces this body with its billing lookup.
*/
export const isReportDownloadLocked = (): Promise<boolean> =>
Promise.resolve(false);
+4 -4
View File
@@ -2,15 +2,15 @@ import { create } from "zustand";
import {
CLOUD_UPGRADE_FEATURE,
type CloudUpgradeFeature,
type UpgradeFeature,
} from "@/types/cloud-upgrade";
interface CloudUpgradeStoreState {
activeFeature: CloudUpgradeFeature | null;
retainedFeature: CloudUpgradeFeature;
activeFeature: UpgradeFeature | null;
retainedFeature: UpgradeFeature;
returnFocusElement: HTMLElement | null;
openCloudUpgrade: (
feature: CloudUpgradeFeature,
feature: UpgradeFeature,
returnFocusElement?: HTMLElement,
) => void;
closeCloudUpgrade: () => void;
+10
View File
@@ -16,3 +16,13 @@ export const CLOUD_UPGRADE_FEATURE = {
export type CloudUpgradeFeature =
(typeof CLOUD_UPGRADE_FEATURE)[keyof typeof CLOUD_UPGRADE_FEATURE];
// Prowler Cloud features gated behind a paid plan.
export const PAID_PLAN_UPGRADE_FEATURE = {
REPORT_DOWNLOAD: "report_download",
} as const;
export type PaidPlanUpgradeFeature =
(typeof PAID_PLAN_UPGRADE_FEATURE)[keyof typeof PAID_PLAN_UPGRADE_FEATURE];
export type UpgradeFeature = CloudUpgradeFeature | PaidPlanUpgradeFeature;