Skip to content

Commit de60133

Browse files
authored
feat(targets): create multiple targets (#291)
* refactor(targets): combine logic create target in transaction * feat(targets): add bulk target creation endpoint * feat(targets): add bulk creation support
1 parent 8713b95 commit de60133

File tree

8 files changed

+872
-74
lines changed

8 files changed

+872
-74
lines changed

console/src/components/ui/create-target.tsx

Lines changed: 139 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
import { Input } from '@/components/ui/input';
1313
import { Label } from '@/components/ui/label';
1414
import { 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';
1619
import { useQueryClient } from '@tanstack/react-query';
1720
import { Loader2Icon, Target } from 'lucide-react';
1821
import { 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+
2981
export 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

Comments
 (0)