mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
feat(mutelisting): add mute button inside finding detailed view (#10303)
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## [1.20.0] (Prowler v5.20.0)
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Mute button in the finding detailed view, allowing users to mute findings directly without going back to the table [(#10303)](https://github.com/prowler-cloud/prowler/pull/10303)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths: Improved error handling for server errors (5xx) and network failures with user-friendly messages instead of raw internal errors and layout changes [(#10249)](https://github.com/prowler-cloud/prowler/pull/10249)
|
||||
|
||||
7
ui/app/(prowler)/attack-paths/(workflow)/layout.tsx
Normal file
7
ui/app/(prowler)/attack-paths/(workflow)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function WorkflowLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
295
ui/components/findings/table/finding-detail.test.tsx
Normal file
295
ui/components/findings/table/finding-detail.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FindingProps } from "@/types";
|
||||
|
||||
import { FindingDetail } from "./finding-detail";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRefresh = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: mockRefresh }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock @/components/shadcn to avoid next-auth import chain
|
||||
vi.mock("@/components/shadcn", () => {
|
||||
const Slot = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
return {
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}) => <button {...props}>{children}</button>,
|
||||
Drawer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
DrawerClose: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DrawerContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DrawerDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DrawerHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DrawerTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
DrawerTrigger: Slot,
|
||||
InfoField: ({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
label: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tabs: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TabsContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TabsList: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TabsTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<>{children}</>
|
||||
),
|
||||
TooltipTrigger: Slot,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
|
||||
CodeSnippet: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children }: { children: React.ReactNode }) => (
|
||||
<span>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities", () => ({
|
||||
EntityInfo: () => <div data-testid="entity-info" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/severity-badge", () => ({
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/status-finding-badge", () => ({
|
||||
FindingStatus: {},
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/iac-utils", () => ({
|
||||
buildGitFileUrl: () => null,
|
||||
extractLineRangeFromUid: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...args: string[]) => args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
// Mock child components that are not under test
|
||||
vi.mock("../mute-findings-modal", () => ({
|
||||
MuteFindingsModal: ({
|
||||
isOpen,
|
||||
findingIds,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
findingIds: string[];
|
||||
}) =>
|
||||
isOpen ? (
|
||||
<div data-testid="mute-modal">Muting {findingIds.length} finding(s)</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("../muted", () => ({
|
||||
Muted: ({ isMuted }: { isMuted: boolean }) =>
|
||||
isMuted ? <span data-testid="muted-badge">Muted</span> : null,
|
||||
}));
|
||||
|
||||
vi.mock("./delta-indicator", () => ({
|
||||
DeltaIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("react-markdown", () => ({
|
||||
default: ({ children }: { children: string }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
const baseFinding: FindingProps = {
|
||||
type: "findings",
|
||||
id: "finding-123",
|
||||
attributes: {
|
||||
uid: "uid-123",
|
||||
delta: null,
|
||||
status: "FAIL",
|
||||
status_extended: "S3 bucket is publicly accessible",
|
||||
severity: "high",
|
||||
check_id: "s3_bucket_public_access",
|
||||
muted: false,
|
||||
check_metadata: {
|
||||
risk: "Public access risk",
|
||||
notes: "",
|
||||
checkid: "s3_bucket_public_access",
|
||||
provider: "aws",
|
||||
severity: "high",
|
||||
checktype: [],
|
||||
dependson: [],
|
||||
relatedto: [],
|
||||
categories: ["security"],
|
||||
checktitle: "S3 Bucket Public Access Check",
|
||||
compliance: null,
|
||||
relatedurl: "",
|
||||
description: "Checks if S3 buckets are publicly accessible",
|
||||
remediation: {
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
recommendation: { url: "", text: "" },
|
||||
},
|
||||
servicename: "s3",
|
||||
checkaliases: [],
|
||||
resourcetype: "AwsS3Bucket",
|
||||
subservicename: "",
|
||||
resourceidtemplate: "",
|
||||
},
|
||||
raw_result: null,
|
||||
inserted_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-02T00:00:00Z",
|
||||
first_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [{ type: "resources", id: "res-1" }] },
|
||||
scan: {
|
||||
data: { type: "scans", id: "scan-1" },
|
||||
attributes: {
|
||||
name: "Daily Scan",
|
||||
trigger: "scheduled",
|
||||
state: "completed",
|
||||
unique_resource_count: 50,
|
||||
progress: 100,
|
||||
scanner_args: { checks_to_execute: [] },
|
||||
duration: 120,
|
||||
started_at: "2024-01-01T00:00:00Z",
|
||||
inserted_at: "2024-01-01T00:00:00Z",
|
||||
completed_at: "2024-01-01T00:02:00Z",
|
||||
scheduled_at: null,
|
||||
next_scan_at: "2024-01-02T00:00:00Z",
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
data: [{ type: "resources", id: "res-1" }],
|
||||
id: "res-1",
|
||||
attributes: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
region: "us-east-1",
|
||||
service: "s3",
|
||||
tags: {},
|
||||
type: "AwsS3Bucket",
|
||||
inserted_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
details: null,
|
||||
partition: "aws",
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { type: "providers", id: "prov-1" } },
|
||||
findings: {
|
||||
meta: { count: 1 },
|
||||
data: [{ type: "findings", id: "finding-123" }],
|
||||
},
|
||||
},
|
||||
links: { self: "/resources/res-1" },
|
||||
},
|
||||
provider: {
|
||||
data: { type: "providers", id: "prov-1" },
|
||||
attributes: {
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "my-account",
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
inserted_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: { type: "provider-secrets", id: "secret-1" } },
|
||||
},
|
||||
links: { self: "/providers/prov-1" },
|
||||
},
|
||||
},
|
||||
links: { self: "/findings/finding-123" },
|
||||
};
|
||||
|
||||
describe("FindingDetail", () => {
|
||||
it("shows the Mute button for non-muted findings", () => {
|
||||
render(<FindingDetail findingDetails={baseFinding} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /mute/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the Mute button for muted findings", () => {
|
||||
const mutedFinding: FindingProps = {
|
||||
...baseFinding,
|
||||
attributes: { ...baseFinding.attributes, muted: true },
|
||||
};
|
||||
|
||||
render(<FindingDetail findingDetails={mutedFinding} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /mute/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the mute modal when clicking the Mute button", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<FindingDetail findingDetails={baseFinding} />);
|
||||
|
||||
expect(screen.queryByTestId("mute-modal")).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /mute/i }));
|
||||
|
||||
expect(screen.getByTestId("mute-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the mute modal for muted findings", () => {
|
||||
const mutedFinding: FindingProps = {
|
||||
...baseFinding,
|
||||
attributes: { ...baseFinding.attributes, muted: true },
|
||||
};
|
||||
|
||||
render(<FindingDetail findingDetails={mutedFinding} />);
|
||||
|
||||
expect(screen.queryByTestId("mute-modal")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the muted badge for muted findings", () => {
|
||||
const mutedFinding: FindingProps = {
|
||||
...baseFinding,
|
||||
attributes: { ...baseFinding.attributes, muted: true },
|
||||
};
|
||||
|
||||
render(<FindingDetail findingDetails={mutedFinding} />);
|
||||
|
||||
expect(screen.getByTestId("muted-badge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, Link, X } from "lucide-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { ExternalLink, Link, VolumeX, X } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
@@ -35,6 +36,7 @@ import { buildGitFileUrl, extractLineRangeFromUid } from "@/lib/iac-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FindingProps, ProviderType } from "@/types";
|
||||
|
||||
import { MuteFindingsModal } from "../mute-findings-modal";
|
||||
import { Muted } from "../muted";
|
||||
import { DeltaIndicator } from "./delta-indicator";
|
||||
|
||||
@@ -85,8 +87,10 @@ export const FindingDetail = ({
|
||||
const resource = finding.relationships?.resource?.attributes;
|
||||
const scan = finding.relationships?.scan?.attributes;
|
||||
const providerDetails = finding.relationships?.provider?.attributes;
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
|
||||
const copyFindingUrl = () => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -106,6 +110,21 @@ export const FindingDetail = ({
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleMuteComplete = () => {
|
||||
setIsMuteModalOpen(false);
|
||||
onOpenChange?.(false);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const muteModal = !attributes.muted && (
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
findingIds={[findingDetails.id]}
|
||||
onComplete={handleMuteComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="flex min-w-0 flex-col gap-4 rounded-lg">
|
||||
{/* Header */}
|
||||
@@ -154,11 +173,24 @@ export const FindingDetail = ({
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="resources">Resources</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="resources">Resources</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{!attributes.muted && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsMuteModalOpen(true)}
|
||||
>
|
||||
<VolumeX className="size-4" />
|
||||
Mute
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* General Tab */}
|
||||
<TabsContent value="general" className="flex flex-col gap-4">
|
||||
@@ -444,29 +476,37 @@ export const FindingDetail = ({
|
||||
|
||||
// If no trigger, render content directly (inline mode)
|
||||
if (!trigger) {
|
||||
return content;
|
||||
return (
|
||||
<>
|
||||
{muteModal}
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// With trigger, wrap in Drawer
|
||||
// With trigger, wrap in Drawer — modal rendered outside to avoid nested overlay issues
|
||||
return (
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Finding Details</DrawerTitle>
|
||||
<DrawerDescription>View the finding details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<>
|
||||
{muteModal}
|
||||
<Drawer
|
||||
direction="right"
|
||||
open={open}
|
||||
defaultOpen={defaultOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="minimal-scrollbar 3xl:w-1/3 h-full w-full overflow-x-hidden overflow-y-auto p-6 md:w-1/2 md:max-w-none">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Finding Details</DrawerTitle>
|
||||
<DrawerDescription>View the finding details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerClose className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DrawerClose>
|
||||
{content}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user