Merge branch 'master' into PRWLR-7160-findings-page-scan-id-filter-improvement

# Conflicts:
#	ui/CHANGELOG.md
#	ui/app/(prowler)/compliance/page.tsx
#	ui/types/components.ts
This commit is contained in:
sumit_chaturvedi
2025-06-03 12:17:29 +05:30
108 changed files with 5916 additions and 1193 deletions
@@ -0,0 +1,179 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
ColumnFindings,
SkeletonTableFindings,
} from "@/components/findings/table";
import { Accordion } from "@/components/ui/accordion/Accordion";
import { DataTable } from "@/components/ui/table";
import { createDict } from "@/lib";
import { ComplianceId, Requirement } from "@/types/compliance";
import { FindingProps, FindingsResponse } from "@/types/components";
import { ComplianceCustomDetails } from "../compliance-custom-details/ens-details";
interface ClientAccordionContentProps {
requirement: Requirement;
scanId: string;
}
export const ClientAccordionContent = ({
requirement,
scanId,
}: ClientAccordionContentProps) => {
const [findings, setFindings] = useState<FindingsResponse | null>(null);
const [expandedFindings, setExpandedFindings] = useState<FindingProps[]>([]);
const searchParams = useSearchParams();
const pageNumber = searchParams.get("page") || "1";
const complianceId = searchParams.get("complianceId") as ComplianceId;
const defaultSort = "severity,status,-inserted_at";
const sort = searchParams.get("sort") || defaultSort;
const loadedPageRef = useRef<string | null>(null);
const loadedSortRef = useRef<string | null>(null);
const isExpandedRef = useRef(false);
const region = searchParams.get("filter[region__in]") || "";
useEffect(() => {
async function loadFindings() {
if (
requirement.check_ids?.length > 0 &&
requirement.status !== "No findings" &&
(loadedPageRef.current !== pageNumber ||
loadedSortRef.current !== sort ||
!isExpandedRef.current)
) {
loadedPageRef.current = pageNumber;
loadedSortRef.current = sort;
isExpandedRef.current = true;
try {
const checkIds = requirement.check_ids;
const encodedSort = sort.replace(/^\+/, "");
const findingsData = await getFindings({
filters: {
"filter[check_id__in]": checkIds.join(","),
"filter[scan]": scanId,
...(region && { "filter[region__in]": region }),
},
page: parseInt(pageNumber, 10),
sort: encodedSort,
});
setFindings(findingsData);
if (findingsData?.data) {
// Create dictionaries for resources, scans, and providers
const resourceDict = createDict("resources", findingsData);
const scanDict = createDict("scans", findingsData);
const providerDict = createDict("providers", findingsData);
// Expand each finding with its corresponding resource, scan, and provider
const expandedData = findingsData.data.map(
(finding: FindingProps) => {
const scan = scanDict[finding.relationships?.scan?.data?.id];
const resource =
resourceDict[finding.relationships?.resources?.data?.[0]?.id];
const provider =
providerDict[scan?.relationships?.provider?.data?.id];
return {
...finding,
relationships: { scan, resource, provider },
};
},
);
setExpandedFindings(expandedData);
}
} catch (error) {
console.error("Error loading findings:", error);
}
}
}
loadFindings();
}, [requirement, scanId, pageNumber, sort, region]);
const checks = requirement.check_ids || [];
const checksList = (
<div className="mb-2 flex items-center">
<span>{checks.join(", ")}</span>
</div>
);
const accordionChecksItems = [
{
key: "checks",
title: (
<div className="flex items-center gap-2">
<span className="text-primary">{checks.length}</span>
{checks.length > 1 ? <span>Checks</span> : <span>Check</span>}
</div>
),
content: checksList,
},
];
const renderFindingsTable = () => {
if (findings === null && requirement.status !== "MANUAL") {
return <SkeletonTableFindings />;
}
if (findings?.data?.length && findings.data.length > 0) {
return (
<div className="p-1">
<DataTable
// Remove the updated_at column as compliance is for the last scan
columns={ColumnFindings.filter(
(_, index) => index !== 4 && index !== 7,
)}
data={expandedFindings || []}
metadata={findings?.meta}
disableScroll={true}
/>
</div>
);
}
return <div>There are no findings for this regions</div>;
};
const renderDetails = () => {
if (!complianceId) {
return null;
}
switch (complianceId) {
case "ens_rd2022_aws":
return (
<div className="w-full">
<ComplianceCustomDetails requirement={requirement} />
</div>
);
default:
return null;
}
};
return (
<div className="w-full">
{renderDetails()}
{checks.length > 0 && (
<div className="mb-6 mt-2">
<Accordion
items={accordionChecksItems}
variant="light"
defaultExpandedKeys={[""]}
className="rounded-lg bg-white dark:bg-prowler-blue-400"
/>
</div>
)}
{renderFindingsTable()}
</div>
);
};
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { Accordion, AccordionItemProps } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
export const ClientAccordionWrapper = ({
items,
defaultExpandedKeys,
}: {
items: AccordionItemProps[];
defaultExpandedKeys: string[];
}) => {
const [selectedKeys, setSelectedKeys] =
useState<string[]>(defaultExpandedKeys);
const [isExpanded, setIsExpanded] = useState(false);
// Function to get all keys except the last level (requirements)
const getAllKeysExceptLastLevel = (items: AccordionItemProps[]): string[] => {
const keys: string[] = [];
const traverse = (items: AccordionItemProps[], level: number = 0) => {
items.forEach((item) => {
// Add current item key if it's not the last level
if (item.items && item.items.length > 0) {
keys.push(item.key);
// Check if the children have their own children (not the last level)
const hasGrandChildren = item.items.some(
(child) => child.items && child.items.length > 0,
);
if (hasGrandChildren) {
traverse(item.items, level + 1);
}
}
});
};
traverse(items);
return keys;
};
const handleToggleExpand = () => {
if (isExpanded) {
setSelectedKeys(defaultExpandedKeys);
} else {
const allKeys = getAllKeysExceptLastLevel(items);
setSelectedKeys(allKeys);
}
setIsExpanded(!isExpanded);
};
const handleSelectionChange = (keys: string[]) => {
setSelectedKeys(keys);
};
return (
<div className="space-y-4">
<div className="flex justify-end">
<CustomButton
variant="flat"
size="sm"
onPress={handleToggleExpand}
ariaLabel={isExpanded ? "Collapse all" : "Expand all"}
>
{isExpanded ? "Collapse all" : "Expand all"}
</CustomButton>
</div>
<Accordion
items={items}
variant="light"
selectionMode="multiple"
defaultExpandedKeys={defaultExpandedKeys}
selectedKeys={selectedKeys}
onSelectionChange={handleSelectionChange}
/>
</div>
);
};
@@ -0,0 +1,26 @@
import { FindingStatus, StatusFindingBadge } from "@/components/ui/table";
import { translateType } from "@/lib/compliance/ens";
interface ComplianceAccordionRequirementTitleProps {
type: string;
name: string;
status: FindingStatus;
}
export const ComplianceAccordionRequirementTitle = ({
type,
name,
status,
}: ComplianceAccordionRequirementTitleProps) => {
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-3/4 items-center gap-1">
<span className="whitespace-nowrap text-sm font-bold capitalize">
{translateType(type)}:
</span>
<span className="whitespace-nowrap text-sm uppercase">{name}</span>
</div>
<StatusFindingBadge status={status} />
</div>
);
};
@@ -0,0 +1,135 @@
import { Tooltip } from "@nextui-org/react";
interface ComplianceAccordionTitleProps {
label: string;
pass: number;
fail: number;
manual?: number;
}
export const ComplianceAccordionTitle = ({
label,
pass,
fail,
manual = 0,
}: ComplianceAccordionTitleProps) => {
const total = pass + fail + manual;
const passPercentage = (pass / total) * 100;
const failPercentage = (fail / total) * 100;
const manualPercentage = (manual / total) * 100;
return (
<div className="flex flex-col items-start justify-between gap-1 md:flex-row md:items-center md:gap-2">
<div className="overflow-hidden md:min-w-0 md:flex-1">
<span
className="block w-full overflow-hidden truncate text-ellipsis text-sm"
title={label}
>
{label.charAt(0).toUpperCase() + label.slice(1)}
</span>
</div>
<div className="mr-4 flex items-center gap-2">
<div className="hidden lg:block">
{total > 0 && (
<span className="whitespace-nowrap text-xs font-medium text-gray-600">
Requirements:
</span>
)}
</div>
<div className="flex h-1.5 w-[200px] overflow-hidden rounded-full bg-gray-100 shadow-inner">
{total > 0 ? (
<div className="flex w-full">
{pass > 0 && (
<Tooltip
content={
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Pass</div>
<div className="text-tiny text-default-400">
{pass} ({passPercentage.toFixed(1)}%)
</div>
</div>
}
size="sm"
placement="top"
delay={0}
closeDelay={0}
>
<div
className="h-full bg-[#3CEC6D] transition-all duration-200 hover:brightness-110"
style={{
width: `${passPercentage}%`,
marginRight: pass > 0 ? "2px" : "0",
}}
/>
</Tooltip>
)}
{fail > 0 && (
<Tooltip
content={
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Fail</div>
<div className="text-tiny text-default-400">
{fail} ({failPercentage.toFixed(1)}%)
</div>
</div>
}
size="sm"
placement="top"
delay={0}
closeDelay={0}
>
<div
className="h-full bg-[#FB718F] transition-all duration-200 hover:brightness-110"
style={{
width: `${failPercentage}%`,
marginRight: manual > 0 ? "2px" : "0",
}}
/>
</Tooltip>
)}
{manual > 0 && (
<Tooltip
content={
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Manual</div>
<div className="text-tiny text-default-400">
{manual} ({manualPercentage.toFixed(1)}%)
</div>
</div>
}
size="sm"
placement="top"
delay={0}
closeDelay={0}
>
<div
className="h-full bg-[#868994] transition-all duration-200 hover:brightness-110"
style={{ width: `${manualPercentage}%` }}
/>
</Tooltip>
)}
</div>
) : (
<div className="h-full w-full bg-gray-200" />
)}
</div>
<Tooltip
content={
<div className="px-1 py-0.5">
<div className="text-xs font-medium">Total requirements</div>
<div className="text-tiny text-default-400">{total}</div>
</div>
}
size="sm"
placement="top"
>
<div className="min-w-[32px] text-center text-xs font-medium text-default-600">
{total > 0 ? total : "—"}
</div>
</Tooltip>
</div>
</div>
);
};
+27 -2
View File
@@ -2,7 +2,7 @@
import { Card, CardBody, Progress } from "@nextui-org/react";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useState } from "react";
import { DownloadIconButton, toast } from "@/components/ui";
@@ -19,6 +19,7 @@ interface ComplianceCardProps {
prevTotalRequirements: number;
scanId: string;
complianceId: string;
id: string;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
@@ -28,8 +29,10 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
totalRequirements,
scanId,
complianceId,
id,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
const hasRegionFilter = searchParams.has("filter[region__in]");
const [isDownloading, setIsDownloading] = useState<boolean>(false);
@@ -68,6 +71,22 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
return "success";
};
const navigateToDetail = () => {
// We will unlock this while developing the rest of complainces.
if (!id.includes("ens") && !id.includes("cis")) {
return;
}
const formattedTitleForUrl = encodeURIComponent(title);
const path = `/compliance/${formattedTitleForUrl}`;
const params = new URLSearchParams();
params.set("complianceId", id);
params.set("version", version);
params.set("scanId", scanId);
router.push(`${path}?${params.toString()}`);
};
const handleDownload = async () => {
setIsDownloading(true);
try {
@@ -78,7 +97,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
};
return (
<Card fullWidth isHoverable shadow="sm">
<Card
fullWidth
isHoverable
shadow="sm"
isPressable
onPress={navigateToDetail}
>
<CardBody className="flex flex-row items-center justify-between space-x-4 dark:bg-prowler-blue-800">
<div className="flex w-full items-center space-x-4">
<Image
@@ -0,0 +1,38 @@
import { Requirement } from "@/types/compliance";
export const ComplianceCustomDetails = ({
requirement,
}: {
requirement: Requirement;
}) => {
return (
<div className="mb-4">
<div className="mb-2 text-sm text-gray-600">
{requirement.description}
</div>
<div className="flex flex-col gap-2 text-sm">
<div className="flex items-center gap-2">
<span className="font-medium">Level:</span>
<span className="capitalize">{requirement.nivel}</span>
</div>
{requirement.dimensiones && requirement.dimensiones.length > 0 && (
<div className="flex items-center gap-2">
<span className="font-medium">Dimensions:</span>
<div className="flex flex-wrap gap-1">
{requirement.dimensiones.map(
(dimension: string, index: number) => (
<span
key={index}
className="rounded-full bg-gray-100 px-2 py-0.5 text-xs capitalize dark:bg-prowler-blue-400"
>
{dimension}
</span>
),
)}
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,47 @@
"use client";
import { Spacer } from "@nextui-org/react";
import { FilterControls } from "@/components/filters";
import { DataTableFilterCustom } from "@/components/ui/table/data-table-filter-custom";
import { DataCompliance } from "./data-compliance";
import { SelectScanComplianceDataProps } from "./select-scan-compliance-data";
interface ComplianceHeaderProps {
scans: SelectScanComplianceDataProps["scans"];
uniqueRegions: string[];
showSearch?: boolean;
showRegionFilter?: boolean;
}
export const ComplianceHeader = ({
scans,
uniqueRegions,
showSearch = true,
showRegionFilter = true,
}: ComplianceHeaderProps) => {
return (
<>
{showSearch && <FilterControls search />}
<Spacer y={8} />
<DataCompliance scans={scans} />
{showRegionFilter && (
<>
<Spacer y={8} />
<DataTableFilterCustom
filters={[
{
key: "region__in",
labelCheckboxGroup: "Regions",
values: uniqueRegions,
},
]}
defaultOpen={true}
/>
</>
)}
<Spacer y={12} />
</>
);
};
@@ -3,6 +3,7 @@ import React from "react";
import { DateWithTime, EntityInfoShort } from "@/components/ui/entities";
import { ProviderType } from "@/types";
interface ComplianceScanInfoProps {
scan: {
providerInfo: {
@@ -3,8 +3,10 @@
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import { SelectScanComplianceData } from "@/components/compliance/data-compliance";
import { SelectScanComplianceDataProps } from "@/types";
import {
SelectScanComplianceData,
SelectScanComplianceDataProps,
} from "@/components/compliance/compliance-header/index";
interface DataComplianceProps {
scans: SelectScanComplianceDataProps["scans"];
}
@@ -1,8 +1,20 @@
import { Select, SelectItem } from "@nextui-org/react";
import { SelectScanComplianceDataProps } from "@/types";
import { ProviderType, ScanProps } from "@/types";
import { ComplianceScanInfo } from "../compliance-scan-info";
import { ComplianceScanInfo } from "./compliance-scan-info";
export interface SelectScanComplianceDataProps {
scans: (ScanProps & {
providerInfo: {
provider: ProviderType;
uid: string;
alias: string;
};
})[];
selectedScanId: string;
onSelectionChange: (selectedKey: string) => void;
}
export const SelectScanComplianceData = ({
scans,
@@ -0,0 +1,30 @@
import { Skeleton } from "@nextui-org/react";
import React from "react";
interface SkeletonAccordionProps {
itemCount?: number;
className?: string;
isCompact?: boolean;
}
export const SkeletonAccordion = ({
itemCount = 3,
className = "",
isCompact = false,
}: SkeletonAccordionProps) => {
const itemHeight = isCompact ? "h-10" : "h-14";
return (
<div
className={`w-full space-y-2 ${className} rounded-xl border border-gray-300 p-2 dark:border-gray-700`}
>
{[...Array(itemCount)].map((_, index) => (
<Skeleton key={index} className="rounded-lg">
<div className={`${itemHeight} bg-default-300`}></div>
</Skeleton>
))}
</div>
);
};
SkeletonAccordion.displayName = "SkeletonAccordion";
@@ -0,0 +1,53 @@
"use client";
import { Skeleton } from "@nextui-org/react";
export const FailedSectionsChartSkeleton = () => {
return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
{/* Title skeleton */}
<Skeleton className="h-4 w-40 rounded-lg">
<div className="h-4 bg-default-200" />
</Skeleton>
{/* Chart area skeleton */}
<div className="ml-24 flex h-full flex-col justify-center space-y-2 p-4">
{/* Bar chart skeleton - 5 horizontal bars */}
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex items-center space-x-4">
{/* Bar skeleton with varying widths */}
<Skeleton
className={`h-10 rounded-lg ${
index === 0
? "w-48"
: index === 1
? "w-40"
: index === 2
? "w-32"
: index === 3
? "w-24"
: "w-16"
}`}
>
<div className="h-6 bg-default-200" />
</Skeleton>
</div>
))}
{/* Legend skeleton */}
<div className="flex justify-center space-x-4 pt-2">
{Array.from({ length: 3 }).map((_, index) => (
<div key={index} className="flex items-center space-x-1">
<Skeleton className="h-3 w-3 rounded-full">
<div className="h-3 w-3 bg-default-200" />
</Skeleton>
<Skeleton className="h-3 w-16 rounded-lg">
<div className="h-3 bg-default-200" />
</Skeleton>
</div>
))}
</div>
</div>
</div>
);
};
@@ -0,0 +1,150 @@
"use client";
import { useTheme } from "next-themes";
import {
Bar,
BarChart,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { translateType } from "@/lib/compliance/ens";
type FailedSectionItem = {
name: string;
total: number;
types: {
[key: string]: number;
};
};
interface FailedSectionsListProps {
sections: FailedSectionItem[];
}
const title = (
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
Failed Sections (Top 5)
</h3>
);
export const FailedSectionsChart = ({ sections }: FailedSectionsListProps) => {
const { theme } = useTheme();
const getTypeColor = (type: string) => {
switch (type.toLowerCase()) {
case "requisito":
return "#ff5356";
case "recomendacion":
return "#FDC53A"; // Increased contrast from #FDDD8A
case "refuerzo":
return "#7FB5FF"; // Increased contrast from #B5D7FF
default:
return "#868994";
}
};
const chartData = [...sections]
.sort((a, b) => b.total - a.total)
.slice(0, 5)
.map((section) => ({
name: section.name.charAt(0).toUpperCase() + section.name.slice(1),
...section.types,
}));
const allTypes = Array.from(
new Set(sections.flatMap((section) => Object.keys(section.types))),
);
// Check if there are no failed sections
if (!sections || sections.length === 0) {
return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
{title}
<div className="flex h-[320px] w-full items-center justify-center">
<p className="text-sm text-gray-500">There are no failed sections</p>
</div>
</div>
);
}
return (
<div className="flex w-[400px] flex-col items-center justify-between lg:w-[600px]">
{title}
<div className="h-[320px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ top: 5, right: 30, left: 40, bottom: 5 }}
maxBarSize={40}
>
<XAxis
type="number"
fontSize={12}
axisLine={false}
tickLine={false}
tick={{
fontSize: 12,
fill: theme === "dark" ? "#94a3b8" : "#374151",
}}
/>
<YAxis
type="category"
dataKey="name"
width={100}
tick={{
fontSize: 12,
fill: theme === "dark" ? "#94a3b8" : "#374151",
}}
axisLine={false}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: theme === "dark" ? "#1e293b" : "white",
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
borderRadius: "6px",
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
fontSize: "12px",
padding: "8px 12px",
color: theme === "dark" ? "white" : "black",
}}
formatter={(value: number, name: string) => [
value,
translateType(name),
]}
cursor={false}
/>
<Legend
formatter={(value) => translateType(value)}
wrapperStyle={{
fontSize: "10px",
display: "flex",
justifyContent: "center",
width: "100%",
}}
iconType="circle"
layout="horizontal"
verticalAlign="bottom"
height={36}
/>
{allTypes.map((type, i) => (
<Bar
key={type}
dataKey={type}
stackId="a"
fill={getTypeColor(type)}
radius={i === allTypes.length - 1 ? [0, 4, 4, 0] : [0, 0, 0, 0]}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./compliance-card";
export * from "./compliance-scan-info";
export * from "./compliance-header/compliance-scan-info";
export * from "./compliance-skeleton-grid";
export * from "./no-scans-available";
@@ -0,0 +1,63 @@
"use client";
import { Skeleton } from "@nextui-org/react";
export const RequirementsChartSkeleton = () => {
return (
<div className="flex h-[320px] flex-col items-center justify-between">
{/* Title skeleton */}
<Skeleton className="h-4 w-32 rounded-lg">
<div className="h-4 bg-default-200" />
</Skeleton>
{/* Pie chart skeleton */}
<div className="relative flex aspect-square w-[200px] min-w-[200px] items-center justify-center">
{/* Outer circle */}
<Skeleton className="absolute h-[200px] w-[200px] rounded-full">
<div className="h-[200px] w-[200px] bg-default-200" />
</Skeleton>
{/* Inner circle (donut hole) */}
<div className="absolute h-[140px] w-[140px] rounded-full bg-background"></div>
{/* Center text skeleton */}
<div className="absolute flex flex-col items-center">
<Skeleton className="h-6 w-8 rounded-lg">
<div className="h-6 bg-default-300" />
</Skeleton>
<Skeleton className="mt-1 h-3 w-6 rounded-lg">
<div className="h-3 bg-default-300" />
</Skeleton>
</div>
</div>
{/* Bottom stats skeleton */}
<div className="mt-2 grid grid-cols-3 gap-4">
<div className="flex flex-col items-center">
<Skeleton className="h-4 w-8 rounded-lg">
<div className="h-4 bg-default-200" />
</Skeleton>
<Skeleton className="mt-1 h-5 w-6 rounded-lg">
<div className="h-5 bg-default-200" />
</Skeleton>
</div>
<div className="flex flex-col items-center">
<Skeleton className="h-4 w-6 rounded-lg">
<div className="h-4 bg-default-200" />
</Skeleton>
<Skeleton className="mt-1 h-5 w-6 rounded-lg">
<div className="h-5 bg-default-200" />
</Skeleton>
</div>
<div className="flex flex-col items-center">
<Skeleton className="h-4 w-12 rounded-lg">
<div className="h-4 bg-default-200" />
</Skeleton>
<Skeleton className="mt-1 h-5 w-6 rounded-lg">
<div className="h-5 bg-default-200" />
</Skeleton>
</div>
</div>
</div>
);
};
@@ -0,0 +1,190 @@
"use client";
import { useTheme } from "next-themes";
import { Cell, Label, Pie, PieChart, Tooltip } from "recharts";
import { ChartConfig, ChartContainer } from "@/components/ui/chart/Chart";
interface RequirementsChartProps {
pass: number;
fail: number;
manual: number;
}
const chartConfig = {
number: {
label: "Requirements",
},
pass: {
label: "Pass",
color: "hsl(var(--chart-success))",
},
fail: {
label: "Fail",
color: "hsl(var(--chart-fail))",
},
manual: {
label: "Manual",
color: "hsl(var(--chart-warning))",
},
} satisfies ChartConfig;
export const RequirementsChart = ({
pass,
fail,
manual,
}: RequirementsChartProps) => {
const { theme } = useTheme();
const chartData = [
{
name: "Pass",
value: pass,
fill: "#3CEC6D",
},
{
name: "Fail",
value: fail,
fill: "#FB718F",
},
{
name: "Manual",
value: manual,
fill: "#868994",
},
];
const totalRequirements = pass + fail + manual;
const emptyChartData = [
{
name: "Empty",
value: 1,
fill: "#64748b",
},
];
interface CustomTooltipProps {
active: boolean;
payload: {
payload: {
name: string;
value: number;
fill: string;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length) {
const data = payload[0];
return (
<div
style={{
backgroundColor: theme === "dark" ? "#1e293b" : "white",
border: `1px solid ${theme === "dark" ? "#475569" : "rgba(0, 0, 0, 0.1)"}`,
borderRadius: "6px",
boxShadow: "0px 4px 12px rgba(0, 0, 0, 0.15)",
fontSize: "12px",
padding: "8px 12px",
color: theme === "dark" ? "white" : "black",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<div
style={{
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: data.payload.fill,
}}
/>
<span>
{data.payload.name}: {data.payload.value}
</span>
</div>
</div>
);
}
return null;
};
return (
<div className="flex h-[320px] flex-col items-center justify-between">
<h3 className="whitespace-nowrap text-xs font-semibold uppercase tracking-wide">
Requirements Status
</h3>
<ChartContainer
config={chartConfig}
className="aspect-square w-[200px] min-w-[200px]"
>
<PieChart>
<Tooltip
cursor={false}
content={<CustomTooltip active={false} payload={[]} />}
/>
<Pie
data={totalRequirements > 0 ? chartData : emptyChartData}
dataKey="value"
nameKey="name"
innerRadius={70}
outerRadius={100}
paddingAngle={2}
cornerRadius={4}
>
{(totalRequirements > 0 ? chartData : emptyChartData).map(
(entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
),
)}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-xl font-bold"
>
{totalRequirements}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 20}
className="fill-foreground text-xs"
>
Total
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
<div className="mt-2 grid grid-cols-3 gap-4">
<div className="flex flex-col items-center">
<div className="text-muted-foreground text-sm">Pass</div>
<div className="font-semibold text-system-success-medium">{pass}</div>
</div>
<div className="flex flex-col items-center">
<div className="text-muted-foreground text-sm">Fail</div>
<div className="font-semibold text-system-error-medium">{fail}</div>
</div>
<div className="flex flex-col items-center">
<div className="text-muted-foreground text-sm">Manual</div>
<div className="font-semibold text-prowler-grey-light">{manual}</div>
</div>
</div>
</div>
);
};
@@ -1,65 +1,11 @@
import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
import { SkeletonTable } from "../../ui/skeleton/skeleton";
export const SkeletonTableFindings = () => {
return (
<Card className="h-full w-full space-y-5 p-4" radius="sm">
{/* Table headers */}
<div className="hidden justify-between md:flex">
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
</div>
{/* Table body */}
<div className="space-y-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
</div>
))}
</div>
</Card>
<div className="bg-card rounded-xl border p-4 shadow-sm">
<SkeletonTable rows={4} columns={7} />
</div>
);
};
@@ -116,9 +116,9 @@ export const FindingsBySeverityChart = ({
>
<LabelList
position="insideRight"
offset={10}
offset={5}
className="fill-foreground font-bold"
fontSize={12}
fontSize={11}
/>
</Bar>
</BarChart>
@@ -146,9 +146,9 @@ export const FindingsByStatusChart: React.FC<FindingsByStatusChartProps> = ({
</PieChart>
</ChartContainer>
<div className="grid w-full grid-cols-2 justify-items-center gap-6">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<div className="flex items-center space-x-2 self-end">
<div className="flex items-center space-x-2">
<Link
href="/findings?filter[status]=PASS"
className="flex items-center space-x-2"
@@ -1,65 +1,11 @@
import { Card, Skeleton } from "@nextui-org/react";
import React from "react";
import { SkeletonTable } from "@/components/ui/skeleton/skeleton";
export const SkeletonTableNewFindings = () => {
return (
<Card className="h-full w-full space-y-5 p-4" radius="sm">
{/* Table headers */}
<div className="hidden justify-between md:flex">
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-2/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
<Skeleton className="w-1/12 rounded-lg">
<div className="h-8 bg-default-200"></div>
</Skeleton>
</div>
{/* Table body */}
<div className="space-y-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="flex flex-col items-center justify-between space-x-0 md:flex-row md:space-x-4"
>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 w-full rounded-lg md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-2/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
<Skeleton className="mb-2 hidden rounded-lg sm:flex md:mb-0 md:w-1/12">
<div className="h-12 bg-default-300"></div>
</Skeleton>
</div>
))}
</div>
</Card>
<div className="bg-card rounded-xl border p-4 shadow-sm">
<SkeletonTable rows={3} columns={7} />
</div>
);
};
@@ -0,0 +1,51 @@
"use client";
import { InfoIcon } from "@/components/icons";
import { SelectViaAWS } from "@/components/providers/workflow/forms/select-credentials-type/aws";
import { SelectViaGCP } from "@/components/providers/workflow/forms/select-credentials-type/gcp";
import { ProviderType } from "@/types/providers";
interface UpdateCredentialsInfoProps {
providerType: ProviderType;
initialVia?: string;
}
export const CredentialsUpdateInfo = ({
providerType,
initialVia,
}: UpdateCredentialsInfoProps) => {
const renderSelectComponent = () => {
if (providerType === "aws") {
return <SelectViaAWS initialVia={initialVia} />;
}
if (providerType === "gcp") {
return <SelectViaGCP initialVia={initialVia} />;
}
return null;
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-default-700">
To update provider credentials,{" "}
<strong>
the same type that was originally configured must be used.
</strong>
</p>
<div className="flex items-center rounded-lg border border-system-warning bg-system-warning-medium p-4 text-sm dark:text-default-300">
<InfoIcon className="mr-2 inline h-4 w-4 flex-shrink-0" />
<p>
If the provider was configured with static credentials, updates must
also use static credentials. If it was configured with a role in AWS
(or service account in GCP),{" "}
<strong>updates must use the same type.</strong>
</p>
</div>
<p className="text-sm font-bold text-default-700">
To switch from one type to another, the provider must be deleted and set
up again.
</p>
{renderSelectComponent()}
</div>
);
};
+1
View File
@@ -1,4 +1,5 @@
export * from "./add-provider-button";
export * from "./credentials-update-info";
export * from "./forms/delete-form";
export * from "./link-to-scans";
export * from "./provider-info";
@@ -10,7 +10,6 @@ import * as z from "zod";
import { useToast } from "@/components/ui";
import { CustomButton, CustomInput } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { ProviderType } from "@/types";
import { addProvider } from "../../../../actions/providers/providers";
import { addProviderFormSchema, ApiError } from "../../../../types";
@@ -171,7 +170,7 @@ export const ConnectAccountForm = () => {
{/* Step 2: UID, alias, and credentials (if AWS) */}
{prevStep === 2 && (
<>
<ProviderTitleDocs providerType={providerType as ProviderType} />
<ProviderTitleDocs providerType={providerType} />
<CustomInput
control={form.control}
name="providerUid"
@@ -1,5 +1,4 @@
export * from "./connect-account-form";
export * from "./radio-group-aws-via-credentials-form";
export * from "./test-connection-form";
export * from "./update-via-credentials-form";
export * from "./update-via-role-form";
@@ -1,12 +1,11 @@
import { Divider, Select, SelectItem, Spacer } from "@nextui-org/react";
import { Control, UseFormSetValue, useWatch } from "react-hook-form";
import { CredentialsRoleHelper } from "@/components/providers/workflow";
import { CustomInput } from "@/components/ui/custom";
import { AWSCredentialsRole } from "@/types";
import { CredentialsRoleHelper } from "../../credentials-role-helper";
export const AWSCredentialsRoleForm = ({
export const AWSRoleCredentialsForm = ({
control,
setValue,
externalId,
@@ -3,7 +3,7 @@ import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { AWSCredentials } from "@/types";
export const AWScredentialsForm = ({
export const AWSStaticCredentialsForm = ({
control,
}: {
control: Control<AWSCredentials>;
@@ -0,0 +1,2 @@
export * from "./aws-role-credentials-form";
export * from "./aws-static-credentials-form";
@@ -0,0 +1,2 @@
export * from "./radio-group-aws-via-credentials-type-form";
export * from "./select-via-aws";
@@ -14,7 +14,7 @@ type RadioGroupAWSViaCredentialsFormProps = {
onChange?: (value: string) => void;
};
export const RadioGroupAWSViaCredentialsForm = ({
export const RadioGroupAWSViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import { RadioGroupAWSViaCredentialsForm } from "../radio-group-aws-via-credentials-form";
import { RadioGroupAWSViaCredentialsTypeForm } from "./radio-group-aws-via-credentials-type-form";
interface SelectViaAWSProps {
initialVia?: string;
@@ -27,7 +27,7 @@ export const SelectViaAWS = ({ initialVia }: SelectViaAWSProps) => {
return (
<Form {...form}>
<RadioGroupAWSViaCredentialsForm
<RadioGroupAWSViaCredentialsTypeForm
control={form.control}
isInvalid={!!form.formState.errors.awsCredentialsType}
errorMessage={form.formState.errors.awsCredentialsType?.message}
@@ -1,12 +1,12 @@
import { Control } from "react-hook-form";
import { CustomInput } from "@/components/ui/custom";
import { GCPCredentials } from "@/types";
import { GCPDefaultCredentials } from "@/types";
export const GCPcredentialsForm = ({
export const GCPDefaultCredentialsForm = ({
control,
}: {
control: Control<GCPCredentials>;
control: Control<GCPDefaultCredentials>;
}) => {
return (
<>
@@ -0,0 +1,34 @@
import { Control } from "react-hook-form";
import { CustomTextarea } from "@/components/ui/custom";
import { GCPServiceAccountKey } from "@/types";
export const GCPServiceAccountKeyForm = ({
control,
}: {
control: Control<GCPServiceAccountKey>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md font-bold leading-9 text-default-foreground">
Connect via Service Account Key
</div>
<div className="text-sm text-default-500">
Please provide the service account key for your GCP credentials.
</div>
</div>
<CustomTextarea
control={control}
name="service_account_key"
label="Service Account Key"
labelPlacement="inside"
placeholder="Paste your Service Account Key JSON content here"
variant="bordered"
minRows={10}
isRequired
isInvalid={!!control._formState.errors.service_account_key}
/>
</>
);
};
@@ -0,0 +1,2 @@
export * from "./gcp-default-credentials-form";
export * from "./gcp-service-account-key-form";
@@ -0,0 +1,3 @@
export * from "./radio-group-gcp-via-credentials-type-form";
export * from "./select-via-gcp";
export * from "./via-service-account-form";
@@ -0,0 +1,77 @@
"use client";
import { RadioGroup } from "@nextui-org/react";
import React from "react";
import { Control, Controller } from "react-hook-form";
import { CustomRadio } from "@/components/ui/custom";
import { FormMessage } from "@/components/ui/form";
type RadioGroupAWSViaCredentialsFormProps = {
control: Control<any>;
isInvalid: boolean;
errorMessage?: string;
onChange?: (value: string) => void;
};
export const RadioGroupGCPViaCredentialsTypeForm = ({
control,
isInvalid,
errorMessage,
onChange,
}: RadioGroupAWSViaCredentialsFormProps) => {
return (
<Controller
name="gcpCredentialsType"
control={control}
render={({ field }) => (
<>
<RadioGroup
className="flex flex-wrap"
isInvalid={isInvalid}
{...field}
value={field.value || ""}
onValueChange={(value) => {
field.onChange(value);
if (onChange) {
onChange(value);
}
}}
>
<div className="flex flex-col gap-4">
<span className="text-sm text-default-500">
Using Service Account
</span>
<CustomRadio
description="Connect using Service Account"
value="service-account"
>
<div className="flex items-center">
<span className="ml-2">Connect via Service Account Key</span>
</div>
</CustomRadio>
<span className="text-sm text-default-500">
Using Application Default Credentials
</span>
<CustomRadio
description="Connect via Credentials"
value="credentials"
>
<div className="flex items-center">
<span className="ml-2">
Connect via Application Default Credentials
</span>
</div>
</CustomRadio>
</div>
</RadioGroup>
{errorMessage && (
<FormMessage className="text-system-error dark:text-system-error">
{errorMessage}
</FormMessage>
)}
</>
)}
/>
);
};
@@ -0,0 +1,38 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { Form } from "@/components/ui/form";
import { RadioGroupGCPViaCredentialsTypeForm } from "./radio-group-gcp-via-credentials-type-form";
interface SelectViaGCPProps {
initialVia?: string;
}
export const SelectViaGCP = ({ initialVia }: SelectViaGCPProps) => {
const router = useRouter();
const form = useForm({
defaultValues: {
gcpCredentialsType: initialVia || "",
},
});
const handleSelectionChange = (value: string) => {
const url = new URL(window.location.href);
url.searchParams.set("via", value);
router.push(url.toString());
};
return (
<Form {...form}>
<RadioGroupGCPViaCredentialsTypeForm
control={form.control}
isInvalid={!!form.formState.errors.gcpCredentialsType}
errorMessage={form.formState.errors.gcpCredentialsType?.message}
onChange={handleSelectionChange}
/>
</Form>
);
};
@@ -0,0 +1,160 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Divider } from "@nextui-org/react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Control, useForm } from "react-hook-form";
import * as z from "zod";
import { addCredentialsProvider } from "@/actions/providers/providers";
import { ProviderTitleDocs } from "@/components/providers/workflow";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import {
addCredentialsServiceAccountFormSchema,
ApiError,
GCPServiceAccountKey,
ProviderType,
} from "@/types";
import { GCPServiceAccountKeyForm } from "./credentials-type/gcp-service-account-key-form";
export const ViaServiceAccountForm = ({
searchParams,
}: {
searchParams: { type: ProviderType; id: string };
}) => {
const router = useRouter();
const { toast } = useToast();
const searchParamsObj = useSearchParams();
// Handler for back button
const handleBackStep = () => {
const currentParams = new URLSearchParams(window.location.search);
currentParams.delete("via");
router.push(`?${currentParams.toString()}`);
};
const providerType = searchParams.type;
const providerId = searchParams.id;
const formSchema = addCredentialsServiceAccountFormSchema(providerType);
type FormSchemaType = z.infer<typeof formSchema>;
const form = useForm<FormSchemaType>({
resolver: zodResolver(formSchema),
defaultValues: {
providerId,
providerType,
...(providerType === "gcp"
? {
service_account_key: "",
secretName: "",
}
: {}),
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormSchemaType) => {
const formData = new FormData();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
formData.append(key, String(value));
}
});
try {
const data = await addCredentialsProvider(formData);
if (data?.errors && data.errors.length > 0) {
data.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
switch (error.source.pointer) {
case "/data/attributes/secret/service_account_key":
form.setError("service_account_key" as keyof FormSchemaType, {
type: "server",
message: errorMessage,
});
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
}
});
} else {
router.push(
`/providers/test-connection?type=${providerType}&id=${providerId}`,
);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error during submission:", error);
toast({
variant: "destructive",
title: "Submission failed",
description: "An error occurred while processing your request.",
});
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col space-y-4"
>
<input type="hidden" name="providerId" value={providerId} />
<input type="hidden" name="providerType" value={providerType} />
<ProviderTitleDocs providerType={providerType} />
<Divider />
{providerType === "gcp" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
/>
)}
<div className="flex w-full justify-end sm:space-x-6">
{searchParamsObj.get("via") === "service-account" && (
<CustomButton
type="button"
ariaLabel="Back"
className="w-1/2 bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={handleBackStep}
startContent={!isLoading && <ChevronLeftIcon size={24} />}
isDisabled={isLoading}
>
<span>Back</span>
</CustomButton>
)}
<CustomButton
type="submit"
ariaLabel={"Save"}
className="w-1/2"
variant="solid"
color="action"
size="lg"
isLoading={isLoading}
endContent={!isLoading && <ChevronRightIcon size={24} />}
>
{isLoading ? <>Loading</> : <span>Next</span>}
</CustomButton>
</div>
</form>
</Form>
);
};
@@ -1 +0,0 @@
export * from "./select-via-aws";
@@ -17,15 +17,15 @@ import {
ApiError,
AWSCredentials,
AzureCredentials,
GCPCredentials,
GCPDefaultCredentials,
KubernetesCredentials,
M365Credentials,
} from "@/types";
import { ProviderTitleDocs } from "../provider-title-docs";
import { AWScredentialsForm } from "./via-credentials/aws-credentials-form";
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { M365CredentialsForm } from "./via-credentials/m365-credentials-form";
@@ -38,7 +38,7 @@ type FormType = CredentialsFormSchema &
AWSCredentials &
AzureCredentials &
M365Credentials &
GCPCredentials &
GCPDefaultCredentials &
KubernetesCredentials;
export const UpdateViaCredentialsForm = ({
@@ -58,7 +58,7 @@ export const UpdateViaCredentialsForm = ({
router.push(`?${currentParams.toString()}`);
};
const providerType = searchParams.type;
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
const providerSecretId = searchParams.secretId || "";
const formSchema = addCredentialsFormSchema(providerType);
@@ -201,12 +201,12 @@ export const UpdateViaCredentialsForm = ({
<input type="hidden" name="providerId" value={providerId} />
<input type="hidden" name="providerType" value={providerType} />
<ProviderTitleDocs providerType={providerType as ProviderType} />
<ProviderTitleDocs providerType={providerType} />
<Divider />
{providerType === "aws" && (
<AWScredentialsForm
<AWSStaticCredentialsForm
control={form.control as unknown as Control<AWSCredentials>}
/>
)}
@@ -221,8 +221,8 @@ export const UpdateViaCredentialsForm = ({
/>
)}
{providerType === "gcp" && (
<GCPcredentialsForm
control={form.control as unknown as Control<GCPCredentials>}
<GCPDefaultCredentialsForm
control={form.control as unknown as Control<GCPDefaultCredentials>}
/>
)}
{providerType === "kubernetes" && (
@@ -17,7 +17,7 @@ import {
AWSCredentialsRole,
} from "@/types";
import { AWSCredentialsRoleForm } from "./via-role/aws-role-form";
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type";
export const UpdateViaRoleForm = ({
searchParams,
@@ -150,7 +150,7 @@ export const UpdateViaRoleForm = ({
{/* Conditional AWS Form */}
{providerType === "aws" && (
<AWSCredentialsRoleForm
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={
form.setValue as unknown as UseFormSetValue<AWSCredentialsRole>
@@ -0,0 +1,169 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Divider } from "@nextui-org/react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Control, useForm } from "react-hook-form";
import * as z from "zod";
import { updateCredentialsProvider } from "@/actions/providers/providers";
import { ProviderTitleDocs } from "@/components/providers/workflow";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import {
addCredentialsServiceAccountFormSchema,
ApiError,
GCPServiceAccountKey,
ProviderType,
} from "@/types";
import { GCPServiceAccountKeyForm } from "./select-credentials-type/gcp/credentials-type";
export const UpdateViaServiceAccountForm = ({
searchParams,
}: {
searchParams: { type: string; id: string; secretId?: string };
}) => {
const router = useRouter();
const { toast } = useToast();
const searchParamsObj = useSearchParams();
// Handler for back button
const handleBackStep = () => {
const currentParams = new URLSearchParams(window.location.search);
currentParams.delete("via");
router.push(`?${currentParams.toString()}`);
};
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
const providerSecretId = searchParams.secretId || "";
const formSchema = addCredentialsServiceAccountFormSchema(providerType);
type FormSchemaType = z.infer<typeof formSchema>;
const form = useForm<FormSchemaType>({
resolver: zodResolver(formSchema),
defaultValues: {
providerId,
providerType,
...(providerType === "gcp"
? {
service_account_key: "",
secretName: "",
}
: {}),
},
});
const isLoading = form.formState.isSubmitting;
const onSubmitClient = async (values: FormSchemaType) => {
if (!providerSecretId) {
toast({
variant: "destructive",
title: "Missing Secret ID",
description: "Cannot update credentials without a valid secret ID.",
});
return;
}
const formData = new FormData();
Object.entries(values).forEach(([key, value]) => {
if (value !== undefined && value !== "") {
formData.append(key, String(value));
}
});
try {
const data = await updateCredentialsProvider(providerSecretId, formData);
if (data?.errors && data.errors.length > 0) {
data.errors.forEach((error: ApiError) => {
const errorMessage = error.detail;
switch (error.source.pointer) {
case "/data/attributes/secret/service_account_key":
form.setError("service_account_key" as keyof FormSchemaType, {
type: "server",
message: errorMessage,
});
break;
default:
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: errorMessage,
});
}
});
} else {
router.push(
`/providers/test-connection?type=${providerType}&id=${providerId}&updated=true`,
);
}
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error during submission:", error);
toast({
variant: "destructive",
title: "Submission failed",
description: "An error occurred while processing your request.",
});
}
};
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmitClient)}
className="flex flex-col space-y-4"
>
<input type="hidden" name="providerId" value={providerId} />
<input type="hidden" name="providerType" value={providerType} />
<ProviderTitleDocs providerType={providerType} />
<Divider />
{providerType === "gcp" && (
<GCPServiceAccountKeyForm
control={form.control as unknown as Control<GCPServiceAccountKey>}
/>
)}
<div className="flex w-full justify-end sm:space-x-6">
{searchParamsObj.get("via") === "service-account" && (
<CustomButton
type="button"
ariaLabel="Back"
className="w-1/2 bg-transparent"
variant="faded"
size="lg"
radius="lg"
onPress={handleBackStep}
startContent={!isLoading && <ChevronLeftIcon size={24} />}
isDisabled={isLoading}
>
<span>Back</span>
</CustomButton>
)}
<CustomButton
type="submit"
ariaLabel={"Save"}
className="w-1/2"
variant="solid"
color="action"
size="lg"
isLoading={isLoading}
endContent={!isLoading && <ChevronRightIcon size={24} />}
>
{isLoading ? <>Loading</> : <span>Next</span>}
</CustomButton>
</div>
</form>
</Form>
);
};
@@ -11,21 +11,21 @@ import { addCredentialsProvider } from "@/actions/providers/providers";
import { useToast } from "@/components/ui";
import { CustomButton } from "@/components/ui/custom";
import { Form } from "@/components/ui/form";
import { ProviderType } from "@/types";
import {
addCredentialsFormSchema,
ApiError,
AWSCredentials,
AzureCredentials,
GCPCredentials,
GCPDefaultCredentials,
KubernetesCredentials,
M365Credentials,
ProviderType,
} from "@/types";
import { ProviderTitleDocs } from "../provider-title-docs";
import { AWScredentialsForm } from "./via-credentials/aws-credentials-form";
import { AWSStaticCredentialsForm } from "./select-credentials-type/aws/credentials-type";
import { GCPDefaultCredentialsForm } from "./select-credentials-type/gcp/credentials-type";
import { AzureCredentialsForm } from "./via-credentials/azure-credentials-form";
import { GCPcredentialsForm } from "./via-credentials/gcp-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { M365CredentialsForm } from "./via-credentials/m365-credentials-form";
@@ -37,7 +37,7 @@ type CredentialsFormSchema = z.infer<
type FormType = CredentialsFormSchema &
AWSCredentials &
AzureCredentials &
GCPCredentials &
GCPDefaultCredentials &
KubernetesCredentials &
M365Credentials;
@@ -58,7 +58,7 @@ export const ViaCredentialsForm = ({
router.push(`?${currentParams.toString()}`);
};
const providerType = searchParams.type;
const providerType = searchParams.type as ProviderType;
const providerId = searchParams.id;
const formSchema = addCredentialsFormSchema(providerType);
@@ -200,12 +200,12 @@ export const ViaCredentialsForm = ({
<input type="hidden" name="providerId" value={providerId} />
<input type="hidden" name="providerType" value={providerType} />
<ProviderTitleDocs providerType={providerType as ProviderType} />
<ProviderTitleDocs providerType={providerType} />
<Divider />
{providerType === "aws" && (
<AWScredentialsForm
<AWSStaticCredentialsForm
control={form.control as unknown as Control<AWSCredentials>}
/>
)}
@@ -220,8 +220,8 @@ export const ViaCredentialsForm = ({
/>
)}
{providerType === "gcp" && (
<GCPcredentialsForm
control={form.control as unknown as Control<GCPCredentials>}
<GCPDefaultCredentialsForm
control={form.control as unknown as Control<GCPDefaultCredentials>}
/>
)}
{providerType === "kubernetes" && (
@@ -1,5 +1,3 @@
export * from "./aws-credentials-form";
export * from "./azure-credentials-form";
export * from "./gcp-credentials-form";
export * from "./k8s-credentials-form";
export * from "./m365-credentials-form";
@@ -17,7 +17,7 @@ import {
AWSCredentialsRole,
} from "@/types";
import { AWSCredentialsRoleForm } from "./via-role/aws-role-form";
import { AWSRoleCredentialsForm } from "./select-credentials-type/aws/credentials-type";
export const ViaRoleForm = ({
searchParams,
@@ -149,7 +149,7 @@ export const ViaRoleForm = ({
<input type="hidden" name="providerType" value={providerType} />
{providerType === "aws" && (
<AWSCredentialsRoleForm
<AWSRoleCredentialsForm
control={form.control as unknown as Control<AWSCredentialsRole>}
setValue={
form.setValue as unknown as UseFormSetValue<AWSCredentialsRole>
@@ -1 +0,0 @@
export * from "./aws-role-form";
+59 -8
View File
@@ -6,7 +6,7 @@ import {
Selection,
} from "@nextui-org/react";
import { ChevronDown } from "lucide-react";
import React, { ReactNode, useCallback, useState } from "react";
import React, { ReactNode, useCallback, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
@@ -24,17 +24,24 @@ export interface AccordionProps {
variant?: "light" | "shadow" | "bordered" | "splitted";
className?: string;
defaultExpandedKeys?: string[];
selectedKeys?: string[];
selectionMode?: "single" | "multiple";
isCompact?: boolean;
showDivider?: boolean;
onItemExpand?: (key: string) => void;
onSelectionChange?: (keys: string[]) => void;
}
const AccordionContent = ({
content,
items,
selectedKeys,
onSelectionChange,
}: {
content: ReactNode;
items?: AccordionItemProps[];
selectedKeys?: string[];
onSelectionChange?: (keys: string[]) => void;
}) => {
return (
<div className="text-sm text-gray-700 dark:text-gray-300">
@@ -46,6 +53,8 @@ const AccordionContent = ({
variant="light"
isCompact
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
/>
</div>
)}
@@ -58,17 +67,54 @@ export const Accordion = ({
variant = "light",
className,
defaultExpandedKeys = [],
selectedKeys,
selectionMode = "single",
isCompact = false,
showDivider = true,
onItemExpand,
onSelectionChange,
}: AccordionProps) => {
const [expandedKeys, setExpandedKeys] = useState<Selection>(
// Determine if component is in controlled or uncontrolled mode
const isControlled = selectedKeys !== undefined;
const [internalExpandedKeys, setInternalExpandedKeys] = useState<Selection>(
new Set(defaultExpandedKeys),
);
const handleSelectionChange = useCallback((keys: Selection) => {
setExpandedKeys(keys);
}, []);
// Use selectedKeys if controlled, otherwise use internal state
const expandedKeys = useMemo(
() => (isControlled ? new Set(selectedKeys) : internalExpandedKeys),
[isControlled, selectedKeys, internalExpandedKeys],
);
const handleSelectionChange = useCallback(
(keys: Selection) => {
const keysArray = Array.from(keys as Set<string>);
// If controlled mode, call parent callback
if (isControlled && onSelectionChange) {
onSelectionChange(keysArray);
} else {
// If uncontrolled, update internal state
setInternalExpandedKeys(keys);
}
// Handle onItemExpand for backward compatibility
if (onItemExpand && keys !== expandedKeys) {
const currentKeys = Array.from(expandedKeys as Set<string>);
const newKeys = keysArray;
const newlyExpandedKeys = newKeys.filter(
(key) => !currentKeys.includes(key),
);
newlyExpandedKeys.forEach((key) => {
onItemExpand(key);
});
}
},
[expandedKeys, onItemExpand, isControlled, onSelectionChange],
);
return (
<NextUIAccordion
@@ -92,14 +138,19 @@ export const Accordion = ({
indicator={<ChevronDown className="text-gray-500" />}
classNames={{
base: index === 0 || index === 1 ? "my-2" : "my-1",
title: "text-sm font-medium",
title: "text-sm font-medium max-w-full overflow-hidden truncate",
subtitle: "text-xs text-gray-500",
trigger:
"p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50",
"p-2 rounded-lg data-[hover=true]:bg-gray-50 dark:data-[hover=true]:bg-gray-800/50 w-full flex items-center",
content: "p-2",
}}
>
<AccordionContent content={item.content} items={item.items} />
<AccordionContent
content={item.content}
items={item.items}
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
/>
</AccordionItem>
))}
</NextUIAccordion>
@@ -70,6 +70,16 @@ interface HorizontalSplitBarProps {
* @default "text-gray-700"
*/
labelColor?: string;
/**
* Growth ratio multiplier (pixels per value unit)
* @default 1
*/
ratio?: number;
/**
* Show zero values in labels
* @default true
*/
showZero?: boolean;
}
/**
@@ -99,6 +109,8 @@ export const HorizontalSplitBar = ({
tooltipContentA,
tooltipContentB,
labelColor = "text-gray-700",
ratio = 1,
showZero = true,
}: HorizontalSplitBarProps) => {
// Reference to the container to measure its width
const containerRef = React.useRef<HTMLDivElement>(null);
@@ -150,8 +162,9 @@ export const HorizontalSplitBar = ({
const halfWidth = availableWidth / 2;
const separatorWidth = 1;
let rawWidthA = valA;
let rawWidthB = valB;
// Apply ratio multiplier to raw widths
let rawWidthA = valA * ratio;
let rawWidthB = valB * ratio;
// Determine if we need to scale to fit in available space
const maxSideWidth = halfWidth - separatorWidth / 2;
@@ -183,7 +196,7 @@ export const HorizontalSplitBar = ({
className={cn("text-xs font-medium", labelColor)}
aria-label={`${formattedValueA} ${tooltipContentA ? tooltipContentA : ""}`}
>
{valA > 0 ? formattedValueA : "0"}
{valA > 0 ? formattedValueA : showZero ? "0" : ""}
</div>
{/* Left bar */}
{valA > 0 && (
@@ -230,7 +243,7 @@ export const HorizontalSplitBar = ({
className={cn("text-xs font-medium", labelColor)}
aria-label={`${formattedValueB} ${tooltipContentB ? tooltipContentB : ""}`}
>
{valB > 0 ? formattedValueB : "0"}
{valB > 0 ? formattedValueB : showZero ? "0" : ""}
</div>
</div>
</div>
+123
View File
@@ -0,0 +1,123 @@
import { cn } from "@/lib/utils";
interface SkeletonProps {
className?: string;
variant?: "default" | "card" | "table" | "text" | "circle" | "rectangular";
width?: string | number;
height?: string | number;
animate?: boolean;
}
export function Skeleton({
className,
variant = "default",
width,
height,
animate = true,
}: SkeletonProps) {
const variantClasses = {
default: "w-full h-4 rounded-lg",
card: "w-full h-40 rounded-xl",
table: "w-full h-60 rounded-lg",
text: "w-24 h-4 rounded-full",
circle: "rounded-full w-8 h-8",
rectangular: "rounded-md",
};
return (
<div
style={{
width: width
? typeof width === "number"
? `${width}px`
: width
: undefined,
height: height
? typeof height === "number"
? `${height}px`
: height
: undefined,
}}
className={cn(
"animate-pulse bg-gray-200 dark:bg-prowler-blue-800",
variantClasses[variant],
!animate && "animate-none",
className,
)}
/>
);
}
export function SkeletonTable({
rows = 5,
columns = 4,
className,
roundedCells = true,
}: {
rows?: number;
columns?: number;
className?: string;
roundedCells?: boolean;
}) {
return (
<div className={cn("w-full space-y-4", className)}>
{/* Header */}
<div className="flex items-center space-x-4 pb-4">
{Array.from({ length: columns }).map((_, index) => (
<Skeleton
key={`header-${index}`}
className={cn("h-8", roundedCells && "rounded-lg")}
width={`${100 / columns}%`}
variant={roundedCells ? "default" : "rectangular"}
/>
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div
key={`row-${rowIndex}`}
className="flex items-center space-x-4 py-3"
>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={`cell-${rowIndex}-${colIndex}`}
className={cn("h-6", roundedCells && "rounded-lg")}
width={`${100 / columns}%`}
variant={roundedCells ? "default" : "rectangular"}
/>
))}
</div>
))}
</div>
);
}
export function SkeletonCard({ className }: { className?: string }) {
return (
<div className={cn("space-y-3", className)}>
<Skeleton variant="card" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
);
}
export function SkeletonText({
lines = 3,
className,
lastLineWidth = "w-1/2",
}: {
lines?: number;
className?: string;
lastLineWidth?: string;
}) {
return (
<div className={cn("space-y-2", className)}>
{Array.from({ length: lines - 1 }).map((_, index) => (
<Skeleton key={index} className="h-4 w-full" variant="text" />
))}
<Skeleton className={cn("h-4", lastLineWidth)} variant="text" />
</div>
);
}
@@ -55,7 +55,7 @@ export const DataTableFilterCustom = ({
size="md"
startContent={<CustomFilterIcon size={16} />}
onPress={() => setShowFilters(!showFilters)}
className="w-fit"
className="w-full max-w-fit"
>
<h3 className="text-small">
{showFilters ? "Hide Filters" : "Show Filters"}
+138 -70
View File
@@ -23,9 +23,19 @@ import {
interface DataTablePaginationProps {
metadata?: MetaDataProps;
disableScroll?: boolean;
}
export function DataTablePagination({ metadata }: DataTablePaginationProps) {
const baseLinkClass =
"relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green";
const disabledLinkClass =
"text-gray-300 dark:text-gray-600 hover:bg-transparent hover:text-gray-300 dark:hover:text-gray-600 cursor-default pointer-events-none";
export function DataTablePagination({
metadata,
disableScroll = false,
}: DataTablePaginationProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
@@ -41,90 +51,148 @@ export function DataTablePagination({ metadata }: DataTablePaginationProps) {
const createPageUrl = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
if (pageNumber === "...") return `${pathname}?${params.toString()}`;
// Preserve all important parameters
const scanId = searchParams.get("scanId");
const id = searchParams.get("id");
const version = searchParams.get("version");
if (+pageNumber > totalPages) {
return `${pathname}?${params.toString()}`;
}
params.set("page", pageNumber.toString());
// Ensure that scanId, id and version are preserved
if (scanId) params.set("scanId", scanId);
if (id) params.set("id", id);
if (version) params.set("version", version);
return `${pathname}?${params.toString()}`;
};
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
return (
<div className="flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8">
<div className="whitespace-nowrap text-sm font-medium">
{totalEntries} entries in Total.
<div className="whitespace-nowrap text-sm">
{totalEntries} entries in total
</div>
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
{/* Rows per page selector */}
<div className="flex items-center space-x-2">
<p className="whitespace-nowrap text-sm font-medium">Rows per page</p>
<Select
value={selectedPageSize}
onValueChange={(value) => {
setSelectedPageSize(value);
{totalEntries > 10 && (
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
{/* Rows per page selector */}
<div className="flex items-center space-x-2">
<p className="whitespace-nowrap text-sm font-medium">
Rows per page
</p>
<Select
value={selectedPageSize}
onValueChange={(value) => {
setSelectedPageSize(value);
const params = new URLSearchParams(searchParams);
params.set("pageSize", value);
params.set("page", "1");
const params = new URLSearchParams(searchParams);
// This pushes the URL without reloading the page
router.push(`${pathname}?${params.toString()}`);
}}
>
<SelectTrigger className="h-8 w-[4.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{itemsPerPageOptions.map((pageSize) => (
<SelectItem
key={pageSize}
value={`${pageSize}`}
className="cursor-pointer"
>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
// Preserve all important parameters
const scanId = searchParams.get("scanId");
const id = searchParams.get("id");
const version = searchParams.get("version");
params.set("pageSize", value);
params.set("page", "1");
// Ensure that scanId, id and version are preserved
if (scanId) params.set("scanId", scanId);
if (id) params.set("id", id);
if (version) params.set("version", version);
// This pushes the URL without reloading the page
if (disableScroll) {
const url = `${pathname}?${params.toString()}`;
router.push(url, { scroll: false });
} else {
router.push(`${pathname}?${params.toString()}`);
}
}}
>
<SelectTrigger className="h-8 w-[4.5rem]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{itemsPerPageOptions.map((pageSize) => (
<SelectItem
key={pageSize}
value={`${pageSize}`}
className="cursor-pointer"
>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center text-sm font-medium">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<Link
aria-label="Go to first page"
className={`${baseLinkClass} ${isFirstPage ? disabledLinkClass : ""}`}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<DoubleArrowLeftIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to previous page"
className={`${baseLinkClass} ${isFirstPage ? disabledLinkClass : ""}`}
href={
isFirstPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage - 1)
}
scroll={!disableScroll}
aria-disabled={isFirstPage}
onClick={(e) => isFirstPage && e.preventDefault()}
>
<ChevronLeftIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className={`${baseLinkClass} ${isLastPage ? disabledLinkClass : ""}`}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(currentPage + 1)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<ChevronRightIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className={`${baseLinkClass} ${isLastPage ? disabledLinkClass : ""}`}
href={
isLastPage
? pathname + "?" + searchParams.toString()
: createPageUrl(totalPages)
}
scroll={!disableScroll}
aria-disabled={isLastPage}
onClick={(e) => isLastPage && e.preventDefault()}
>
<DoubleArrowRightIcon className="size-4" aria-hidden="true" />
</Link>
</div>
</div>
<div className="flex items-center justify-center text-sm font-medium">
Page {currentPage} of {totalPages}
</div>
<div className="flex items-center space-x-2">
<Link
aria-label="Go to first page"
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
href={createPageUrl(1)}
aria-disabled="true"
>
<DoubleArrowLeftIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to previous page"
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
href={createPageUrl(currentPage - 1)}
aria-disabled="true"
>
<ChevronLeftIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to next page"
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
href={createPageUrl(currentPage + 1)}
>
<ChevronRightIcon className="size-4" aria-hidden="true" />
</Link>
<Link
aria-label="Go to last page"
className="page-link relative block rounded border-0 bg-transparent px-3 py-1.5 text-gray-800 outline-none transition-all duration-300 hover:bg-gray-200 hover:text-gray-800 focus:shadow-none dark:text-prowler-theme-green"
href={createPageUrl(totalPages)}
>
<DoubleArrowRightIcon className="size-4" aria-hidden="true" />
</Link>
</div>
</div>
)}
</div>
);
}
+6 -1
View File
@@ -29,12 +29,14 @@ interface DataTableProviderProps<TData, TValue> {
data: TData[];
metadata?: MetaDataProps;
customFilters?: FilterOption[];
disableScroll?: boolean;
}
export function DataTable<TData, TValue>({
columns,
data,
metadata,
disableScroll = false,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -109,7 +111,10 @@ export function DataTable<TData, TValue>({
</div>
{metadata && (
<div className="flex w-full items-center space-x-2 py-4">
<DataTablePagination metadata={metadata} />
<DataTablePagination
metadata={metadata}
disableScroll={disableScroll}
/>
</div>
)}
</>
@@ -16,10 +16,12 @@ const statusColorMap: Record<
export const StatusFindingBadge = ({
status,
size = "sm",
value,
...props
}: {
status: FindingStatus;
size?: "sm" | "md" | "lg";
value?: string | number;
}) => {
const color = statusColorMap[status];
@@ -33,6 +35,7 @@ export const StatusFindingBadge = ({
>
<span className="text-xs font-light tracking-wide text-default-600">
{status.charAt(0).toUpperCase() + status.slice(1).toLowerCase()}
{value !== undefined && `: ${value}`}
</span>
</Chip>
);
@@ -5,7 +5,7 @@ import { useState } from "react";
import { CustomAlertModal, CustomButton } from "@/components/ui/custom";
import { DateWithTime, InfoField } from "@/components/ui/entities";
import { MembershipDetailData } from "@/types/users/users";
import { MembershipDetailData } from "@/types/users";
import { EditTenantForm } from "../forms";
@@ -1,6 +1,6 @@
import { Card, CardBody, CardHeader } from "@nextui-org/react";
import { MembershipDetailData, TenantDetailData } from "@/types/users/users";
import { MembershipDetailData, TenantDetailData } from "@/types/users";
import { MembershipItem } from "./membership-item";
+1 -1
View File
@@ -6,7 +6,7 @@ import { useState } from "react";
import { CustomButton } from "@/components/ui/custom/custom-button";
import { getRolePermissions } from "@/lib/permissions";
import { RoleData, RoleDetail } from "@/types/users/users";
import { RoleData, RoleDetail } from "@/types/users";
interface PermissionItemProps {
enabled: boolean;
+1 -1
View File
@@ -1,6 +1,6 @@
import { Card, CardBody, CardHeader } from "@nextui-org/react";
import { RoleData, RoleDetail } from "@/types/users/users";
import { RoleData, RoleDetail } from "@/types/users";
import { RoleItem } from "./role-item";
@@ -3,7 +3,7 @@
import { Card, CardBody, Divider } from "@nextui-org/react";
import { DateWithTime, InfoField, SnippetChip } from "@/components/ui/entities";
import { UserDataWithRoles } from "@/types/users/users";
import { UserDataWithRoles } from "@/types/users";
import { ProwlerShort } from "../../icons";