mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #3782 from Infisical/ENG-2900
improvement(secret-scanning): Multi-select actions
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user