From 69e1d19abe5bd05efa6bb2afe039722d292a323a Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:08:49 +0200 Subject: [PATCH] feat(ui): open the paid plan upgrade modal from report downloads (#12875) --- ui/actions/scans/scans.test.ts | 46 +++ ui/actions/scans/scans.ts | 12 + .../compliance/[compliancetitle]/page.tsx | 37 ++- .../_actions/cross-provider.test.ts | 31 ++ .../_components/cross-account-detail.tsx | 3 + .../_components/cross-provider-detail.tsx | 3 + .../cross-provider-pdf-button.test.tsx | 32 ++ .../_components/cross-provider-pdf-button.tsx | 11 +- .../_lib/aggregated-compliance-actions.ts | 12 + ui/app/(prowler)/compliance/page.tsx | 29 +- ui/app/(prowler)/scans/page.tsx | 11 +- .../api/scans/[scanId]/report/route.test.ts | 41 ++- ui/app/api/scans/[scanId]/report/route.ts | 12 + ui/components/compliance/compliance-card.tsx | 4 + .../compliance-download-container.test.tsx | 40 +++ .../compliance-download-container.tsx | 62 ++-- .../compliance/compliance-overview-grid.tsx | 3 + .../compliance/threatscore-badge.test.tsx | 63 +++- .../compliance/threatscore-badge.tsx | 51 ++-- .../scans/table/scan-jobs-columns.tsx | 41 ++- .../table/scan-jobs-row-actions.test.tsx | 32 ++ .../scans/table/scan-jobs-row-actions.tsx | 9 +- ui/components/scans/table/scan-jobs-table.tsx | 3 + .../shared/cloud-upgrade-modal.test.tsx | 97 +++++- ui/components/shared/cloud-upgrade-modal.tsx | 281 +++++++++++++----- ui/hooks/use-report-download.ts | 18 ++ ui/lib/cloud-upgrade.ts | 48 +++ ui/lib/report-download-access.ts | 10 + ui/store/cloud-upgrade/store.ts | 8 +- ui/types/cloud-upgrade.ts | 10 + 30 files changed, 887 insertions(+), 173 deletions(-) create mode 100644 ui/hooks/use-report-download.ts create mode 100644 ui/lib/report-download-access.ts diff --git a/ui/actions/scans/scans.test.ts b/ui/actions/scans/scans.test.ts index bf91484ee7..6b11d9c314 100644 --- a/ui/actions/scans/scans.test.ts +++ b/ui/actions/scans/scans.test.ts @@ -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.", + }); + }); +}); diff --git a/ui/actions/scans/scans.ts b/ui/actions/scans/scans.ts index 89f832552c..f6f637b605 100644 --- a/ui/actions/scans/scans.ts +++ b/ui/actions/scans/scans.ts @@ -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 => { + if (await isReportDownloadLocked()) { + return { error: REPORT_DOWNLOAD_LOCKED_ERROR }; + } + const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/scans/${scanId}/${urlPath}`); diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index b153c203f9..6ba928cb21 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -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 ( ); @@ -124,6 +129,7 @@ export default async function ComplianceDetail({ } const crossAccountTitle = compliancetitle.split("-").join(" "); + const subscriptionOnly = await subscriptionOnlyPromise; return ( @@ -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} /> )} diff --git a/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts b/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts index edd1481aac..9f0cfa5d87 100644 --- a/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts +++ b/ui/app/(prowler)/compliance/_actions/cross-provider.test.ts @@ -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", () => { diff --git a/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx index 9c662558f5..53e502fcb9 100644 --- a/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-account-detail.tsx @@ -48,6 +48,7 @@ interface CrossAccountDetailProps { providerType: KnownProviderType; searchParams: Record; 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={ diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx index 9bdfc91688..8b91362db0 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-detail.tsx @@ -44,6 +44,7 @@ interface CrossProviderDetailProps { complianceId: string; searchParams: Record; 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={ diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx index 12b74a41bf..923f6cdcbd 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.test.tsx @@ -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( + , + ); + + // 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 = { diff --git a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx index 3e19460feb..1056fbf9d1 100644 --- a/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx +++ b/ui/app/(prowler)/compliance/_components/cross-provider-pdf-button.tsx @@ -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={} label={`Download latest${formatGeneratedAt(availablePdf.completedAt)}`} description={availablePdf.filename} - onSelect={() => downloadPdf(availablePdf.taskId)} + onSelect={() => + runReportDownload(() => downloadPdf(availablePdf.taskId)) + } /> )} } label="Generate new report…" - onSelect={() => setDialogOpen(true)} + onSelect={() => runReportDownload(() => setDialogOpen(true))} /> )} diff --git a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts index 6c8303dc83..6d6004e5e5 100644 --- a/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts +++ b/ui/app/(prowler)/compliance/_lib/aggregated-compliance-actions.ts @@ -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 => { + if (await isReportDownloadLocked()) { + return { error: REPORT_DOWNLOAD_LOCKED_ERROR }; + } + const headers = await getAuthHeaders({ contentType: false }); try { diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index 015521f6fc..a643e70330 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -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} /> )} @@ -273,6 +278,7 @@ export default async function Compliance({ scanId={selectedScanId} selectedScan={selectedScanData} watchlistPromise={watchlistPromise} + subscriptionOnly={subscriptionOnly} /> @@ -302,11 +308,13 @@ const SSRComplianceGrid = async ({ scanId, selectedScan, watchlistPromise, + subscriptionOnly, }: { searchParams: SearchParamsProps; scanId: string | null; selectedScan?: ScanEntity; watchlistPromise: Promise; + 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} /> ); diff --git a/ui/app/(prowler)/scans/page.tsx b/ui/app/(prowler)/scans/page.tsx index 4b308921e4..fe4e93c98e 100644 --- a/ui/app/(prowler)/scans/page.tsx +++ b/ui/app/(prowler)/scans/page.tsx @@ -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({ @@ -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} /> ); }; diff --git a/ui/app/api/scans/[scanId]/report/route.test.ts b/ui/app/api/scans/[scanId]/report/route.test.ts index ad5e9194ea..468482a54c 100644 --- a/ui/app/api/scans/[scanId]/report/route.test.ts +++ b/ui/app/api/scans/[scanId]/report/route.test.ts @@ -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) { diff --git a/ui/app/api/scans/[scanId]/report/route.ts b/ui/app/api/scans/[scanId]/report/route.ts index 5b891385cb..7c0c7d1083 100644 --- a/ui/app/api/scans/[scanId]/report/route.ts +++ b/ui/app/api/scans/[scanId]/report/route.ts @@ -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 = diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index e5bc4f57c0..a3fd54fa69 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -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 = ({ @@ -62,6 +64,7 @@ export const ComplianceCard: React.FC = ({ id, isLatestCisForProvider = false, watchlistAction, + subscriptionOnly = false, }) => { const searchParams = useSearchParams(); const router = useRouter(); @@ -174,6 +177,7 @@ export const ComplianceCard: React.FC = ({ isLatestCisForProvider, )} disabled={hasRegionFilter} + subscriptionOnly={subscriptionOnly} /> {watchlistAction} diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx index 34f877a789..814a32b2b3 100644 --- a/ui/components/compliance/compliance-download-container.test.tsx +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -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( + , + ); + + // 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(); diff --git a/ui/components/compliance/compliance-download-container.tsx b/ui/components/compliance/compliance-download-container.tsx index 9c49b1df80..520694ceae 100644 --- a/ui/components/compliance/compliance-download-container.tsx +++ b/ui/components/compliance/compliance-download-container.tsx @@ -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", diff --git a/ui/components/compliance/compliance-overview-grid.tsx b/ui/components/compliance/compliance-overview-grid.tsx index 16500dd5e8..e28e7566f3 100644 --- a/ui/components/compliance/compliance-overview-grid.tsx +++ b/ui/components/compliance/compliance-overview-grid.tsx @@ -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 ? ( ({ + 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( + , + ); + + // 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"); diff --git a/ui/components/compliance/threatscore-badge.tsx b/ui/components/compliance/threatscore-badge.tsx index f3e526fc3b..7d4308e504 100644 --- a/ui/components/compliance/threatscore-badge.tsx +++ b/ui/components/compliance/threatscore-badge.tsx @@ -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 ( diff --git a/ui/components/scans/table/scan-jobs-columns.tsx b/ui/components/scans/table/scan-jobs-columns.tsx index dc040368fa..0a9e4ce311 100644 --- a/ui/components/scans/table/scan-jobs-columns.tsx +++ b/ui/components/scans/table/scan-jobs-columns.tsx @@ -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 = { @@ -121,12 +125,17 @@ const resourcesColumn: ColumnDef = { const actionsColumn = ( tab: ScanJobsTab, - capability?: ScanScheduleCapability, + { capability, subscriptionOnly }: ScanJobsRowActionOptions, ): ColumnDef => ({ id: "actions", header: ({ column }) => , cell: ({ row }) => ( - + ), enableSorting: false, }); @@ -141,7 +150,7 @@ const durationColumn: ColumnDef = { }; const activeColumns = ( - capability?: ScanScheduleCapability, + rowActionOptions: ScanJobsRowActionOptions, ): ColumnDef[] => [ 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[] => [ 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[] => [ accountColumn, scanInfoColumn, scheduledScanScheduleColumn, nextScanColumn, lastScanColumn, - actionsColumn(SCAN_JOBS_TAB.SCHEDULED, capability), + actionsColumn(SCAN_JOBS_TAB.SCHEDULED, rowActionOptions), ]; export function getScanJobsColumns( options: GetScanJobsColumnsOptions, ): ColumnDef[] { - 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); } diff --git a/ui/components/scans/table/scan-jobs-row-actions.test.tsx b/ui/components/scans/table/scan-jobs-row-actions.test.tsx index 07ea83ba94..c9e2ef3944 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.test.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.test.tsx @@ -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( + , + ); + + // 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(); diff --git a/ui/components/scans/table/scan-jobs-row-actions.tsx b/ui/components/scans/table/scan-jobs-row-actions.tsx index fc8b67ff52..ed43777f65 100644 --- a/ui/components/scans/table/scan-jobs-row-actions.tsx +++ b/ui/components/scans/table/scan-jobs-row-actions.tsx @@ -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({ } label="Download Scan Reports" - onSelect={() => downloadScanZip(scan.id, toast)} + onSelect={() => + runReportDownload(() => downloadScanZip(scan.id, toast)) + } /> )} diff --git a/ui/components/scans/table/scan-jobs-table.tsx b/ui/components/scans/table/scan-jobs-table.tsx index 294ae6865c..4d0c5ccf8a 100644 --- a/ui/components/scans/table/scan-jobs-table.tsx +++ b/ui/components/scans/table/scan-jobs-table.tsx @@ -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"); diff --git a/ui/components/shared/cloud-upgrade-modal.test.tsx b/ui/components/shared/cloud-upgrade-modal.test.tsx index 2ac1ec1c68..0687d9c564 100644 --- a/ui/components/shared/cloud-upgrade-modal.test.tsx +++ b/ui/components/shared/cloud-upgrade-modal.test.tsx @@ -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(); @@ -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(); + + // 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(); + + // 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(); + + // 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(); + }); }); diff --git a/ui/components/shared/cloud-upgrade-modal.tsx b/ui/components/shared/cloud-upgrade-modal.tsx index 3c7080427b..df288d7d6c 100644 --- a/ui/components/shared/cloud-upgrade-modal.tsx +++ b/ui/components/shared/cloud-upgrade-modal.tsx @@ -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) => ( + +); + +const UpgradeModalLayout = ({ + open, + onClose, + returnFocusElement, + title, + description, + badge, + benefits, + primaryCta, + secondaryCta, + footerNote, +}: UpgradeModalLayoutProps) => ( + !nextOpen && onClose()} + onOpenAutoFocus={allowInitialAutoFocus} + onCloseAutoFocus={(event) => { + event.preventDefault(); + returnFocusElement?.focus(); + }} + title={title} + description={description} + size="2xl" + > +
+
+
+
+ {badge} +
+ +
    + {benefits.map((benefit) => ( +
  • +
  • + ))} +
+ +
+ {primaryCta && } + +
+ + {footerNote && ( +

+ {footerNote} +

+ )} +
+
+); + +interface LocalServerUpgradeModalProps extends UpgradeModalVariantProps { + feature: CloudUpgradeFeature; +} + +const LocalServerUpgradeModal = ({ + feature, + ...modalProps +}: LocalServerUpgradeModalProps) => { + const content = CLOUD_UPGRADE_CONTENT[feature]; + + return ( + + ); +}; + +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 ( + + ); +}; + 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 ( - !open && closeCloudUpgrade()} - onOpenAutoFocus={allowInitialAutoFocus} - onCloseAutoFocus={(event) => { - event.preventDefault(); - returnFocusElement?.focus(); - }} - title={content.title} - description={content.description} - size="2xl" - > -
-
-
-
- Available in Prowler Cloud -
+ // Cloud only upsells paid plans; Local Server only upsells Prowler Cloud. + if (isCloud()) { + return isPaidPlanUpgradeFeature(feature) ? ( + + ) : null; + } -
    - {content.benefits.map((benefit) => ( -
  • -
  • - ))} -
- - - -

- {CLOUD_UPGRADE_FOOTER_NOTE} -

-
-
- ); + return isCloudUpgradeFeature(feature) ? ( + + ) : null; }; diff --git a/ui/hooks/use-report-download.ts b/ui/hooks/use-report-download.ts new file mode 100644 index 0000000000..ce7bd43b1b --- /dev/null +++ b/ui/hooks/use-report-download.ts @@ -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) => { + if (subscriptionOnly) { + openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD); + return; + } + + return download(); + }; +}; diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts index 2dfc79f0d7..a3a6dc0e8c 100644 --- a/ui/lib/cloud-upgrade.ts +++ b/ui/lib/cloud-upgrade.ts @@ -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; + +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; + +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(); +}; diff --git a/ui/lib/report-download-access.ts b/ui/lib/report-download-access.ts new file mode 100644 index 0000000000..afc565b89a --- /dev/null +++ b/ui/lib/report-download-access.ts @@ -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 => + Promise.resolve(false); diff --git a/ui/store/cloud-upgrade/store.ts b/ui/store/cloud-upgrade/store.ts index 21e421ccb0..ee806c4ccb 100644 --- a/ui/store/cloud-upgrade/store.ts +++ b/ui/store/cloud-upgrade/store.ts @@ -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; diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts index 4c821390f5..39d7fd113e 100644 --- a/ui/types/cloud-upgrade.ts +++ b/ui/types/cloud-upgrade.ts @@ -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;