From cd90a911584e570f626d884691246ae46d3df9a9 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:43:06 +0200 Subject: [PATCH] fix(ui): address findings triage QA feedback (#11791) --- .../findings/findings-triage.adapter.test.ts | 1 - .../findings/findings-triage.adapter.ts | 2 - .../findings-view/findings-view.ssr.tsx | 3 +- .../table/column-finding-resources.test.tsx | 39 ++++++++ .../table/column-finding-resources.tsx | 13 +-- .../table/column-standalone-findings.test.tsx | 7 ++ .../table/column-standalone-findings.tsx | 2 +- .../table/finding-note-modal.test.tsx | 59 +++++++++++-- .../findings/table/finding-note-modal.tsx | 75 +++++++++++----- .../table/finding-triage-cells.test.tsx | 88 +++++++++++++++++-- .../findings/table/finding-triage-cells.tsx | 39 +++++++- .../table/finding-triage-status-control.tsx | 38 ++++---- .../table/finding-triage-submit.test.ts | 2 - .../resource-detail-drawer-content.test.tsx | 6 ++ .../resource-detail-drawer-content.tsx | 2 + .../table/column-latest-findings.tsx | 8 ++ ui/components/shadcn/dialog.tsx | 2 +- ui/lib/external-urls.ts | 2 + ui/types/findings-triage.ts | 14 ++- 19 files changed, 333 insertions(+), 69 deletions(-) diff --git a/ui/actions/findings/findings-triage.adapter.test.ts b/ui/actions/findings/findings-triage.adapter.test.ts index 6df034cc40..c84500677c 100644 --- a/ui/actions/findings/findings-triage.adapter.test.ts +++ b/ui/actions/findings/findings-triage.adapter.test.ts @@ -400,7 +400,6 @@ describe("adaptFindingTriageDetailResponse", () => { canEdit: true, noteBody: "Current note visible only inside the modal.", maxNoteLength: 500, - privacyCopy: "This note is only visible to your team.", }), ); }); diff --git a/ui/actions/findings/findings-triage.adapter.ts b/ui/actions/findings/findings-triage.adapter.ts index f00db7e33b..2ddfec94e9 100644 --- a/ui/actions/findings/findings-triage.adapter.ts +++ b/ui/actions/findings/findings-triage.adapter.ts @@ -1,7 +1,6 @@ import { FINDING_TRIAGE_BILLING_HREF, FINDING_TRIAGE_NOTE_MAX_LENGTH, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_STATUS, FINDING_TRIAGE_STATUS_LABELS, type FindingTriageDetail, @@ -215,6 +214,5 @@ export function adaptFindingTriageDetailResponse( noteId: attributes.note_id || null, noteBody, maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, }; } diff --git a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx index 0396795cb9..a9cef2cb36 100644 --- a/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx +++ b/ui/app/(prowler)/_overview/graphs-tabs/findings-view/findings-view.ssr.tsx @@ -6,7 +6,7 @@ import { LinkToFindings } from "@/components/overview"; import { ColumnLatestFindings } from "@/components/overview/new-findings-table/table"; import { CardTitle } from "@/components/shadcn"; import { DataTable } from "@/components/ui/table"; -import { FINDINGS_FILTERED_SORT } from "@/lib"; +import { FINDINGS_FILTERED_SORT, MUTED_FILTER } from "@/lib"; import { createDict } from "@/lib/helper"; import { FindingProps, SearchParamsProps } from "@/types"; @@ -23,6 +23,7 @@ export async function FindingsViewSSR({ searchParams }: FindingsViewSSRProps) { const defaultFilters = { "filter[status]": "FAIL", "filter[delta]": "new", + "filter[muted]": MUTED_FILTER.EXCLUDE, }; const filters = pickFilterParams(searchParams); diff --git a/ui/components/findings/table/column-finding-resources.test.tsx b/ui/components/findings/table/column-finding-resources.test.tsx index 7c56f7f683..bb43873da3 100644 --- a/ui/components/findings/table/column-finding-resources.test.tsx +++ b/ui/components/findings/table/column-finding-resources.test.tsx @@ -7,6 +7,13 @@ import type { } from "react"; import { describe, expect, it, vi } from "vitest"; +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/shadcn", () => ({ Button: ({ children, ...props }: ButtonHTMLAttributes) => ( @@ -394,6 +401,38 @@ describe("column-finding-resources", () => { expect(screen.getByText(EDITING_UNAVAILABLE_COPY)).toBeInTheDocument(); }); + it("should keep the compact Triage label on resource cells for headerless nested rows", () => { + // Given + const columns = getColumnFindingResources({ + rowSelection: {}, + selectableRowCount: 1, + }); + const triageColumn = columns.find( + (col) => (col as { id?: string }).id === "triage", + ); + if (!triageColumn?.cell) { + throw new Error("triage column not found"); + } + const CellComponent = triageColumn.cell as (props: { + row: { original: FindingResourceRow }; + }) => ReactNode; + + // When + render( +
+ {CellComponent({ + row: { + original: makeResource({ triage: makeTriageSummary() }), + }, + })} +
, + ); + + // Then — expanded finding-group rows render without a header row, so the + // cell itself must carry the label, like Service/Region/Last seen do. + expect(screen.getByText("Triage")).toBeInTheDocument(); + }); + it("should disable non-paying Cloud triage control with only-in-Cloud tooltip copy", () => { // Given const columns = getColumnFindingResources({ diff --git a/ui/components/findings/table/column-finding-resources.tsx b/ui/components/findings/table/column-finding-resources.tsx index 37761e1e87..7ab2cb01ff 100644 --- a/ui/components/findings/table/column-finding-resources.tsx +++ b/ui/components/findings/table/column-finding-resources.tsx @@ -354,17 +354,20 @@ export function getColumnFindingResources({ }, enableSorting: false, }, - // Triage + // Triage — keep the compact label: these cells also render inside + // expanded finding-group rows, which have no header row of their own. { id: "triage", header: ({ column }) => ( ), cell: ({ row }) => ( - + + + ), enableSorting: false, }, diff --git a/ui/components/findings/table/column-standalone-findings.test.tsx b/ui/components/findings/table/column-standalone-findings.test.tsx index 94d0f86adc..7b9a2afe8c 100644 --- a/ui/components/findings/table/column-standalone-findings.test.tsx +++ b/ui/components/findings/table/column-standalone-findings.test.tsx @@ -5,6 +5,13 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }), })); +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/findings/mute-findings-modal", () => ({ MuteFindingsModal: () => null, })); diff --git a/ui/components/findings/table/column-standalone-findings.tsx b/ui/components/findings/table/column-standalone-findings.tsx index e8837d0462..7a7c8bb8b4 100644 --- a/ui/components/findings/table/column-standalone-findings.tsx +++ b/ui/components/findings/table/column-standalone-findings.tsx @@ -67,7 +67,7 @@ function FindingTitleCell({ finding={finding} defaultOpen={defaultOpen} trigger={ -
+

{finding.attributes.check_metadata.checktitle}

diff --git a/ui/components/findings/table/finding-note-modal.test.tsx b/ui/components/findings/table/finding-note-modal.test.tsx index 73b6a2f61c..4d40c382a3 100644 --- a/ui/components/findings/table/finding-note-modal.test.tsx +++ b/ui/components/findings/table/finding-note-modal.test.tsx @@ -9,6 +9,13 @@ vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({ ), })); +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/shadcn/modal", () => ({ Modal: ({ children, @@ -44,7 +51,6 @@ beforeAll(() => { import { FINDING_TRIAGE_DISABLED_REASON, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_STATUS, type FindingTriageDetail, type UpdateFindingTriageInput, @@ -72,7 +78,6 @@ function makeTriageDetail( noteId: "note-1", noteBody: "Existing investigation note", maxNoteLength: 500, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, ...overrides, }; } @@ -246,7 +251,45 @@ describe("FindingNoteModal", () => { expect(onOpenChange).not.toHaveBeenCalledWith(false); }); - it("should render counter, privacy copy, and cancel/update actions", async () => { + it("should lock the status picker for resolved findings while keeping the note editable", async () => { + // Given + const user = userEvent.setup(); + const onTriageUpdateAction = vi.fn(); + renderNoteModal({ + triage: makeTriageDetail({ + status: FINDING_TRIAGE_STATUS.RESOLVED, + label: "Resolved", + }), + onTriageUpdateAction, + }); + + // Then — automation owns the transition out of Resolved. + expect( + screen.getByRole("combobox", { name: "Triage status" }), + ).toBeDisabled(); + expect( + screen.getByText( + "Triage status is managed automatically once the finding is resolved.", + ), + ).toBeVisible(); + expect(screen.getByLabelText("Note text")).toBeEnabled(); + + // When — the note itself can still be updated. + const textarea = screen.getByLabelText("Note text"); + await user.clear(textarea); + await user.type(textarea, "Documenting the resolution."); + await user.click(screen.getByRole("button", { name: "Save changes" })); + + // Then + expect(onTriageUpdateAction).toHaveBeenCalledWith( + expect.objectContaining({ note: "Documenting the resolution." }), + ); + expect(onTriageUpdateAction).toHaveBeenCalledWith( + expect.not.objectContaining({ status: expect.anything() }), + ); + }); + + it("should render counter and cancel/update actions without privacy copy", async () => { // Given const user = userEvent.setup(); const onOpenChange = vi.fn(); @@ -258,7 +301,9 @@ describe("FindingNoteModal", () => { // Then expect(screen.getByText("3/500")).toBeInTheDocument(); - expect(screen.getByText(FINDING_TRIAGE_NOTE_PRIVACY_COPY)).toBeVisible(); + expect( + screen.queryByText("This note is only visible to your team."), + ).not.toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Cancel" })); expect(onOpenChange).toHaveBeenCalledWith(false); expect( @@ -305,7 +350,11 @@ describe("FindingNoteModal", () => { await user.click(screen.getByRole("option", { name: "Risk Accepted" })); // Then - expect(screen.getByText(/will be muted/i)).toBeVisible(); + expect( + screen.getByText( + "Changing triage to Risk Accepted will mute the finding", + ), + ).toBeVisible(); await waitFor(() => expect(screen.queryByRole("listbox")).not.toBeInTheDocument(), ); diff --git a/ui/components/findings/table/finding-note-modal.tsx b/ui/components/findings/table/finding-note-modal.tsx index 02f1557579..90c9241017 100644 --- a/ui/components/findings/table/finding-note-modal.tsx +++ b/ui/components/findings/table/finding-note-modal.tsx @@ -5,14 +5,24 @@ import { type FormEvent, useRef, useState } from "react"; import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon"; import { Alert, AlertDescription, Button, Textarea } from "@/components/shadcn"; import { Modal } from "@/components/shadcn/modal"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/shadcn/tooltip"; import { CloudFeatureBadgeLink } from "@/components/shared/cloud-feature-badge"; +import { CustomLink } from "@/components/ui/custom/custom-link"; +import { DOCS_URLS } from "@/lib/external-urls"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_ORIGIN, + FINDING_TRIAGE_RESOLVED_LOCKED_COPY, FINDING_TRIAGE_STATUS, type FindingTriageDetail, type FindingTriageStatus, + getFindingTriageMuteInfoCopy, isMutelistShortcutStatus, + isTriageStatusLocked, } from "@/types/findings-triage"; import type { ProviderType } from "@/types/providers"; @@ -37,10 +47,8 @@ interface FindingNoteModalProps { onTriageUpdateAction?: FindingTriageUpdateHandler; } -const MUTELIST_INFO_COPY = - "This finding will be muted through the existing Mutelist flow."; const REMEDIATING_INFO_COPY = - "Once this finding is fixed and passes in the next scan, it will be automatically changed to Resolved."; + "Once this finding is remediated, if in the following scan its status changes to Pass, it will be automatically changed to Resolved"; export function FindingNoteModal({ open, @@ -68,6 +76,7 @@ export function FindingNoteModal({ isMutelistShortcutStatus(selectedStatus); const shouldShowRemediatingInfo = selectedStatus === FINDING_TRIAGE_STATUS.REMEDIATING; + const isStatusLocked = isTriageStatusLocked(triage.status); // Opened from a dropdown item: move focus into the dialog on mount so Radix's // aria-hidden is not applied to the still-focused dropdown that opened it. const handleOpenAutoFocus = (event: Event) => { @@ -118,7 +127,10 @@ export function FindingNoteModal({ title="Add Triage Note" size="lg" > -
+ {/* min-w-0: the form is a grid item of DialogContent; without it, long + unbreakable content (e.g. resource UIDs) widens the grid track past + the modal instead of truncating. */} +
{findingContext.providerType ? ( @@ -129,12 +141,17 @@ export function FindingNoteModal({ )}
-
-

- {findingContext.title} -

+
+ + +

+ {findingContext.title} +

+
+ {findingContext.title} +
{(findingContext.resource || findingContext.provider) && ( -

+

{[findingContext.resource, findingContext.provider] .filter(Boolean) .join(" · ")} @@ -143,27 +160,44 @@ export function FindingNoteModal({

-
+
Status: - +
+ +
+ {isStatusLocked && ( + + + {FINDING_TRIAGE_RESOLVED_LOCKED_COPY} + + + )} + {shouldShowMutelistInfo && ( - {MUTELIST_INFO_COPY} + + {getFindingTriageMuteInfoCopy(selectedStatus)} + )} {shouldShowRemediatingInfo && ( - {REMEDIATING_INFO_COPY} + + {REMEDIATING_INFO_COPY}.{" "} + + Learn more + + )} @@ -184,10 +218,7 @@ export function FindingNoteModal({ textareaSize="lg" onChange={(event) => setNote(event.target.value)} /> -
-

- {triage.privacyCopy} -

+

{note.length}/{triage.maxNoteLength}

diff --git a/ui/components/findings/table/finding-triage-cells.test.tsx b/ui/components/findings/table/finding-triage-cells.test.tsx index eeb67db2eb..3d0a417860 100644 --- a/ui/components/findings/table/finding-triage-cells.test.tsx +++ b/ui/components/findings/table/finding-triage-cells.test.tsx @@ -8,14 +8,17 @@ vi.mock("@/components/shadcn/modal", () => ({ children, open, title, + description, }: { children: ReactNode; open: boolean; title?: string; + description?: string; }) => open ? (

{title}

+ {description &&

{description}

} {children}
) : null, @@ -27,6 +30,13 @@ vi.mock("next/navigation", () => ({ }), })); +// CustomLink pulls the "@/lib" barrel (and next-auth with it) into the unit env. +vi.mock("@/components/ui/custom/custom-link", () => ({ + CustomLink: ({ href, children }: { href: string; children: ReactNode }) => ( + {children} + ), +})); + vi.mock("@/components/shadcn/dropdown", () => ({ ActionDropdownItem: ({ label, @@ -66,6 +76,7 @@ import { import { FindingNoteActionItem, + FindingTriageStatusBadge, FindingTriageStatusCell, } from "./finding-triage-cells"; @@ -162,11 +173,6 @@ describe("finding triage cells", () => { await user.click(statusControl); // Then - expect(screen.getByText("Triage").parentElement).toHaveClass( - "text-text-neutral-secondary", - "text-[10px]", - "whitespace-nowrap", - ); expect(statusControl.parentElement).toHaveClass("w-32"); expect(statusControl).toHaveAttribute("data-size", "xs"); expect(within(statusControl).getByText("Under Review")).toHaveClass( @@ -197,6 +203,22 @@ describe("finding triage cells", () => { ).toHaveClass("text-text-neutral-secondary"); }); + it("renders a read-only triage status badge with the status color", () => { + // Given / When + render( + , + ); + + // Then + expect(screen.getByText("Triage:")).toBeInTheDocument(); + expect(screen.getByText("Remediating")).toHaveClass("text-bg-data-info"); + }); + it("should disable table status mutation when no update handler is wired", async () => { // Given const user = userEvent.setup(); @@ -222,6 +244,57 @@ describe("finding triage cells", () => { expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); }); + it("should lock the table status picker for resolved findings", async () => { + // Given + const user = userEvent.setup(); + const onTriageUpdateAction = vi.fn(); + render( + , + ); + + const statusControl = screen.getByRole("combobox", { + name: "Triage status", + }); + + // When + await user.click(statusControl); + + // Then — automation owns the transition out of Resolved. + expect(statusControl).toBeDisabled(); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect( + screen.getAllByText( + "Triage status is managed automatically once the finding is resolved.", + ).length, + ).toBeGreaterThan(0); + expect(onTriageUpdateAction).not.toHaveBeenCalled(); + }); + + it("should keep the note action available for resolved findings", () => { + // Given + render( + , + ); + + // Then — the lock only applies to status transitions, not notes. + expect( + screen.getByRole("button", { name: "Add Triage Note" }), + ).toBeEnabled(); + }); + it("should not open an editable empty-note modal for an existing note without a loader", async () => { // Given const user = userEvent.setup(); @@ -647,6 +720,11 @@ describe("finding triage cells", () => { // Then: the user is warned before the server action handles muting. expect(screen.getByRole("dialog", { name: "Mute finding?" })).toBeVisible(); + expect( + screen.getByText( + "Changing triage to False Positive will mute the finding", + ), + ).toBeVisible(); expect(onTriageUpdateAction).not.toHaveBeenCalled(); // When diff --git a/ui/components/findings/table/finding-triage-cells.tsx b/ui/components/findings/table/finding-triage-cells.tsx index d86551da40..1ce3f98667 100644 --- a/ui/components/findings/table/finding-triage-cells.tsx +++ b/ui/components/findings/table/finding-triage-cells.tsx @@ -9,16 +9,18 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn/tooltip"; +import { cn } from "@/lib/utils"; import { FINDING_TRIAGE_DISABLED_REASON, FINDING_TRIAGE_NOTE_MAX_LENGTH, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_ORIGIN, + FINDING_TRIAGE_RESOLVED_LOCKED_COPY, FINDING_TRIAGE_STATUS_LABELS, type FindingTriageDetail, type FindingTriageLoadedNote, type FindingTriageStatus, type FindingTriageSummary, + isTriageStatusLocked, type UpdateFindingTriageInput, } from "@/types/findings-triage"; @@ -29,6 +31,7 @@ import { import { FindingTriageStatusControl, type FindingTriageUpdateHandler, + TRIAGE_STATUS_TEXT_CLASS, } from "./finding-triage-status-control"; export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud"; @@ -37,14 +40,21 @@ export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable."; const getDisabledCopy = ({ triage, hasUpdateHandler, + lockResolved = false, }: { triage: FindingTriageSummary; hasUpdateHandler: boolean; + lockResolved?: boolean; }): string | undefined => { if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.CLOUD_ONLY) { return CLOUD_ONLY_TOOLTIP_COPY; } + // Status-picker only: notes stay available on resolved findings. + if (lockResolved && isTriageStatusLocked(triage.status)) { + return FINDING_TRIAGE_RESOLVED_LOCKED_COPY; + } + if (triage.canEdit && !hasUpdateHandler) { return EDITING_UNAVAILABLE_COPY; } @@ -60,7 +70,6 @@ const getTriageDetailFromSummary = ( noteId: loadedNote?.noteId ?? null, noteBody: loadedNote?.noteBody ?? "", maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, }); export function FindingTriageStatusCell({ @@ -151,6 +160,7 @@ export function FindingTriageStatusCell({ const disabledCopy = getDisabledCopy({ triage, hasUpdateHandler: Boolean(onTriageUpdateAction), + lockResolved: true, }); if (!disabledCopy) { return control; @@ -159,8 +169,7 @@ export function FindingTriageStatusCell({ return ( - {/* Block-level so the tooltip wrapper doesn't add inline baseline spacing - that would push the compact "Triage" label below the sibling columns. */} + {/* Block-level wrapper keeps the picker aligned with the sibling columns. */} {control} {disabledCopy} @@ -168,6 +177,28 @@ export function FindingTriageStatusCell({ ); } +// Read-only triage status indicator, e.g. for the side drawer header where the +// editable picker would be out of place among the status/severity badges. +export function FindingTriageStatusBadge({ + triage, +}: { + triage: FindingTriageSummary; +}) { + return ( +
+ Triage: + + {triage.label} + +
+ ); +} + export function FindingNoteActionItem({ triage, findingContext = { title: "Finding" }, diff --git a/ui/components/findings/table/finding-triage-status-control.tsx b/ui/components/findings/table/finding-triage-status-control.tsx index e50fac1157..2b6a7a2407 100644 --- a/ui/components/findings/table/finding-triage-status-control.tsx +++ b/ui/components/findings/table/finding-triage-status-control.tsx @@ -3,7 +3,6 @@ import { type ComponentProps, useState } from "react"; import { Button } from "@/components/shadcn"; -import { InfoField } from "@/components/shadcn/info-field/info-field"; import { Modal } from "@/components/shadcn/modal"; import { Select, @@ -19,8 +18,10 @@ import { type FindingTriageManualStatus, type FindingTriageStatus, type FindingTriageSummary, + getFindingTriageMuteInfoCopy, isManualStatus, isMutelistShortcutStatus, + isTriageStatusLocked, type UpdateFindingTriageInput, } from "@/types/findings-triage"; @@ -32,7 +33,7 @@ type TriageStatusPickerSize = NonNullable< ComponentProps["size"] >; -const TRIAGE_STATUS_TEXT_CLASS = { +export const TRIAGE_STATUS_TEXT_CLASS = { open: "text-text-error-primary", under_review: "text-text-warning-primary", remediating: "text-bg-data-info", @@ -43,8 +44,6 @@ const TRIAGE_STATUS_TEXT_CLASS = { } as const satisfies Record; const MUTELIST_CONFIRMATION_TITLE = "Mute finding?"; -const MUTELIST_CONFIRMATION_COPY = - "Changing to this triage status will mute the finding."; function TriageStatusPicker({ disabled, @@ -119,7 +118,7 @@ export function FindingTriageStatusControl( if (props.origin === FINDING_TRIAGE_ORIGIN.MODAL) { return ( @@ -127,7 +126,10 @@ export function FindingTriageStatusControl( } const canMutateFromTable = - triage.canEdit && Boolean(props.onTriageUpdateAction) && !isTableUpdating; + triage.canEdit && + Boolean(props.onTriageUpdateAction) && + !isTableUpdating && + !isTriageStatusLocked(triage.status); const applyTableStatus = async (status: FindingTriageManualStatus) => { if (!props.onTriageUpdateAction || status === triage.status) { @@ -174,16 +176,14 @@ export function FindingTriageStatusControl( return ( <> - -
- -
-
+
+ +
{tableUpdateError && ( {tableUpdateError} @@ -197,7 +197,11 @@ export function FindingTriageStatusControl( } }} title={MUTELIST_CONFIRMATION_TITLE} - description={MUTELIST_CONFIRMATION_COPY} + description={ + pendingShortcutStatus + ? getFindingTriageMuteInfoCopy(pendingShortcutStatus) + : undefined + } size="sm" >
diff --git a/ui/components/findings/table/finding-triage-submit.test.ts b/ui/components/findings/table/finding-triage-submit.test.ts index 15ca3e70ea..12315129f0 100644 --- a/ui/components/findings/table/finding-triage-submit.test.ts +++ b/ui/components/findings/table/finding-triage-submit.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { FINDING_TRIAGE_NOTE_MAX_LENGTH, - FINDING_TRIAGE_NOTE_PRIVACY_COPY, FINDING_TRIAGE_STATUS, type FindingTriageDetail, } from "@/types/findings-triage"; @@ -26,7 +25,6 @@ function makeTriageDetail( noteId: "note-1", noteBody: "Existing investigation note", maxNoteLength: FINDING_TRIAGE_NOTE_MAX_LENGTH, - privacyCopy: FINDING_TRIAGE_NOTE_PRIVACY_COPY, ...overrides, }; } diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx index dcb66a2865..d413b8ac08 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.test.tsx @@ -461,6 +461,12 @@ vi.mock("../finding-triage-cells", () => ({ ) : ( - ), + FindingTriageStatusBadge: ({ triage }: { triage: { label: string } }) => ( +
+ Triage: + {triage.label} +
+ ), })); vi.mock("./resource-detail-skeleton", () => ({ diff --git a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx index 3ce3258c5b..64feea3b60 100644 --- a/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx +++ b/ui/components/findings/table/resource-detail-drawer/resource-detail-drawer-content.tsx @@ -84,6 +84,7 @@ import { Muted } from "../../muted"; import { DeltaIndicator } from "../delta-indicator"; import { FindingNoteActionItem, + FindingTriageStatusBadge, FindingTriageStatusCell, } from "../finding-triage-cells"; import { DeltaValues, NotificationIndicator } from "../notification-indicator"; @@ -560,6 +561,7 @@ export function ResourceDetailDrawerContent({ {findingIsMuted !== undefined && ( )} + {findingTriage && }
{showCheckMetaContent ? ( diff --git a/ui/components/overview/new-findings-table/table/column-latest-findings.tsx b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx index 6310e2dc40..0b2737bad9 100644 --- a/ui/components/overview/new-findings-table/table/column-latest-findings.tsx +++ b/ui/components/overview/new-findings-table/table/column-latest-findings.tsx @@ -2,10 +2,18 @@ import { ColumnDef } from "@tanstack/react-table"; +import { + loadLatestFindingTriageNote, + updateFindingTriage, +} from "@/actions/findings"; import { getStandaloneFindingColumns } from "@/components/findings/table/column-standalone-findings"; import { FindingProps } from "@/types"; export const ColumnLatestFindings: ColumnDef[] = getStandaloneFindingColumns({ includeUpdatedAt: true, + onTriageUpdateAction: async (input) => { + await updateFindingTriage(input); + }, + onTriageNoteLoadAction: loadLatestFindingTriageNote, }); diff --git a/ui/components/shadcn/dialog.tsx b/ui/components/shadcn/dialog.tsx index 63721874a1..e260eae95c 100644 --- a/ui/components/shadcn/dialog.tsx +++ b/ui/components/shadcn/dialog.tsx @@ -85,7 +85,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/ui/lib/external-urls.ts b/ui/lib/external-urls.ts index f56461e7a5..f8eb1b7d73 100644 --- a/ui/lib/external-urls.ts +++ b/ui/lib/external-urls.ts @@ -6,6 +6,8 @@ export const DOCS_URLS = { "https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings", FINDINGS_INGESTION: "https://docs.prowler.com/user-guide/tutorials/prowler-app-import-findings", + FINDINGS_TRIAGE: + "https://docs.prowler.com/user-guide/tutorials/prowler-app-findings-triage", AWS_ORGANIZATIONS: "https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations", ALERTS: "https://docs.prowler.com/user-guide/tutorials/prowler-app-alerts", diff --git a/ui/types/findings-triage.ts b/ui/types/findings-triage.ts index 6dcfe8ef5f..c0a9c9ef02 100644 --- a/ui/types/findings-triage.ts +++ b/ui/types/findings-triage.ts @@ -54,6 +54,17 @@ export const isMutelistShortcutStatus = (status: unknown): boolean => { ); }; +export const getFindingTriageMuteInfoCopy = (status: FindingTriageStatus) => + `Changing triage to ${FINDING_TRIAGE_STATUS_LABELS[status]} will mute the finding`; + +// Only RESOLVED locks manual edits: automation owns the transition out of it +// (REOPENED on a failing rescan), while REOPENED invites human re-triage. +export const isTriageStatusLocked = (status: FindingTriageStatus): boolean => + status === FINDING_TRIAGE_STATUS.RESOLVED; + +export const FINDING_TRIAGE_RESOLVED_LOCKED_COPY = + "Triage status is managed automatically once the finding is resolved." as const; + export const FINDING_TRIAGE_DISABLED_REASON = { CLOUD_ONLY: "cloud_only", FORBIDDEN: "forbidden", @@ -69,8 +80,6 @@ export const FINDING_TRIAGE_ORIGIN = { } as const; export const FINDING_TRIAGE_NOTE_MAX_LENGTH = 500 as const; -export const FINDING_TRIAGE_NOTE_PRIVACY_COPY = - "This note is only visible to your team." as const; export const FINDING_TRIAGE_BILLING_HREF = "https://prowler.com/pricing" as const; @@ -92,7 +101,6 @@ export interface FindingTriageDetail extends FindingTriageSummary { noteId: string | null; noteBody: string; maxNoteLength: typeof FINDING_TRIAGE_NOTE_MAX_LENGTH; - privacyCopy: typeof FINDING_TRIAGE_NOTE_PRIVACY_COPY; } export interface UpdateFindingTriageInput {