Compare commits

...

15 Commits

Author SHA1 Message Date
Alan Buscaglia
b151d97793 docs: add interface depth and reuse rules to AGENTS.md 2025-12-18 13:20:04 +01:00
alejandrobailo
0c771e3320 refactor: code improvements 2025-12-18 12:54:57 +01:00
alejandrobailo
e8c8b18c09 feat: add simple Mutelist description 2025-12-18 12:29:28 +01:00
alejandrobailo
a20f4bb21f feat: revert muted finding filter checkbox behavior 2025-12-18 12:01:58 +01:00
alejandrobailo
0d0dabe166 fix: build conflicts 2025-12-17 15:19:43 +01:00
alejandrobailo
c83fa97d96 fix: update compliance accordion to use getColumnFindings function 2025-12-17 15:07:24 +01:00
alejandrobailo
12f78dc556 chore: CHANGELOG.md updated 2025-12-17 15:02:10 +01:00
alejandrobailo
832922afcf chore: update dependencies 2025-12-17 14:38:44 +01:00
alejandrobailo
43d314b546 chore: update navigation and cleanup obsolete mutelist modal 2025-12-17 14:38:31 +01:00
alejandrobailo
60cbdf5b48 feat: add mute action to findings with modal and floating button 2025-12-17 14:38:25 +01:00
alejandrobailo
1a73ca8d0f feat: add checkbox selection to findings table 2025-12-17 14:38:19 +01:00
alejandrobailo
d2fe09d6b0 feat: add row selection support to DataTable component 2025-12-17 14:38:10 +01:00
alejandrobailo
4104f9600e feat: add mutelist page with tabs and mute rules table 2025-12-17 14:38:05 +01:00
alejandrobailo
3e08e8b720 feat: add MuteRules server actions CRUD 2025-12-17 14:37:58 +01:00
alejandrobailo
0be4a7cd52 feat: add MuteRule types for simple mutelist 2025-12-17 14:36:48 +01:00
38 changed files with 2235 additions and 616 deletions

View File

@@ -13,6 +13,32 @@
- ALWAYS: `const X = { A: "a", B: "b" } as const; type T = typeof X[keyof typeof X]`
- NEVER: `type T = "a" | "b"`
### Interfaces
- ALWAYS: One level depth only; object property → dedicated interface (recursive)
- ALWAYS: Reuse via `extends`
- NEVER: Inline nested objects
```typescript
// ✅ CORRECT
interface UserAddress {
street: string;
city: string;
}
interface User {
id: string;
address: UserAddress;
}
interface Admin extends User {
permissions: string[];
}
// ❌ WRONG
interface User {
address: { street: string; city: string };
}
```
### Styling
- Single class: `className="bg-slate-800 text-white"`

View File

@@ -9,6 +9,7 @@ All notable changes to the **Prowler UI** are documented in this file.
- Risk Radar component with category-based severity breakdown to Overview page [(#9532)](https://github.com/prowler-cloud/prowler/pull/9532)
- More extensive resource details (partition, details and metadata) within Findings detail and Resources detail view [(#9515)](https://github.com/prowler-cloud/prowler/pull/9515)
- Integrated Prowler MCP server with Lighthouse AI for dynamic tool execution [(#9255)](https://github.com/prowler-cloud/prowler/pull/9255)
- Implement "MuteList Simple" feature allowing users to mute findings directly from the findings table with checkbox selection, and a new dedicated /mutelist route with Simple (mute rules list) and Advanced (YAML config) tabs. [(#9577)](https://github.com/prowler-cloud/prowler/pull/9577)
### 🔄 Changed

View File

@@ -0,0 +1,8 @@
export {
createMuteRule,
deleteMuteRule,
getMuteRule,
getMuteRules,
toggleMuteRule,
updateMuteRule,
} from "./mute-rules";

View File

@@ -0,0 +1,387 @@
"use server";
import { revalidatePath } from "next/cache";
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
import {
DeleteMuteRuleActionState,
MuteRuleActionState,
MuteRuleData,
MuteRulesResponse,
} from "@/types/mute-rules";
interface GetMuteRulesParams {
page?: number;
pageSize?: number;
sort?: string;
filters?: Record<string, string>;
}
export const getMuteRules = async (
params: GetMuteRulesParams = {},
): Promise<MuteRulesResponse | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/mute-rules`);
if (params.page) {
url.searchParams.append("page[number]", params.page.toString());
}
if (params.pageSize) {
url.searchParams.append("page[size]", params.pageSize.toString());
}
if (params.sort) {
url.searchParams.append("sort", params.sort);
}
if (params.filters) {
Object.entries(params.filters).forEach(([key, value]) => {
url.searchParams.append(`filter[${key}]`, value);
});
}
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
next: { revalidate: 0 },
});
if (!response.ok) {
// Don't log authorization errors as they're expected when endpoint is not available
if (response.status !== 401 && response.status !== 403) {
console.error(`Failed to fetch mute rules: ${response.statusText}`);
}
return undefined;
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error fetching mute rules:", error);
return undefined;
}
};
export const getMuteRule = async (
id: string,
): Promise<MuteRuleData | undefined> => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}/mute-rules/${id}`);
try {
const response = await fetch(url.toString(), {
method: "GET",
headers,
});
if (!response.ok) {
// Don't log authorization errors as they're expected when endpoint is not available
if (response.status !== 401 && response.status !== 403) {
console.error(`Failed to fetch mute rule: ${response.statusText}`);
}
return undefined;
}
const data = await response.json();
return data.data;
} catch (error) {
console.error("Error fetching mute rule:", error);
return undefined;
}
};
export const createMuteRule = async (
_prevState: MuteRuleActionState,
formData: FormData,
): Promise<MuteRuleActionState> => {
const headers = await getAuthHeaders({ contentType: true });
const name = formData.get("name") as string;
const reason = formData.get("reason") as string;
const findingIdsRaw = formData.get("finding_ids") as string;
// Validate required fields
if (!name || name.length < 3) {
return {
errors: {
name: "Name must be at least 3 characters",
},
};
}
if (!reason || reason.length < 3) {
return {
errors: {
reason: "Reason must be at least 3 characters",
},
};
}
let findingIds: string[];
try {
findingIds = JSON.parse(findingIdsRaw);
if (!Array.isArray(findingIds) || findingIds.length === 0) {
throw new Error("Invalid finding IDs");
}
} catch {
return {
errors: {
finding_ids: "At least one finding must be selected",
},
};
}
try {
const url = new URL(`${apiBaseUrl}/mute-rules`);
const bodyData = {
data: {
type: "mute-rules",
attributes: {
name,
reason,
finding_ids: findingIds,
},
},
};
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify(bodyData),
});
if (!response.ok) {
let errorMessage = `Failed to create mute rule: ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage =
errorData?.errors?.[0]?.detail || errorData?.message || errorMessage;
} catch {
// JSON parsing failed, use default error message
}
throw new Error(errorMessage);
}
revalidatePath("/findings");
revalidatePath("/mutelist");
return {
success: "Mute rule created successfully! Findings are now muted.",
};
} catch (error) {
console.error("Error creating mute rule:", error);
return {
errors: {
general:
error instanceof Error
? error.message
: "Error creating mute rule. Please try again.",
},
};
}
};
export const updateMuteRule = async (
_prevState: MuteRuleActionState,
formData: FormData,
): Promise<MuteRuleActionState> => {
const headers = await getAuthHeaders({ contentType: true });
const id = formData.get("id") as string;
const name = formData.get("name") as string;
const reason = formData.get("reason") as string;
const enabledRaw = formData.get("enabled") as string;
if (!id) {
return {
errors: {
general: "Mute rule ID is required for update",
},
};
}
// Validate optional fields if provided
if (
name !== null &&
name !== undefined &&
name.length > 0 &&
name.length < 3
) {
return {
errors: {
name: "Name must be at least 3 characters",
},
};
}
if (
reason !== null &&
reason !== undefined &&
reason.length > 0 &&
reason.length < 3
) {
return {
errors: {
reason: "Reason must be at least 3 characters",
},
};
}
try {
const url = new URL(`${apiBaseUrl}/mute-rules/${id}`);
const attributes: Record<string, string | boolean> = {};
if (name) attributes.name = name;
if (reason) attributes.reason = reason;
if (enabledRaw !== null && enabledRaw !== undefined) {
attributes.enabled = enabledRaw === "true";
}
const bodyData = {
data: {
type: "mute-rules",
id,
attributes,
},
};
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(bodyData),
});
if (!response.ok) {
let errorMessage = `Failed to update mute rule: ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage =
errorData?.errors?.[0]?.detail || errorData?.message || errorMessage;
} catch {
// JSON parsing failed, use default error message
}
throw new Error(errorMessage);
}
revalidatePath("/mutelist");
return { success: "Mute rule updated successfully!" };
} catch (error) {
console.error("Error updating mute rule:", error);
return {
errors: {
general:
error instanceof Error
? error.message
: "Error updating mute rule. Please try again.",
},
};
}
};
export const toggleMuteRule = async (
id: string,
enabled: boolean,
): Promise<{ success?: string; error?: string }> => {
const headers = await getAuthHeaders({ contentType: true });
try {
const url = new URL(`${apiBaseUrl}/mute-rules/${id}`);
const bodyData = {
data: {
type: "mute-rules",
id,
attributes: {
enabled,
},
},
};
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify(bodyData),
});
if (!response.ok) {
let errorMessage = `Failed to toggle mute rule: ${response.statusText}`;
try {
const errorData = await response.json();
errorMessage =
errorData?.errors?.[0]?.detail || errorData?.message || errorMessage;
} catch {
// JSON parsing failed, use default error message
}
throw new Error(errorMessage);
}
revalidatePath("/mutelist");
return {
success: `Mute rule ${enabled ? "enabled" : "disabled"} successfully!`,
};
} catch (error) {
console.error("Error toggling mute rule:", error);
return {
error:
error instanceof Error
? error.message
: "Error toggling mute rule. Please try again.",
};
}
};
export const deleteMuteRule = async (
_prevState: DeleteMuteRuleActionState,
formData: FormData,
): Promise<DeleteMuteRuleActionState> => {
const headers = await getAuthHeaders({ contentType: true });
const id = formData.get("id") as string;
if (!id) {
return {
errors: {
general: "Mute rule ID is required for deletion",
},
};
}
try {
const url = new URL(`${apiBaseUrl}/mute-rules/${id}`);
const response = await fetch(url.toString(), {
method: "DELETE",
headers,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.errors?.[0]?.detail ||
`Failed to delete mute rule: ${response.statusText}`,
);
}
revalidatePath("/mutelist");
return { success: "Mute rule deleted successfully!" };
} catch (error) {
console.error("Error deleting mute rule:", error);
return {
errors: {
general:
error instanceof Error
? error.message
: "Error deleting mute rule. Please try again.",
},
};
}
};
// Note: Adding findings to existing mute rules is not supported by the API.
// The MuteRuleUpdateSerializer only allows updating name, reason, and enabled fields.
// finding_ids can only be specified when creating a new mute rule.
// Note: Unmute functionality is not currently supported by the API.
// The FindingViewSet only allows GET operations, and deleting a mute rule
// does not unmute the findings ("Previously muted findings remain muted").

View File

@@ -11,11 +11,10 @@ import { getProviders } from "@/actions/providers";
import { getScans } from "@/actions/scans";
import { FindingsFilters } from "@/components/findings/findings-filters";
import {
ColumnFindings,
FindingsTableWithSelection,
SkeletonTableFindings,
} from "@/components/findings/table";
import { ContentLayout } from "@/components/ui";
import { DataTable } from "@/components/ui/table";
import {
createDict,
createScanDetailsMapping,
@@ -164,9 +163,7 @@ const SSRDataTable = async ({
<p>{findingsData.errors[0].detail}</p>
</div>
)}
<DataTable
key={Date.now()}
columns={ColumnFindings}
<FindingsTableWithSelection
data={expandedResponse?.data || []}
metadata={findingsData?.meta}
/>

View File

@@ -0,0 +1,297 @@
"use client";
import { Textarea } from "@heroui/input";
import { Trash2 } from "lucide-react";
import { useActionState, useEffect, useState } from "react";
import {
createMutedFindingsConfig,
deleteMutedFindingsConfig,
getMutedFindingsConfig,
updateMutedFindingsConfig,
} from "@/actions/processors";
import { Button, Card, Skeleton } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { fontMono } from "@/config/fonts";
import {
convertToYaml,
defaultMutedFindingsConfig,
parseYamlValidation,
} from "@/lib/yaml";
import {
MutedFindingsConfigActionState,
ProcessorData,
} from "@/types/processors";
export function AdvancedMutelistForm() {
const [config, setConfig] = useState<ProcessorData | null>(null);
const [configText, setConfigText] = useState("");
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [yamlValidation, setYamlValidation] = useState<{
isValid: boolean;
error?: string;
}>({ isValid: true });
const [hasUserStartedTyping, setHasUserStartedTyping] = useState(false);
// Unified action that decides to create or update based on ID presence
const saveConfig = async (
_prevState: MutedFindingsConfigActionState,
formData: FormData,
): Promise<MutedFindingsConfigActionState> => {
const id = formData.get("id");
if (id) {
return updateMutedFindingsConfig(_prevState, formData);
}
return createMutedFindingsConfig(_prevState, formData);
};
const [state, formAction, isPending] = useActionState<
MutedFindingsConfigActionState,
FormData
>(saveConfig, null);
const { toast } = useToast();
useEffect(() => {
getMutedFindingsConfig().then((result) => {
setConfig(result || null);
const yamlConfig = convertToYaml(result?.attributes.configuration || "");
setConfigText(yamlConfig);
setHasUserStartedTyping(false);
if (yamlConfig) {
setYamlValidation(parseYamlValidation(yamlConfig));
}
setIsLoading(false);
});
}, []);
useEffect(() => {
if (state?.success) {
toast({
title: "Configuration saved successfully",
description: state.success,
});
// Reload config to get the created/updated data (shows Delete button)
getMutedFindingsConfig().then((result) => {
setConfig(result || null);
});
} else if (state?.errors?.general) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: state.errors.general,
});
} else if (state?.errors?.configuration) {
setHasUserStartedTyping(false);
}
}, [state, toast]);
const handleConfigChange = (value: string) => {
setConfigText(value);
setHasUserStartedTyping(true);
const validation = parseYamlValidation(value);
setYamlValidation(validation);
};
const handleDelete = async () => {
if (!config) return;
setIsDeleting(true);
const formData = new FormData();
formData.append("id", config.id);
try {
const result = await deleteMutedFindingsConfig(null, formData);
if (result?.success) {
toast({
title: "Configuration deleted successfully",
description: result.success,
});
setConfig(null);
setConfigText("");
} else if (result?.errors?.general) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: result.errors.general,
});
}
} catch {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "Error deleting configuration. Please try again.",
});
} finally {
setIsDeleting(false);
setShowDeleteConfirmation(false);
}
};
if (isLoading) {
return (
<Card variant="base" className="p-6">
<div className="flex flex-col gap-4">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-[400px] w-full" />
<div className="flex w-full justify-end gap-4">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-24" />
</div>
</div>
</Card>
);
}
return (
<>
{/* Delete Confirmation Modal */}
<CustomAlertModal
isOpen={showDeleteConfirmation}
onOpenChange={setShowDeleteConfirmation}
title="Delete Mutelist Configuration"
size="md"
>
<div className="flex flex-col gap-4">
<p className="text-default-600 text-sm">
Are you sure you want to delete this configuration? This action
cannot be undone.
</p>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDeleting}
onClick={handleDelete}
>
<Trash2 className="size-4" />
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</div>
</div>
</CustomAlertModal>
<Card variant="base" className="p-6">
<form action={formAction} className="flex flex-col gap-4">
{config && <input type="hidden" name="id" value={config.id} />}
<div className="flex flex-col gap-4">
<div>
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Advanced Mutelist Configuration
</h3>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next
scan.
</strong>
</li>
<li>
Use this for pattern-based muting with wildcards, regions, and
tags.
</li>
<li>
Learn more about configuring the Mutelist{" "}
<CustomLink
size="sm"
href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings"
>
here
</CustomLink>
.
</li>
<li>
A default Mutelist is used to exclude certain predefined
resources if no Mutelist is provided.
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
>
Mutelist Configuration (YAML)
</label>
<div>
<Textarea
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
/>
{yamlValidation.isValid &&
configText &&
hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<span>Valid YAML format</span>
</div>
)}
</div>
</div>
</div>
<div className="flex w-full justify-end gap-4">
{config && (
<Button
type="button"
aria-label="Delete Configuration"
variant="outline"
size="lg"
onClick={() => setShowDeleteConfirmation(true)}
disabled={isPending || isDeleting}
>
<Trash2 className="size-4" />
Delete
</Button>
)}
<Button
type="submit"
size="lg"
disabled={
isPending || !yamlValidation.isValid || !configText.trim()
}
>
{isPending ? "Saving..." : config ? "Update" : "Save"}
</Button>
</div>
</form>
</Card>
</>
);
}

View File

@@ -0,0 +1,5 @@
export { MuteRuleEditForm } from "./mute-rule-edit-form";
export { MuteRuleEnabledToggle } from "./mute-rule-enabled-toggle";
export { MuteRuleRowActions } from "./mute-rule-row-actions";
export { muteRulesColumns } from "./mute-rules-columns";
export { MuteRulesTable, MuteRulesTableSkeleton } from "./mute-rules-table";

View File

@@ -0,0 +1,94 @@
"use client";
import { Input, Textarea } from "@heroui/input";
import { Dispatch, SetStateAction, useActionState, useEffect } from "react";
import { updateMuteRule } from "@/actions/mute-rules";
import { useToast } from "@/components/ui";
import { FormButtons } from "@/components/ui/form";
import { MuteRuleActionState, MuteRuleData } from "@/types/mute-rules";
interface MuteRuleEditFormProps {
muteRule: MuteRuleData;
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
}
export function MuteRuleEditForm({
muteRule,
setIsOpen,
onCancel,
}: MuteRuleEditFormProps) {
const { toast } = useToast();
const [state, formAction, isPending] = useActionState<
MuteRuleActionState,
FormData
>(updateMuteRule, null);
useEffect(() => {
if (state?.success) {
toast({
title: "Success",
description: state.success,
});
setIsOpen(false);
} else if (state?.errors?.general) {
toast({
variant: "destructive",
title: "Error",
description: state.errors.general,
});
}
}, [state, toast, setIsOpen]);
return (
<form action={formAction} className="flex flex-col gap-4">
<input type="hidden" name="id" value={muteRule.id} />
<Input
name="name"
label="Name"
placeholder="Enter rule name"
defaultValue={muteRule.attributes.name}
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
/>
<Textarea
name="reason"
label="Reason"
placeholder="Enter the reason for muting these findings"
defaultValue={muteRule.attributes.reason}
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
/>
<div className="text-default-500 text-xs">
<p>
This rule is applied to{" "}
{muteRule.attributes.finding_uids?.length || 0} findings.
</p>
<p className="mt-1">
Note: You cannot modify the findings associated with this rule after
creation.
</p>
</div>
<FormButtons
setIsOpen={setIsOpen}
onCancel={onCancel}
submitText="Update"
isDisabled={isPending}
/>
</form>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { Switch } from "@heroui/switch";
import { useState } from "react";
import { toggleMuteRule } from "@/actions/mute-rules";
import { useToast } from "@/components/ui";
import { MuteRuleData } from "@/types/mute-rules";
interface MuteRuleEnabledToggleProps {
muteRule: MuteRuleData;
}
export function MuteRuleEnabledToggle({
muteRule,
}: MuteRuleEnabledToggleProps) {
const [isEnabled, setIsEnabled] = useState(muteRule.attributes.enabled);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleToggle = async (value: boolean) => {
setIsLoading(true);
setIsEnabled(value);
const result = await toggleMuteRule(muteRule.id, value);
if (result.error) {
// Revert on error
setIsEnabled(!value);
toast({
variant: "destructive",
title: "Error",
description: result.error,
});
} else if (result.success) {
toast({
title: "Success",
description: result.success,
});
}
setIsLoading(false);
};
return (
<Switch
isSelected={isEnabled}
onValueChange={handleToggle}
isDisabled={isLoading}
size="sm"
aria-label={`Toggle mute rule ${muteRule.attributes.name}`}
/>
);
}

View File

@@ -0,0 +1,168 @@
"use client";
import {
Dropdown,
DropdownItem,
DropdownMenu,
DropdownSection,
DropdownTrigger,
} from "@heroui/dropdown";
import { Pencil, Trash2 } from "lucide-react";
import { useActionState, useEffect, useState } from "react";
import { deleteMuteRule } from "@/actions/mute-rules";
import { VerticalDotsIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { MuteRuleData } from "@/types/mute-rules";
import { MuteRuleEditForm } from "./mute-rule-edit-form";
interface MuteRuleRowActionsProps {
muteRule: MuteRuleData;
}
export function MuteRuleRowActions({ muteRule }: MuteRuleRowActionsProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const { toast } = useToast();
const [deleteState, deleteAction, isDeleting] = useActionState(
deleteMuteRule,
null,
);
useEffect(() => {
if (deleteState?.success) {
toast({
title: "Success",
description: deleteState.success,
});
setIsDeleteModalOpen(false);
} else if (deleteState?.errors?.general) {
toast({
variant: "destructive",
title: "Error",
description: deleteState.errors.general,
});
}
}, [deleteState, toast]);
return (
<>
{/* Edit Modal */}
<CustomAlertModal
isOpen={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
title="Edit Mute Rule"
size="lg"
>
<MuteRuleEditForm
muteRule={muteRule}
setIsOpen={setIsEditModalOpen}
onCancel={() => setIsEditModalOpen(false)}
/>
</CustomAlertModal>
{/* Delete Confirmation Modal */}
<CustomAlertModal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
title="Delete Mute Rule"
size="md"
>
<div className="flex flex-col gap-4">
<p className="text-default-600 text-sm">
Are you sure you want to delete the mute rule &quot;
{muteRule.attributes.name}&quot;? This action cannot be undone.
</p>
<p className="text-default-500 text-xs">
Note: This will not unmute the findings that were muted by this
rule.
</p>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<form action={deleteAction}>
<input type="hidden" name="id" value={muteRule.id} />
<Button
type="submit"
variant="destructive"
size="lg"
disabled={isDeleting}
>
<Trash2 className="size-4" />
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</form>
</div>
</div>
</CustomAlertModal>
{/* Actions Dropdown */}
<div className="flex items-center justify-center px-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<Button
variant="outline"
size="icon-sm"
className="size-7 rounded-full"
>
<VerticalDotsIcon
size={16}
className="text-text-neutral-secondary"
/>
</Button>
</DropdownTrigger>
<DropdownMenu
closeOnSelect
aria-label="Mute rule actions"
color="default"
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="edit"
description="Edit rule name and reason"
textValue="Edit"
startContent={
<Pencil className="text-default-500 pointer-events-none size-4 shrink-0" />
}
onPress={() => setIsEditModalOpen(true)}
>
Edit
</DropdownItem>
<DropdownItem
key="delete"
description="Delete this mute rule"
textValue="Delete"
className="text-danger"
color="danger"
classNames={{
description: "text-danger",
}}
startContent={
<Trash2 className="pointer-events-none size-4 shrink-0" />
}
onPress={() => setIsDeleteModalOpen(true)}
>
Delete
</DropdownItem>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</>
);
}

View File

@@ -0,0 +1,101 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DateWithTime } from "@/components/ui/entities";
import { DataTableColumnHeader } from "@/components/ui/table";
import { MuteRuleData } from "@/types/mute-rules";
import { MuteRuleEnabledToggle } from "./mute-rule-enabled-toggle";
import { MuteRuleRowActions } from "./mute-rule-row-actions";
export const muteRulesColumns: ColumnDef<MuteRuleData>[] = [
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => {
const name = row.original.attributes.name;
return (
<div className="max-w-[200px]">
<p className="truncate text-sm font-medium">{name}</p>
</div>
);
},
},
{
accessorKey: "reason",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Reason" />
),
cell: ({ row }) => {
const reason = row.original.attributes.reason;
return (
<div className="max-w-[300px]">
<p className="truncate text-sm text-slate-600 dark:text-slate-400">
{reason}
</p>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "finding_count",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Findings" />
),
cell: ({ row }) => {
const count = row.original.attributes.finding_uids?.length || 0;
return (
<div className="w-[80px]">
<span className="rounded-full bg-slate-100 px-2 py-1 text-xs font-medium dark:bg-slate-800">
{count}
</span>
</div>
);
},
enableSorting: false,
},
{
accessorKey: "inserted_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title="Created"
param="inserted_at"
/>
),
cell: ({ row }) => {
const insertedAt = row.original.attributes.inserted_at;
return (
<div className="w-[120px]">
<DateWithTime dateTime={insertedAt} />
</div>
);
},
},
{
accessorKey: "enabled",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Enabled" />
),
cell: ({ row }) => {
return <MuteRuleEnabledToggle muteRule={row.original} />;
},
enableSorting: false,
},
{
id: "actions",
header: () => (
<div className="flex items-center justify-center px-2">
<span className="text-sm font-semibold">Actions</span>
</div>
),
cell: ({ row }) => {
return <MuteRuleRowActions muteRule={row.original} />;
},
enableSorting: false,
},
];

View File

@@ -0,0 +1,111 @@
import { Info } from "lucide-react";
import { getMuteRules } from "@/actions/mute-rules";
import { Card, Skeleton } from "@/components/shadcn";
import { DataTable } from "@/components/ui/table";
import { SearchParamsProps } from "@/types/components";
import { muteRulesColumns } from "./mute-rules-columns";
interface MuteRulesTableProps {
searchParams: SearchParamsProps;
}
export async function MuteRulesTable({ searchParams }: MuteRulesTableProps) {
const page = parseInt(searchParams.page?.toString() || "1", 10);
const pageSize = parseInt(searchParams.pageSize?.toString() || "10", 10);
const sort = searchParams.sort?.toString() || "-inserted_at";
const muteRulesData = await getMuteRules({
page,
pageSize,
sort,
});
const muteRules = muteRulesData?.data || [];
if (muteRules.length === 0) {
return (
<Card variant="base" className="p-8">
<div className="flex flex-col items-center justify-center gap-4 text-center">
<div className="rounded-full bg-slate-100 p-4 dark:bg-slate-800">
<Info className="size-8 text-slate-500" />
</div>
<div>
<h3 className="text-lg font-medium text-slate-900 dark:text-white">
No mute rules yet
</h3>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Mute rules are created when you mute findings from the Findings
page. Select findings and click &quot;Mute&quot; to create your
first rule.
</p>
</div>
</div>
</Card>
);
}
return (
<Card variant="base" className="p-6">
<div className="mb-6">
<h3 className="text-default-700 mb-2 text-lg font-semibold">
Simple Mutelist Rules
</h3>
<ul className="text-default-600 list-disc pl-5 text-sm">
<li>
<strong>
These rules take effect immediately on existing findings.
</strong>
</li>
<li>
Create rules by selecting findings from the Findings page and
clicking &quot;Mute&quot;.
</li>
<li>Toggle rules on/off to enable or disable muting.</li>
</ul>
</div>
<DataTable
columns={muteRulesColumns}
data={muteRules}
metadata={
muteRulesData?.meta
? { ...muteRulesData.meta, version: "" }
: undefined
}
/>
</Card>
);
}
export function MuteRulesTableSkeleton() {
return (
<div className="flex flex-col gap-4">
<div className="rounded-lg border border-slate-200 dark:border-slate-800">
<div className="border-b border-slate-200 p-4 dark:border-slate-800">
<div className="flex gap-8">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-16" />
</div>
</div>
{[...Array(5)].map((_, i) => (
<div
key={i}
className="flex items-center gap-8 border-b border-slate-200 p-4 last:border-0 dark:border-slate-800"
>
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-5 w-10" />
<Skeleton className="size-8 rounded-full" />
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { List, Settings } from "lucide-react";
import { ReactNode } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/shadcn";
import { AdvancedMutelistForm } from "./_components/advanced-mutelist-form";
interface MutelistTabsProps {
simpleContent: ReactNode;
}
export function MutelistTabs({ simpleContent }: MutelistTabsProps) {
return (
<Tabs defaultValue="simple" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="simple" className="gap-2">
<List className="size-4" />
Simple
</TabsTrigger>
<TabsTrigger value="advanced" className="gap-2">
<Settings className="size-4" />
Advanced
</TabsTrigger>
</TabsList>
<TabsContent value="simple">{simpleContent}</TabsContent>
<TabsContent value="advanced">
<AdvancedMutelistForm />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,28 @@
import { Suspense } from "react";
import { ContentLayout } from "@/components/ui";
import { SearchParamsProps } from "@/types/components";
import { MuteRulesTable, MuteRulesTableSkeleton } from "./_components/simple";
import { MutelistTabs } from "./mutelist-tabs";
export default async function MutelistPage({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
const searchParamsKey = JSON.stringify(resolvedSearchParams);
return (
<ContentLayout title="Mutelist" icon="lucide:volume-x">
<MutelistTabs
simpleContent={
<Suspense key={searchParamsKey} fallback={<MuteRulesTableSkeleton />}>
<MuteRulesTable searchParams={resolvedSearchParams} />
</Suspense>
}
/>
</ContentLayout>
);
}

View File

@@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { getFindings } from "@/actions/findings/findings";
import {
ColumnFindings,
getColumnFindings,
SkeletonTableFindings,
} from "@/components/findings/table";
import { Accordion } from "@/components/ui/accordion/Accordion";
@@ -159,8 +159,12 @@ export const ClientAccordionContent = ({
<h4 className="mb-2 text-sm font-medium">Findings</h4>
<DataTable
// Remove the updated_at column as compliance is for the last scan
columns={ColumnFindings.filter((_, index) => index !== 7)}
// Remove select and updated_at columns for compliance view
columns={getColumnFindings({}, 0).filter(
(col) =>
col.id !== "select" &&
!("accessorKey" in col && col.accessorKey === "updated_at"),
)}
data={expandedFindings || []}
metadata={findings?.meta}
disableScroll={true}

View File

@@ -1,44 +1,63 @@
"use client";
import { Checkbox } from "@heroui/checkbox";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { Checkbox } from "@/components/shadcn";
// Constants for muted filter URL values
const MUTED_FILTER_VALUES = {
EXCLUDE: "false",
INCLUDE: "include",
} as const;
export const CustomCheckboxMutedFindings = () => {
const { updateFilter, clearFilter } = useUrlFilters();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [excludeMuted, setExcludeMuted] = useState(
searchParams.get("filter[muted]") === "false",
);
const handleMutedChange = (value: boolean) => {
setExcludeMuted(value);
// Get the current muted filter value from URL
// Middleware ensures filter[muted] is always present when navigating to /findings
const mutedFilterValue = searchParams.get("filter[muted]");
// Only URL update if value is false else remove filter
if (value) {
updateFilter("muted", "false");
// URL states:
// - filter[muted]=false → Exclude muted (checkbox UNCHECKED)
// - filter[muted]=include → Include muted (checkbox CHECKED)
const includeMuted = mutedFilterValue === MUTED_FILTER_VALUES.INCLUDE;
const handleMutedChange = (checked: boolean | "indeterminate") => {
const isChecked = checked === true;
const params = new URLSearchParams(searchParams.toString());
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
} else {
clearFilter("muted");
// Exclude muted: apply filter to show only non-muted
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
}
// Reset to page 1 when changing filter
if (params.has("page")) {
params.set("page", "1");
}
router.push(`${pathname}?${params.toString()}`, { scroll: false });
};
return (
<div className="flex h-full text-nowrap">
<div className="flex h-full items-center gap-2 text-nowrap">
<Checkbox
classNames={{
label: "text-small",
wrapper: "checkbox-update",
}}
size="md"
color="primary"
aria-label="Include Mutelist"
isSelected={excludeMuted}
onValueChange={handleMutedChange}
id="include-muted"
checked={includeMuted}
onCheckedChange={handleMutedChange}
aria-label="Include muted findings"
/>
<label
htmlFor="include-muted"
className="cursor-pointer text-sm leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Exclude muted findings
</Checkbox>
Include muted findings
</label>
</div>
);
};

View File

@@ -0,0 +1,44 @@
"use client";
import { VolumeX } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/shadcn";
import { MuteFindingsModal } from "./mute-findings-modal";
interface FloatingMuteButtonProps {
selectedCount: number;
selectedFindingIds: string[];
onComplete?: () => void;
}
export function FloatingMuteButton({
selectedCount,
selectedFindingIds,
onComplete,
}: FloatingMuteButtonProps) {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<MuteFindingsModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
findingIds={selectedFindingIds}
onComplete={onComplete}
/>
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
<Button
onClick={() => setIsModalOpen(true)}
size="lg"
className="shadow-lg"
>
<VolumeX className="size-5" />
Mute ({selectedCount})
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { Input, Textarea } from "@heroui/input";
import { Dispatch, SetStateAction, useActionState, useEffect } from "react";
import { createMuteRule } from "@/actions/mute-rules";
import { useToast } from "@/components/ui";
import { CustomAlertModal } from "@/components/ui/custom";
import { FormButtons } from "@/components/ui/form";
import { MuteRuleActionState } from "@/types/mute-rules";
interface MuteFindingsModalProps {
isOpen: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
findingIds: string[];
onComplete?: () => void;
}
export function MuteFindingsModal({
isOpen,
onOpenChange,
findingIds,
onComplete,
}: MuteFindingsModalProps) {
const { toast } = useToast();
const [state, formAction, isPending] = useActionState<
MuteRuleActionState,
FormData
>(createMuteRule, null);
useEffect(() => {
if (state?.success) {
toast({
title: "Success",
description: state.success,
});
onOpenChange(false);
onComplete?.();
} else if (state?.errors?.general) {
toast({
variant: "destructive",
title: "Error",
description: state.errors.general,
});
}
}, [state, toast, onOpenChange, onComplete]);
const handleCancel = () => {
onOpenChange(false);
};
return (
<CustomAlertModal
isOpen={isOpen}
onOpenChange={onOpenChange}
title="Mute Findings"
size="lg"
>
<form action={formAction} className="flex flex-col gap-4">
<input
type="hidden"
name="finding_ids"
value={JSON.stringify(findingIds)}
/>
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
<p className="text-sm text-slate-600 dark:text-slate-400">
You are about to mute{" "}
<span className="font-semibold text-slate-900 dark:text-white">
{findingIds.length}
</span>{" "}
{findingIds.length === 1 ? "finding" : "findings"}.
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
Muted findings will be hidden by default but can be shown using
filters.
</p>
</div>
<Input
name="name"
label="Rule Name"
placeholder="e.g., Ignore dev environment S3 buckets"
isRequired
variant="bordered"
isInvalid={!!state?.errors?.name}
errorMessage={state?.errors?.name}
isDisabled={isPending}
description="A descriptive name for this mute rule"
/>
<Textarea
name="reason"
label="Reason"
placeholder="e.g., These are expected findings in the development environment"
isRequired
variant="bordered"
minRows={3}
maxRows={6}
isInvalid={!!state?.errors?.reason}
errorMessage={state?.errors?.reason}
isDisabled={isPending}
description="Explain why these findings are being muted"
/>
<FormButtons
setIsOpen={onOpenChange}
onCancel={handleCancel}
submitText="Mute Findings"
isDisabled={isPending}
/>
</form>
</CustomAlertModal>
);
}

View File

@@ -1,12 +1,20 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { Database } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { DataTableRowDetails } from "@/components/findings/table";
import { DataTableRowActions } from "@/components/findings/table/data-table-row-actions";
import { InfoIcon } from "@/components/icons";
import { InfoIcon, MutedIcon } from "@/components/icons";
import {
Checkbox,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/shadcn";
import {
DateWithTime,
EntityInfo,
@@ -20,7 +28,6 @@ import {
} from "@/components/ui/table";
import { FindingProps, ProviderType } from "@/types";
import { Muted } from "../muted";
import { DeltaIndicator } from "./delta-indicator";
const getFindingsData = (row: { original: FindingProps }) => {
@@ -88,187 +95,275 @@ const FindingDetailsCell = ({ row }: { row: any }) => {
);
};
export const ColumnFindings: ColumnDef<FindingProps>[] = [
{
id: "moreInfo",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <FindingDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "check",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Finding"}
param="check_id"
/>
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
const {
attributes: { muted, muted_reason },
} = getFindingsData(row);
const { delta } = row.original.attributes;
// Function to generate columns with access to selection state
export function getColumnFindings(
rowSelection: RowSelectionState,
selectableRowCount: number,
): ColumnDef<FindingProps>[] {
// Calculate selection state from rowSelection for header checkbox
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
const isAllSelected =
selectedCount > 0 && selectedCount === selectableRowCount;
const isSomeSelected =
selectedCount > 0 && selectedCount < selectableRowCount;
return [
{
id: "select",
header: ({ table }) => {
// Use state calculated from rowSelection to force re-render
const headerChecked = isAllSelected
? true
: isSomeSelected
? "indeterminate"
: false;
return (
<div className="3xl:max-w-[660px] relative flex max-w-[410px] flex-row items-center gap-2">
<div className="flex flex-row items-center gap-4">
{delta === "new" || delta === "changed" ? (
<DeltaIndicator delta={delta} />
) : null}
<p className="mr-7 text-sm break-words whitespace-normal">
{checktitle}
</p>
return (
<div className="flex w-6 items-center justify-center">
<Checkbox
checked={headerChecked}
onCheckedChange={(checked) =>
table.toggleAllPageRowsSelected(checked === true)
}
aria-label="Select all"
// Disable when no rows are selectable (all muted)
disabled={selectableRowCount === 0}
/>
</div>
<span className="absolute top-1/2 -right-2 -translate-y-1/2">
<Muted isMuted={muted} mutedReason={muted_reason || ""} />
</span>
</div>
);
},
},
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
);
},
cell: ({ row }) => {
const finding = row.original;
const isMuted = finding.attributes.muted;
const mutedReason = finding.attributes.muted_reason;
return (
<SnippetChip
value={resourceName as string}
formatter={(value: string) => `...${value.slice(-10)}`}
icon={<Database size={16} />}
// Show muted icon with tooltip for muted findings
if (isMuted) {
const ruleName = mutedReason || "Unknown rule";
return (
<div className="flex w-6 items-center justify-center">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="border-system-severity-critical/40 cursor-pointer rounded-full border p-0.5">
<MutedIcon className="text-system-severity-critical size-3.5" />
</div>
</TooltipTrigger>
<TooltipContent>
<Link
href="/mutelist"
className="text-button-tertiary hover:text-button-tertiary-hover flex items-center gap-1 text-xs underline-offset-4"
>
<span className="text-text-neutral-primary">
Mute rule:
</span>
<span className="max-w-[150px] truncate">{ruleName}</span>
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Use rowSelection directly instead of row.getIsSelected()
// This ensures re-render when selection state changes
const isSelected = !!rowSelection[row.id];
return (
<div className="flex w-6 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
row.toggleSelected(checked === true)
}
aria-label="Select row"
/>
</div>
);
},
enableSorting: false,
enableHiding: false,
},
{
id: "moreInfo",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Details" />
),
cell: ({ row }) => <FindingDetailsCell row={row} />,
enableSorting: false,
},
{
accessorKey: "check",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Finding"}
param="check_id"
/>
);
},
enableSorting: false,
},
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Severity"}
param="severity"
/>
),
cell: ({ row }) => {
const {
attributes: { severity },
} = getFindingsData(row);
return <SeverityBadge severity={severity} />;
},
},
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"Status"} param="status" />
),
cell: ({ row }) => {
const {
attributes: { status },
} = getFindingsData(row);
),
cell: ({ row }) => {
const { checktitle } = getFindingsMetadata(row);
const { delta } = row.original.attributes;
return <StatusFindingBadge status={status} />;
return (
<div className="3xl:max-w-[660px] flex max-w-[410px] flex-row items-center gap-2">
<div className="flex flex-row items-center gap-4">
{delta === "new" || delta === "changed" ? (
<DeltaIndicator delta={delta} />
) : null}
<p className="text-sm break-words whitespace-normal">
{checktitle}
</p>
</div>
</div>
);
},
},
},
{
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Last seen"}
param="updated_at"
/>
),
cell: ({ row }) => {
const {
attributes: { updated_at },
} = getFindingsData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={updated_at} />
</div>
);
},
},
// {
// accessorKey: "scanName",
// header: "Scan Name",
// cell: ({ row }) => {
// const name = getScanData(row, "name");
{
accessorKey: "resourceName",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Resource name" />
),
cell: ({ row }) => {
const resourceName = getResourceData(row, "name");
// return (
// <p className="text-small">
// {typeof name === "string" || typeof name === "number"
// ? name
// : "Invalid data"}
// </p>
// );
// },
// },
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<div className="w-[80px] text-xs">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
},
enableSorting: false,
},
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" />
),
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-xs">{servicename}</p>;
},
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
return (
<SnippetChip
value={resourceName as string}
formatter={(value: string) => `...${value.slice(-10)}`}
icon={<Database size={16} />}
/>
</>
);
);
},
enableSorting: false,
},
enableSorting: false,
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
{
accessorKey: "severity",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Severity"}
param="severity"
/>
),
cell: ({ row }) => {
const {
attributes: { severity },
} = getFindingsData(row);
return <SeverityBadge severity={severity} />;
},
},
enableSorting: false,
},
];
{
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Status"}
param="status"
/>
),
cell: ({ row }) => {
const {
attributes: { status },
} = getFindingsData(row);
return <StatusFindingBadge status={status} />;
},
},
{
accessorKey: "updated_at",
header: ({ column }) => (
<DataTableColumnHeader
column={column}
title={"Last seen"}
param="updated_at"
/>
),
cell: ({ row }) => {
const {
attributes: { updated_at },
} = getFindingsData(row);
return (
<div className="w-[100px]">
<DateWithTime dateTime={updated_at} />
</div>
);
},
},
// {
// accessorKey: "scanName",
// header: "Scan Name",
// cell: ({ row }) => {
// const name = getScanData(row, "name");
// return (
// <p className="text-small">
// {typeof name === "string" || typeof name === "number"
// ? name
// : "Invalid data"}
// </p>
// );
// },
// },
{
accessorKey: "region",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Region" />
),
cell: ({ row }) => {
const region = getResourceData(row, "region");
return (
<div className="w-[80px] text-xs">
{typeof region === "string" ? region : "Invalid region"}
</div>
);
},
enableSorting: false,
},
{
accessorKey: "service",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Service" />
),
cell: ({ row }) => {
const { servicename } = getFindingsMetadata(row);
return <p className="max-w-96 truncate text-xs">{servicename}</p>;
},
enableSorting: false,
},
{
accessorKey: "cloudProvider",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Cloud Provider" />
),
cell: ({ row }) => {
const provider = getProviderData(row, "provider");
const alias = getProviderData(row, "alias");
const uid = getProviderData(row, "uid");
return (
<>
<EntityInfo
cloudProvider={provider as ProviderType}
entityAlias={alias as string}
entityId={uid as string}
/>
</>
);
},
enableSorting: false,
},
{
id: "actions",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Actions" />
),
cell: ({ row }) => {
return <DataTableRowActions row={row} />;
},
enableSorting: false,
},
];
}

View File

@@ -8,25 +8,73 @@ import {
DropdownTrigger,
} from "@heroui/dropdown";
import { Row } from "@tanstack/react-table";
import { useState } from "react";
import { VolumeOff, VolumeX } from "lucide-react";
import { useRouter } from "next/navigation";
import { useContext, useState } from "react";
import { MuteFindingsModal } from "@/components/findings/mute-findings-modal";
import { SendToJiraModal } from "@/components/findings/send-to-jira-modal";
import { VerticalDotsIcon } from "@/components/icons";
import { JiraIcon } from "@/components/icons/services/IconServices";
import { Button } from "@/components/shadcn";
import type { FindingProps } from "@/types/components";
import { FindingsSelectionContext } from "./findings-selection-context";
interface DataTableRowActionsProps {
row: Row<FindingProps>;
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const router = useRouter();
const finding = row.original;
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
const isMuted = finding.attributes.muted;
// Get selection context - if there are other selected rows, include them
const selectionContext = useContext(FindingsSelectionContext);
const { selectedFindingIds, clearSelection } = selectionContext || {
selectedFindingIds: [],
clearSelection: () => {},
};
const findingTitle =
finding.attributes.check_metadata?.checktitle || "Security Finding";
// If current finding is selected and there are multiple selections, mute all
// Otherwise, just mute this single finding
const isCurrentSelected = selectedFindingIds.includes(finding.id);
const hasMultipleSelected = selectedFindingIds.length > 1;
const getMuteIds = (): string[] => {
if (isCurrentSelected && hasMultipleSelected) {
// Mute all selected including current
return selectedFindingIds;
}
// Just mute the current finding
return [finding.id];
};
const getMuteDescription = (): string => {
if (isMuted) {
return "This finding is already muted";
}
const ids = getMuteIds();
if (ids.length > 1) {
return `Mute ${ids.length} selected findings`;
}
return "Mute this finding";
};
const handleMuteComplete = () => {
if (isCurrentSelected && hasMultipleSelected) {
clearSelection();
}
router.refresh();
};
return (
<>
<SendToJiraModal
@@ -36,14 +84,28 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
findingTitle={findingTitle}
/>
<div className="relative flex items-center justify-end gap-2">
<MuteFindingsModal
isOpen={isMuteModalOpen}
onOpenChange={setIsMuteModalOpen}
findingIds={getMuteIds()}
onComplete={handleMuteComplete}
/>
<div className="flex items-center justify-center px-2">
<Dropdown
className="border-border-neutral-secondary bg-bg-neutral-secondary border shadow-xl"
placement="bottom"
>
<DropdownTrigger>
<Button variant="ghost" size="icon-sm" className="rounded-full">
<VerticalDotsIcon className="text-slate-400" />
<Button
variant="outline"
size="icon-sm"
className="size-7 rounded-full"
>
<VerticalDotsIcon
size={16}
className="text-text-neutral-secondary"
/>
</Button>
</DropdownTrigger>
<DropdownMenu
@@ -53,6 +115,27 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
variant="flat"
>
<DropdownSection title="Actions">
<DropdownItem
key="mute"
description={getMuteDescription()}
textValue="Mute"
isDisabled={isMuted}
startContent={
isMuted ? (
<VolumeOff className="text-default-300 pointer-events-none size-5 shrink-0" />
) : (
<VolumeX className="text-default-500 pointer-events-none size-5 shrink-0" />
)
}
onPress={() => setIsMuteModalOpen(true)}
>
{isMuted ? "Muted" : "Mute"}
{!isMuted && isCurrentSelected && hasMultipleSelected && (
<span className="ml-1 text-xs text-slate-500">
({selectedFindingIds.length})
</span>
)}
</DropdownItem>
<DropdownItem
key="jira"
description="Create a Jira issue for this finding"

View File

@@ -0,0 +1,30 @@
"use client";
import { createContext, useContext } from "react";
import { FindingProps } from "@/types";
interface FindingsSelectionContextValue {
selectedFindingIds: string[];
selectedFindings: FindingProps[];
clearSelection: () => void;
isSelected: (id: string) => boolean;
}
export const FindingsSelectionContext =
createContext<FindingsSelectionContextValue>({
selectedFindingIds: [],
selectedFindings: [],
clearSelection: () => {},
isSelected: () => false,
});
export function useFindingsSelection() {
const context = useContext(FindingsSelectionContext);
if (!context) {
throw new Error(
"useFindingsSelection must be used within a FindingsSelectionProvider",
);
}
return context;
}

View File

@@ -0,0 +1,98 @@
"use client";
import { Row, RowSelectionState } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { DataTable } from "@/components/ui/table";
import { FindingProps, MetaDataProps } from "@/types";
import { FloatingMuteButton } from "../floating-mute-button";
import { getColumnFindings } from "./column-findings";
import { FindingsSelectionContext } from "./findings-selection-context";
interface FindingsTableWithSelectionProps {
data: FindingProps[];
metadata?: MetaDataProps;
}
export function FindingsTableWithSelection({
data,
metadata,
}: FindingsTableWithSelectionProps) {
const router = useRouter();
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Reset selection when page changes
useEffect(() => {
setRowSelection({});
}, [metadata?.pagination?.page]);
// Ensure data is always an array for safe operations
const safeData = data ?? [];
// Get selected finding IDs and data (only non-muted findings can be selected)
const selectedFindingIds = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => safeData[parseInt(idx)]?.id)
.filter(Boolean);
const selectedFindings = Object.keys(rowSelection)
.filter((key) => rowSelection[key])
.map((idx) => safeData[parseInt(idx)])
.filter(Boolean);
// Count of selectable rows (non-muted findings only)
const selectableRowCount = safeData.filter((f) => !f.attributes.muted).length;
// Function to determine if a row can be selected (muted findings cannot be selected)
const getRowCanSelect = (row: Row<FindingProps>): boolean => {
return !row.original.attributes.muted;
};
const clearSelection = () => {
setRowSelection({});
};
const isSelected = (id: string) => {
return selectedFindingIds.includes(id);
};
// Handle mute completion: clear selection and refresh data
const handleMuteComplete = () => {
clearSelection();
router.refresh();
};
// Generate columns with access to rowSelection state and selectable row count
const columns = getColumnFindings(rowSelection, selectableRowCount);
return (
<FindingsSelectionContext.Provider
value={{
selectedFindingIds,
selectedFindings,
clearSelection,
isSelected,
}}
>
<DataTable
columns={columns}
data={safeData}
metadata={metadata}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
getRowCanSelect={getRowCanSelect}
/>
{selectedFindingIds.length > 0 && (
<FloatingMuteButton
selectedCount={selectedFindingIds.length}
selectedFindingIds={selectedFindingIds}
onComplete={handleMuteComplete}
/>
)}
</FindingsSelectionContext.Provider>
);
}

View File

@@ -2,4 +2,6 @@ export * from "./column-findings";
export * from "./data-table-row-actions";
export * from "./data-table-row-details";
export * from "./finding-detail";
export * from "./findings-selection-context";
export * from "./findings-table-with-selection";
export * from "./skeleton-table-findings";

View File

@@ -14,6 +14,7 @@ import { SamlConfigForm } from "./saml-config-form";
export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
const [isSamlModalOpen, setIsSamlModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { toast } = useToast();
const id = samlConfig?.id;
@@ -30,6 +31,7 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
title: "SAML configuration removed",
description: result.success,
});
setIsDeleteModalOpen(false);
} else if (result.errors?.general) {
toast({
variant: "destructive",
@@ -37,7 +39,7 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
description: result.errors.general,
});
}
} catch (error) {
} catch {
toast({
variant: "destructive",
title: "Error",
@@ -50,6 +52,7 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
return (
<>
{/* Configure SAML Modal */}
<CustomAlertModal
isOpen={isSamlModalOpen}
onOpenChange={setIsSamlModalOpen}
@@ -61,6 +64,42 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
/>
</CustomAlertModal>
{/* Delete Confirmation Modal */}
<CustomAlertModal
isOpen={isDeleteModalOpen}
onOpenChange={setIsDeleteModalOpen}
title="Remove SAML Configuration"
size="md"
>
<div className="flex flex-col gap-4">
<p className="text-default-600 text-sm">
Are you sure you want to remove the SAML SSO configuration? Users
will no longer be able to sign in using SAML.
</p>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDeleting}
onClick={handleRemoveSaml}
>
<Trash2Icon className="size-4" />
{isDeleting ? "Removing..." : "Remove"}
</Button>
</div>
</div>
</CustomAlertModal>
<Card variant="base" padding="lg">
<CardHeader>
<div className="flex flex-col gap-1">
@@ -98,11 +137,10 @@ export const SamlIntegrationCard = ({ samlConfig }: { samlConfig?: any }) => {
<Button
size="sm"
variant="destructive"
disabled={isDeleting}
onClick={handleRemoveSaml}
onClick={() => setIsDeleteModalOpen(true)}
>
{!isDeleting && <Trash2Icon size={16} />}
{isDeleting ? "Removing..." : "Remove"}
<Trash2Icon size={16} />
Remove
</Button>
)}
</div>

View File

@@ -1,3 +1,2 @@
export * from "./delete-form";
export * from "./edit-form";
export { MutedFindingsConfigForm } from "./muted-findings-config-form";

View File

@@ -1,274 +0,0 @@
"use client";
import { Textarea } from "@heroui/input";
import {
Dispatch,
SetStateAction,
useActionState,
useEffect,
useState,
} from "react";
import {
createMutedFindingsConfig,
deleteMutedFindingsConfig,
getMutedFindingsConfig,
updateMutedFindingsConfig,
} from "@/actions/processors";
import { DeleteIcon } from "@/components/icons";
import { Button } from "@/components/shadcn";
import { useToast } from "@/components/ui";
import { CustomLink } from "@/components/ui/custom/custom-link";
import { FormButtons } from "@/components/ui/form";
import { fontMono } from "@/config/fonts";
import {
convertToYaml,
defaultMutedFindingsConfig,
parseYamlValidation,
} from "@/lib/yaml";
import {
MutedFindingsConfigActionState,
ProcessorData,
} from "@/types/processors";
interface MutedFindingsConfigFormProps {
setIsOpen: Dispatch<SetStateAction<boolean>>;
onCancel?: () => void;
}
export const MutedFindingsConfigForm = ({
setIsOpen,
onCancel,
}: MutedFindingsConfigFormProps) => {
const [config, setConfig] = useState<ProcessorData | null>(null);
const [configText, setConfigText] = useState("");
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [yamlValidation, setYamlValidation] = useState<{
isValid: boolean;
error?: string;
}>({ isValid: true });
const [hasUserStartedTyping, setHasUserStartedTyping] = useState(false);
const [state, formAction, isPending] = useActionState<
MutedFindingsConfigActionState,
FormData
>(config ? updateMutedFindingsConfig : createMutedFindingsConfig, null);
const { toast } = useToast();
useEffect(() => {
getMutedFindingsConfig().then((result) => {
setConfig(result || null);
const yamlConfig = convertToYaml(result?.attributes.configuration || "");
setConfigText(yamlConfig);
setHasUserStartedTyping(false); // Reset when loading initial config
if (yamlConfig) {
setYamlValidation(parseYamlValidation(yamlConfig));
}
});
}, []);
useEffect(() => {
if (state?.success) {
toast({
title: "Configuration saved successfully",
description: state.success,
});
setIsOpen(false);
} else if (state?.errors?.general) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: state.errors.general,
});
} else if (state?.errors?.configuration) {
// Reset typing state when there are new server errors
setHasUserStartedTyping(false);
}
}, [state, toast, setIsOpen]);
const handleConfigChange = (value: string) => {
setConfigText(value);
// Clear server errors when user starts typing
setHasUserStartedTyping(true);
// Validate YAML in real-time
const validation = parseYamlValidation(value);
setYamlValidation(validation);
};
const handleDelete = async () => {
if (!config) return;
setIsDeleting(true);
const formData = new FormData();
formData.append("id", config.id);
try {
const result = await deleteMutedFindingsConfig(null, formData);
if (result?.success) {
toast({
title: "Configuration deleted successfully",
description: result.success,
});
setIsOpen(false);
} else if (result?.errors?.general) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: result.errors.general,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "Oops! Something went wrong",
description: "Error deleting configuration. Please try again.",
});
} finally {
setIsDeleting(false);
setShowDeleteConfirmation(false);
}
};
if (showDeleteConfirmation) {
return (
<div className="flex flex-col gap-4">
<h3 className="text-default-700 text-lg font-semibold">
Delete Mutelist Configuration
</h3>
<p className="text-default-600 text-sm">
Are you sure you want to delete this configuration? This action cannot
be undone.
</p>
<div className="flex w-full justify-center gap-6">
<Button
type="button"
aria-label="Cancel"
className="w-full bg-transparent"
variant="outline"
size="lg"
onClick={() => setShowDeleteConfirmation(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
type="button"
aria-label="Delete"
className="w-full"
variant="destructive"
size="lg"
disabled={isDeleting}
onClick={handleDelete}
>
{isDeleting ? (
"Deleting"
) : (
<>
<DeleteIcon size={24} />
Delete
</>
)}
</Button>
</div>
</div>
);
}
return (
<form action={formAction} className="flex flex-col gap-4">
{config && <input type="hidden" name="id" value={config.id} />}
<div className="flex flex-col gap-4">
<div>
<ul className="text-default-600 mb-4 list-disc pl-5 text-sm">
<li>
<strong>
This Mutelist configuration will take effect on the next scan.
</strong>
</li>
<li>
Mutelist configuration can be modified at anytime on the Providers
and Scans pages.
</li>
<li>
Learn more about configuring the Mutelist{" "}
<CustomLink href="https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-mute-findings">
here
</CustomLink>
.
</li>
<li>
A default Mutelist is used, to exclude certain predefined
resources, if no Mutelist is provided.
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="configuration"
className="text-default-700 text-sm font-medium"
>
Mutelist Configuration
</label>
<div>
<Textarea
id="configuration"
name="configuration"
placeholder={defaultMutedFindingsConfig}
variant="bordered"
value={configText}
onChange={(e) => handleConfigChange(e.target.value)}
minRows={20}
maxRows={20}
isInvalid={
(!hasUserStartedTyping && !!state?.errors?.configuration) ||
!yamlValidation.isValid
}
errorMessage={
(!hasUserStartedTyping && state?.errors?.configuration) ||
(!yamlValidation.isValid ? yamlValidation.error : "")
}
classNames={{
input: fontMono.className + " text-sm",
base: "min-h-[400px]",
errorMessage: "whitespace-pre-wrap",
}}
/>
{yamlValidation.isValid && configText && hasUserStartedTyping && (
<div className="text-tiny text-success my-1 flex items-center px-1">
<span>Valid YAML format</span>
</div>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<FormButtons
setIsOpen={setIsOpen}
onCancel={onCancel}
submitText={config ? "Update" : "Save"}
isDisabled={!yamlValidation.isValid || !configText.trim()}
/>
{config && (
<Button
type="button"
aria-label="Delete Configuration"
className="w-full"
variant="outline"
size="default"
onClick={() => setShowDeleteConfirmation(true)}
disabled={isPending}
>
<DeleteIcon size={20} />
Delete Configuration
</Button>
)}
</div>
</form>
);
};

View File

@@ -1,76 +1,17 @@
"use client";
import { SettingsIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import Link from "next/link";
import { Button } from "@/components/shadcn";
import { CustomAlertModal } from "@/components/ui/custom";
import { useUIStore } from "@/store/ui/store";
import { MutedFindingsConfigForm } from "./forms";
export const MutedFindingsConfigButton = () => {
const pathname = usePathname();
const {
isMutelistModalOpen,
openMutelistModal,
closeMutelistModal,
hasProviders,
shouldAutoOpenMutelist,
resetMutelistModalRequest,
} = useUIStore();
useEffect(() => {
if (!shouldAutoOpenMutelist) {
return;
}
if (pathname !== "/providers") {
return;
}
if (hasProviders) {
openMutelistModal();
}
resetMutelistModalRequest();
}, [
hasProviders,
openMutelistModal,
pathname,
resetMutelistModalRequest,
shouldAutoOpenMutelist,
]);
const handleOpenModal = () => {
if (hasProviders) {
openMutelistModal();
}
};
return (
<>
<CustomAlertModal
isOpen={isMutelistModalOpen}
onOpenChange={closeMutelistModal}
title="Configure Mutelist"
size="3xl"
>
<MutedFindingsConfigForm
setIsOpen={closeMutelistModal}
onCancel={closeMutelistModal}
/>
</CustomAlertModal>
<Button
variant="outline"
onClick={handleOpenModal}
disabled={!hasProviders}
>
<Button variant="outline" asChild>
<Link href="/mutelist">
<SettingsIcon size={20} />
Configure Mutelist
</Button>
</>
</Link>
</Button>
);
};

View File

@@ -0,0 +1,31 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -4,6 +4,7 @@ export * from "./card/card";
export * from "./card/resource-stats-card/resource-stats-card";
export * from "./card/resource-stats-card/resource-stats-card-content";
export * from "./card/resource-stats-card/resource-stats-card-header";
export * from "./checkbox/checkbox";
export * from "./combobox";
export * from "./dropdown/dropdown";
export * from "./select/multiselect";

View File

@@ -35,7 +35,7 @@ function TooltipTrigger({
function TooltipContent({
className,
sideOffset = 0,
sideOffset = 4,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
@@ -45,13 +45,12 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
"border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-lg border px-3 py-1.5 text-xs text-balance shadow-lg",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);

View File

@@ -17,7 +17,6 @@ import { MenuItem } from "@/components/ui/sidebar/menu-item";
import { useAuth } from "@/hooks";
import { getMenuList } from "@/lib/menu-list";
import { cn } from "@/lib/utils";
import { useUIStore } from "@/store/ui/store";
import { GroupProps } from "@/types";
import { RolePermissionAttributes } from "@/types/users";
@@ -56,14 +55,9 @@ const filterMenus = (menuGroups: GroupProps[], labelsToHide: string[]) => {
export const Menu = ({ isOpen }: { isOpen: boolean }) => {
const pathname = usePathname();
const { permissions } = useAuth();
const { hasProviders, openMutelistModal, requestMutelistModalOpen } =
useUIStore();
const menuList = getMenuList({
pathname,
hasProviders,
openMutelistModal,
requestMutelistModalOpen,
});
const labelsToHide = MENU_HIDE_RULES.filter((rule) =>

View File

@@ -8,6 +8,9 @@ import {
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
OnChangeFn,
Row,
RowSelectionState,
SortingState,
useReactTable,
} from "@tanstack/react-table";
@@ -30,6 +33,11 @@ interface DataTableProviderProps<TData, TValue> {
metadata?: MetaDataProps;
customFilters?: FilterOption[];
disableScroll?: boolean;
enableRowSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
/** Function to determine if a row can be selected */
getRowCanSelect?: (row: Row<TData>) => boolean;
}
export function DataTable<TData, TValue>({
@@ -37,6 +45,10 @@ export function DataTable<TData, TValue>({
data,
metadata,
disableScroll = false,
enableRowSelection = false,
rowSelection,
onRowSelectionChange,
getRowCanSelect,
}: DataTableProviderProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
@@ -45,26 +57,35 @@ export function DataTable<TData, TValue>({
data,
columns,
enableSorting: true,
// Use getRowCanSelect function if provided, otherwise use boolean
enableRowSelection: getRowCanSelect ?? enableRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
onRowSelectionChange,
manualPagination: true,
state: {
sorting,
columnFilters,
rowSelection: rowSelection ?? {},
},
});
// Calculate selection key to force header re-render when selection changes
const selectionKey = rowSelection
? Object.keys(rowSelection).filter((k) => rowSelection[k]).length
: 0;
return (
<>
<div className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col justify-between gap-4 overflow-auto border p-4">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={`${headerGroup.id}-${selectionKey}`}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>

View File

@@ -16,7 +16,6 @@ import {
VolumeX,
Warehouse,
} from "lucide-react";
import type { MouseEvent } from "react";
import { ProwlerShort } from "@/components/icons";
import {
@@ -30,17 +29,9 @@ import { GroupProps } from "@/types";
interface MenuListOptions {
pathname: string;
hasProviders?: boolean;
openMutelistModal?: () => void;
requestMutelistModalOpen?: () => void;
}
export const getMenuList = ({
pathname,
hasProviders,
openMutelistModal,
requestMutelistModalOpen,
}: MenuListOptions): GroupProps[] => {
export const getMenuList = ({ pathname }: MenuListOptions): GroupProps[] => {
return [
{
groupLabel: "",
@@ -79,7 +70,7 @@ export const getMenuList = ({
groupLabel: "",
menus: [
{
href: "/findings",
href: "/findings?filter[muted]=false",
label: "Findings",
icon: Tag,
},
@@ -105,28 +96,10 @@ export const getMenuList = ({
submenus: [
{ href: "/providers", label: "Cloud Providers", icon: CloudCog },
{
href: "/providers",
href: "/mutelist",
label: "Mutelist",
icon: VolumeX,
disabled: hasProviders === false,
active: false,
onClick: (event: MouseEvent<HTMLAnchorElement>) => {
if (hasProviders === false) {
event.preventDefault();
event.stopPropagation();
return;
}
requestMutelistModalOpen?.();
if (pathname !== "/providers") {
return;
}
event.preventDefault();
event.stopPropagation();
openMutelistModal?.();
},
active: pathname === "/mutelist",
},
{ href: "/manage-groups", label: "Provider Groups", icon: Group },
{ href: "/scans", label: "Scan Jobs", icon: Timer },

View File

@@ -49,6 +49,20 @@ export default auth((req: NextRequest & { auth: any }) => {
}
}
// Redirect /findings to include default muted filter if not present
if (
pathname === "/findings" &&
!req.nextUrl.searchParams.has("filter[muted]")
) {
const findingsUrl = new URL("/findings", req.url);
// Preserve existing search params
req.nextUrl.searchParams.forEach((value, key) => {
findingsUrl.searchParams.set(key, value);
});
findingsUrl.searchParams.set("filter[muted]", "false");
return NextResponse.redirect(findingsUrl);
}
return NextResponse.next();
});

View File

@@ -36,6 +36,7 @@
"@next/third-parties": "15.5.9",
"@radix-ui/react-alert-dialog": "1.1.14",
"@radix-ui/react-avatar": "1.1.11",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-collapsible": "1.1.12",
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-dropdown-menu": "2.1.15",

32
ui/pnpm-lock.yaml generated
View File

@@ -54,6 +54,9 @@ importers:
'@radix-ui/react-avatar':
specifier: 1.1.11
version: 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-checkbox':
specifier: 1.3.3
version: 1.3.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-collapsible':
specifier: 1.1.12
version: 1.1.12(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
@@ -2182,6 +2185,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collapsible@1.1.11':
resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==}
peerDependencies:
@@ -11185,6 +11201,22 @@ snapshots:
'@types/react': 19.1.13
'@types/react-dom': 19.1.9(@types/react@19.1.13)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.13)(react@19.2.2)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.13)(react@19.2.2)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.13)(react@19.2.2)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.13)(react@19.2.2)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.1.13)(react@19.2.2)
react: 19.2.2
react-dom: 19.2.2(react@19.2.2)
optionalDependencies:
'@types/react': 19.1.13
'@types/react-dom': 19.1.9(@types/react@19.1.13)
'@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.9(@types/react@19.1.13))(@types/react@19.1.13)(react-dom@19.2.2(react@19.2.2))(react@19.2.2)':
dependencies:
'@radix-ui/primitive': 1.1.2

View File

@@ -3,37 +3,21 @@ import { persist } from "zustand/middleware";
interface UIStoreState {
isSideMenuOpen: boolean;
isMutelistModalOpen: boolean;
hasProviders: boolean;
shouldAutoOpenMutelist: boolean;
openSideMenu: () => void;
closeSideMenu: () => void;
openMutelistModal: () => void;
closeMutelistModal: () => void;
setHasProviders: (value: boolean) => void;
requestMutelistModalOpen: () => void;
resetMutelistModalRequest: () => void;
}
export const useUIStore = create<UIStoreState>()(
persist(
(set) => ({
isSideMenuOpen: false,
isMutelistModalOpen: false,
hasProviders: false,
shouldAutoOpenMutelist: false,
openSideMenu: () => set({ isSideMenuOpen: true }),
closeSideMenu: () => set({ isSideMenuOpen: false }),
openMutelistModal: () =>
set({
isMutelistModalOpen: true,
shouldAutoOpenMutelist: false,
}),
closeMutelistModal: () => set({ isMutelistModalOpen: false }),
setHasProviders: (value: boolean) => set({ hasProviders: value }),
requestMutelistModalOpen: () => set({ shouldAutoOpenMutelist: true }),
resetMutelistModalRequest: () => set({ shouldAutoOpenMutelist: false }),
}),
{
name: "ui-store",

62
ui/types/mute-rules.ts Normal file
View File

@@ -0,0 +1,62 @@
export interface MuteRuleAttributes {
inserted_at: string;
updated_at: string;
name: string;
reason: string;
enabled: boolean;
finding_uids: string[];
}
export interface MuteRuleRelationships {
created_by?: {
data: {
type: "users";
id: string;
} | null;
};
}
export interface MuteRuleData {
type: "mute-rules";
id: string;
attributes: MuteRuleAttributes;
relationships?: MuteRuleRelationships;
}
export interface MuteRulesResponse {
data: MuteRuleData[];
meta: {
pagination: {
page: number;
pages: number;
count: number;
};
};
links: {
first: string;
last: string;
next: string | null;
prev: string | null;
};
}
export interface MuteRuleResponse {
data: MuteRuleData;
}
export type MuteRuleActionState = {
errors?: {
name?: string;
reason?: string;
finding_ids?: string;
general?: string;
};
success?: string;
} | null;
export type DeleteMuteRuleActionState = {
errors?: {
general?: string;
};
success?: string;
} | null;