diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 2ad497eb5e9..d4f378adf88 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -4,9 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file. ## [1.25.0] (Prowler UNRELEASED) -### ❌ Removed +### 🔄 Changed -- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly +- Redesign compliance page with a horizontal ThreatScore card (always-visible pillar breakdown + ActionDropdown), client-side search for compliance frameworks, compact scan selector trigger, responsive mobile filters, download-started toasts for CSV/PDF exports, enhanced compliance cards with truncated titles, and Alert-based empty/error states; migrate Progress component from HeroUI to shadcn [(#10767)](https://github.com/prowler-cloud/prowler/pull/10767) +- Backward-compatibility middleware redirect from `/sign-up?invitation_token=…` to `/invitation/accept?invitation_token=…`; new invitation emails use `/invitation/accept` directly [(#10797)](https://github.com/prowler-cloud/prowler/pull/10797) --- diff --git a/ui/actions/compliances/compliances.ts b/ui/actions/compliances/compliances.ts index b23723f6705..d5f4fd49542 100644 --- a/ui/actions/compliances/compliances.ts +++ b/ui/actions/compliances/compliances.ts @@ -6,12 +6,10 @@ import { handleApiResponse } from "@/lib/server-actions-helper"; export const getCompliancesOverview = async ({ scanId, region, - query, filters = {}, }: { scanId?: string; region?: string | string[]; - query?: string; filters?: Record; } = {}) => { const headers = await getAuthHeaders({ contentType: false }); @@ -31,8 +29,6 @@ export const getCompliancesOverview = async ({ setParam("filter[scan_id]", scanId); setParam("filter[region__in]", region); - if (query) url.searchParams.set("filter[search]", query); - try { const response = await fetch(url.toString(), { headers, @@ -46,15 +42,16 @@ export const getCompliancesOverview = async ({ }; export const getComplianceOverviewMetadataInfo = async ({ - query = "", sort = "", filters = {}, -}) => { +}: { + sort?: string; + filters?: Record; +} = {}) => { const headers = await getAuthHeaders({ contentType: false }); const url = new URL(`${apiBaseUrl}/compliance-overviews/metadata`); - if (query) url.searchParams.append("filter[search]", query); if (sort) url.searchParams.append("sort", sort); Object.entries(filters).forEach(([key, value]) => { diff --git a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx index dd86be1f8a5..069826c3ae8 100644 --- a/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx +++ b/ui/app/(prowler)/compliance/[compliancetitle]/page.tsx @@ -78,7 +78,7 @@ export default async function ComplianceDetail({ await Promise.all([ getComplianceOverviewMetadataInfo({ filters: { - "filter[scan_id]": selectedScanId, + "filter[scan_id]": selectedScanId ?? undefined, }, }), getComplianceAttributes(complianceId), diff --git a/ui/app/(prowler)/compliance/page.test.tsx b/ui/app/(prowler)/compliance/page.test.tsx new file mode 100644 index 00000000000..42bbbe672f3 --- /dev/null +++ b/ui/app/(prowler)/compliance/page.test.tsx @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("Compliance overview page", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "page.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("delegates client-side search to ComplianceOverviewGrid", () => { + expect(source).toContain("ComplianceOverviewGrid"); + expect(source).not.toContain("filter[search]"); + }); +}); diff --git a/ui/app/(prowler)/compliance/page.tsx b/ui/app/(prowler)/compliance/page.tsx index e3e0a637050..92015cd142f 100644 --- a/ui/app/(prowler)/compliance/page.tsx +++ b/ui/app/(prowler)/compliance/page.tsx @@ -1,3 +1,4 @@ +import { Info } from "lucide-react"; import { Suspense } from "react"; import { @@ -7,12 +8,14 @@ import { import { getThreatScore } from "@/actions/overview"; import { getScans } from "@/actions/scans"; import { - ComplianceCard, ComplianceSkeletonGrid, NoScansAvailable, ThreatScoreBadge, } from "@/components/compliance"; -import { ComplianceHeader } from "@/components/compliance/compliance-header/compliance-header"; +import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters"; +import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid"; +import { Alert, AlertDescription } from "@/components/shadcn/alert"; +import { Card, CardContent } from "@/components/shadcn/card/card"; import { ContentLayout } from "@/components/ui"; import { ExpandedScanData, @@ -30,12 +33,6 @@ export default async function Compliance({ const resolvedSearchParams = await searchParams; const searchParamsKey = JSON.stringify(resolvedSearchParams || {}); - const filters = Object.fromEntries( - Object.entries(resolvedSearchParams).filter(([key]) => - key.startsWith("filter["), - ), - ); - const scansData = await getScans({ filters: { "filter[state]": "completed", @@ -79,9 +76,12 @@ export default async function Compliance({ .filter(Boolean) as ExpandedScanData[]; // Use scanId from URL, or select the first scan if not provided - const selectedScanId = - resolvedSearchParams.scanId || expandedScansData[0]?.id || null; - const query = (filters["filter[search]"] as string) || ""; + const scanIdParam = resolvedSearchParams.scanId; + const scanIdFromUrl = Array.isArray(scanIdParam) + ? scanIdParam[0] + : scanIdParam; + const selectedScanId: string | null = + scanIdFromUrl || expandedScansData[0]?.id || null; // Find the selected scan const selectedScan = expandedScansData.find( @@ -102,7 +102,6 @@ export default async function Compliance({ // Fetch metadata if we have a selected scan const metadataInfoData = selectedScanId ? await getComplianceOverviewMetadataInfo({ - query, filters: { "filter[scan_id]": selectedScanId, }, @@ -131,28 +130,39 @@ export default async function Compliance({ {selectedScanId ? ( <> -
-
- -
- {threatScoreData && - typeof selectedScanId === "string" && - selectedScan && ( -
- -
- )} + {/* Row 1: Filters */} +
+
- }> + + {/* Row 2: ThreatScore card — full width, horizontal */} + {threatScoreData && + typeof selectedScanId === "string" && + selectedScan && ( +
+ +
+ )} + + {/* Row 3: Compliance grid with client-side search */} + + + + } + > key.startsWith("filter[")), - ); - - // Extract query from filters - const query = (filters["filter[search]"] as string) || ""; - // Only fetch compliance data if we have a valid scanId const compliancesData = scanId && scanId.trim() !== "" ? await getCompliancesOverview({ scanId, region: regionFilter, - query, }) : { data: [], errors: [] }; const type = compliancesData?.data?.type; + const frameworks = compliancesData?.data + ?.filter((compliance: ComplianceOverviewData) => { + return compliance.attributes.framework !== "ProwlerThreatScore"; + }) + .sort((a: ComplianceOverviewData, b: ComplianceOverviewData) => + a.attributes.framework.localeCompare(b.attributes.framework), + ); // Check if the response contains no data if ( @@ -204,58 +212,49 @@ const SSRComplianceGrid = async ({ type === "tasks" ) { return ( -
-
- No compliance data available for the selected scan. -
-
+ + + + This scan has no compliance data available yet, please select a + different one. + + ); } // Handle errors returned by the API if (compliancesData?.errors?.length > 0) { return ( -
-
Provide a valid scan ID.
-
+ + + Provide a valid scan ID. + ); } return ( -
- {compliancesData.data - .filter((compliance: ComplianceOverviewData) => { - // Filter out ProwlerThreatScore from the grid - return compliance.attributes.framework !== "ProwlerThreatScore"; - }) - .sort((a: ComplianceOverviewData, b: ComplianceOverviewData) => - a.attributes.framework.localeCompare(b.attributes.framework), - ) - .map((compliance: ComplianceOverviewData) => { - const { attributes, id } = compliance; - const { - framework, - version, - requirements_passed, - total_requirements, - } = attributes; + + + + ); +}; - return ( - - ); - })} -
+const ComplianceOverviewPanel = ({ + children, +}: { + children: React.ReactNode; +}) => { + return ( + + {children} + ); }; diff --git a/ui/components/compliance/compliance-card.test.tsx b/ui/components/compliance/compliance-card.test.tsx new file mode 100644 index 00000000000..c7a199a7fce --- /dev/null +++ b/ui/components/compliance/compliance-card.test.tsx @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("ComplianceCard", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "compliance-card.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("keeps the shadcn Card base variant", () => { + expect(source).toContain('variant="base"'); + }); + + it("uses a responsive stacked layout for narrow screens", () => { + expect(source).toContain("flex-col"); + expect(source).toContain("sm:flex-row"); + }); + + it("uses the shadcn progress component instead of Hero UI", () => { + expect(source).toContain('from "@/components/shadcn/progress"'); + expect(source).not.toContain("@heroui/progress"); + }); + + it("places compact actions in the icon column on larger screens", () => { + expect(source).toContain('orientation="column"'); + expect(source).toContain('buttonWidth="icon"'); + }); +}); diff --git a/ui/components/compliance/compliance-card.tsx b/ui/components/compliance/compliance-card.tsx index 5d1b0425ba9..2c9f383f2bf 100644 --- a/ui/components/compliance/compliance-card.tsx +++ b/ui/components/compliance/compliance-card.tsx @@ -1,11 +1,20 @@ "use client"; -import { Progress } from "@heroui/progress"; import Image from "next/image"; import { useRouter, useSearchParams } from "next/navigation"; import { Card, CardContent } from "@/components/shadcn/card/card"; +import { Progress } from "@/components/shadcn/progress"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { getReportTypeForFramework } from "@/lib/compliance/compliance-report-types"; +import { + getScoreIndicatorClass, + type ScoreColorVariant, +} from "@/lib/compliance/score-utils"; import { ScanEntity } from "@/types/scans"; import { getComplianceIcon } from "../icons"; @@ -45,13 +54,9 @@ export const ComplianceCard: React.FC = ({ (passingRequirements / totalRequirements) * 100, ); - const getRatingColor = (ratingPercentage: number) => { - if (ratingPercentage <= 10) { - return "danger"; - } - if (ratingPercentage <= 40) { - return "warning"; - } + const getRatingVariant = (value: number): ScoreColorVariant => { + if (value <= 10) return "danger"; + if (value <= 40) return "warning"; return "success"; }; @@ -80,58 +85,76 @@ export const ComplianceCard: React.FC = ({ onClick={navigateToDetail} > -
- {getComplianceIcon(title) && ( - {`${title} - )} -
-

- {formatTitle(title)} - {version ? ` - ${version}` : ""} -

- +
+ {getComplianceIcon(title) && ( + {`${title} + )} +
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.stopPropagation(); + } }} - color={getRatingColor(ratingPercentage)} - /> -
- + role="group" + tabIndex={0} + > + +
+
+
+ + +

+ {formatTitle(title)} + {version ? ` - ${version}` : ""} +

+
+ + {formatTitle(title)} + {version ? ` - ${version}` : ""} + +
+
+
+ + Score: + + + {ratingPercentage}% + +
+ +
+
+ {passingRequirements} / {totalRequirements} Passing Requirements - -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - } - }} - role="group" - tabIndex={0} - > - -
diff --git a/ui/components/compliance/compliance-download-container.test.tsx b/ui/components/compliance/compliance-download-container.test.tsx new file mode 100644 index 00000000000..4a13c35fbc1 --- /dev/null +++ b/ui/components/compliance/compliance-download-container.test.tsx @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { downloadComplianceCsvMock, downloadComplianceReportPdfMock } = + vi.hoisted(() => ({ + downloadComplianceCsvMock: vi.fn(), + downloadComplianceReportPdfMock: vi.fn(), + })); + +vi.mock("@/lib/helper", () => ({ + downloadComplianceCsv: downloadComplianceCsvMock, + downloadComplianceReportPdf: downloadComplianceReportPdfMock, +})); + +vi.mock("@/components/ui", () => ({ + toast: {}, +})); + +import { ComplianceDownloadContainer } from "./compliance-download-container"; + +describe("ComplianceDownloadContainer", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "compliance-download-container.tsx"); + const source = readFileSync(filePath, "utf8"); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the shared action dropdown for the card actions mode", () => { + expect(source).toContain("ActionDropdown"); + expect(source).not.toContain("@heroui/button"); + }); + + it("should expose an accessible actions menu trigger", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: "Open compliance export actions" }), + ).toBeInTheDocument(); + }); + + it("should support fixed icon-sized dropdown trigger in column mode", () => { + render( + , + ); + + const trigger = screen.getByRole("button", { + name: "Open compliance export actions", + }); + expect(trigger.className).toContain("border-text-neutral-secondary"); + }); + + it("should open export actions from the compact trigger", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + + expect(screen.getByText("Download CSV report")).toBeInTheDocument(); + expect(screen.getByText("Download PDF report")).toBeInTheDocument(); + }); + + it("should trigger both downloads from the actions menu", async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + await user.click( + screen.getByRole("menuitem", { name: /Download CSV report/i }), + ); + await user.click( + screen.getByRole("button", { name: "Open compliance export actions" }), + ); + await user.click( + screen.getByRole("menuitem", { name: /Download PDF report/i }), + ); + + expect(downloadComplianceCsvMock).toHaveBeenCalledWith( + "scan-1", + "compliance-1", + {}, + ); + expect(downloadComplianceReportPdfMock).toHaveBeenCalledWith( + "scan-1", + "threatscore", + {}, + ); + }); +}); diff --git a/ui/components/compliance/compliance-download-container.tsx b/ui/components/compliance/compliance-download-container.tsx index 526057ea5c1..415da1d85a0 100644 --- a/ui/components/compliance/compliance-download-container.tsx +++ b/ui/components/compliance/compliance-download-container.tsx @@ -4,6 +4,15 @@ import { DownloadIcon, FileTextIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; +import { + ActionDropdown, + ActionDropdownItem, +} from "@/components/shadcn/dropdown"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { toast } from "@/components/ui"; import type { ComplianceReportType } from "@/lib/compliance/compliance-report-types"; import { @@ -18,6 +27,9 @@ interface ComplianceDownloadContainerProps { reportType?: ComplianceReportType; compact?: boolean; disabled?: boolean; + orientation?: "row" | "column"; + buttonWidth?: "auto" | "icon"; + presentation?: "buttons" | "dropdown"; } export const ComplianceDownloadContainer = ({ @@ -26,9 +38,14 @@ export const ComplianceDownloadContainer = ({ reportType, compact = false, disabled = false, + orientation = "row", + buttonWidth = "auto", + presentation = "buttons", }: ComplianceDownloadContainerProps) => { const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); + const isIconWidth = buttonWidth === "icon"; + const isDropdown = presentation === "dropdown"; const handleDownloadCsv = async () => { if (isDownloadingCsv) return; @@ -52,40 +69,116 @@ export const ComplianceDownloadContainer = ({ const buttonClassName = cn( "border-button-primary text-button-primary hover:bg-button-primary/10", - compact && "h-7 px-2 text-xs", + compact && + !isIconWidth && + "h-7 px-2 text-xs sm:w-full sm:justify-center sm:px-2.5", + orientation === "column" && !isIconWidth && "w-full", + isIconWidth && "size-10 rounded-lg p-0", ); + const labelClassName = isIconWidth + ? "sr-only" + : compact + ? "sr-only sm:not-sr-only" + : undefined; + const showTooltip = compact || isIconWidth; return ( -
- - {reportType && ( - + {reportType && ( + + } + label="Download PDF report" + onSelect={handleDownloadPdf} + disabled={disabled || isDownloadingPdf} + /> + )} + + ) : ( +
+ + + + + {showTooltip && ( + Download CSV report + )} + + {reportType && ( + + + + + {showTooltip && ( + Download PDF report + )} + + )} +
)}
); diff --git a/ui/components/compliance/compliance-header/compliance-filters.tsx b/ui/components/compliance/compliance-header/compliance-filters.tsx new file mode 100644 index 00000000000..474b61d4e7e --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-filters.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { ClearFiltersButton } from "@/components/filters/clear-filters-button"; +import { + MultiSelect, + MultiSelectContent, + MultiSelectItem, + MultiSelectSelectAll, + MultiSelectSeparator, + MultiSelectTrigger, + MultiSelectValue, +} from "@/components/shadcn/select/multiselect"; +import { useUrlFilters } from "@/hooks/use-url-filters"; + +import { ScanSelector, SelectScanComplianceDataProps } from "./scan-selector"; + +interface ComplianceFiltersProps { + scans: SelectScanComplianceDataProps["scans"]; + uniqueRegions: string[]; + selectedScanId: string; +} + +export const ComplianceFilters = ({ + scans, + uniqueRegions, + selectedScanId, +}: ComplianceFiltersProps) => { + const router = useRouter(); + const searchParams = useSearchParams(); + const { updateFilter } = useUrlFilters(); + + const handleScanChange = (selectedKey: string) => { + const params = new URLSearchParams(searchParams); + params.set("scanId", selectedKey); + router.push(`?${params.toString()}`, { scroll: false }); + }; + + const regionValues = + searchParams.get("filter[region__in]")?.split(",").filter(Boolean) ?? []; + + return ( +
+
+ +
+ {uniqueRegions.length > 0 && ( +
+ updateFilter("region__in", values)} + > + + + + + Select All + + {uniqueRegions.map((region) => ( + + {region} + + ))} + + +
+ )} + +
+ ); +}; diff --git a/ui/components/compliance/compliance-header/compliance-header.test.tsx b/ui/components/compliance/compliance-header/compliance-header.test.tsx new file mode 100644 index 00000000000..b646199f7f6 --- /dev/null +++ b/ui/components/compliance/compliance-header/compliance-header.test.tsx @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("ComplianceHeader", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "compliance-header.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("renders the scan selector inside the shared filters grid using default layout", () => { + expect(source).toContain("prependElement"); + expect(source).toContain(" { const frameworkFilters = []; + const prependElement = showProviders ? ( + + ) : undefined; // Add CIS Profile Level filter if framework is CIS if (framework === "CIS") { @@ -42,6 +45,7 @@ export const ComplianceHeader = ({ key: "cis_profile_level", labelCheckboxGroup: "Level", values: ["Level 1", "Level 2"], + width: "wide" as const, index: 0, // Show first showSelectAll: false, // No "Select All" option since Level 2 includes Level 1 defaultValues: ["Level 2"], // Default to Level 2 selected (which includes Level 1) @@ -55,6 +59,7 @@ export const ComplianceHeader = ({ key: "region__in", labelCheckboxGroup: "Regions", values: uniqueRegions, + width: "wide" as const, index: 1, // Show after framework filters }, ] @@ -77,9 +82,11 @@ export const ComplianceHeader = ({ {selectedScan && } {/* Showed in the compliance page */} - {showProviders && } - {!hideFilters && allFilters.length > 0 && ( - + {!hideFilters && (allFilters.length > 0 || showProviders) && ( + )}
{logoPath && complianceTitle && ( diff --git a/ui/components/compliance/compliance-header/data-compliance.tsx b/ui/components/compliance/compliance-header/data-compliance.tsx index 992dc3d683b..4787d00921c 100644 --- a/ui/components/compliance/compliance-header/data-compliance.tsx +++ b/ui/components/compliance/compliance-header/data-compliance.tsx @@ -7,11 +7,13 @@ import { ScanSelector, SelectScanComplianceDataProps, } from "@/components/compliance/compliance-header/index"; +import { cn } from "@/lib/utils"; interface DataComplianceProps { scans: SelectScanComplianceDataProps["scans"]; + className?: string; } -export const DataCompliance = ({ scans }: DataComplianceProps) => { +export const DataCompliance = ({ scans, className }: DataComplianceProps) => { const router = useRouter(); const searchParams = useSearchParams(); @@ -36,7 +38,7 @@ export const DataCompliance = ({ scans }: DataComplianceProps) => { }; return ( -
+
{ const selectedScan = scans.find((item) => item.id === selectedScanId); + const triggerLabel = selectedScan ? getScanEntityLabel(selectedScan) : ""; return (