From 35d25a7218ee80f9c6e00db8b6f79f23308e51f5 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:58:15 +0200 Subject: [PATCH] feat(ui): limit locked tenants to a ten-row findings preview (#13130) --- ui/app/(prowler)/findings/page.test.ts | 11 + ui/app/(prowler)/findings/page.tsx | 17 +- .../table/findings-group-table.test.tsx | 189 +++++++++++++++- .../findings/table/findings-group-table.tsx | 79 +++++-- ...findings-preview-lock.integration.test.tsx | 212 ++++++++++++++++++ .../table/findings-preview-lock.test.tsx | 33 +++ .../findings/table/findings-preview-lock.tsx | 53 +++++ .../table/inline-resource-container.tsx | 30 +-- .../table/data-table-pagination.test.tsx | 58 ++++- .../shadcn/table/data-table-pagination.tsx | 82 ++++++- .../shadcn/table/data-table.test.tsx | 65 ++++++ ui/components/shadcn/table/data-table.tsx | 161 ++++++++++++- ui/hooks/use-scrollport-pin.ts | 50 +++++ ui/lib/cloud-upgrade.test.ts | 24 +- ui/lib/cloud-upgrade.ts | 13 ++ ui/lib/feature-flags/shared.test.ts | 1 + ui/lib/feature-flags/shared.ts | 3 + ui/lib/findings-access.test.ts | 20 ++ ui/lib/findings-access.ts | 10 + ui/types/cloud-upgrade.ts | 1 + ui/vitest.setup.ts | 2 + 21 files changed, 1048 insertions(+), 66 deletions(-) create mode 100644 ui/components/findings/table/findings-preview-lock.integration.test.tsx create mode 100644 ui/components/findings/table/findings-preview-lock.test.tsx create mode 100644 ui/components/findings/table/findings-preview-lock.tsx create mode 100644 ui/hooks/use-scrollport-pin.ts create mode 100644 ui/lib/findings-access.test.ts create mode 100644 ui/lib/findings-access.ts diff --git a/ui/app/(prowler)/findings/page.test.ts b/ui/app/(prowler)/findings/page.test.ts index 419bcc80fb..24853f7614 100644 --- a/ui/app/(prowler)/findings/page.test.ts +++ b/ui/app/(prowler)/findings/page.test.ts @@ -37,6 +37,17 @@ describe("findings page", () => { expect(source).toContain("applyDefaultMutedFilter(filtersWithScanDates)"); }); + it("pins a locked findings preview to the first page and hands the limit to the table", () => { + expect(source).toContain("await isFindingsPreviewLocked()"); + expect(source).toContain("const page = previewLocked\n ? 1"); + expect(source).toContain( + "const pageSize = previewLocked\n ? FINDINGS_PREVIEW_ROW_LIMIT * 2", + ); + expect(source).toContain( + "previewRowLimit={previewLocked ? FINDINGS_PREVIEW_ROW_LIMIT : undefined}", + ); + }); + it("guards errors array access with a length check", () => { expect(source).toContain("errors?.length > 0"); }); diff --git a/ui/app/(prowler)/findings/page.tsx b/ui/app/(prowler)/findings/page.tsx index 0a8c3817ca..4b4930e4be 100644 --- a/ui/app/(prowler)/findings/page.tsx +++ b/ui/app/(prowler)/findings/page.tsx @@ -19,6 +19,10 @@ import { extractSortAndKey, hasDateOrScanFilter, } from "@/lib"; +import { + FINDINGS_PREVIEW_ROW_LIMIT, + isFindingsPreviewLocked, +} from "@/lib/findings-access"; import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters"; import { ScanProps } from "@/types"; import { SearchParamsProps } from "@/types/components"; @@ -110,8 +114,16 @@ const SSRDataTable = async ({ searchParams: SearchParamsProps; filters: Record; }) => { - const page = parseInt(searchParams.page?.toString() || "1", 10); - const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10); + // A locked preview ignores the URL paging: it always shows the first page, + // the readable rows plus one blurred page as a teaser. Filters and sort + // still apply, so the readable rows are the most critical of the selection. + const previewLocked = await isFindingsPreviewLocked(); + const page = previewLocked + ? 1 + : parseInt(searchParams.page?.toString() || "1", 10); + const pageSize = previewLocked + ? FINDINGS_PREVIEW_ROW_LIMIT * 2 + : parseInt(searchParams.pageSize?.toString() || "10", 10); const expandedCheckIdParam = searchParams.expandedCheckId; const expandedCheckId = Array.isArray(expandedCheckIdParam) ? expandedCheckIdParam[0] @@ -150,6 +162,7 @@ const SSRDataTable = async ({ resolvedFilters={filters} hasHistoricalData={hasHistoricalData} expandedCheckId={expandedCheckId} + previewRowLimit={previewLocked ? FINDINGS_PREVIEW_ROW_LIMIT : undefined} /> ); diff --git a/ui/components/findings/table/findings-group-table.test.tsx b/ui/components/findings/table/findings-group-table.test.tsx index 9ed9c7032a..2cb265a973 100644 --- a/ui/components/findings/table/findings-group-table.test.tsx +++ b/ui/components/findings/table/findings-group-table.test.tsx @@ -1,9 +1,11 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { Fragment, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource"; +import { useCloudUpgradeStore } from "@/store"; +import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { FINDINGS_ROW_TYPE, type FindingGroupRow, @@ -43,8 +45,10 @@ vi.mock("@/components/shadcn/table", () => ({ data, toolbarRightContent, getRowAttributes, + getRowCanSelect, onRowSelectionChange, renderAfterRow, + previewLock, }: { data?: Array<{ checkId?: string }>; toolbarRightContent?: ReactNode; @@ -52,6 +56,11 @@ vi.mock("@/components/shadcn/table", () => ({ index: number; original: { checkId?: string }; }) => Record; + getRowCanSelect?: (row: { + index: number; + original: { checkId?: string }; + }) => boolean; + previewLock?: { visibleRowCount: number; overlay: ReactNode }; onRowSelectionChange?: ( updater: (previous: Record) => Record, ) => void; @@ -62,12 +71,23 @@ vi.mock("@/components/shadcn/table", () => ({ }) => (
{toolbarRightContent}
+ {previewLock && ( +
+ {previewLock.overlay} +
+ )} {(data ?? []).map((original, index) => ( @@ -117,7 +137,35 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({ })); vi.mock("@/components/onboarding", () => ({ - OnboardingTrigger: () =>
, + // Exposes the "Open a finding group" step so tests can drive it and read + // whether the handler waited for the resources step. + OnboardingTrigger: ({ + stepHandlers, + }: { + stepHandlers?: Record< + string, + { + onNext?: (context: { + waitForStep: (target: string) => Promise; + }) => Promise; + } + >; + }) => ( + + ), PageReady: () =>
, })); @@ -421,4 +469,141 @@ describe("FindingsGroupTable", () => { resourceSearch: undefined, }); }); + + describe("when the table is locked to a preview", () => { + const lockedGroups = [ + makeGroup("check-a"), + makeGroup("check-b"), + makeGroup("check-c"), + ]; + + beforeEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("floats the unlock prompt over the table and keeps locked groups unselectable", () => { + // Given / When + render( + , + ); + + // Then + expect(screen.getByTestId("preview-lock")).toHaveAttribute( + "data-visible", + "2", + ); + expect( + screen.getByRole("button", { name: "Unlock All Findings" }), + ).toBeInTheDocument(); + expect(screen.getByTestId("row-1")).toHaveAttribute( + "data-can-select", + "true", + ); + expect(screen.getByTestId("row-2")).toHaveAttribute( + "data-can-select", + "false", + ); + }); + + it("still opens an expanded deep link inside the preview", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.getByRole("button", { name: "Select finding-1" }), + ).toBeInTheDocument(); + }); + + it("skips the tour resources step when the only drillable group is locked", async () => { + // Given: the previewed rows cannot drill down, a locked one can + const user = userEvent.setup(); + delete document.body.dataset.tourWaited; + render( + , + ); + + // When + await user.click( + screen.getByRole("button", { name: "Open first group" }), + ); + + // Then: no drill-down, no upgrade prompt, and the tour did not wait + await waitFor(() => + expect(document.body.dataset.tourWaited).toBe("false"), + ); + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBeNull(); + }); + + it("ignores an expanded deep link that points past the preview", () => { + // Given / When + render( + , + ); + + // Then + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + }); + + it("answers a drill-down past the preview with the upgrade prompt", async () => { + // Given + const user = userEvent.setup(); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "Expand check-c" })); + + // Then + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW, + ); + expect( + screen.queryByRole("button", { name: "Select finding-1" }), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/components/findings/table/findings-group-table.tsx b/ui/components/findings/table/findings-group-table.tsx index 2b34f5a6ce..e0004ff7d1 100644 --- a/ui/components/findings/table/findings-group-table.tsx +++ b/ui/components/findings/table/findings-group-table.tsx @@ -21,7 +21,9 @@ import { } from "@/lib/lighthouse/context/contributions"; import { getFlowById } from "@/lib/onboarding"; import { createExploreFindingsTourStepHandlers } from "@/lib/tours/explore-findings.tour"; +import { useCloudUpgradeStore } from "@/store"; import { FindingGroupRow, MetaDataProps } from "@/types"; +import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; import { JIRA_DISPATCH_MODE, JIRA_DISPATCH_TARGET } from "@/types/integrations"; import { LIGHTHOUSE_CONTEXT_LIMIT } from "@/types/lighthouse-context"; @@ -29,6 +31,7 @@ import { FloatingSelectionActions } from "../floating-selection-actions"; import { getColumnFindingGroups } from "./column-finding-groups"; import { canMuteFindingGroup } from "./finding-group-selection"; +import { FindingsPreviewLock } from "./findings-preview-lock"; import { FindingsSelectionContext } from "./findings-selection-context"; import { InlineResourceContainer, @@ -75,12 +78,24 @@ function buildEntityCountLabel( return `${count} ${count === 1 ? singular : plural}`; } +// Groups a locked tenant can open; the rest of the page is a blurred teaser. +function getPreviewedGroups( + groups: FindingGroupRow[], + previewRowLimit: number | undefined, +): FindingGroupRow[] { + return previewRowLimit === undefined + ? groups + : groups.slice(0, previewRowLimit); +} + interface FindingsGroupTableProps { data: FindingGroupRow[]; metadata?: MetaDataProps; resolvedFilters: Record; hasHistoricalData: boolean; expandedCheckId?: string; + /** Rows the user can read; the rest render locked behind the upgrade prompt. */ + previewRowLimit?: number; } export function FindingsGroupTable({ @@ -89,10 +104,13 @@ export function FindingsGroupTable({ resolvedFilters, hasHistoricalData, expandedCheckId: requestedExpandedCheckId, + previewRowLimit, }: FindingsGroupTableProps) { const safeData = data ?? EMPTY_FINDING_GROUPS; const requestedGroup = requestedExpandedCheckId - ? safeData.find((group) => group.checkId === requestedExpandedCheckId) + ? getPreviewedGroups(safeData, previewRowLimit).find( + (group) => group.checkId === requestedExpandedCheckId, + ) : undefined; const initialExpandedCheckId = requestedGroup && canDrillDownFindingGroup(requestedGroup) @@ -107,6 +125,7 @@ export function FindingsGroupTable({ resolvedFilters={resolvedFilters} hasHistoricalData={hasHistoricalData} initialExpandedCheckId={initialExpandedCheckId} + previewRowLimit={previewRowLimit} /> ); } @@ -117,6 +136,7 @@ interface FindingsGroupTableContentProps { resolvedFilters: Record; hasHistoricalData: boolean; initialExpandedCheckId: string | null; + previewRowLimit?: number; } const FindingsGroupTableContent = ({ @@ -125,9 +145,13 @@ const FindingsGroupTableContent = ({ resolvedFilters, hasHistoricalData, initialExpandedCheckId, + previewRowLimit, }: FindingsGroupTableContentProps) => { const router = useRouter(); const searchParams = useSearchParams(); + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); const [rowSelection, setRowSelection] = useState({}); const [selectedExpandedCheckId, setSelectedExpandedCheckId] = useState< string | null @@ -139,8 +163,13 @@ const FindingsGroupTableContent = ({ const inlineRef = useRef(null); const safeData = data ?? EMPTY_FINDING_GROUPS; + const previewedGroups = getPreviewedGroups(safeData, previewRowLimit); + const isPreviewedRow = (index: number) => + previewRowLimit === undefined || index < previewRowLimit; + const openFindingsPreviewUpgrade = () => + openCloudUpgrade(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW); const expandedGroupCandidate = selectedExpandedCheckId - ? safeData.find((group) => group.checkId === selectedExpandedCheckId) + ? previewedGroups.find((group) => group.checkId === selectedExpandedCheckId) : undefined; const expandedGroup = expandedGroupCandidate && canDrillDownFindingGroup(expandedGroupCandidate) @@ -236,7 +265,7 @@ const FindingsGroupTableContent = ({ findingCount: activeResourceSelection.length, }); - const selectableRowCount = safeData.filter((g) => + const selectableRowCount = previewedGroups.filter((g) => canMuteFindingGroup({ resourcesFail: g.resourcesFail, resourcesTotal: g.resourcesTotal, @@ -247,12 +276,15 @@ const FindingsGroupTableContent = ({ const getRowCanSelect = (row: Row): boolean => { const group = row.original; - return canMuteFindingGroup({ - resourcesFail: group.resourcesFail, - resourcesTotal: group.resourcesTotal, - muted: group.muted, - mutedCount: group.mutedCount, - }); + return ( + isPreviewedRow(row.index) && + canMuteFindingGroup({ + resourcesFail: group.resourcesFail, + resourcesTotal: group.resourcesTotal, + muted: group.muted, + mutedCount: group.mutedCount, + }) + ); }; const clearSelection = () => { @@ -293,6 +325,11 @@ const FindingsGroupTableContent = ({ }; const handleDrillDown = (checkId: string, group: FindingGroupRow) => { + // Locked rows are inert, so this only guards programmatic callers. + if (!previewedGroups.some((candidate) => candidate.checkId === checkId)) { + openFindingsPreviewUpgrade(); + return; + } if (!canDrillDownFindingGroup(group)) return; // Toggle: same group collapses, different group switches @@ -314,13 +351,14 @@ const FindingsGroupTableContent = ({ }; // Drives the onboarding "Open a finding group" step: opens the first row when - // drillable, otherwise the first drillable group. Returns false when none can - // open so the tour skips the resources step instead of hanging. + // drillable, otherwise the first drillable previewed group (a locked row + // would only open the upgrade prompt). Returns false when none can open so + // the tour skips the resources step instead of hanging. const openFirstFindingGroup = (): boolean => { const target = - safeData[0] && canDrillDownFindingGroup(safeData[0]) - ? safeData[0] - : safeData.find((group) => canDrillDownFindingGroup(group)); + previewedGroups[0] && canDrillDownFindingGroup(previewedGroups[0]) + ? previewedGroups[0] + : previewedGroups.find((group) => canDrillDownFindingGroup(group)); if (!target) return false; handleDrillDown(target.checkId, target); return true; @@ -424,6 +462,19 @@ const FindingsGroupTableContent = ({ getRowAttributes={(row) => row.index === 0 ? { "data-tour-id": "explore-findings-group" } : {} } + previewLock={ + previewRowLimit !== undefined + ? { + visibleRowCount: previewRowLimit, + overlay: ( + + ), + onLockedInteraction: openFindingsPreviewUpgrade, + } + : undefined + } />
diff --git a/ui/components/findings/table/findings-preview-lock.integration.test.tsx b/ui/components/findings/table/findings-preview-lock.integration.test.tsx new file mode 100644 index 0000000000..b8766381bc --- /dev/null +++ b/ui/components/findings/table/findings-preview-lock.integration.test.tsx @@ -0,0 +1,212 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { page, userEvent } from "vitest/browser"; + +import { render } from "@/__tests__/render-browser"; +import { DataTable } from "@/components/shadcn/table/data-table"; +import { useCloudUpgradeStore } from "@/store"; +import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { FindingsPreviewLock } from "./findings-preview-lock"; + +interface PreviewRow { + name: string; +} + +const columns: ColumnDef[] = [ + { accessorKey: "name", header: "Finding group" }, +]; + +describe("Findings preview layout", () => { + afterEach(async () => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + await page.viewport(1280, 800); + }); + + it.each([ + { count: 11, width: 1280 }, + { count: 13, width: 1280 }, + { count: 20, width: 1280 }, + { count: 11, width: 375 }, + { count: 13, width: 375 }, + { count: 20, width: 375 }, + ])( + "keeps the unlock card above pagination with $count groups at $width px", + async ({ count, width }) => { + // Given: filtered results may leave too few blurred rows to contain the card. + await page.viewport(width, 1400); + const screen = await render( + ({ + name: `Finding ${index + 1}`, + }))} + metadata={{ + pagination: { page: 1, pages: 1, count }, + version: "v1", + }} + previewLock={{ + visibleRowCount: 10, + overlay: , + onLockedInteraction: vi.fn(), + }} + />, + ); + + // When + const card = screen.getByRole("region", { name: "Findings preview" }); + await expect.element(card).toBeVisible(); + const tableScrollport = screen + .getByRole("table") + .element().parentElement!; + + // Then: the CTA needs no hidden vertical scroll inside the table. + await expect + .poll( + () => + card.element().getBoundingClientRect().bottom - + tableScrollport.getBoundingClientRect().bottom, + ) + .toBeLessThanOrEqual(0); + expect(tableScrollport.scrollHeight).toBeLessThanOrEqual( + tableScrollport.clientHeight, + ); + + await screen.getByRole("button", { name: "Unlock All Findings" }).click(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW, + ); + }, + ); + + it("keeps the card visible when a wide table scrolls and the viewport shrinks", async () => { + // Given + await page.viewport(1280, 1400); + const screen = await render( + ( + {row.original.name} + ), + }, + ]} + data={Array.from({ length: 11 }, (_, index) => ({ + name: `Finding ${index + 1}`, + }))} + previewLock={{ + visibleRowCount: 10, + overlay: , + onLockedInteraction: vi.fn(), + }} + />, + ); + const card = screen.getByRole("region", { name: "Findings preview" }); + await expect.element(card).toBeVisible(); + const tableScrollport = screen.getByRole("table").element().parentElement!; + const originalLeft = card.element().getBoundingClientRect().left; + + // When + tableScrollport.scrollLeft = 400; + + // Then: horizontal scrolling preserves the card's position. + expect(tableScrollport.scrollLeft).toBeGreaterThan(0); + await expect + .poll(() => card.element().getBoundingClientRect().left) + .toBe(originalLeft); + + // When: wrapping makes the card taller after mount. + await page.viewport(375, 1400); + + // Then: both the horizontal pin and the reserved height follow the resize. + await expect + .poll( + () => + card.element().getBoundingClientRect().right - + tableScrollport.getBoundingClientRect().right, + ) + .toBeLessThanOrEqual(0); + await expect + .poll( + () => + card.element().getBoundingClientRect().bottom - + tableScrollport.getBoundingClientRect().bottom, + ) + .toBeLessThanOrEqual(0); + expect(tableScrollport.scrollHeight).toBeLessThanOrEqual( + tableScrollport.clientHeight, + ); + await screen.getByRole("button", { name: "Unlock All Findings" }).click(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW, + ); + }); + + it("opens the upgrade prompt when clicking a blurred row without firing its actions", async () => { + // Given + await page.viewport(1280, 1800); + const onLockedInteraction = vi.fn(); + const onRowAction = vi.fn(); + const onRowClick = vi.fn(); + const screen = await render( +
+ ( + + ), + }, + ]} + data={Array.from({ length: 20 }, (_, index) => ({ + name: `Finding ${index + 1}`, + }))} + onRowClick={onRowClick} + previewLock={{ + visibleRowCount: 10, + overlay: , + onLockedInteraction, + }} + /> + +
, + ); + + // When: a real pointer click at the inert content's position, outside the card. + await screen + .getByText("Finding 20", { exact: true }) + .click({ force: true }); + + // Then + expect(onLockedInteraction).toHaveBeenCalledOnce(); + expect(onRowAction).not.toHaveBeenCalled(); + expect(onRowClick).not.toHaveBeenCalled(); + + // When / Then: keyboard focus skips every blurred action, keeping the CTA usable. + ( + screen + .getByRole("button", { name: "Finding 10", exact: true }) + .element() as HTMLButtonElement + ).focus(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "Unlock All Findings" })) + .toHaveFocus(); + await userEvent.tab(); + await expect + .element(screen.getByRole("button", { name: "After table" })) + .toHaveFocus(); + + // Visible row actions still work. + await screen + .getByRole("button", { name: "Finding 1", exact: true }) + .click(); + expect(onRowAction).toHaveBeenCalledOnce(); + }); +}); diff --git a/ui/components/findings/table/findings-preview-lock.test.tsx b/ui/components/findings/table/findings-preview-lock.test.tsx new file mode 100644 index 0000000000..ea60a4aaf2 --- /dev/null +++ b/ui/components/findings/table/findings-preview-lock.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useCloudUpgradeStore } from "@/store"; +import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +import { FindingsPreviewLock } from "./findings-preview-lock"; + +describe("FindingsPreviewLock", () => { + beforeEach(() => { + useCloudUpgradeStore.getState().closeCloudUpgrade(); + }); + + it("tells the user how much of the list is hidden and opens the paid plan upgrade", async () => { + // Given + const user = userEvent.setup(); + render(); + + // When + await user.click( + screen.getByRole("button", { name: "Unlock All Findings" }), + ); + + // Then + expect( + screen.getByText("Showing the 10 most critical of 1,234 finding groups"), + ).toBeInTheDocument(); + expect(useCloudUpgradeStore.getState().activeFeature).toBe( + PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW, + ); + }); +}); diff --git a/ui/components/findings/table/findings-preview-lock.tsx b/ui/components/findings/table/findings-preview-lock.tsx new file mode 100644 index 0000000000..f52d4c761b --- /dev/null +++ b/ui/components/findings/table/findings-preview-lock.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { Lock } from "lucide-react"; + +import { Button } from "@/components/shadcn/button/button"; +import { FINDINGS_PREVIEW_ROW_LIMIT } from "@/lib/findings-access"; +import { useCloudUpgradeStore } from "@/store"; +import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; + +interface FindingsPreviewLockProps { + totalCount?: number; +} + +// Floats over the blurred rows of a locked /findings table. +export function FindingsPreviewLock({ totalCount }: FindingsPreviewLockProps) { + const openCloudUpgrade = useCloudUpgradeStore( + (state) => state.openCloudUpgrade, + ); + const scope = + totalCount !== undefined + ? `of ${totalCount.toLocaleString()} finding groups` + : "finding groups"; + + return ( +
+
+
+

+ Showing the {FINDINGS_PREVIEW_ROW_LIMIT} most critical {scope} +

+

+ Upgrade to unlock the full list, pagination, and every affected + resource. +

+ +
+ ); +} diff --git a/ui/components/findings/table/inline-resource-container.tsx b/ui/components/findings/table/inline-resource-container.tsx index 9a34ba8c1f..4e1ee01684 100644 --- a/ui/components/findings/table/inline-resource-container.tsx +++ b/ui/components/findings/table/inline-resource-container.tsx @@ -19,8 +19,8 @@ import { Skeleton } from "@/components/shadcn/skeleton/skeleton"; import { LoadingState } from "@/components/shadcn/spinner/loading-state"; import { TableCell, TableRow } from "@/components/shadcn/table"; import { useFindingGroupResourceState } from "@/hooks/use-finding-group-resource-state"; -import { useMountEffect } from "@/hooks/use-mount-effect"; import { useScrollHint } from "@/hooks/use-scroll-hint"; +import { useScrollportPin } from "@/hooks/use-scrollport-pin"; import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions"; import { cn } from "@/lib/utils"; import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel"; @@ -224,31 +224,9 @@ export function InlineResourceContainer({ // moves the group columns while this block stays in place — which also // lets the sub-table's own sticky actions column anchor to a scrollport // that is actually visible from the start. - const [scrollportPin, setScrollportPin] = useState<{ - width: number; - left: number; - } | null>(null); - useMountEffect(() => { - const scrollParent = scrollContainerRef.current?.closest( - "[data-table-scroll-container]", - ); - if (!(scrollParent instanceof HTMLElement)) return; - const measure = () => { - const styles = getComputedStyle(scrollParent); - const paddingLeft = parseFloat(styles.paddingLeft); - setScrollportPin({ - width: - scrollParent.clientWidth - - paddingLeft - - parseFloat(styles.paddingRight), - left: paddingLeft, - }); - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(scrollParent); - return () => observer.disconnect(); - }); + const scrollportPin = useScrollportPin(() => + scrollContainerRef.current?.closest("[data-table-scroll-container]"), + ); // Combine scrollContainerRef (for IntersectionObserver root) with scrollHintContainerRef const combinedScrollRef = (node: HTMLDivElement | null) => { diff --git a/ui/components/shadcn/table/data-table-pagination.test.tsx b/ui/components/shadcn/table/data-table-pagination.test.tsx index 88e606cdf1..c5e8417cb8 100644 --- a/ui/components/shadcn/table/data-table-pagination.test.tsx +++ b/ui/components/shadcn/table/data-table-pagination.test.tsx @@ -1,13 +1,17 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MetaDataProps } from "@/types"; -const mocks = vi.hoisted(() => ({ searchParams: new URLSearchParams() })); +const mocks = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), + push: vi.fn(), +})); vi.mock("next/navigation", () => ({ usePathname: () => "/providers", - useRouter: () => ({ push: vi.fn() }), + useRouter: () => ({ push: mocks.push }), useSearchParams: () => mocks.searchParams, })); @@ -176,4 +180,54 @@ describe("DataTablePagination", () => { "20", ); }); + + describe("when the table is locked to a preview", () => { + const lockedMetadata: MetaDataProps = { + pagination: { page: 1, pages: 2, count: 25, itemsPerPage: [10, 20, 50] }, + version: "latest", + }; + + it("counts pages by the visible rows and routes navigation to the lock handler", async () => { + // Given + const user = userEvent.setup(); + const onLockedInteraction = vi.fn(); + render( + , + ); + + // When + await user.click(screen.getByLabelText("Go to next page")); + await user.click(screen.getByLabelText("Go to last page")); + + // Then + expect(screen.getByText("Page 1 of 3")).toBeInTheDocument(); + expect(onLockedInteraction).toHaveBeenCalledTimes(2); + expect(mocks.push).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Go to next page")).not.toHaveAttribute( + "href", + ); + }); + + it("opens the lock handler instead of the rows-per-page menu", async () => { + // Given + const user = userEvent.setup(); + const onLockedInteraction = vi.fn(); + render( + , + ); + + // When + await user.click(screen.getByRole("button", { name: "10" })); + + // Then + expect(onLockedInteraction).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId("page-size-select")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/components/shadcn/table/data-table-pagination.tsx b/ui/components/shadcn/table/data-table-pagination.tsx index 9e19395a0e..0d6aa77923 100644 --- a/ui/components/shadcn/table/data-table-pagination.tsx +++ b/ui/components/shadcn/table/data-table-pagination.tsx @@ -5,6 +5,7 @@ import { ChevronLast, ChevronLeft, ChevronRight, + Lock, } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -20,11 +21,22 @@ import { getPaginationInfo } from "@/lib"; import { cn } from "@/lib/utils"; import { MetaDataProps } from "@/types"; +/** + * Preview lock: the table only shows `visibleRowCount` rows, so every control + * reports pages by that count and hands the click to `onLockedInteraction` + * (an upgrade prompt) instead of paging. + */ +export interface DataTablePaginationLock { + visibleRowCount: number; + onLockedInteraction: () => void; +} + interface DataTablePaginationProps { metadata?: MetaDataProps; disableScroll?: boolean; /** Prefix for URL params to avoid conflicts (e.g., "findings" -> "findingsPage") */ paramPrefix?: string; + lock?: DataTablePaginationLock; /* * Controlled mode: Use these props to manage pagination via React state @@ -44,10 +56,21 @@ const NAV_BUTTON_STYLES = { disabled: "text-text-neutral-tertiary cursor-not-allowed pointer-events-none", } as const; +const PAGE_SIZE_TRIGGER_CLASS_NAME = + "bg-bg-neutral-tertiary border-border-neutral-tertiary !h-auto !w-auto !min-w-0 !gap-1 !rounded-full !px-[19px] !py-[9px] !text-xs !font-medium backdrop-blur-[46px]"; + +const LOCKED_NAV_CONTROLS = [ + { label: "Go to first page", Icon: ChevronFirst }, + { label: "Go to previous page", Icon: ChevronLeft }, + { label: "Go to next page", Icon: ChevronRight }, + { label: "Go to last page", Icon: ChevronLast }, +] as const; + export function DataTablePagination({ metadata, disableScroll = false, paramPrefix = "", + lock, controlledPage, controlledPageSize, onPageChange, @@ -73,10 +96,15 @@ export function DataTablePagination({ const { currentPage: metaCurrentPage, - totalPages, + totalPages: metaTotalPages, totalEntries, itemsPerPageOptions, } = getPaginationInfo(metadata); + // A locked table shows fewer rows than it fetched: count pages by what the + // user actually sees so "Page 1 of N" stays honest. + const totalPages = lock + ? Math.ceil(totalEntries / lock.visibleRowCount) + : metaTotalPages; // Gated separately: a large page size collapses the table to one page, and the // selector is the only way back to a smaller one. @@ -90,11 +118,13 @@ export function DataTablePagination({ if (!showPageSizeSelector && !showPageNavigation) return null; // For controlled mode, use controlled values; for prefixed, read from URL; otherwise use metadata - const currentPage = isControlled - ? controlledPage - : paramPrefix - ? parseInt(searchParams.get(pageParam) || "1", 10) - : metaCurrentPage; + const currentPage = lock + ? 1 + : isControlled + ? controlledPage + : paramPrefix + ? parseInt(searchParams.get(pageParam) || "1", 10) + : metaCurrentPage; const createPageUrl = (pageNumber: number | string) => { const params = new URLSearchParams(searchParams); @@ -138,7 +168,25 @@ export function DataTablePagination({ return (
{/* Rows per page selector */} - {showPageSizeSelector && ( + {showPageSizeSelector && lock && ( +
+ + Rows per page + + +
+ )} + {showPageSizeSelector && !lock && (
Rows per page @@ -178,7 +226,7 @@ export function DataTablePagination({ > @@ -203,7 +251,23 @@ export function DataTablePagination({ Page {currentPage} of {totalPages}
- {isControlled ? ( + {lock ? ( + // Every arrow looks enabled on purpose: the click is the upsell. + LOCKED_NAV_CONTROLS.map(({ label, Icon }) => ( + + )) + ) : isControlled ? ( <>
, + onLockedInteraction: vi.fn(), + }} + />, + ); + + // When + const visibleRow = screen.getByText("Finding B").closest("tr"); + const lockedRow = screen.getByText("Finding C").closest("tr"); + + // Then + expect(visibleRow?.querySelector("[inert]")).toBeNull(); + expect(lockedRow?.querySelector("td > div")).toHaveAttribute("inert"); + expect(lockedRow).toHaveAttribute("aria-hidden", "true"); + expect(lockedRow).toHaveClass("select-none"); + expect(screen.getByText("Unlock every finding")).toBeInTheDocument(); + expect(renderAfterRow).toHaveBeenCalledWith( + expect.objectContaining({ index: 1 }), + ); + expect(renderAfterRow).not.toHaveBeenCalledWith( + expect.objectContaining({ index: 2 }), + ); + }); + + it("renders no overlay when every row fits in the preview", () => { + // Given / When + render( + Unlock every finding
, + onLockedInteraction: vi.fn(), + }} + />, + ); + + // Then + expect( + screen.queryByText("Unlock every finding"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Finding A").closest("tr")).not.toHaveAttribute( + "inert", + ); + }); + }); }); diff --git a/ui/components/shadcn/table/data-table.tsx b/ui/components/shadcn/table/data-table.tsx index 3d36dc7869..e4dcc0dc48 100644 --- a/ui/components/shadcn/table/data-table.tsx +++ b/ui/components/shadcn/table/data-table.tsx @@ -28,9 +28,13 @@ import { TableRow, } from "@/components/shadcn/table"; import { DataTableAnimatedRow } from "@/components/shadcn/table/data-table-animated-row"; -import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination"; +import { + DataTablePagination, + type DataTablePaginationLock, +} from "@/components/shadcn/table/data-table-pagination"; import { DataTableSearch } from "@/components/shadcn/table/data-table-search"; import { useFilterTransitionOptional } from "@/contexts"; +import { useScrollportPin } from "@/hooks/use-scrollport-pin"; import { cn } from "@/lib/utils"; import { FilterOption, MetaDataProps } from "@/types"; @@ -49,6 +53,20 @@ const TABLE_COLUMN_GAP_CLASS = "pr-6"; const STICKY_ACTION_COLUMN_CLASS = "sticky right-0 z-20 min-w-12"; const STICKY_ACTION_CELL_CLASS = `${STICKY_ACTION_COLUMN_CLASS} last:rounded-r-none! overflow-visible bg-bg-neutral-secondary before:pointer-events-none before:absolute before:inset-y-0 before:-left-8 before:w-8 before:bg-gradient-to-r before:from-transparent before:to-bg-neutral-secondary before:content-[''] group-hover:bg-bg-neutral-tertiary group-hover:before:to-bg-neutral-tertiary group-data-[state=selected]:bg-bg-neutral-tertiary group-data-[state=selected]:before:to-bg-neutral-tertiary`; +// Locked cells stay in the DOM as a teaser but cannot be read or focused. +// The row receives clicks to open the upgrade prompt instead of cell actions. +// The blur goes on the cells: browsers do not reliably filter table rows. +const LOCKED_ROW_CLASS_NAME = + "cursor-pointer select-none opacity-70 hover:bg-transparent [&>td]:blur-[6px]"; + +/** + * Preview lock: rows past `visibleRowCount` render blurred with inert content, + * `overlay` floats over them, and row/pagination clicks call `onLockedInteraction`. + */ +export interface DataTablePreviewLock extends DataTablePaginationLock { + overlay: ReactNode; +} + const getTableColumnClassName = ( columnId: string, variant: "header" | "cell", @@ -136,6 +154,7 @@ interface DataTableProviderProps { header?: ReactNode; /** Optional content rendered in the toolbar before the total entries count. */ toolbarRightContent?: ReactNode; + previewLock?: DataTablePreviewLock; } export function DataTable({ @@ -169,6 +188,7 @@ export function DataTable({ getRowAttributes, header, toolbarRightContent, + previewLock, }: DataTableProviderProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -212,6 +232,17 @@ export function DataTable({ // Track whether initial expansion has been applied const hasInitiallyExpanded = useRef(false); + // The preview overlay pins itself to the visible scrollport: centered on the + // full table width it would scroll away (and clip) on wide tables. Measured + // on mount only, which is when a locked table arrives from the server. + const scrollContainerRef = useRef(null); + const previewOverlayRef = useRef(null); + const lastRowRef = useRef(null); + const [previewPaddingBottom, setPreviewPaddingBottom] = useState(0); + const scrollportPin = useScrollportPin(() => + previewLock ? scrollContainerRef.current : null, + ); + // Expand all rows on mount when defaultExpanded={true} useEffect(() => { if ( @@ -244,8 +275,54 @@ export function DataTable({ const showToolbar = showSearch || metadata || toolbarRightContent; const rows = table.getRowModel().rows; + // Position in the rendered order, not row.index: the lock hides what the + // user would see next, whatever the data order is. + const isLockedPosition = (position: number) => + previewLock !== undefined && position >= previewLock.visibleRowCount; + const overlayPosition = + previewLock !== undefined && rows.length > previewLock.visibleRowCount + ? previewLock.visibleRowCount - 1 + : null; + + // A short result set may not have enough blurred rows to contain the card. + // Reserve its overflow inside the table so the scrollport cannot clip the CTA. + useEffect(() => { + const overlay = previewOverlayRef.current; + const lastRow = lastRowRef.current; + const scrollContainer = scrollContainerRef.current; + if (overlayPosition === null || !overlay || !lastRow || !scrollContainer) { + return; + } + + const measure = () => { + setPreviewPaddingBottom( + Math.max( + 0, + Math.ceil( + overlay.getBoundingClientRect().bottom - + lastRow.getBoundingClientRect().bottom, + ), + ), + ); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(overlay); + observer.observe(scrollContainer); + return () => observer.disconnect(); + }, [overlayPosition, rows.length]); + + const handleRowClick = ( + row: Row, + target: HTMLElement | null, + position: number, + ) => { + if (isLockedPosition(position)) { + previewLock?.onLockedInteraction(); + return; + } - const handleRowClick = (row: Row, target: HTMLElement | null) => { if (!onRowClick) { return; } @@ -259,6 +336,7 @@ export function DataTable({ return (
({
)} -
{original.checkId}
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -334,7 +419,7 @@ export function DataTable({ {rows?.length ? ( - rows.map((row) => + rows.map((row, position) => getSubRows && row.depth > 0 ? ( ({ ) : ( - handleRowClick(row, event.target as HTMLElement) + handleRowClick( + row, + event.target as HTMLElement, + position, + ) } > {row.getVisibleCells().map((cell) => ( @@ -360,14 +460,54 @@ export function DataTable({ "cell", )} > - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), + {isLockedPosition(position) ? ( +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} +
+ ) : ( + flexRender( + cell.column.columnDef.cell, + cell.getContext(), + ) )} ))}
- {renderAfterRow?.(row)} + {!isLockedPosition(position) && renderAfterRow?.(row)} + {position === overlayPosition && previewLock && ( + // Zero-height row: the overlay floats over the locked + // rows below without pushing them down. + + + {/* Sticky wrapper sized to the scrollport; without a + measurement it spans the row. z-30 clears the + sticky actions cells (z-20) of the locked rows. */} +
0 + ? { width: scrollportPin.width } + : undefined + } + > +
+ {previewLock.overlay} +
+
+
+
+ )}
), ) @@ -393,6 +533,7 @@ export function DataTable({ controlledPageSize={controlledPageSize} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} + lock={previewLock} /> )} diff --git a/ui/hooks/use-scrollport-pin.ts b/ui/hooks/use-scrollport-pin.ts new file mode 100644 index 0000000000..905c13ea03 --- /dev/null +++ b/ui/hooks/use-scrollport-pin.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; + +import { useMountEffect } from "./use-mount-effect"; + +export interface ScrollportPin { + /** Visible content width of the scrollport (padding excluded). */ + width: number; + /** Left padding of the scrollport, for a sticky child that keeps the inset. */ + left: number; +} + +/** + * Measures a horizontally scrolling container once on mount and on resize, so + * a sticky child can span exactly the visible area instead of the full + * scroll width. Resolves to null until measured (or without ResizeObserver). + */ +export function useScrollportPin( + resolveScrollport: () => Element | null | undefined, +): ScrollportPin | null { + const [pin, setPin] = useState(null); + + useMountEffect(() => { + const scrollport = resolveScrollport(); + if ( + !(scrollport instanceof HTMLElement) || + typeof ResizeObserver === "undefined" + ) { + return; + } + const measure = () => { + const styles = getComputedStyle(scrollport); + const paddingLeft = parseFloat(styles.paddingLeft); + setPin({ + width: + scrollport.clientWidth - + paddingLeft - + parseFloat(styles.paddingRight), + left: paddingLeft, + }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(scrollport); + return () => observer.disconnect(); + }); + + return pin; +} diff --git a/ui/lib/cloud-upgrade.test.ts b/ui/lib/cloud-upgrade.test.ts index 5b762eb514..417b51fc3a 100644 --- a/ui/lib/cloud-upgrade.test.ts +++ b/ui/lib/cloud-upgrade.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; -import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade"; +import { + CLOUD_UPGRADE_FEATURE, + PAID_PLAN_UPGRADE_FEATURE, +} from "@/types/cloud-upgrade"; import { CLOUD_UPGRADE_CONTENT, CLOUD_UPGRADE_FOOTER_NOTE, getCloudUpgradeCompareUrl, getCloudUpgradePrimaryUrl, + getPaidPlanUpgradeBillingHref, + isPaidPlanUpgradeFeature, + PAID_PLAN_UPGRADE_CONTENT, } from "./cloud-upgrade"; describe("cloud upgrade content", () => { @@ -90,3 +96,19 @@ describe("cloud upgrade URLs", () => { expect(url.searchParams.get("utm_content")).toBe(contentSlug); }); }); + +describe("paid plan upgrade content", () => { + it("sends the findings preview upgrade to billing with its feature", () => { + // Given / When / Then + expect( + isPaidPlanUpgradeFeature(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW), + ).toBe(true); + expect( + getPaidPlanUpgradeBillingHref(PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW), + ).toBe("/billing?feature=findings_preview"); + expect( + PAID_PLAN_UPGRADE_CONTENT[PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW] + .title, + ).toBe("See Every Finding in Your Account"); + }); +}); diff --git a/ui/lib/cloud-upgrade.ts b/ui/lib/cloud-upgrade.ts index 3f4c3bbb49..f21e19c82e 100644 --- a/ui/lib/cloud-upgrade.ts +++ b/ui/lib/cloud-upgrade.ts @@ -7,6 +7,8 @@ import { } from "@/types/cloud-upgrade"; import { MAX_SAML_ADDITIONAL_EMAIL_DOMAINS } from "@/types/saml"; +import { FINDINGS_PREVIEW_ROW_LIMIT } from "./findings-access"; + interface CloudUpgradeContent { title: string; description: string; @@ -215,11 +217,22 @@ export const PAID_PLAN_UPGRADE_ADMIN_NOTE = const CLOUD_UTM_SOURCE = "prowler-cloud"; const PAID_PLAN_UPGRADE_UTM_CONTENT = { + [PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]: "findings-preview", [PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: "registry", [PAID_PLAN_UPGRADE_FEATURE.REPORT_DOWNLOAD]: "report-download", } as const satisfies Record; export const PAID_PLAN_UPGRADE_CONTENT = { + [PAID_PLAN_UPGRADE_FEATURE.FINDINGS_PREVIEW]: { + title: "See Every Finding in Your Account", + description: `Trial accounts preview the ${FINDINGS_PREVIEW_ROW_LIMIT} most critical finding groups. Paid plans unlock the full list.`, + benefits: [ + "Browse, sort, and page through every finding group", + "Drill into each affected resource across all your providers", + "Download reports and compliance evidence for the full scan", + ], + primaryCta: "Upgrade to See All Findings", + }, [PAID_PLAN_UPGRADE_FEATURE.REGISTRY]: { title: "Unlock Prowler Registry", description: "Prowler Registry is included in paid plans.", diff --git a/ui/lib/feature-flags/shared.test.ts b/ui/lib/feature-flags/shared.test.ts index cd453a9f5d..39718fa72a 100644 --- a/ui/lib/feature-flags/shared.test.ts +++ b/ui/lib/feature-flags/shared.test.ts @@ -7,6 +7,7 @@ describe("FEATURE_FLAG", () => { expect(FEATURE_FLAG).toEqual({ ISSUES: "issues-feature", BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME", + FINDINGS_TRIAL_PREVIEW: "findings-trial-preview", }); }); diff --git a/ui/lib/feature-flags/shared.ts b/ui/lib/feature-flags/shared.ts index 7e54f06bf5..eb7e11684f 100644 --- a/ui/lib/feature-flags/shared.ts +++ b/ui/lib/feature-flags/shared.ts @@ -9,6 +9,9 @@ export const FEATURE_FLAG = { // Boolean flag evaluated server-side per tenant in cloud. When on, it pins // the tenant to legacy billing; off or absent means the new Metronome system. BILLING_SYSTEM_METRONOME: "BILLING_SYSTEM_METRONOME", + // Boolean flag evaluated server-side per tenant in cloud. When on, trial + // tenants only preview the most critical finding groups on /findings. + FINDINGS_TRIAL_PREVIEW: "findings-trial-preview", } as const; export type FeatureFlag = (typeof FEATURE_FLAG)[keyof typeof FEATURE_FLAG]; diff --git a/ui/lib/findings-access.test.ts b/ui/lib/findings-access.test.ts new file mode 100644 index 0000000000..7025550106 --- /dev/null +++ b/ui/lib/findings-access.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { + FINDINGS_PREVIEW_ROW_LIMIT, + isFindingsPreviewLocked, +} from "./findings-access"; + +describe("findings access", () => { + it("never locks the findings preview on self-hosted deployments", async () => { + // Given / When + const locked = await isFindingsPreviewLocked(); + + // Then + expect(locked).toBe(false); + }); + + it("previews the ten most critical finding groups when locked", () => { + expect(FINDINGS_PREVIEW_ROW_LIMIT).toBe(10); + }); +}); diff --git a/ui/lib/findings-access.ts b/ui/lib/findings-access.ts new file mode 100644 index 0000000000..20809a8051 --- /dev/null +++ b/ui/lib/findings-access.ts @@ -0,0 +1,10 @@ +/** Finding groups a locked tenant can read on /findings; the rest of the page renders blurred. */ +export const FINDINGS_PREVIEW_ROW_LIMIT = 10; + +/** + * Whether the current tenant only previews the most critical finding groups. + * Self-hosted deployments never lock the list; the Prowler Cloud overlay + * replaces this body with its billing and feature-flag lookup. + */ +export const isFindingsPreviewLocked = (): Promise => + Promise.resolve(false); diff --git a/ui/types/cloud-upgrade.ts b/ui/types/cloud-upgrade.ts index e612f72f3e..e7b860a8d5 100644 --- a/ui/types/cloud-upgrade.ts +++ b/ui/types/cloud-upgrade.ts @@ -19,6 +19,7 @@ export type CloudUpgradeFeature = // Prowler Cloud features gated behind a paid plan. export const PAID_PLAN_UPGRADE_FEATURE = { + FINDINGS_PREVIEW: "findings_preview", REGISTRY: "registry", REPORT_DOWNLOAD: "report_download", } as const; diff --git a/ui/vitest.setup.ts b/ui/vitest.setup.ts index 2cc2aa8a8a..e71e41c8dd 100644 --- a/ui/vitest.setup.ts +++ b/ui/vitest.setup.ts @@ -77,6 +77,8 @@ if (!Range.prototype.getBoundingClientRect) { } class MockResizeObserver implements ResizeObserver { + // Same signature as the real constructor; the callback never fires in jsdom. + constructor(_callback: ResizeObserverCallback) {} observe() {} unobserve() {} disconnect() {}