@harbinger-ai/harbinger 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bounty/actions.js +45 -0
- package/lib/chat/actions.js +338 -20
- package/lib/chat/components/agents-page.js +535 -162
- package/lib/chat/components/agents-page.jsx +528 -230
- package/lib/chat/components/icons.js +105 -0
- package/lib/chat/components/icons.jsx +129 -0
- package/lib/chat/components/page-layout.js +41 -2
- package/lib/chat/components/page-layout.jsx +40 -2
- package/lib/chat/components/settings-providers-page.js +647 -112
- package/lib/chat/components/settings-providers-page.jsx +641 -134
- package/lib/chat/components/targets-page.js +554 -96
- package/lib/chat/components/targets-page.jsx +464 -114
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
3
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
4
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
5
|
-
import { CrosshairIcon, PlusIcon, TrashIcon, ChevronDownIcon, GlobeIcon, SpinnerIcon, DownloadIcon, CheckIcon } from './icons.js';
|
|
6
|
-
import { getPrograms, createProgram, deleteProgram, getTargets, createTarget, deleteTarget, updateTarget, syncTargetsFromPlatform, syncAllTargets, getSyncStatus } from '../../bounty/actions.js';
|
|
5
|
+
import { CrosshairIcon, PlusIcon, TrashIcon, ChevronDownIcon, GlobeIcon, SpinnerIcon, DownloadIcon, CheckIcon, SearchIcon, CopyIcon, UploadIcon, FilterIcon } from './icons.js';
|
|
6
|
+
import { getPrograms, createProgram, deleteProgram, getTargets, createTarget, deleteTarget, updateTarget, syncTargetsFromPlatform, syncAllTargets, getSyncStatus, getProgramTargetCounts, bulkImportTargets, exportTargets } from '../../bounty/actions.js';
|
|
7
7
|
|
|
8
8
|
const PLATFORMS = [
|
|
9
9
|
{ id: 'hackerone', label: 'HackerOne', bg: 'bg-purple-500/10', text: 'text-purple-500', border: 'border-purple-500/20' },
|
|
@@ -15,6 +15,7 @@ const PLATFORMS = [
|
|
|
15
15
|
];
|
|
16
16
|
|
|
17
17
|
const TARGET_TYPES = ['domain', 'wildcard', 'ip', 'cidr', 'url', 'api', 'mobile'];
|
|
18
|
+
const TYPE_ICONS = { domain: '\u{1f310}', wildcard: '*', ip: '\u{1f4cd}', cidr: '\u{1f5a7}', url: '\u{1f517}', api: '\u{2699}\u{fe0f}', mobile: '\u{1f4f1}' };
|
|
18
19
|
const STATUS_COLORS = {
|
|
19
20
|
in_scope: { bg: 'bg-green-500/10', text: 'text-green-500', border: 'border-green-500/20', dot: 'bg-green-500' },
|
|
20
21
|
out_of_scope: { bg: 'bg-red-500/10', text: 'text-red-500', border: 'border-red-500/20', dot: 'bg-red-500' },
|
|
@@ -36,6 +37,24 @@ function getPlatStyle(platformId) {
|
|
|
36
37
|
return PLATFORMS.find(p => p.id === platformId) || PLATFORMS[5];
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
// ─── Copy Button ──────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function CopyBtn({ text }) {
|
|
43
|
+
const [copied, setCopied] = useState(false);
|
|
44
|
+
const handleCopy = async () => {
|
|
45
|
+
try { await navigator.clipboard.writeText(text); } catch { /* fallback */ }
|
|
46
|
+
setCopied(true);
|
|
47
|
+
setTimeout(() => setCopied(false), 1500);
|
|
48
|
+
};
|
|
49
|
+
return (
|
|
50
|
+
<button onClick={handleCopy} className="shrink-0 p-1 text-muted-foreground hover:text-[--cyan] transition-colors" title="Copy">
|
|
51
|
+
{copied ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Sync Panel ───────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
39
58
|
function SyncPanel({ onSync, syncStatus }) {
|
|
40
59
|
const [syncing, setSyncing] = useState(null);
|
|
41
60
|
const [results, setResults] = useState(null);
|
|
@@ -62,21 +81,15 @@ function SyncPanel({ onSync, syncStatus }) {
|
|
|
62
81
|
const syncablePlatforms = PLATFORMS.filter(p => p.id !== 'custom');
|
|
63
82
|
|
|
64
83
|
return (
|
|
65
|
-
<motion.div
|
|
66
|
-
|
|
67
|
-
animate={{ opacity: 1, y: 0 }}
|
|
68
|
-
transition={{ duration: 0.3 }}
|
|
69
|
-
className="rounded-lg border border-white/[0.06] bg-[--card] p-4 mb-6"
|
|
70
|
-
>
|
|
84
|
+
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }}
|
|
85
|
+
className="rounded-lg border border-white/[0.06] bg-[--card] p-4 mb-6">
|
|
71
86
|
<div className="flex items-center justify-between mb-3">
|
|
72
87
|
<div>
|
|
73
88
|
<div className="flex items-center gap-2 mb-0.5">
|
|
74
89
|
<DownloadIcon size={14} />
|
|
75
90
|
<h3 className="text-sm font-mono font-medium text-[--cyan]">Sync from Bounty Platforms</h3>
|
|
76
91
|
</div>
|
|
77
|
-
<p className="text-[11px] text-muted-foreground font-mono">
|
|
78
|
-
Import programs and targets from bounty-targets-data
|
|
79
|
-
</p>
|
|
92
|
+
<p className="text-[11px] text-muted-foreground font-mono">Import programs and targets from bounty-targets-data</p>
|
|
80
93
|
</div>
|
|
81
94
|
{syncStatus?.lastSyncedAt && (
|
|
82
95
|
<span className="text-[10px] text-muted-foreground font-mono">Last sync: {timeAgo(syncStatus.lastSyncedAt)}</span>
|
|
@@ -87,23 +100,16 @@ function SyncPanel({ onSync, syncStatus }) {
|
|
|
87
100
|
{syncablePlatforms.map(p => {
|
|
88
101
|
const count = syncStatus?.platformCounts?.[p.id] || 0;
|
|
89
102
|
return (
|
|
90
|
-
<button
|
|
91
|
-
|
|
92
|
-
onClick={() => handleSync(p.id)}
|
|
93
|
-
disabled={syncing !== null}
|
|
94
|
-
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium border transition-colors hover:bg-white/[0.04] disabled:opacity-50 ${p.border}`}
|
|
95
|
-
>
|
|
103
|
+
<button key={p.id} onClick={() => handleSync(p.id)} disabled={syncing !== null}
|
|
104
|
+
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium border transition-colors hover:bg-white/[0.04] disabled:opacity-50 ${p.border}`}>
|
|
96
105
|
{syncing === p.id ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
|
|
97
106
|
{p.label}
|
|
98
107
|
{count > 0 && <span className={`inline-flex rounded-full px-1.5 py-0.5 text-[9px] font-mono ${p.bg} ${p.text}`}>{count}</span>}
|
|
99
108
|
</button>
|
|
100
109
|
);
|
|
101
110
|
})}
|
|
102
|
-
<button
|
|
103
|
-
|
|
104
|
-
disabled={syncing !== null}
|
|
105
|
-
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-[--cyan] text-[--primary-foreground] hover:opacity-90 disabled:opacity-50 transition-opacity"
|
|
106
|
-
>
|
|
111
|
+
<button onClick={() => handleSync('all')} disabled={syncing !== null}
|
|
112
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-[--cyan] text-[--primary-foreground] hover:opacity-90 disabled:opacity-50 transition-opacity">
|
|
107
113
|
{syncing === 'all' ? <SpinnerIcon size={12} /> : <DownloadIcon size={12} />}
|
|
108
114
|
Sync All
|
|
109
115
|
</button>
|
|
@@ -111,25 +117,15 @@ function SyncPanel({ onSync, syncStatus }) {
|
|
|
111
117
|
|
|
112
118
|
<div className="flex items-center gap-2 mb-3">
|
|
113
119
|
<label className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">Max programs:</label>
|
|
114
|
-
<input
|
|
115
|
-
type="number"
|
|
116
|
-
value={maxPrograms}
|
|
117
|
-
onChange={e => setMaxPrograms(e.target.value)}
|
|
120
|
+
<input type="number" value={maxPrograms} onChange={e => setMaxPrograms(e.target.value)}
|
|
118
121
|
className="w-20 text-xs border border-white/[0.06] rounded-md px-2 py-1 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors"
|
|
119
|
-
min="0"
|
|
120
|
-
placeholder="0 = all"
|
|
121
|
-
/>
|
|
122
|
+
min="0" placeholder="0 = all" />
|
|
122
123
|
<span className="text-[10px] text-muted-foreground font-mono">(0 = unlimited)</span>
|
|
123
124
|
</div>
|
|
124
125
|
|
|
125
126
|
<AnimatePresence>
|
|
126
127
|
{results && (
|
|
127
|
-
<motion.div
|
|
128
|
-
initial={{ opacity: 0, height: 0 }}
|
|
129
|
-
animate={{ opacity: 1, height: 'auto' }}
|
|
130
|
-
exit={{ opacity: 0, height: 0 }}
|
|
131
|
-
className="overflow-hidden"
|
|
132
|
-
>
|
|
128
|
+
<motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden">
|
|
133
129
|
<div className="rounded-md bg-black/30 border border-white/[0.04] p-3 mt-2">
|
|
134
130
|
{results.error ? (
|
|
135
131
|
<p className="text-xs font-mono text-[--destructive]">{results.error}</p>
|
|
@@ -169,8 +165,13 @@ function SyncPanel({ onSync, syncStatus }) {
|
|
|
169
165
|
);
|
|
170
166
|
}
|
|
171
167
|
|
|
172
|
-
|
|
168
|
+
// ─── Program Card ─────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
function ProgramCard({ program, onSelect, selected, onDelete, index, targetCount }) {
|
|
173
171
|
const plat = getPlatStyle(program.platform);
|
|
172
|
+
const tc = targetCount || { total: 0, inScope: 0, outOfScope: 0 };
|
|
173
|
+
const hasTargets = tc.total > 0;
|
|
174
|
+
|
|
174
175
|
return (
|
|
175
176
|
<motion.button
|
|
176
177
|
initial={{ opacity: 0, x: -8 }}
|
|
@@ -183,11 +184,7 @@ function ProgramCard({ program, onSelect, selected, onDelete, index }) {
|
|
|
183
184
|
: 'border-white/[0.06] bg-[--card] hover:border-[--cyan]/20'
|
|
184
185
|
}`}
|
|
185
186
|
>
|
|
186
|
-
<div className=
|
|
187
|
-
<div className="w-2 h-2 rounded-full bg-[#ff5f57]" />
|
|
188
|
-
<div className="w-2 h-2 rounded-full bg-[#febc2e]" />
|
|
189
|
-
<div className="w-2 h-2 rounded-full bg-[#28c840]" />
|
|
190
|
-
</div>
|
|
187
|
+
<div className={`shrink-0 w-2 h-2 rounded-full ${hasTargets ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
|
191
188
|
<div className="flex-1 min-w-0">
|
|
192
189
|
<p className="text-sm font-mono font-medium truncate">{program.name}</p>
|
|
193
190
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
|
@@ -196,46 +193,236 @@ function ProgramCard({ program, onSelect, selected, onDelete, index }) {
|
|
|
196
193
|
{program.syncHandle && <span className="inline-flex rounded-full bg-[--cyan]/10 border border-[--cyan]/20 px-1.5 py-0.5 text-[9px] font-mono text-[--cyan]">synced</span>}
|
|
197
194
|
</div>
|
|
198
195
|
</div>
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
197
|
+
{hasTargets && (
|
|
198
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-green-500/10 text-green-500 border border-green-500/20 px-2 py-0.5 text-[9px] font-mono font-medium">
|
|
199
|
+
{tc.total}
|
|
200
|
+
</span>
|
|
201
|
+
)}
|
|
202
|
+
{!program.syncHandle && (
|
|
203
|
+
<button onClick={(e) => { e.stopPropagation(); onDelete(program.id); }}
|
|
204
|
+
className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors">
|
|
205
|
+
<TrashIcon size={12} />
|
|
206
|
+
</button>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
202
209
|
</motion.button>
|
|
203
210
|
);
|
|
204
211
|
}
|
|
205
212
|
|
|
206
|
-
|
|
213
|
+
// ─── Target Row ───────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
function TargetRow({ target, onDelete, onStatusChange, onUpdate, index }) {
|
|
216
|
+
const [expanded, setExpanded] = useState(false);
|
|
217
|
+
const [notes, setNotes] = useState(target.notes || '');
|
|
218
|
+
const [savingNotes, setSavingNotes] = useState(false);
|
|
207
219
|
const st = STATUS_COLORS[target.status] || STATUS_COLORS.in_scope;
|
|
220
|
+
const icon = TYPE_ICONS[target.type] || '\u{1f310}';
|
|
221
|
+
|
|
222
|
+
async function handleSaveNotes() {
|
|
223
|
+
setSavingNotes(true);
|
|
224
|
+
await onUpdate(target.id, { notes });
|
|
225
|
+
setSavingNotes(false);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let techs = [];
|
|
229
|
+
try { if (target.technologies) techs = JSON.parse(target.technologies); } catch {}
|
|
230
|
+
|
|
208
231
|
return (
|
|
209
232
|
<motion.div
|
|
210
233
|
initial={{ opacity: 0, y: 6 }}
|
|
211
234
|
animate={{ opacity: 1, y: 0 }}
|
|
212
|
-
transition={{ duration: 0.2, delay: index * 0.
|
|
213
|
-
className="
|
|
235
|
+
transition={{ duration: 0.2, delay: Math.min(index * 0.015, 0.3) }}
|
|
236
|
+
className="rounded-lg border border-white/[0.06] bg-[--card] hover:border-[--cyan]/20 transition-colors"
|
|
214
237
|
>
|
|
215
|
-
<div className=
|
|
216
|
-
|
|
217
|
-
<
|
|
218
|
-
<div className="flex
|
|
219
|
-
<
|
|
220
|
-
|
|
221
|
-
|
|
238
|
+
<div className="flex items-center gap-3 p-3 cursor-pointer" onClick={() => setExpanded(!expanded)}>
|
|
239
|
+
<div className={`shrink-0 w-2 h-2 rounded-full ${st.dot}`} />
|
|
240
|
+
<span className="text-sm shrink-0 w-6 text-center">{icon}</span>
|
|
241
|
+
<div className="flex-1 min-w-0">
|
|
242
|
+
<p className="text-sm font-mono font-medium truncate select-all">{target.value}</p>
|
|
243
|
+
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
|
244
|
+
<span className="inline-flex rounded-full bg-white/5 border border-white/10 px-2 py-0.5 text-[10px] font-mono font-medium text-muted-foreground">{target.type}</span>
|
|
245
|
+
{target.syncSource && <span className="inline-flex rounded-full bg-[--cyan]/10 border border-[--cyan]/20 px-1.5 py-0.5 text-[9px] font-mono text-[--cyan]">{target.syncSource}</span>}
|
|
246
|
+
{techs.length > 0 && <span className="text-[10px] font-mono text-muted-foreground truncate max-w-[200px]">{techs.join(', ')}</span>}
|
|
247
|
+
{target.lastScannedAt && <span className="text-[9px] font-mono text-muted-foreground">scanned {timeAgo(target.lastScannedAt)}</span>}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
<CopyBtn text={target.value} />
|
|
251
|
+
<select
|
|
252
|
+
value={target.status}
|
|
253
|
+
onClick={e => e.stopPropagation()}
|
|
254
|
+
onChange={(e) => { e.stopPropagation(); onStatusChange(target.id, e.target.value); }}
|
|
255
|
+
className={`text-[10px] font-mono font-medium rounded-full px-2 py-0.5 border cursor-pointer bg-transparent ${st.bg} ${st.text} ${st.border} focus:outline-none`}
|
|
256
|
+
>
|
|
257
|
+
<option value="in_scope">in scope</option>
|
|
258
|
+
<option value="testing">testing</option>
|
|
259
|
+
<option value="completed">completed</option>
|
|
260
|
+
<option value="out_of_scope">out of scope</option>
|
|
261
|
+
</select>
|
|
262
|
+
<span className={`transition-transform shrink-0 text-muted-foreground ${expanded ? 'rotate-180' : ''}`}>
|
|
263
|
+
<ChevronDownIcon size={14} />
|
|
264
|
+
</span>
|
|
265
|
+
<button onClick={(e) => { e.stopPropagation(); onDelete(target.id); }}
|
|
266
|
+
className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors">
|
|
267
|
+
<TrashIcon size={12} />
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<AnimatePresence>
|
|
272
|
+
{expanded && (
|
|
273
|
+
<motion.div
|
|
274
|
+
initial={{ height: 0, opacity: 0 }}
|
|
275
|
+
animate={{ height: 'auto', opacity: 1 }}
|
|
276
|
+
exit={{ height: 0, opacity: 0 }}
|
|
277
|
+
transition={{ duration: 0.2 }}
|
|
278
|
+
className="overflow-hidden"
|
|
279
|
+
>
|
|
280
|
+
<div className="border-t border-white/[0.06] px-4 py-3 flex flex-col gap-3">
|
|
281
|
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
282
|
+
<div>
|
|
283
|
+
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Type</span>
|
|
284
|
+
<p className="text-xs font-mono mt-0.5">{icon} {target.type}</p>
|
|
285
|
+
</div>
|
|
286
|
+
<div>
|
|
287
|
+
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Status</span>
|
|
288
|
+
<p className="text-xs font-mono mt-0.5">{target.status.replace('_', ' ')}</p>
|
|
289
|
+
</div>
|
|
290
|
+
<div>
|
|
291
|
+
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Added</span>
|
|
292
|
+
<p className="text-xs font-mono mt-0.5">{timeAgo(target.createdAt)}</p>
|
|
293
|
+
</div>
|
|
294
|
+
<div>
|
|
295
|
+
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Last Scan</span>
|
|
296
|
+
<p className="text-xs font-mono mt-0.5">{target.lastScannedAt ? timeAgo(target.lastScannedAt) : '\u2014'}</p>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
{techs.length > 0 && (
|
|
300
|
+
<div>
|
|
301
|
+
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Technologies</span>
|
|
302
|
+
<div className="flex flex-wrap gap-1 mt-1">
|
|
303
|
+
{techs.map(t => (
|
|
304
|
+
<span key={t} className="inline-flex rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20 px-2 py-0.5 text-[9px] font-mono">{t}</span>
|
|
305
|
+
))}
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
<div>
|
|
310
|
+
<span className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Notes</span>
|
|
311
|
+
<div className="flex gap-2 mt-1">
|
|
312
|
+
<textarea
|
|
313
|
+
value={notes}
|
|
314
|
+
onChange={e => setNotes(e.target.value)}
|
|
315
|
+
rows={2}
|
|
316
|
+
placeholder="Add notes..."
|
|
317
|
+
className="flex-1 text-[11px] border border-white/[0.06] rounded-md p-2 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors resize-y"
|
|
318
|
+
/>
|
|
319
|
+
<button onClick={handleSaveNotes} disabled={savingNotes}
|
|
320
|
+
className="self-end inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50">
|
|
321
|
+
{savingNotes ? <SpinnerIcon size={10} /> : <CheckIcon size={10} />} Save
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
</motion.div>
|
|
327
|
+
)}
|
|
328
|
+
</AnimatePresence>
|
|
329
|
+
</motion.div>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ─── Import Dialog ────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
function ImportDialog({ programId, onClose, onImported }) {
|
|
336
|
+
const [text, setText] = useState('');
|
|
337
|
+
const [importing, setImporting] = useState(false);
|
|
338
|
+
const [result, setResult] = useState(null);
|
|
339
|
+
const fileRef = useRef(null);
|
|
340
|
+
|
|
341
|
+
function handleFile(e) {
|
|
342
|
+
const file = e.target.files?.[0];
|
|
343
|
+
if (!file) return;
|
|
344
|
+
const reader = new FileReader();
|
|
345
|
+
reader.onload = (ev) => setText(ev.target.result);
|
|
346
|
+
reader.readAsText(file);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseTargets(raw) {
|
|
350
|
+
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
351
|
+
return lines.map(line => {
|
|
352
|
+
// Try JSON
|
|
353
|
+
try { const obj = JSON.parse(line); if (obj.value) return obj; } catch {}
|
|
354
|
+
// Try CSV: value,type
|
|
355
|
+
const parts = line.split(',').map(s => s.trim());
|
|
356
|
+
const value = parts[0];
|
|
357
|
+
const type = TARGET_TYPES.includes(parts[1]) ? parts[1] : guessType(value);
|
|
358
|
+
return { value, type };
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function guessType(v) {
|
|
363
|
+
if (/^\*\./.test(v)) return 'wildcard';
|
|
364
|
+
if (/^https?:\/\//.test(v)) return 'url';
|
|
365
|
+
if (/\/\d{1,2}$/.test(v)) return 'cidr';
|
|
366
|
+
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(v)) return 'ip';
|
|
367
|
+
return 'domain';
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function handleImport() {
|
|
371
|
+
const items = parseTargets(text);
|
|
372
|
+
if (items.length === 0) return;
|
|
373
|
+
setImporting(true);
|
|
374
|
+
const res = await bulkImportTargets(programId, items);
|
|
375
|
+
setResult(res);
|
|
376
|
+
setImporting(false);
|
|
377
|
+
if (!res.error) onImported();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
|
|
382
|
+
className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
383
|
+
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
|
384
|
+
<div className="relative w-full max-w-lg rounded-lg border border-[--cyan]/20 bg-[--card] shadow-2xl">
|
|
385
|
+
<div className="flex items-center justify-between p-4 border-b border-white/[0.06]">
|
|
386
|
+
<div className="flex items-center gap-2">
|
|
387
|
+
<div className="flex items-center gap-1"><div className="w-2 h-2 rounded-full bg-[#ff5f57]" /><div className="w-2 h-2 rounded-full bg-[#febc2e]" /><div className="w-2 h-2 rounded-full bg-[#28c840]" /></div>
|
|
388
|
+
<span className="font-mono text-[10px] font-medium text-[--cyan] uppercase tracking-wider ml-1">Import Targets</span>
|
|
389
|
+
</div>
|
|
390
|
+
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><span className="text-lg">×</span></button>
|
|
391
|
+
</div>
|
|
392
|
+
<div className="p-4">
|
|
393
|
+
<p className="text-xs font-mono text-muted-foreground mb-3">Paste targets (one per line) or upload a file. Accepted: plain list, CSV (value,type), JSON.</p>
|
|
394
|
+
<div className="flex gap-2 mb-3">
|
|
395
|
+
<button onClick={() => fileRef.current?.click()}
|
|
396
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors">
|
|
397
|
+
<UploadIcon size={12} /> Upload File
|
|
398
|
+
</button>
|
|
399
|
+
<input ref={fileRef} type="file" accept=".txt,.csv,.json" className="hidden" onChange={handleFile} />
|
|
400
|
+
</div>
|
|
401
|
+
<textarea value={text} onChange={e => setText(e.target.value)} rows={8} placeholder={"example.com\n*.api.example.com\n10.0.0.0/8,cidr\nhttps://app.example.com,url"}
|
|
402
|
+
className="w-full text-[11px] border border-white/[0.06] rounded-md p-3 bg-black/20 font-mono placeholder:text-muted-foreground/30 focus:outline-none focus:border-[--cyan]/40 focus:ring-1 focus:ring-[--cyan]/20 transition-colors resize-y" />
|
|
403
|
+
{text && (
|
|
404
|
+
<p className="text-[10px] font-mono text-muted-foreground mt-1">{parseTargets(text).length} targets detected</p>
|
|
405
|
+
)}
|
|
406
|
+
{result && (
|
|
407
|
+
<p className={`text-xs font-mono mt-2 ${result.error ? 'text-[--destructive]' : 'text-green-500'}`}>
|
|
408
|
+
{result.error || `${result.imported} targets imported`}
|
|
409
|
+
</p>
|
|
410
|
+
)}
|
|
411
|
+
<div className="flex justify-end gap-2 mt-3">
|
|
412
|
+
<button onClick={onClose} className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground">Cancel</button>
|
|
413
|
+
<button onClick={handleImport} disabled={importing || !text.trim()}
|
|
414
|
+
className="inline-flex items-center gap-1.5 rounded-md px-4 py-2 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors disabled:opacity-50">
|
|
415
|
+
{importing ? <SpinnerIcon size={12} /> : <UploadIcon size={12} />} Import
|
|
416
|
+
</button>
|
|
417
|
+
</div>
|
|
222
418
|
</div>
|
|
223
419
|
</div>
|
|
224
|
-
<select
|
|
225
|
-
value={target.status}
|
|
226
|
-
onChange={(e) => onStatusChange(target.id, e.target.value)}
|
|
227
|
-
className={`text-[10px] font-mono font-medium rounded-full px-2 py-0.5 border cursor-pointer bg-transparent ${st.bg} ${st.text} ${st.border} focus:outline-none`}
|
|
228
|
-
>
|
|
229
|
-
<option value="in_scope">in scope</option>
|
|
230
|
-
<option value="testing">testing</option>
|
|
231
|
-
<option value="completed">completed</option>
|
|
232
|
-
<option value="out_of_scope">out of scope</option>
|
|
233
|
-
</select>
|
|
234
|
-
<button onClick={() => onDelete(target.id)} className="shrink-0 p-1 text-muted-foreground hover:text-[--destructive] rounded transition-colors"><TrashIcon size={12} /></button>
|
|
235
420
|
</motion.div>
|
|
236
421
|
);
|
|
237
422
|
}
|
|
238
423
|
|
|
424
|
+
// ─── Add Target/Program Form ──────────────────────────────────────────────────
|
|
425
|
+
|
|
239
426
|
function AddForm({ fields, onSubmit }) {
|
|
240
427
|
const [values, setValues] = useState({});
|
|
241
428
|
const [open, setOpen] = useState(false);
|
|
@@ -247,20 +434,19 @@ function AddForm({ fields, onSubmit }) {
|
|
|
247
434
|
);
|
|
248
435
|
|
|
249
436
|
return (
|
|
250
|
-
<motion.div
|
|
251
|
-
|
|
252
|
-
animate={{ opacity: 1, scale: 1 }}
|
|
253
|
-
className="flex flex-wrap items-end gap-2 p-3 rounded-lg border border-white/[0.06] bg-[--card]"
|
|
254
|
-
>
|
|
437
|
+
<motion.div initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }}
|
|
438
|
+
className="flex flex-wrap items-end gap-2 p-3 rounded-lg border border-white/[0.06] bg-[--card]">
|
|
255
439
|
{fields.map(f => (
|
|
256
440
|
<div key={f.name} className="flex flex-col gap-1">
|
|
257
441
|
<label className="text-[10px] text-muted-foreground font-mono uppercase tracking-wider">{f.label}</label>
|
|
258
442
|
{f.type === 'select' ? (
|
|
259
|
-
<select value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })}
|
|
443
|
+
<select value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })}
|
|
444
|
+
className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
|
|
260
445
|
{f.options.map(o => <option key={o} value={o}>{o}</option>)}
|
|
261
446
|
</select>
|
|
262
447
|
) : (
|
|
263
|
-
<input type={f.type || 'text'} placeholder={f.placeholder} value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })}
|
|
448
|
+
<input type={f.type || 'text'} placeholder={f.placeholder} value={values[f.name] || ''} onChange={e => setValues({ ...values, [f.name]: e.target.value })}
|
|
449
|
+
className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono min-w-[120px] placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
|
|
264
450
|
)}
|
|
265
451
|
</div>
|
|
266
452
|
))}
|
|
@@ -270,26 +456,45 @@ function AddForm({ fields, onSubmit }) {
|
|
|
270
456
|
);
|
|
271
457
|
}
|
|
272
458
|
|
|
459
|
+
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
460
|
+
|
|
273
461
|
export function TargetsPage() {
|
|
274
462
|
const [programs_, setPrograms] = useState([]);
|
|
275
463
|
const [targets_, setTargets] = useState([]);
|
|
464
|
+
const [targetCounts, setTargetCounts] = useState({});
|
|
276
465
|
const [selectedProgram, setSelectedProgram] = useState(null);
|
|
277
466
|
const [loading, setLoading] = useState(true);
|
|
467
|
+
const [loadingTargets, setLoadingTargets] = useState(false);
|
|
278
468
|
const [syncStatus, setSyncStatus] = useState(null);
|
|
279
469
|
const [platformFilter, setPlatformFilter] = useState('all');
|
|
470
|
+
const [programSearch, setProgramSearch] = useState('');
|
|
471
|
+
const [targetSearch, setTargetSearch] = useState('');
|
|
472
|
+
const [typeFilter, setTypeFilter] = useState('all');
|
|
473
|
+
const [sortBy, setSortBy] = useState('default');
|
|
474
|
+
const [showImport, setShowImport] = useState(false);
|
|
475
|
+
const targetsRef = useRef(null);
|
|
280
476
|
|
|
281
477
|
async function load() {
|
|
282
|
-
const [p, ss] = await Promise.all([getPrograms(), getSyncStatus()]);
|
|
478
|
+
const [p, ss, tc] = await Promise.all([getPrograms(), getSyncStatus(), getProgramTargetCounts()]);
|
|
283
479
|
setPrograms(p);
|
|
284
480
|
setSyncStatus(ss);
|
|
285
|
-
|
|
481
|
+
setTargetCounts(tc);
|
|
482
|
+
// Auto-select first program that has targets, or first overall
|
|
483
|
+
if (p.length > 0 && !selectedProgram) {
|
|
484
|
+
const withTargets = p.find(prog => tc[prog.id]?.total > 0);
|
|
485
|
+
setSelectedProgram(withTargets ? withTargets.id : p[0].id);
|
|
486
|
+
}
|
|
286
487
|
setLoading(false);
|
|
287
488
|
}
|
|
288
489
|
|
|
289
490
|
async function loadTargets() {
|
|
290
491
|
if (!selectedProgram) { setTargets([]); return; }
|
|
492
|
+
setLoadingTargets(true);
|
|
291
493
|
const t = await getTargets(selectedProgram);
|
|
292
494
|
setTargets(t);
|
|
495
|
+
setLoadingTargets(false);
|
|
496
|
+
// Scroll targets panel to top on program change
|
|
497
|
+
if (targetsRef.current) targetsRef.current.scrollTop = 0;
|
|
293
498
|
}
|
|
294
499
|
|
|
295
500
|
useEffect(() => { load(); }, []);
|
|
@@ -322,11 +527,13 @@ export function TargetsPage() {
|
|
|
322
527
|
async function handleAddTarget(values) {
|
|
323
528
|
await createTarget({ programId: selectedProgram, type: values.type || 'domain', value: values.value || '' });
|
|
324
529
|
loadTargets();
|
|
530
|
+
load(); // refresh counts
|
|
325
531
|
}
|
|
326
532
|
|
|
327
533
|
async function handleDeleteTarget(id) {
|
|
328
534
|
await deleteTarget(id);
|
|
329
535
|
loadTargets();
|
|
536
|
+
load(); // refresh counts
|
|
330
537
|
}
|
|
331
538
|
|
|
332
539
|
async function handleStatusChange(id, status) {
|
|
@@ -334,11 +541,71 @@ export function TargetsPage() {
|
|
|
334
541
|
loadTargets();
|
|
335
542
|
}
|
|
336
543
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
544
|
+
async function handleUpdateTarget(id, data) {
|
|
545
|
+
await updateTarget(id, data);
|
|
546
|
+
loadTargets();
|
|
547
|
+
}
|
|
340
548
|
|
|
341
|
-
|
|
549
|
+
async function handleExport() {
|
|
550
|
+
if (!selectedProgram) return;
|
|
551
|
+
const t = await exportTargets(selectedProgram);
|
|
552
|
+
const prog = programs_.find(p => p.id === selectedProgram);
|
|
553
|
+
const csv = ['value,type,status,notes', ...t.map(r => `${r.value},${r.type},${r.status},${(r.notes || '').replace(/,/g, ';')}`)].join('\n');
|
|
554
|
+
const blob = new Blob([csv], { type: 'text/csv' });
|
|
555
|
+
const url = URL.createObjectURL(blob);
|
|
556
|
+
const a = document.createElement('a');
|
|
557
|
+
a.href = url;
|
|
558
|
+
a.download = `${(prog?.name || 'targets').replace(/\s+/g, '_').toLowerCase()}_targets.csv`;
|
|
559
|
+
a.click();
|
|
560
|
+
URL.revokeObjectURL(url);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ─── Filtering ──────────────────────────────────────────────────────────────
|
|
564
|
+
|
|
565
|
+
// Sort programs: those with targets first, then alphabetical
|
|
566
|
+
const sortedPrograms = [...programs_].sort((a, b) => {
|
|
567
|
+
const aCount = targetCounts[a.id]?.total || 0;
|
|
568
|
+
const bCount = targetCounts[b.id]?.total || 0;
|
|
569
|
+
if (aCount > 0 && bCount === 0) return -1;
|
|
570
|
+
if (aCount === 0 && bCount > 0) return 1;
|
|
571
|
+
if (aCount !== bCount) return bCount - aCount;
|
|
572
|
+
return a.name.localeCompare(b.name);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const filteredPrograms = sortedPrograms.filter(p => {
|
|
576
|
+
if (platformFilter !== 'all' && p.platform !== platformFilter) return false;
|
|
577
|
+
if (programSearch && !p.name.toLowerCase().includes(programSearch.toLowerCase())) return false;
|
|
578
|
+
return true;
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
const selectedProg = programs_.find(p => p.id === selectedProgram);
|
|
582
|
+
const tc = selectedProgram ? (targetCounts[selectedProgram] || { total: 0, inScope: 0, outOfScope: 0 }) : { total: 0, inScope: 0, outOfScope: 0 };
|
|
583
|
+
|
|
584
|
+
// Filter and sort targets
|
|
585
|
+
let displayTargets = [...targets_];
|
|
586
|
+
if (targetSearch) {
|
|
587
|
+
const q = targetSearch.toLowerCase();
|
|
588
|
+
displayTargets = displayTargets.filter(t => t.value.toLowerCase().includes(q) || t.type.includes(q));
|
|
589
|
+
}
|
|
590
|
+
if (typeFilter !== 'all') {
|
|
591
|
+
displayTargets = displayTargets.filter(t => t.type === typeFilter);
|
|
592
|
+
}
|
|
593
|
+
if (sortBy === 'type') {
|
|
594
|
+
displayTargets.sort((a, b) => a.type.localeCompare(b.type));
|
|
595
|
+
} else if (sortBy === 'status') {
|
|
596
|
+
displayTargets.sort((a, b) => a.status.localeCompare(b.status));
|
|
597
|
+
} else if (sortBy === 'alpha') {
|
|
598
|
+
displayTargets.sort((a, b) => a.value.localeCompare(b.value));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Group targets by type for header display
|
|
602
|
+
const typeGroups = {};
|
|
603
|
+
for (const t of displayTargets) {
|
|
604
|
+
typeGroups[t.type] = (typeGroups[t.type] || 0) + 1;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const totalInScope = targets_.filter(t => t.status === 'in_scope').length;
|
|
608
|
+
const totalTargets = Object.values(targetCounts).reduce((s, c) => s + c.total, 0);
|
|
342
609
|
|
|
343
610
|
if (loading) return <div className="flex flex-col gap-3">{[...Array(3)].map((_, i) => <div key={i} className="h-16 animate-pulse rounded-lg bg-white/[0.04] border border-white/[0.06]" />)}</div>;
|
|
344
611
|
|
|
@@ -355,7 +622,7 @@ export function TargetsPage() {
|
|
|
355
622
|
{programs_.length} programs
|
|
356
623
|
</span>
|
|
357
624
|
<span className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-[10px] font-mono font-medium bg-green-500/10 text-green-500 border border-green-500/20">
|
|
358
|
-
{
|
|
625
|
+
{totalTargets.toLocaleString()} targets
|
|
359
626
|
</span>
|
|
360
627
|
</div>
|
|
361
628
|
</div>
|
|
@@ -364,34 +631,25 @@ export function TargetsPage() {
|
|
|
364
631
|
|
|
365
632
|
{/* Platform filter tabs */}
|
|
366
633
|
<div className="flex gap-1.5 mb-4 overflow-x-auto pb-1 scrollbar-thin">
|
|
367
|
-
<button
|
|
368
|
-
onClick={() => setPlatformFilter('all')}
|
|
634
|
+
<button onClick={() => setPlatformFilter('all')}
|
|
369
635
|
className={`shrink-0 px-3 py-1 rounded-full text-[10px] font-mono font-medium border transition-colors ${
|
|
370
|
-
platformFilter === 'all'
|
|
371
|
-
|
|
372
|
-
: 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
|
|
373
|
-
}`}
|
|
374
|
-
>
|
|
636
|
+
platformFilter === 'all' ? 'bg-[--cyan]/10 text-[--cyan] border-[--cyan]/20' : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
|
|
637
|
+
}`}>
|
|
375
638
|
All ({programs_.length})
|
|
376
639
|
</button>
|
|
377
640
|
{PLATFORMS.filter(p => programs_.some(prog => prog.platform === p.id)).map(p => (
|
|
378
|
-
<button
|
|
379
|
-
key={p.id}
|
|
380
|
-
onClick={() => setPlatformFilter(p.id)}
|
|
641
|
+
<button key={p.id} onClick={() => setPlatformFilter(p.id)}
|
|
381
642
|
className={`shrink-0 px-3 py-1 rounded-full text-[10px] font-mono font-medium border transition-colors ${
|
|
382
|
-
platformFilter === p.id
|
|
383
|
-
|
|
384
|
-
: 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
|
|
385
|
-
}`}
|
|
386
|
-
>
|
|
643
|
+
platformFilter === p.id ? `${p.bg} ${p.text} ${p.border}` : 'border-white/[0.06] text-muted-foreground hover:text-foreground hover:border-white/[0.12]'
|
|
644
|
+
}`}>
|
|
387
645
|
{p.label} ({programs_.filter(prog => prog.platform === p.id).length})
|
|
388
646
|
</button>
|
|
389
647
|
))}
|
|
390
648
|
</div>
|
|
391
649
|
|
|
392
|
-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
650
|
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6" style={{ minHeight: '60vh' }}>
|
|
393
651
|
{/* Programs panel */}
|
|
394
|
-
<div className="lg:col-span-1">
|
|
652
|
+
<div className="lg:col-span-1 flex flex-col min-h-0">
|
|
395
653
|
<div className="flex items-center justify-between mb-3">
|
|
396
654
|
<h2 className="text-[10px] font-mono font-medium uppercase tracking-wider text-muted-foreground">Programs</h2>
|
|
397
655
|
<AddForm
|
|
@@ -404,7 +662,13 @@ export function TargetsPage() {
|
|
|
404
662
|
onSubmit={handleAddProgram}
|
|
405
663
|
/>
|
|
406
664
|
</div>
|
|
407
|
-
|
|
665
|
+
{/* Program search */}
|
|
666
|
+
<div className="relative mb-2">
|
|
667
|
+
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={12} /></div>
|
|
668
|
+
<input placeholder="Search programs..." value={programSearch} onChange={e => setProgramSearch(e.target.value)}
|
|
669
|
+
className="w-full text-xs border border-white/[0.06] rounded-md pl-8 pr-3 py-1.5 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
|
|
670
|
+
</div>
|
|
671
|
+
<div className="flex flex-col gap-2 flex-1 overflow-y-auto scrollbar-thin">
|
|
408
672
|
{filteredPrograms.length === 0 ? (
|
|
409
673
|
<div className="flex flex-col items-center py-8 text-center">
|
|
410
674
|
<div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-3 mb-3"><GlobeIcon size={20} /></div>
|
|
@@ -412,48 +676,134 @@ export function TargetsPage() {
|
|
|
412
676
|
<p className="text-[10px] font-mono text-muted-foreground mt-1">Sync from platforms or add manually</p>
|
|
413
677
|
</div>
|
|
414
678
|
) : filteredPrograms.map((p, i) => (
|
|
415
|
-
<ProgramCard key={p.id} program={p} selected={selectedProgram === p.id} onSelect={setSelectedProgram} onDelete={handleDeleteProgram} index={i} />
|
|
679
|
+
<ProgramCard key={p.id} program={p} selected={selectedProgram === p.id} onSelect={setSelectedProgram} onDelete={handleDeleteProgram} index={i} targetCount={targetCounts[p.id]} />
|
|
416
680
|
))}
|
|
417
681
|
</div>
|
|
418
682
|
</div>
|
|
419
683
|
|
|
420
684
|
{/* Targets panel */}
|
|
421
|
-
<div className="lg:col-span-2">
|
|
685
|
+
<div className="lg:col-span-2 flex flex-col min-h-0">
|
|
422
686
|
<div className="flex items-center justify-between mb-3">
|
|
423
687
|
<h2 className="text-[10px] font-mono font-medium uppercase tracking-wider text-muted-foreground">
|
|
424
|
-
Targets {
|
|
688
|
+
Targets {selectedProg && (<>
|
|
689
|
+
{'\u2014'} {selectedProg.name}
|
|
690
|
+
{tc.total > 0 && <span className="text-[--cyan] ml-1">({tc.total})</span>}
|
|
691
|
+
</>)}
|
|
425
692
|
</h2>
|
|
426
693
|
{selectedProgram && (
|
|
427
|
-
<
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
{
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
694
|
+
<div className="flex items-center gap-2">
|
|
695
|
+
<button onClick={() => setShowImport(true)}
|
|
696
|
+
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground hover:text-foreground">
|
|
697
|
+
<UploadIcon size={12} /> Import
|
|
698
|
+
</button>
|
|
699
|
+
{targets_.length > 0 && (
|
|
700
|
+
<button onClick={handleExport}
|
|
701
|
+
className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-mono font-medium border border-white/[0.06] hover:bg-white/[0.04] transition-colors text-muted-foreground hover:text-foreground">
|
|
702
|
+
<DownloadIcon size={12} /> Export
|
|
703
|
+
</button>
|
|
704
|
+
)}
|
|
705
|
+
<AddForm
|
|
706
|
+
fields={[
|
|
707
|
+
{ name: 'value', label: 'Target', placeholder: '*.example.com' },
|
|
708
|
+
{ name: 'type', label: 'Type', type: 'select', options: TARGET_TYPES },
|
|
709
|
+
]}
|
|
710
|
+
onSubmit={handleAddTarget}
|
|
711
|
+
/>
|
|
712
|
+
</div>
|
|
434
713
|
)}
|
|
435
714
|
</div>
|
|
715
|
+
|
|
716
|
+
{/* Target filters */}
|
|
717
|
+
{selectedProgram && targets_.length > 0 && (
|
|
718
|
+
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
|
719
|
+
<div className="relative flex-1 min-w-[150px]">
|
|
720
|
+
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground"><SearchIcon size={12} /></div>
|
|
721
|
+
<input placeholder="Search targets..." value={targetSearch} onChange={e => setTargetSearch(e.target.value)}
|
|
722
|
+
className="w-full text-xs border border-white/[0.06] rounded-md pl-8 pr-3 py-1.5 bg-black/20 font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-[--cyan]/40 transition-colors" />
|
|
723
|
+
</div>
|
|
724
|
+
<select value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
|
|
725
|
+
className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
|
|
726
|
+
<option value="all">All types</option>
|
|
727
|
+
{TARGET_TYPES.map(t => <option key={t} value={t}>{TYPE_ICONS[t]} {t}</option>)}
|
|
728
|
+
</select>
|
|
729
|
+
<select value={sortBy} onChange={e => setSortBy(e.target.value)}
|
|
730
|
+
className="text-xs border border-white/[0.06] rounded-md px-2 py-1.5 bg-black/20 font-mono focus:outline-none focus:border-[--cyan]/40 transition-colors">
|
|
731
|
+
<option value="default">Recent</option>
|
|
732
|
+
<option value="type">By type</option>
|
|
733
|
+
<option value="status">By status</option>
|
|
734
|
+
<option value="alpha">A-Z</option>
|
|
735
|
+
</select>
|
|
736
|
+
</div>
|
|
737
|
+
)}
|
|
738
|
+
|
|
739
|
+
{/* Type summary chips */}
|
|
740
|
+
{selectedProgram && Object.keys(typeGroups).length > 1 && (
|
|
741
|
+
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
742
|
+
{Object.entries(typeGroups).sort((a, b) => b[1] - a[1]).map(([type, count]) => (
|
|
743
|
+
<button key={type} onClick={() => setTypeFilter(typeFilter === type ? 'all' : type)}
|
|
744
|
+
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-mono font-medium border transition-colors cursor-pointer ${
|
|
745
|
+
typeFilter === type
|
|
746
|
+
? 'bg-[--cyan]/10 text-[--cyan] border-[--cyan]/20'
|
|
747
|
+
: 'bg-white/5 border-white/10 text-muted-foreground hover:text-foreground'
|
|
748
|
+
}`}>
|
|
749
|
+
{TYPE_ICONS[type]} {type} ({count})
|
|
750
|
+
</button>
|
|
751
|
+
))}
|
|
752
|
+
</div>
|
|
753
|
+
)}
|
|
754
|
+
|
|
436
755
|
<AnimatePresence mode="wait">
|
|
437
756
|
{!selectedProgram ? (
|
|
438
|
-
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center">
|
|
757
|
+
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
|
|
439
758
|
<div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><CrosshairIcon size={24} /></div>
|
|
440
759
|
<p className="text-sm font-mono font-medium mb-1">Select a program</p>
|
|
441
760
|
<p className="text-[11px] font-mono text-muted-foreground">Choose a program to manage its targets</p>
|
|
442
761
|
</motion.div>
|
|
443
|
-
) :
|
|
444
|
-
<motion.div key="
|
|
762
|
+
) : loadingTargets ? (
|
|
763
|
+
<motion.div key="loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
|
|
764
|
+
<SpinnerIcon size={24} className="text-[--cyan] mb-3" />
|
|
765
|
+
<p className="text-xs font-mono text-muted-foreground">Loading targets...</p>
|
|
766
|
+
</motion.div>
|
|
767
|
+
) : displayTargets.length === 0 && targets_.length === 0 ? (
|
|
768
|
+
<motion.div key="no-targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
|
|
445
769
|
<div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><CrosshairIcon size={24} /></div>
|
|
446
770
|
<p className="text-sm font-mono font-medium mb-1">No targets yet</p>
|
|
447
|
-
<p className="text-[11px] font-mono text-muted-foreground">Add domains, IPs, and URLs to start hunting</p>
|
|
771
|
+
<p className="text-[11px] font-mono text-muted-foreground mb-4">Add domains, IPs, and URLs to start hunting</p>
|
|
772
|
+
<div className="flex gap-2">
|
|
773
|
+
<button onClick={() => setShowImport(true)}
|
|
774
|
+
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20 hover:bg-[--cyan] hover:text-[--primary-foreground] transition-colors">
|
|
775
|
+
<UploadIcon size={12} /> Import Targets
|
|
776
|
+
</button>
|
|
777
|
+
</div>
|
|
778
|
+
</motion.div>
|
|
779
|
+
) : displayTargets.length === 0 ? (
|
|
780
|
+
<motion.div key="no-match" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col items-center py-12 text-center flex-1">
|
|
781
|
+
<div className="rounded-full bg-white/[0.04] border border-white/[0.06] p-4 mb-4"><SearchIcon size={24} /></div>
|
|
782
|
+
<p className="text-sm font-mono text-muted-foreground">No targets match your filter</p>
|
|
448
783
|
</motion.div>
|
|
449
784
|
) : (
|
|
450
|
-
<motion.div key="targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
|
451
|
-
|
|
785
|
+
<motion.div key="targets" initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
|
786
|
+
ref={targetsRef}
|
|
787
|
+
className="flex flex-col gap-2 flex-1 overflow-y-auto scrollbar-thin">
|
|
788
|
+
{displayTargets.map((t, i) => (
|
|
789
|
+
<TargetRow key={t.id} target={t} onDelete={handleDeleteTarget} onStatusChange={handleStatusChange} onUpdate={handleUpdateTarget} index={i} />
|
|
790
|
+
))}
|
|
452
791
|
</motion.div>
|
|
453
792
|
)}
|
|
454
793
|
</AnimatePresence>
|
|
455
794
|
</div>
|
|
456
795
|
</div>
|
|
796
|
+
|
|
797
|
+
{/* Import dialog */}
|
|
798
|
+
<AnimatePresence>
|
|
799
|
+
{showImport && selectedProgram && (
|
|
800
|
+
<ImportDialog
|
|
801
|
+
programId={selectedProgram}
|
|
802
|
+
onClose={() => setShowImport(false)}
|
|
803
|
+
onImported={() => { setShowImport(false); loadTargets(); load(); }}
|
|
804
|
+
/>
|
|
805
|
+
)}
|
|
806
|
+
</AnimatePresence>
|
|
457
807
|
</>
|
|
458
808
|
);
|
|
459
809
|
}
|