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..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 @@ -187,6 +187,56 @@ 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() + .max(500), + 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..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({ @@ -140,6 +141,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/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/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, @@ -159,6 +168,13 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => { setPage }); + const currentPageData = useMemo( + () => filteredFindings.slice(offset, perPage * page), + [filteredFindings, offset, perPage, page] + ); + + const currentPageDataIds = useMemo(() => currentPageData.map((f) => f.id), [currentPageData]); + const handleSort = (column: FindingsOrderBy) => { if (column === orderBy) { toggleOrderDirection(); @@ -276,6 +292,37 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => { + - {filteredFindings.slice(offset, perPage * page).map((finding) => ( + {currentPageData.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 +418,38 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => { handlePopUpToggle("updateFinding", isOpen)} - finding={popUp.updateFinding.data} + onComplete={() => setSelectedRows([])} + 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..25e5bf77e 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningFindingsPage/components/SecretScanningUpdateFindingModal.tsx @@ -17,56 +17,75 @@ import { SECRET_SCANNING_FINDING_STATUS_ICON_MAP } from "@app/helpers/secretScan import { SecretScanningFindingStatus, TSecretScanningFinding, - useUpdateSecretScanningFinding + useUpdateMultipleSecretScanningFinding } from "@app/hooks/api/secretScanningV2"; type Props = { - finding?: TSecretScanningFinding; + findings?: TSecretScanningFinding[]; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; + onComplete?: () => 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 updateFinding = useUpdateSecretScanningFinding(); +const Content = ({ findings, onComplete }: ContentProps) => { + const updateMultipleFindings = useUpdateMultipleSecretScanningFinding(); + + 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) => { + if (!data.status) return; + try { - await updateFinding.mutateAsync({ - ...data, - findingId: finding.id, - projectId: finding.projectId - }); + if (findings.length > 1) { + await updateMultipleFindings.mutateAsync( + findings.map((f) => ({ + ...data, + status: data.status!, + findingId: f.id, + projectId: f.projectId + })) + ); + } else { + await updateMultipleFindings.mutateAsync([ + { + ...data, + status: data.status, + findingId: findings[0].id, + projectId: findings[0].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 +100,15 @@ const Content = ({ finding, onComplete }: ContentProps) => {
+ 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(); + }} + /> + Platform
@@ -333,11 +380,17 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => {