From aa577b095c38236abdd04170f6ad22124bb29ea9 Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 12 Jun 2025 01:25:53 -0400 Subject: [PATCH 1/6] improvement(secret-scanning): Multi-select actions --- .../components/SecretScanningFindingRow.tsx | 20 +++++- .../SecretScanningFindingsTable.tsx | 62 ++++++++++++++++++- .../SecretScanningUpdateFindingModal.tsx | 47 ++++++++------ 3 files changed, 108 insertions(+), 21 deletions(-) diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingRow.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingRow.tsx index 7f47bf6c1..f2aea5a5f 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingRow.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingRow.tsx @@ -9,6 +9,7 @@ import { ProjectPermissionCan } from "@app/components/permissions"; import { Badge, Button, + Checkbox, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -34,11 +35,18 @@ import { } from "@app/hooks/api/secretScanningV2"; type Props = { + isSelected: boolean; + onToggleSelect: (e: boolean) => void; finding: TSecretScanningFinding; onUpdate: (finding: TSecretScanningFinding) => void; }; -export const SecretScanningFindingRow = ({ finding, onUpdate }: Props) => { +export const SecretScanningFindingRow = ({ + isSelected, + onToggleSelect, + finding, + onUpdate +}: Props) => { const { resourceName, id, @@ -84,6 +92,16 @@ export const SecretScanningFindingRow = ({ finding, onUpdate }: Props) => { )} key={`resource-${id}`} > + + onToggleSelect(!isSelected)} + onClick={(e) => { + e.stopPropagation(); + }} + /> +
{ status: initStatus ? [initStatus] : [] }); + const [selectedRows, setSelectedRows] = useState([]); + const { search, setSearch, @@ -276,6 +285,20 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => { + {filteredFindings.slice(offset, perPage * page).map((finding) => ( + v + ? setSelectedRows((sr) => [...sr, finding.id]) + : setSelectedRows((sr) => sr.filter((r) => r !== finding.id)) + } key={finding.id} finding={finding} - onUpdate={() => handlePopUpOpen("updateFinding", finding)} + onUpdate={() => handlePopUpOpen("updateFinding", [finding])} /> ))} @@ -365,8 +394,37 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => { handlePopUpToggle("updateFinding", isOpen)} - finding={popUp.updateFinding.data} + findings={popUp.updateFinding.data} /> + {selectedRows.length > 0 && ( +
+ + {selectedRows.length} finding{selectedRows.length === 1 ? "" : "s"} selected + + +
+ + {(isAllowed) => ( + + )} + +
+
+ )} ); }; diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx index 3651ae3c2..c4e04df91 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx @@ -21,52 +21,60 @@ import { } from "@app/hooks/api/secretScanningV2"; type Props = { - finding?: TSecretScanningFinding; + findings?: TSecretScanningFinding[]; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; }; const FormSchema = z.object({ remarks: z.string().max(256, "Cannot exceed 256 characters").optional(), - status: z.nativeEnum(SecretScanningFindingStatus) + status: z.nativeEnum(SecretScanningFindingStatus).optional() }); type FormType = z.infer; type ContentProps = { - finding: TSecretScanningFinding; + findings: TSecretScanningFinding[]; onComplete: () => void; }; -const Content = ({ finding, onComplete }: ContentProps) => { +const Content = ({ findings, onComplete }: ContentProps) => { const updateFinding = useUpdateSecretScanningFinding(); + const single = findings.length === 1; + const { handleSubmit, control } = useForm({ resolver: zodResolver(FormSchema), defaultValues: { - status: finding.status, - remarks: finding.remarks ?? "" + status: single ? findings[0].status : undefined, + remarks: single ? findings[0].remarks : undefined } }); const onSubmit = async (data: FormType) => { try { - await updateFinding.mutateAsync({ - ...data, - findingId: finding.id, - projectId: finding.projectId - }); + for await (const finding of findings) { + // If a status wasn't set, don't update + if (data.status) { + await updateFinding.mutateAsync({ + ...data, + status: data.status, + findingId: finding.id, + projectId: finding.projectId + }); + } + } createNotification({ type: "success", - text: "Finding status successfully updated" + text: `Finding status${single ? "" : "es"} successfully updated` }); onComplete(); } catch { createNotification({ type: "error", - text: "Failed to update Finding status" + text: `Failed to update finding status${single ? "" : "es"}` }); } }; @@ -81,12 +89,15 @@ const Content = ({ finding, onComplete }: ContentProps) => {
- {filteredFindings.slice(offset, perPage * page).map((finding) => ( + {currentPageData.map((finding) => ( diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx index c4e04df91..0a91d7355 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx @@ -144,8 +144,11 @@ export const SecretScanningUpdateFindingModal = ({ findings, isOpen, onOpenChang return ( - - onOpenChange(false)} /> + + onOpenChange(false)} /> ); From fec47ef81c9f2f237e7436d9de439d5c7e1eb7ca Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 12 Jun 2025 01:59:47 -0400 Subject: [PATCH 3/6] Mass-update endpoint --- .../secret-scanning-v2-router.ts | 49 +++++++++++++++++++ .../hooks/api/secretScanningV2/mutations.tsx | 25 ++++++++++ .../SecretScanningUpdateFindingModal.tsx | 28 +++++++---- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index 70cfd08dc..60704f69b 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -187,6 +187,55 @@ export const registerSecretScanningV2Router = async (server: FastifyZodProvider) } }); + server.route({ + method: "PATCH", + url: "/findings", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.SecretScanning], + description: "Update one or more Secret Scanning Findings in a batch.", + body: z + .object({ + findingId: z.string().trim().min(1, "Finding ID required").describe(SecretScanningFindings.UPDATE.findingId), + status: z.nativeEnum(SecretScanningFindingStatus).optional().describe(SecretScanningFindings.UPDATE.status), + remarks: z.string().nullish().describe(SecretScanningFindings.UPDATE.remarks) + }) + .array(), + response: { + 200: z.object({ findings: SecretScanningFindingSchema.array() }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { body, permission } = req; + + const updatedFindingPromises = body.map(async (findingUpdatePayload) => { + const { finding, projectId } = await server.services.secretScanningV2.updateSecretScanningFindingById( + findingUpdatePayload, + permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.SECRET_SCANNING_FINDING_UPDATE, + metadata: findingUpdatePayload + } + }); + + return finding; + }); + + const findings = await Promise.all(updatedFindingPromises); + + return { findings }; + } + }); + server.route({ method: "GET", url: "/configs", diff --git a/frontend/src/hooks/api/secretScanningV2/mutations.tsx b/frontend/src/hooks/api/secretScanningV2/mutations.tsx index 18bbbf236..2ed90911d 100644 --- a/frontend/src/hooks/api/secretScanningV2/mutations.tsx +++ b/frontend/src/hooks/api/secretScanningV2/mutations.tsx @@ -140,6 +140,31 @@ export const useUpdateSecretScanningFinding = () => { }); }; +export const useUpdateMultipleSecretScanningFinding = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (findings: TUpdateSecretScanningFinding[]) => { + const { data } = await apiRequest.patch( + `/api/v2/secret-scanning/findings`, + findings + ); + + return data.finding; + }, + onSuccess: (_, findings) => { + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.listFindings(findings[0].projectId) + }); + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.findingCount(findings[0].projectId) + }); + queryClient.invalidateQueries({ + queryKey: secretScanningV2Keys.dataSource() + }); + } + }); +}; + export const useUpdateSecretScanningConfig = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx index 0a91d7355..d7a17572a 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx @@ -17,6 +17,7 @@ import { SECRET_SCANNING_FINDING_STATUS_ICON_MAP } from "@app/helpers/secretScan import { SecretScanningFindingStatus, TSecretScanningFinding, + useUpdateMultipleSecretScanningFinding, useUpdateSecretScanningFinding } from "@app/hooks/api/secretScanningV2"; @@ -40,6 +41,7 @@ type ContentProps = { const Content = ({ findings, onComplete }: ContentProps) => { const updateFinding = useUpdateSecretScanningFinding(); + const updateMultipleFindings = useUpdateMultipleSecretScanningFinding(); const single = findings.length === 1; @@ -52,17 +54,25 @@ const Content = ({ findings, onComplete }: ContentProps) => { }); const onSubmit = async (data: FormType) => { + if (!data.status) return; + try { - for await (const finding of findings) { - // If a status wasn't set, don't update - if (data.status) { - await updateFinding.mutateAsync({ + if (findings.length > 1) { + await updateMultipleFindings.mutateAsync( + findings.map((f) => ({ ...data, - status: data.status, - findingId: finding.id, - projectId: finding.projectId - }); - } + status: data.status!, + findingId: f.id, + projectId: f.projectId + })) + ); + } else { + await updateFinding.mutateAsync({ + ...data, + status: data.status, + findingId: findings[0].id, + projectId: findings[0].projectId + }); } createNotification({ From c0f8f50981c4521606d9223ac302023d19653c61 Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 12 Jun 2025 02:04:01 -0400 Subject: [PATCH 4/6] lint --- frontend/src/hooks/api/secretScanningV2/mutations.tsx | 2 +- .../pages/auth/SignUpInvitePage/SignUpInvitePage.tsx | 2 +- .../components/SecretScanningFindingsTable.tsx | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/hooks/api/secretScanningV2/mutations.tsx b/frontend/src/hooks/api/secretScanningV2/mutations.tsx index 2ed90911d..a936c2a4f 100644 --- a/frontend/src/hooks/api/secretScanningV2/mutations.tsx +++ b/frontend/src/hooks/api/secretScanningV2/mutations.tsx @@ -145,7 +145,7 @@ export const useUpdateMultipleSecretScanningFinding = () => { return useMutation({ mutationFn: async (findings: TUpdateSecretScanningFinding[]) => { const { data } = await apiRequest.patch( - `/api/v2/secret-scanning/findings`, + "/api/v2/secret-scanning/findings", findings ); diff --git a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx index 9cb739736..fe27e5d7d 100644 --- a/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx +++ b/frontend/src/pages/auth/SignUpInvitePage/SignUpInvitePage.tsx @@ -27,8 +27,8 @@ import { } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { fetchOrganizations } from "@app/hooks/api/organization/queries"; -import { ProjectType } from "@app/hooks/api/workspace/types"; import { isLoggedIn } from "@app/hooks/api/reactQuery"; +import { ProjectType } from "@app/hooks/api/workspace/types"; // eslint-disable-next-line new-cap const client = new jsrp.client(); diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx index 105db9f31..2061c27c6 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx @@ -11,6 +11,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useSearch } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; +import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, Checkbox, @@ -31,6 +32,10 @@ import { Tr } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; +import { + ProjectPermissionSecretScanningFindingActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; import { SECRET_SCANNING_DATA_SOURCE_MAP, SECRET_SCANNING_FINDING_STATUS_ICON_MAP @@ -45,11 +50,6 @@ import { import { SecretScanningFindingRow } from "./SecretScanningFindingRow"; import { SecretScanningUpdateFindingModal } from "./SecretScanningUpdateFindingModal"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { - ProjectPermissionSecretScanningFindingActions, - ProjectPermissionSub -} from "@app/context/ProjectPermissionContext/types"; enum FindingsOrderBy { ResourceName = "resource-name", From 8a93c0bd5948729570d97ae898d11d62bd1c5e9c Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 12 Jun 2025 02:16:07 -0400 Subject: [PATCH 5/6] Cap array --- .../v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts index 60704f69b..929a60df5 100644 --- a/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-scanning-v2-routers/secret-scanning-v2-router.ts @@ -203,7 +203,8 @@ export const registerSecretScanningV2Router = async (server: FastifyZodProvider) status: z.nativeEnum(SecretScanningFindingStatus).optional().describe(SecretScanningFindings.UPDATE.status), remarks: z.string().nullish().describe(SecretScanningFindings.UPDATE.remarks) }) - .array(), + .array() + .max(500), response: { 200: z.object({ findings: SecretScanningFindingSchema.array() }) } From 3cac4ef927219814903cf3c885a528b62c6ad927 Mon Sep 17 00:00:00 2001 From: x032205 Date: Thu, 12 Jun 2025 11:43:32 -0400 Subject: [PATCH 6/6] Reviews --- .../hooks/api/secretScanningV2/mutations.tsx | 1 + .../SecretScanningFindingsTable.tsx | 1 + .../SecretScanningUpdateFindingModal.tsx | 38 ++++++++++++------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/frontend/src/hooks/api/secretScanningV2/mutations.tsx b/frontend/src/hooks/api/secretScanningV2/mutations.tsx index a936c2a4f..beb1cffa0 100644 --- a/frontend/src/hooks/api/secretScanningV2/mutations.tsx +++ b/frontend/src/hooks/api/secretScanningV2/mutations.tsx @@ -115,6 +115,7 @@ export const useTriggerSecretScanningDataSource = () => { }); }; +// If possible, use useUpdateMultipleSecretScanningFinding instead. export const useUpdateSecretScanningFinding = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx index 2061c27c6..57bc57a4e 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningFindingsTable.tsx @@ -418,6 +418,7 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => { handlePopUpToggle("updateFinding", isOpen)} + onComplete={() => setSelectedRows([])} findings={popUp.updateFinding.data} /> {selectedRows.length > 0 && ( diff --git a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx index d7a17572a..25e5bf77e 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx @@ -17,14 +17,14 @@ import { SECRET_SCANNING_FINDING_STATUS_ICON_MAP } from "@app/helpers/secretScan import { SecretScanningFindingStatus, TSecretScanningFinding, - useUpdateMultipleSecretScanningFinding, - useUpdateSecretScanningFinding + useUpdateMultipleSecretScanningFinding } from "@app/hooks/api/secretScanningV2"; type Props = { findings?: TSecretScanningFinding[]; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; }; const FormSchema = z.object({ @@ -40,7 +40,6 @@ type ContentProps = { }; const Content = ({ findings, onComplete }: ContentProps) => { - const updateFinding = useUpdateSecretScanningFinding(); const updateMultipleFindings = useUpdateMultipleSecretScanningFinding(); const single = findings.length === 1; @@ -67,12 +66,14 @@ const Content = ({ findings, onComplete }: ContentProps) => { })) ); } else { - await updateFinding.mutateAsync({ - ...data, - status: data.status, - findingId: findings[0].id, - projectId: findings[0].projectId - }); + await updateMultipleFindings.mutateAsync([ + { + ...data, + status: data.status, + findingId: findings[0].id, + projectId: findings[0].projectId + } + ]); } createNotification({ @@ -135,8 +136,8 @@ const Content = ({ findings, onComplete }: ContentProps) => {
+ + setSelectedRows((sr) => + sr.length === filteredFindings.length ? [] : filteredFindings.map((f) => f.id) + ) + } + onClick={(e) => { + e.stopPropagation(); + }} + /> + Platform
@@ -335,9 +358,15 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => {
- setSelectedRows((sr) => - sr.length === filteredFindings.length ? [] : filteredFindings.map((f) => f.id) - ) + isChecked={ + currentPageDataIds.length > 0 && + currentPageDataIds.every((id) => selectedRows.includes(id)) } + onCheckedChange={() => { + const allCurrentlySelectedOnPage = + currentPageDataIds.length > 0 && + currentPageDataIds.every((id) => selectedRows.includes(id)); + + if (allCurrentlySelectedOnPage) { + // Deselect all on current page + setSelectedRows((prev) => + prev.filter((rowId) => !currentPageDataIds.includes(rowId)) + ); + } else { + // Select all on current page + setSelectedRows((prev) => { + const newSelectedRows = new Set(prev); + currentPageDataIds.forEach((id) => newSelectedRows.add(id)); + return Array.from(newSelectedRows); + }); + } + }} onClick={(e) => { e.stopPropagation(); }} @@ -356,7 +380,7 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => {