@@ -12,7 +12,10 @@ import {
1212import { Input } from '@/components/ui/input' ;
1313import { Label } from '@/components/ui/label' ;
1414import { useWorkspaceSelector } from '@/hooks/useWorkspaceSelector' ;
15- import { useTargetsControllerCreateTarget } from '@/services/apis/gen/queries' ;
15+ import {
16+ useTargetsControllerCreateMultipleTargets ,
17+ type BulkTargetResultDto ,
18+ } from '@/services/apis/gen/queries' ;
1619import { useQueryClient } from '@tanstack/react-query' ;
1720import { Loader2Icon , Target } from 'lucide-react' ;
1821import { useState } from 'react' ;
@@ -26,6 +29,55 @@ type FormValues = {
2629 value : string ;
2730} ;
2831
32+ /**
33+ * Parse comma-separated input into array of trimmed, non-empty domain strings
34+ */
35+ const parseTargetsInput = ( input : string ) : string [ ] => {
36+ return input
37+ . split ( ',' )
38+ . map ( ( t ) => t . trim ( ) )
39+ . filter ( ( t ) => t . length > 0 ) ;
40+ } ;
41+
42+ /**
43+ * Find duplicate values in array (case-insensitive)
44+ * Returns array of duplicate values in lowercase
45+ */
46+ const findDuplicates = ( values : string [ ] ) : string [ ] => {
47+ const seen = new Set < string > ( ) ;
48+ const duplicates = new Set < string > ( ) ;
49+
50+ for ( const value of values ) {
51+ const lowerValue = value . toLowerCase ( ) ;
52+ if ( seen . has ( lowerValue ) ) {
53+ duplicates . add ( lowerValue ) ;
54+ } else {
55+ seen . add ( lowerValue ) ;
56+ }
57+ }
58+
59+ return Array . from ( duplicates ) ;
60+ } ;
61+
62+ /**
63+ * Validate multiple domains using the same regex pattern
64+ */
65+ const validateDomains = ( input : string ) : string | true => {
66+ const targets = parseTargetsInput ( input ) ;
67+
68+ if ( targets . length === 0 ) {
69+ return 'Please enter at least one domain.' ;
70+ }
71+
72+ for ( const target of targets ) {
73+ if ( ! domainRegex . test ( target ) ) {
74+ return `"${ target } " is not a valid domain name.` ;
75+ }
76+ }
77+
78+ return true ;
79+ } ;
80+
2981export function CreateTarget ( ) {
3082 const [ open , setOpen ] = useState ( false ) ;
3183 const { selectedWorkspace, workspaces } = useWorkspaceSelector ( ) ;
@@ -37,34 +89,67 @@ export function CreateTarget() {
3789 formState : { errors } ,
3890 reset,
3991 setValue,
92+ getValues,
93+ setError,
94+ clearErrors,
4095 } = useForm < FormValues > ( ) ;
4196 const queryClient = useQueryClient ( ) ;
42- const { mutate, isPending } = useTargetsControllerCreateTarget ( ) ;
97+ const { mutate, isPending } = useTargetsControllerCreateMultipleTargets ( ) ;
4398 const navigate = useNavigate ( ) ;
99+
44100 function onSubmit ( data : FormValues ) {
45- if ( selectedWorkspace )
46- mutate (
47- {
48- data : {
49- value : data . value ,
50- workspaceId : selectedWorkspace ,
51- } ,
101+ if ( ! selectedWorkspace ) return ;
102+
103+ const targets = parseTargetsInput ( data . value ) ;
104+ const duplicates = findDuplicates ( targets ) ;
105+
106+ if ( duplicates . length > 0 ) {
107+ setError ( 'value' , {
108+ type : 'manual' ,
109+ message : `Duplicate values detected: ${ duplicates . join ( ', ' ) } ` ,
110+ } ) ;
111+ return ;
112+ }
113+
114+ // Clear any previous errors
115+ clearErrors ( 'value' ) ;
116+
117+ // Create unique targets array (deduplicated)
118+ const uniqueTargets = Array . from (
119+ new Set ( targets . map ( ( t ) => t . toLowerCase ( ) ) ) ,
120+ ) ;
121+
122+ mutate (
123+ {
124+ data : {
125+ targets : uniqueTargets . map ( ( value ) => ( { value } ) ) ,
52126 } ,
53- {
54- onError : ( ) => {
55- toast . error ( 'Failed to create target' ) ;
56- } ,
57- onSuccess : ( res ) => {
58- navigate ( `/targets/${ res . id } ?animation=true&page=1&pageSize=100` ) ;
59- toast . success ( 'Target created successfully' ) ;
127+ } ,
128+ {
129+ onError : ( ) => {
130+ toast . error ( 'Failed to create targets' ) ;
131+ } ,
132+ onSuccess : ( res : BulkTargetResultDto ) => {
133+ if ( res . totalCreated > 0 ) {
134+ navigate ( `/targets?page=1&pageSize=100` ) ;
135+ toast . success (
136+ `Successfully created ${ res . totalCreated } target${ res . totalCreated > 1 ? 's' : '' } .` ,
137+ ) ;
60138 setOpen ( false ) ;
61139 reset ( ) ;
62140 queryClient . refetchQueries ( {
63- queryKey : [ 'targets' , res . id ] ,
141+ queryKey : [ 'targets' ] ,
64142 } ) ;
65- } ,
143+ }
144+
145+ if ( res . totalSkipped > 0 ) {
146+ toast . info (
147+ `${ res . totalSkipped } target${ res . totalSkipped > 1 ? 's' : '' } skipped (already exist).` ,
148+ ) ;
149+ }
66150 } ,
67- ) ;
151+ } ,
152+ ) ;
68153 }
69154
70155 const title = isAssetsDiscovery ? 'Start discovery' : 'Create target' ;
@@ -81,41 +166,56 @@ export function CreateTarget() {
81166 < DialogHeader >
82167 < DialogTitle > { title } </ DialogTitle >
83168 < DialogDescription >
84- Enter the domain you want to scan.
169+ Enter one or more domains to scan, separated by commas .
85170 </ DialogDescription >
86171 </ DialogHeader >
87172 < form onSubmit = { handleSubmit ( onSubmit ) } >
88173 < div className = "grid gap-4 mb-3" >
89174 < div className = "grid gap-3" >
90- < Label htmlFor = "name-1" > Target </ Label >
175+ < Label htmlFor = "name-1" > Targets </ Label >
91176 < Input
92177 id = "name-1"
93- placeholder = "e.g. example.com"
178+ placeholder = "e.g. example.com, test.com, demo.org "
94179 autoComplete = "off"
95180 { ...register ( 'value' , {
96181 required : 'Domain is required.' ,
97- validate : ( value ) =>
98- domainRegex . test ( value . trim ( ) ) ||
99- 'Please enter a valid domain name (no IP addresses).' ,
182+ validate : validateDomains ,
100183 } ) }
101184 onPaste = { ( e ) => {
102185 e . preventDefault ( ) ;
103186 const pastedText = e . clipboardData ?. getData ( 'text' ) || '' ;
104187 const trimmedText = pastedText . trim ( ) ;
105- let rootDomain = trimmedText ;
106- if ( trimmedText ) {
107- try {
108- const url = new URL (
109- trimmedText . includes ( '://' )
110- ? trimmedText
111- : `http://${ trimmedText } ` ,
112- ) ;
113- rootDomain = url . hostname || trimmedText ;
114- } catch {
115- rootDomain = trimmedText ;
116- }
117- }
118- setValue ( 'value' , rootDomain ) ;
188+
189+ // Parse multiple domains from pasted text (comma or newline separated)
190+ const pastedDomains = trimmedText
191+ . split ( / [ , \n ] + / )
192+ . map ( ( t ) => t . trim ( ) )
193+ . filter ( ( t ) => t . length > 0 )
194+ . map ( ( domain ) => {
195+ // Extract root domain from URL if needed
196+ try {
197+ const url = new URL (
198+ domain . includes ( '://' ) ? domain : `http://${ domain } ` ,
199+ ) ;
200+ return url . hostname || domain ;
201+ } catch {
202+ return domain ;
203+ }
204+ } ) ;
205+
206+ // Get current value and merge
207+ const currentValue = getValues ( 'value' ) || '' ;
208+ const currentDomains = currentValue
209+ ? parseTargetsInput ( currentValue )
210+ : [ ] ;
211+ const allDomains = [ ...currentDomains , ...pastedDomains ] ;
212+
213+ // Remove duplicates
214+ const uniqueDomains = Array . from (
215+ new Set ( allDomains . map ( ( d ) => d . toLowerCase ( ) ) ) ,
216+ ) ;
217+
218+ setValue ( 'value' , uniqueDomains . join ( ', ' ) ) ;
119219 } }
120220 />
121221 { errors . value && (
0 commit comments