@@ -4,12 +4,15 @@ import SortingCaret from "@/components/common/SortingCaret";
44import { useAssetMenu } from "@/hooks/useAssetMenu" ;
55import AuthGuard from "@/components/AuthGuard" ;
66
7+ import AcceptRiskDialog from "@/components/AcceptRiskDialog" ;
8+ import FalsePositiveDialog from "@/components/FalsePositiveDialog" ;
79import Page from "@/components/Page" ;
10+ import { browserApiClient } from "@/services/devGuardApi" ;
811import type { FirstPartyVuln , Paged } from "@/types/api/api" ;
912import { createColumnHelper , flexRender } from "@tanstack/react-table" ;
1013import type { ColumnDef } from "@tanstack/react-table" ;
1114import { useRouter } from "next/navigation" ;
12- import { useMemo , useState } from "react" ;
15+ import { useCallback , useMemo , useState } from "react" ;
1316import type { FunctionComponent } from "react" ;
1417
1518import { classNames } from "@/utils/common" ;
@@ -19,10 +22,12 @@ import AssetTitle from "@/components/common/AssetTitle";
1922import CustomPagination from "@/components/common/CustomPagination" ;
2023import EmptyParty from "@/components/common/EmptyParty" ;
2124import 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" ;
2327import { Tabs , TabsList , TabsTrigger } from "@/components/ui/tabs" ;
2428import { useAssetBranchesAndTags } from "@/hooks/useActiveAssetVersion" ;
2529import useTable from "@/hooks/useTable" ;
30+ import { toast } from "@/lib/toast" ;
2631import { buildFilterSearchParams } from "@/utils/url" ;
2732import { Loader2 } from "lucide-react" ;
2833import { 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