Skip to content

Commit fd48396

Browse files
authored
Merge pull request #801 from l3montree-dev/feat/code-risk-bulk
feat: added selection for bulk updates
2 parents 50e7383 + 03420f5 commit fd48396

1 file changed

Lines changed: 287 additions & 5 deletions

File tree

  • src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/code-risks

src/app/(loading-group)/[organizationSlug]/projects/[projectSlug]/assets/[assetSlug]/refs/[assetVersionSlug]/code-risks/page.tsx

Lines changed: 287 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@ import SortingCaret from "@/components/common/SortingCaret";
44
import { useAssetMenu } from "@/hooks/useAssetMenu";
55
import AuthGuard from "@/components/AuthGuard";
66

7+
import AcceptRiskDialog from "@/components/AcceptRiskDialog";
8+
import FalsePositiveDialog from "@/components/FalsePositiveDialog";
79
import Page from "@/components/Page";
10+
import { browserApiClient } from "@/services/devGuardApi";
811
import type { FirstPartyVuln, Paged } from "@/types/api/api";
912
import { createColumnHelper, flexRender } from "@tanstack/react-table";
1013
import type { ColumnDef } from "@tanstack/react-table";
1114
import { useRouter } from "next/navigation";
12-
import { useMemo, useState } from "react";
15+
import { useCallback, useMemo, useState } from "react";
1316
import type { FunctionComponent } from "react";
1417

1518
import { classNames } from "@/utils/common";
@@ -19,10 +22,12 @@ import AssetTitle from "@/components/common/AssetTitle";
1922
import CustomPagination from "@/components/common/CustomPagination";
2023
import EmptyParty from "@/components/common/EmptyParty";
2124
import Section from "@/components/common/Section";
22-
import { Button } from "@/components/ui/button";
25+
import { AsyncButton, Button } from "@/components/ui/button";
26+
import { Checkbox } from "@/components/ui/checkbox";
2327
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
2428
import { useAssetBranchesAndTags } from "@/hooks/useActiveAssetVersion";
2529
import useTable from "@/hooks/useTable";
30+
import { toast } from "@/lib/toast";
2631
import { buildFilterSearchParams } from "@/utils/url";
2732
import { Loader2 } from "lucide-react";
2833
import { usePathname, useSearchParams } from "next/navigation";
@@ -86,6 +91,34 @@ const Index: FunctionComponent = () => {
8691
const router = useRouter();
8792
const [isOpen, setIsOpen] = useState(false);
8893

94+
const [selectedVulnIds, setSelectedVulnIds] = useState<Set<string>>(
95+
new Set(),
96+
);
97+
98+
const [acceptDialogOpen, setAcceptDialogOpen] = useState(false);
99+
const [falsePositiveDialogOpen, setFalsePositiveDialogOpen] = useState(false);
100+
101+
const handleToggleVuln = useCallback((id: string) => {
102+
setSelectedVulnIds((prev) => {
103+
const next = new Set(prev);
104+
if (next.has(id)) {
105+
next.delete(id);
106+
} else {
107+
next.add(id);
108+
}
109+
return next;
110+
});
111+
}, []);
112+
113+
const handleToggleAll = useCallback((ids: string[]) => {
114+
setSelectedVulnIds((prev) => {
115+
const allSelected = ids.length > 0 && ids.every((id) => prev.has(id));
116+
const next = new Set(prev);
117+
ids.forEach((id) => (allSelected ? next.delete(id) : next.add(id)));
118+
return next;
119+
});
120+
}, []);
121+
89122
let { organizationSlug, projectSlug, assetSlug, assetVersionSlug } =
90123
useDecodedParams() as {
91124
organizationSlug: string;
@@ -121,6 +154,7 @@ const Index: FunctionComponent = () => {
121154
data: vulns,
122155
isLoading,
123156
error,
157+
mutate: mutateVulns,
124158
} = useSWR<Paged<FirstPartyVuln>>(
125159
uri +
126160
"refs/" +
@@ -133,6 +167,96 @@ const Index: FunctionComponent = () => {
133167
keepPreviousData: true,
134168
},
135169
);
170+
171+
const handleBulkAction = useCallback(
172+
async (bulkParams: {
173+
vulnIds: string[];
174+
status: string;
175+
justification: string;
176+
mechanicalJustification?: string;
177+
}) => {
178+
const optimisticState =
179+
bulkParams.status === "falsePositive"
180+
? "falsePositive"
181+
: bulkParams.status === "accepted"
182+
? "accepted"
183+
: bulkParams.status === "reopened"
184+
? "open"
185+
: undefined;
186+
187+
const optimisticData =
188+
optimisticState && vulns
189+
? {
190+
...vulns,
191+
data: vulns.data.map((v) =>
192+
bulkParams.vulnIds.includes(v.id)
193+
? {
194+
...v,
195+
state: optimisticState as FirstPartyVuln["state"],
196+
}
197+
: v,
198+
),
199+
}
200+
: undefined;
201+
202+
await mutateVulns(
203+
async () => {
204+
const resp = await browserApiClient(
205+
uri + "refs/" + assetVersionSlug + "/first-party-vulns/batch/",
206+
{
207+
method: "POST",
208+
body: JSON.stringify({
209+
vulnIds: bulkParams.vulnIds,
210+
status: bulkParams.status,
211+
justification: bulkParams.justification,
212+
mechanicalJustification:
213+
bulkParams.mechanicalJustification ?? "",
214+
}),
215+
},
216+
);
217+
218+
if (!resp.ok) {
219+
throw new Error("Bulk action failed");
220+
}
221+
222+
setSelectedVulnIds((prev) => {
223+
const next = new Set(prev);
224+
bulkParams.vulnIds.forEach((id) => next.delete(id));
225+
return next;
226+
});
227+
228+
return undefined;
229+
},
230+
{
231+
optimisticData,
232+
rollbackOnError: true,
233+
revalidate: true,
234+
},
235+
);
236+
},
237+
[uri, assetVersionSlug, mutateVulns, vulns],
238+
);
239+
240+
const { selectedOpenIds, selectedClosedIds } = useMemo(() => {
241+
if (!vulns?.data) return { selectedOpenIds: [], selectedClosedIds: [] };
242+
243+
const stateById = new Map<string, string>();
244+
vulns.data.forEach((v) => stateById.set(v.id, v.state));
245+
246+
const openIds: string[] = [];
247+
const closedIds: string[] = [];
248+
249+
selectedVulnIds.forEach((id) => {
250+
const state = stateById.get(id);
251+
if (state === "open") {
252+
openIds.push(id);
253+
} else if (state === "accepted" || state === "falsePositive") {
254+
closedIds.push(id);
255+
}
256+
});
257+
258+
return { selectedOpenIds: openIds, selectedClosedIds: closedIds };
259+
}, [vulns, selectedVulnIds]);
136260
const isClosed = searchParams?.get("state") === "closed";
137261
const filterOptions = useMemo(() => {
138262
const options = [
@@ -194,6 +318,17 @@ const Index: FunctionComponent = () => {
194318
const pathname = usePathname();
195319
const push = useRouterQuery();
196320

321+
const pageSelectableIds = table
322+
.getRowModel()
323+
.rows.filter((r) => r.original.state !== "fixed")
324+
.map((r) => r.original.id);
325+
const allPageSelected =
326+
pageSelectableIds.length > 0 &&
327+
pageSelectableIds.every((id) => selectedVulnIds.has(id));
328+
const somePageSelected = pageSelectableIds.some((id) =>
329+
selectedVulnIds.has(id),
330+
);
331+
197332
return (
198333
<Page Menu={assetMenu} title={"Risk Handling"} Title={<AssetTitle />}>
199334
<div className="flex flex-row items-center justify-between">
@@ -273,8 +408,81 @@ const Index: FunctionComponent = () => {
273408
<div className="overflow-auto">
274409
<table className="w-full overflow-x-auto text-sm">
275410
<thead className="border-b bg-card text-foreground">
411+
<AuthGuard require="member">
412+
{selectedVulnIds.size > 0 && (
413+
<tr className="bg-muted/50">
414+
<td colSpan={4} className="px-4 py-2">
415+
<div className="flex flex-row items-center justify-end">
416+
<div className="flex flex-row items-center gap-2">
417+
{selectedClosedIds.length > 0 && (
418+
<AsyncButton
419+
variant="secondary"
420+
onClick={async () => {
421+
const count = selectedClosedIds.length;
422+
await handleBulkAction({
423+
vulnIds: selectedClosedIds,
424+
status: "reopened",
425+
justification: "",
426+
});
427+
toast("Reopened", {
428+
description: `${count} code risk${count !== 1 ? "s" : ""} reopened.`,
429+
});
430+
}}
431+
>
432+
Reopen ({selectedClosedIds.length})
433+
</AsyncButton>
434+
)}
435+
{selectedOpenIds.length > 0 && (
436+
<>
437+
<Button
438+
variant="secondary"
439+
onClick={() =>
440+
setFalsePositiveDialogOpen(true)
441+
}
442+
>
443+
False Positive ({selectedOpenIds.length})
444+
</Button>
445+
<Button
446+
variant="secondary"
447+
onClick={() => setAcceptDialogOpen(true)}
448+
>
449+
Accept Risk ({selectedOpenIds.length})
450+
</Button>
451+
</>
452+
)}
453+
<Button
454+
variant="ghost"
455+
onClick={() => setSelectedVulnIds(new Set())}
456+
>
457+
Clear
458+
</Button>
459+
</div>
460+
</div>
461+
</td>
462+
</tr>
463+
)}
464+
</AuthGuard>
276465
{table.getHeaderGroups().map((headerGroup) => (
277466
<tr key={headerGroup.id}>
467+
<AuthGuard require="member">
468+
<th className="w-10 p-4 text-left">
469+
<div onClick={(e) => e.stopPropagation()}>
470+
<Checkbox
471+
checked={
472+
allPageSelected
473+
? true
474+
: somePageSelected
475+
? "indeterminate"
476+
: false
477+
}
478+
onCheckedChange={() =>
479+
handleToggleAll(pageSelectableIds)
480+
}
481+
disabled={pageSelectableIds.length === 0}
482+
/>
483+
</div>
484+
</th>
485+
</AuthGuard>
278486
{headerGroup.headers.map((header) => (
279487
<th
280488
className="cursor-pointer whitespace-nowrap break-normal p-4 text-left"
@@ -313,6 +521,9 @@ const Index: FunctionComponent = () => {
313521
)}
314522
key={el}
315523
>
524+
<AuthGuard require="member">
525+
<td className="p-4" />
526+
</AuthGuard>
316527
<td className="p-4">
317528
<Skeleton className="w-1/2 h-[20px]" />
318529
</td>
@@ -326,9 +537,15 @@ const Index: FunctionComponent = () => {
326537
))}
327538
{table.getRowModel().rows.map((row, i, arr) => (
328539
<tr
329-
onClick={() =>
330-
router?.push(pathname + "/" + row.original.id)
331-
}
540+
onClick={(e) => {
541+
if (
542+
(e.target as HTMLElement).closest(
543+
'button, input, [role="checkbox"]',
544+
)
545+
)
546+
return;
547+
router?.push(pathname + "/" + row.original.id);
548+
}}
332549
className={classNames(
333550
"relative cursor-pointer align-top transition-all",
334551
i === arr.length - 1 ? "" : "border-b",
@@ -337,6 +554,20 @@ const Index: FunctionComponent = () => {
337554
)}
338555
key={row.original.id}
339556
>
557+
<AuthGuard require="member">
558+
<td className="p-4">
559+
{row.original.state !== "fixed" && (
560+
<div onClick={(e) => e.stopPropagation()}>
561+
<Checkbox
562+
checked={selectedVulnIds.has(row.original.id)}
563+
onCheckedChange={() =>
564+
handleToggleVuln(row.original.id)
565+
}
566+
/>
567+
</div>
568+
)}
569+
</td>
570+
</AuthGuard>
340571
{row.getVisibleCells().map((cell) => (
341572
<td className="p-4" key={cell.id}>
342573
{flexRender(
@@ -365,6 +596,57 @@ const Index: FunctionComponent = () => {
365596
open={isOpen}
366597
onOpenChange={setIsOpen}
367598
/>
599+
600+
<AcceptRiskDialog
601+
open={acceptDialogOpen}
602+
onOpenChange={setAcceptDialogOpen}
603+
onSubmit={async (justification) => {
604+
if (selectedOpenIds.length === 0) return false;
605+
const count = selectedOpenIds.length;
606+
await handleBulkAction({
607+
vulnIds: selectedOpenIds,
608+
status: "accepted",
609+
justification,
610+
});
611+
toast("Risk Accepted", {
612+
description: `${count} code risk${count !== 1 ? "s" : ""} accepted.`,
613+
});
614+
return true;
615+
}}
616+
description={
617+
<>
618+
You are about to accept the risk for {selectedOpenIds.length}{" "}
619+
selected code risk{selectedOpenIds.length !== 1 ? "s" : ""}. This
620+
acknowledges you are aware of the vulnerability and its potential
621+
impact.
622+
</>
623+
}
624+
/>
625+
626+
<FalsePositiveDialog
627+
open={falsePositiveDialogOpen}
628+
onOpenChange={setFalsePositiveDialogOpen}
629+
onSubmit={async (data) => {
630+
if (selectedOpenIds.length === 0) return false;
631+
const count = selectedOpenIds.length;
632+
await handleBulkAction({
633+
vulnIds: selectedOpenIds,
634+
status: "falsePositive",
635+
justification: data.justification,
636+
mechanicalJustification: data.mechanicalJustification,
637+
});
638+
toast("Marked as False Positive", {
639+
description: `${count} code risk${count !== 1 ? "s" : ""} marked as false positive.`,
640+
});
641+
return true;
642+
}}
643+
description={
644+
<>
645+
You are about to mark {selectedOpenIds.length} selected code risk
646+
{selectedOpenIds.length !== 1 ? "s" : ""} as false positive.
647+
</>
648+
}
649+
/>
368650
</Page>
369651
);
370652
};

0 commit comments

Comments
 (0)