mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-03 17:54:05 +00:00
feat(ui): open the paid plan upgrade modal from report downloads (#12875)
This commit is contained in:
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user