Merge pull request #3782 from Infisical/ENG-2900

improvement(secret-scanning): Multi-select actions
This commit is contained in:
x032205
2025-06-12 11:56:28 -04:00
committed by GitHub
6 changed files with 241 additions and 28 deletions

View File

@@ -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",

View File

@@ -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<TSecretScanningFindingResponse>(
"/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({

View File

@@ -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();

View File

@@ -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}`}
>
<Td className="pr-0">
<Checkbox
id={`checkbox-${id}`}
isChecked={isSelected}
onCheckedChange={() => onToggleSelect(!isSelected)}
onClick={(e) => {
e.stopPropagation();
}}
/>
</Td>
<Td className="!min-w-[4rem] max-w-0">
<div className="flex w-full items-center">
<img

View File

@@ -11,7 +11,10 @@ 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,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -29,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
@@ -85,6 +92,8 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => {
status: initStatus ? [initStatus] : []
});
const [selectedRows, setSelectedRows] = useState<string[]>([]);
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) => {
<Table>
<THead>
<Tr>
<Th className="">
<Checkbox
id="checkbox-select-all"
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();
}}
/>
</Th>
<Th className="min-w-[10rem]">Platform</Th>
<Th className="w-1/4">
<div className="flex items-center">
@@ -333,11 +380,17 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => {
</Tr>
</THead>
<TBody>
{filteredFindings.slice(offset, perPage * page).map((finding) => (
{currentPageData.map((finding) => (
<SecretScanningFindingRow
isSelected={selectedRows.includes(finding.id)}
onToggleSelect={(v) =>
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])}
/>
))}
</TBody>
@@ -365,8 +418,38 @@ export const SecretScanningFindingsTable = ({ findings }: Props) => {
<SecretScanningUpdateFindingModal
isOpen={popUp.updateFinding.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("updateFinding", isOpen)}
finding={popUp.updateFinding.data}
onComplete={() => setSelectedRows([])}
findings={popUp.updateFinding.data}
/>
{selectedRows.length > 0 && (
<div className="mt-4 flex items-center justify-between rounded-lg border border-mineshaft-600 bg-mineshaft-800 p-2 pl-4">
<span>
{selectedRows.length} finding{selectedRows.length === 1 ? "" : "s"} selected
</span>
<div className="flex gap-2">
<ProjectPermissionCan
I={ProjectPermissionSecretScanningFindingActions.Update}
a={ProjectPermissionSub.SecretScanningFindings}
>
{(isAllowed) => (
<Button
onClick={() =>
handlePopUpOpen(
"updateFinding",
findings.filter((f) => selectedRows.includes(f.id))
)
}
colorSchema="secondary"
isDisabled={!isAllowed}
>
Update Status
</Button>
)}
</ProjectPermissionCan>
</div>
</div>
)}
</div>
);
};

View File

@@ -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<typeof FormSchema>;
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<FormType>({
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) => {
<FormControl label="Status" isError={Boolean(error)} errorText={error?.message}>
<Select
value={value}
placeholder="Select status..."
onValueChange={onChange}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
dropdownContainerClassName="max-w-none"
icon={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].icon}
iconClassName={SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].className}
icon={value ? SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].icon : undefined}
iconClassName={
value ? SECRET_SCANNING_FINDING_STATUS_ICON_MAP[value].className : undefined
}
>
{Object.values(SecretScanningFindingStatus).map((status) => {
return (
@@ -114,8 +136,8 @@ const Content = ({ finding, onComplete }: ContentProps) => {
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
<Button
type="submit"
isLoading={updateFinding.isPending}
isDisabled={updateFinding.isPending}
isLoading={updateMultipleFindings.isPending}
isDisabled={updateMultipleFindings.isPending}
colorSchema="secondary"
>
Update Status
@@ -128,13 +150,27 @@ const Content = ({ finding, onComplete }: ContentProps) => {
);
};
export const SecretScanningUpdateFindingModal = ({ finding, isOpen, onOpenChange }: Props) => {
if (!finding) return null;
export const SecretScanningUpdateFindingModal = ({
findings,
isOpen,
onOpenChange,
onComplete
}: Props) => {
if (!findings?.length) return null;
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title="Update Finding" subTitle="Update the status or leave remarks">
<Content finding={finding} onComplete={() => onOpenChange(false)} />
<ModalContent
title={`Update Finding${findings.length === 1 ? "" : "s"}`}
subTitle="Update the status or leave remarks"
>
<Content
findings={findings}
onComplete={() => {
onOpenChange(false);
if (onComplete) onComplete();
}}
/>
</ModalContent>
</Modal>
);