@lievclouds/dsh-cortex 0.4.4 → 1.0.0
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.
Potentially problematic release.
This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.
- package/README.md +1 -1
- package/client.js +428 -406
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -106
- package/src/cortex-ui/dsh/api.ts +5 -2
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
- package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
- package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
- package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
- package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
- package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/cortex-ui/shared/ipc.ts +3 -4
- package/src/cortex-ui/shared/types.ts +9 -0
- package/src/host/cortex/fs.js +72 -0
- package/src/host/cortex/index.js +49 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/settings.js +54 -0
- package/src/host/cortex/tools.js +2 -0
- package/src/host/cortex/vulns.js +36 -13
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- package/src/host/miniapp/wxapkg.js +416 -0
- package/src/host/reports-office.js +341 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
|
+
import { FileInput, Loader2, X } from 'lucide-react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { Button } from '@renderer/components/ui/button'
|
|
6
|
+
import { parseCurlCommand, type CurlParseErrorCode } from '@renderer/lib/curl'
|
|
7
|
+
import { useI18n } from '@renderer/i18n'
|
|
8
|
+
import { getPortalContainer } from '../../../dsh/portal'
|
|
9
|
+
|
|
10
|
+
interface CurlImportDialogProps {
|
|
11
|
+
open: boolean
|
|
12
|
+
onOpenChange: (open: boolean) => void
|
|
13
|
+
/** Receives the raw HTTP request text to replace the editor content. */
|
|
14
|
+
onApply: (raw: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Paste a curl command and turn it into the raw request editor format.
|
|
19
|
+
* Parsing is offline and best effort; unsupported options are ignored.
|
|
20
|
+
*/
|
|
21
|
+
export function CurlImportDialog({
|
|
22
|
+
open,
|
|
23
|
+
onOpenChange,
|
|
24
|
+
onApply
|
|
25
|
+
}: CurlImportDialogProps) {
|
|
26
|
+
const { t } = useI18n()
|
|
27
|
+
const [input, setInput] = useState('')
|
|
28
|
+
const [error, setError] = useState<string | null>(null)
|
|
29
|
+
const [busy, setBusy] = useState(false)
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return
|
|
33
|
+
setError(null)
|
|
34
|
+
}, [open])
|
|
35
|
+
|
|
36
|
+
const errorMessage = (code: CurlParseErrorCode): string => {
|
|
37
|
+
switch (code) {
|
|
38
|
+
case 'empty':
|
|
39
|
+
return t('retest.repeater.curl.empty')
|
|
40
|
+
case 'no-url':
|
|
41
|
+
return t('retest.repeater.curl.noUrl')
|
|
42
|
+
case 'unterminated-quote':
|
|
43
|
+
return t('retest.repeater.curl.unterminatedQuote')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const apply = (): void => {
|
|
48
|
+
if (busy) return
|
|
49
|
+
setBusy(true)
|
|
50
|
+
try {
|
|
51
|
+
const result = parseCurlCommand(input)
|
|
52
|
+
if (!result.ok) {
|
|
53
|
+
setError(errorMessage(result.code))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
setError(null)
|
|
57
|
+
onApply(result.raw)
|
|
58
|
+
toast.success(t('retest.repeater.curl.applied'))
|
|
59
|
+
onOpenChange(false)
|
|
60
|
+
} finally {
|
|
61
|
+
setBusy(false)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
67
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
68
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
|
|
69
|
+
<DialogPrimitive.Content
|
|
70
|
+
data-testid="repeater-curl-dialog"
|
|
71
|
+
className="fixed left-1/2 top-1/2 z-[70] w-[min(620px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background p-5 shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
72
|
+
>
|
|
73
|
+
<div className="flex items-start gap-2">
|
|
74
|
+
<FileInput
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
|
77
|
+
/>
|
|
78
|
+
<div className="min-w-0">
|
|
79
|
+
<DialogPrimitive.Title className="text-[14px] font-medium tracking-tight text-foreground">
|
|
80
|
+
{t('retest.repeater.curl.importTitle')}
|
|
81
|
+
</DialogPrimitive.Title>
|
|
82
|
+
<DialogPrimitive.Description className="mt-1 text-[10.5px] leading-relaxed text-muted-foreground">
|
|
83
|
+
{t('retest.repeater.curl.importDescription')}
|
|
84
|
+
</DialogPrimitive.Description>
|
|
85
|
+
</div>
|
|
86
|
+
<Button
|
|
87
|
+
variant="ghost"
|
|
88
|
+
size="icon-sm"
|
|
89
|
+
aria-label={t('common.close')}
|
|
90
|
+
onClick={() => onOpenChange(false)}
|
|
91
|
+
className="ml-auto shrink-0"
|
|
92
|
+
>
|
|
93
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<label
|
|
98
|
+
htmlFor="repeater-curl-input"
|
|
99
|
+
className="mt-4 block text-[10.5px] font-medium text-foreground"
|
|
100
|
+
>
|
|
101
|
+
{t('retest.repeater.curl.inputLabel')}
|
|
102
|
+
</label>
|
|
103
|
+
<textarea
|
|
104
|
+
id="repeater-curl-input"
|
|
105
|
+
data-testid="repeater-curl-input"
|
|
106
|
+
aria-label={t('retest.repeater.curl.inputLabel')}
|
|
107
|
+
value={input}
|
|
108
|
+
spellCheck={false}
|
|
109
|
+
autoFocus
|
|
110
|
+
onChange={(event) => {
|
|
111
|
+
setInput(event.target.value)
|
|
112
|
+
if (error !== null) setError(null)
|
|
113
|
+
}}
|
|
114
|
+
onKeyDown={(event) => {
|
|
115
|
+
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
|
116
|
+
event.preventDefault()
|
|
117
|
+
apply()
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
120
|
+
placeholder={t('retest.repeater.curl.inputPlaceholder')}
|
|
121
|
+
className="mt-1.5 min-h-[170px] w-full resize-y rounded-lg border border-border bg-[hsl(var(--code))] px-3 py-2.5 font-mono text-[11px] leading-relaxed text-[hsl(220_14%_92%)] caret-[hsl(174_77%_45%)] placeholder:text-[hsl(220_10%_62%)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
122
|
+
/>
|
|
123
|
+
<p className="mt-1.5 text-[10px] leading-relaxed text-muted-foreground">
|
|
124
|
+
{t('retest.repeater.curl.importHint')}
|
|
125
|
+
</p>
|
|
126
|
+
{error !== null ? (
|
|
127
|
+
<p
|
|
128
|
+
role="alert"
|
|
129
|
+
className="mt-2 rounded-md border border-red-200 bg-red-50 px-2.5 py-1.5 text-[10.5px] leading-relaxed text-red-700"
|
|
130
|
+
>
|
|
131
|
+
{error}
|
|
132
|
+
</p>
|
|
133
|
+
) : null}
|
|
134
|
+
|
|
135
|
+
<div className="mt-4 flex items-center justify-end gap-2">
|
|
136
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
|
137
|
+
{t('common.cancel')}
|
|
138
|
+
</Button>
|
|
139
|
+
<Button
|
|
140
|
+
size="sm"
|
|
141
|
+
data-testid="repeater-curl-parse"
|
|
142
|
+
disabled={busy}
|
|
143
|
+
onClick={apply}
|
|
144
|
+
className="gap-1.5"
|
|
145
|
+
>
|
|
146
|
+
{busy ? (
|
|
147
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
148
|
+
) : (
|
|
149
|
+
<FileInput aria-hidden="true" className="h-3.5 w-3.5" />
|
|
150
|
+
)}
|
|
151
|
+
{t('retest.repeater.curl.parse')}
|
|
152
|
+
</Button>
|
|
153
|
+
</div>
|
|
154
|
+
</DialogPrimitive.Content>
|
|
155
|
+
</DialogPrimitive.Portal>
|
|
156
|
+
</DialogPrimitive.Root>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
2
|
+
import { Loader2, Save, Star, Trash2, X } from 'lucide-react'
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import type { RepeaterSendResult } from '@shared/repeater'
|
|
6
|
+
import { Button } from '@renderer/components/ui/button'
|
|
7
|
+
import { Input } from '@renderer/components/ui/input'
|
|
8
|
+
import {
|
|
9
|
+
deleteRepeaterCollection,
|
|
10
|
+
listRepeaterCollections,
|
|
11
|
+
saveRepeaterCollection,
|
|
12
|
+
type RepeaterCollectionEntry
|
|
13
|
+
} from '@renderer/lib/repeaterStoreApi'
|
|
14
|
+
import { useI18n } from '@renderer/i18n'
|
|
15
|
+
import { getPortalContainer } from '../../../dsh/portal'
|
|
16
|
+
|
|
17
|
+
interface RepeaterCollectionsDialogProps {
|
|
18
|
+
open: boolean
|
|
19
|
+
/** Saving/loading needs a selected finding; actions are disabled without it. */
|
|
20
|
+
vulnId: string | null
|
|
21
|
+
/** Current editor text, stored verbatim so loading restores the exact bytes. */
|
|
22
|
+
raw: string
|
|
23
|
+
/** Parsed current request, `null` when the editor content cannot be parsed. */
|
|
24
|
+
request: RepeaterSendResult['request'] | null
|
|
25
|
+
/** Last response for the current request, if any. */
|
|
26
|
+
response: RepeaterSendResult['response'] | null
|
|
27
|
+
onOpenChange: (open: boolean) => void
|
|
28
|
+
onLoad: (item: RepeaterCollectionEntry) => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const NAME_MAX = 80
|
|
32
|
+
|
|
33
|
+
function describeError(cause: unknown): string {
|
|
34
|
+
return cause instanceof Error ? cause.message : String(cause)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatTime(value: string): string {
|
|
38
|
+
const date = new Date(value)
|
|
39
|
+
if (Number.isNaN(date.getTime())) return value
|
|
40
|
+
return date.toLocaleString()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Named request library for the single Repeater. A favorite keeps the raw
|
|
45
|
+
* editor text plus the parsed request/response so loading it restores the whole
|
|
46
|
+
* workbench state.
|
|
47
|
+
*/
|
|
48
|
+
export function RepeaterCollectionsDialog({
|
|
49
|
+
open,
|
|
50
|
+
vulnId,
|
|
51
|
+
raw,
|
|
52
|
+
request,
|
|
53
|
+
response,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
onLoad
|
|
56
|
+
}: RepeaterCollectionsDialogProps) {
|
|
57
|
+
const { t } = useI18n()
|
|
58
|
+
const [items, setItems] = useState<RepeaterCollectionEntry[]>([])
|
|
59
|
+
const [name, setName] = useState('')
|
|
60
|
+
const [nameError, setNameError] = useState<string | null>(null)
|
|
61
|
+
const [loading, setLoading] = useState(false)
|
|
62
|
+
const [saving, setSaving] = useState(false)
|
|
63
|
+
const [error, setError] = useState<string | null>(null)
|
|
64
|
+
const [deletingId, setDeletingId] = useState<string | null>(null)
|
|
65
|
+
|
|
66
|
+
const load = useCallback(async (): Promise<void> => {
|
|
67
|
+
setLoading(true)
|
|
68
|
+
setError(null)
|
|
69
|
+
try {
|
|
70
|
+
const next = await listRepeaterCollections()
|
|
71
|
+
setItems(Array.isArray(next) ? next : [])
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
setError(describeError(cause))
|
|
74
|
+
} finally {
|
|
75
|
+
setLoading(false)
|
|
76
|
+
}
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (!open) return
|
|
81
|
+
void load()
|
|
82
|
+
}, [open, load])
|
|
83
|
+
|
|
84
|
+
const canSave = vulnId !== null && request !== null && !saving
|
|
85
|
+
|
|
86
|
+
const save = async (): Promise<void> => {
|
|
87
|
+
if (!canSave || vulnId === null || request === null) return
|
|
88
|
+
const trimmed = name.trim()
|
|
89
|
+
if (trimmed === '' || trimmed.length > NAME_MAX) {
|
|
90
|
+
setNameError(t('retest.repeater.favorites.nameInvalid'))
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
setSaving(true)
|
|
94
|
+
setNameError(null)
|
|
95
|
+
try {
|
|
96
|
+
await saveRepeaterCollection({
|
|
97
|
+
name: trimmed,
|
|
98
|
+
vulnId,
|
|
99
|
+
raw,
|
|
100
|
+
request,
|
|
101
|
+
...(response === null ? {} : { response })
|
|
102
|
+
})
|
|
103
|
+
setName('')
|
|
104
|
+
toast.success(t('retest.repeater.favorites.saved'))
|
|
105
|
+
await load()
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
toast.error(
|
|
108
|
+
t('retest.repeater.favorites.saveFailed', { error: describeError(cause) })
|
|
109
|
+
)
|
|
110
|
+
} finally {
|
|
111
|
+
setSaving(false)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const loadItem = (item: RepeaterCollectionEntry): void => {
|
|
116
|
+
if (vulnId === null) return
|
|
117
|
+
onLoad(item)
|
|
118
|
+
onOpenChange(false)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const remove = async (item: RepeaterCollectionEntry): Promise<void> => {
|
|
122
|
+
if (vulnId === null || deletingId !== null) return
|
|
123
|
+
setDeletingId(item.id)
|
|
124
|
+
try {
|
|
125
|
+
await deleteRepeaterCollection(item.id)
|
|
126
|
+
toast.success(t('retest.repeater.favorites.deleted'))
|
|
127
|
+
await load()
|
|
128
|
+
} catch (cause) {
|
|
129
|
+
toast.error(
|
|
130
|
+
t('retest.repeater.favorites.deleteFailed', { error: describeError(cause) })
|
|
131
|
+
)
|
|
132
|
+
} finally {
|
|
133
|
+
setDeletingId(null)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange} modal={false}>
|
|
139
|
+
<DialogPrimitive.Portal container={getPortalContainer()}>
|
|
140
|
+
<DialogPrimitive.Overlay className="sheet-overlay fixed inset-0 z-[70] bg-[hsl(224_18%_10%/0.3)]" />
|
|
141
|
+
<DialogPrimitive.Content
|
|
142
|
+
data-testid="repeater-collections-dialog"
|
|
143
|
+
className="fixed left-1/2 top-1/2 z-[70] flex max-h-[min(82vh,660px)] w-[min(620px,calc(100vw-2rem))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl border border-border bg-background shadow-panel focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
|
146
|
+
<Star aria-hidden="true" className="h-3.5 w-3.5 text-muted-foreground" />
|
|
147
|
+
<DialogPrimitive.Title className="text-[14px] font-medium tracking-tight text-foreground">
|
|
148
|
+
{t('retest.repeater.favorites.title')}
|
|
149
|
+
</DialogPrimitive.Title>
|
|
150
|
+
<span className="rounded border border-border bg-sidebar px-1.5 py-0.5 font-mono text-[9.5px] tabular-nums text-muted-foreground">
|
|
151
|
+
{items.length}
|
|
152
|
+
</span>
|
|
153
|
+
<Button
|
|
154
|
+
variant="ghost"
|
|
155
|
+
size="icon-sm"
|
|
156
|
+
aria-label={t('common.close')}
|
|
157
|
+
onClick={() => onOpenChange(false)}
|
|
158
|
+
className="ml-auto"
|
|
159
|
+
>
|
|
160
|
+
<X aria-hidden="true" className="h-3.5 w-3.5" />
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
<DialogPrimitive.Description className="sr-only">
|
|
164
|
+
{t('retest.repeater.favorites.description')}
|
|
165
|
+
</DialogPrimitive.Description>
|
|
166
|
+
|
|
167
|
+
<div className="border-b border-border px-4 py-3">
|
|
168
|
+
<label
|
|
169
|
+
htmlFor="repeater-collection-name"
|
|
170
|
+
className="text-[10.5px] font-medium text-foreground"
|
|
171
|
+
>
|
|
172
|
+
{t('retest.repeater.favorites.nameLabel')}
|
|
173
|
+
</label>
|
|
174
|
+
<div className="mt-1.5 flex items-center gap-1.5">
|
|
175
|
+
<Input
|
|
176
|
+
id="repeater-collection-name"
|
|
177
|
+
data-testid="repeater-favorite-name"
|
|
178
|
+
value={name}
|
|
179
|
+
maxLength={NAME_MAX}
|
|
180
|
+
spellCheck={false}
|
|
181
|
+
disabled={saving || vulnId === null}
|
|
182
|
+
placeholder={t('retest.repeater.favorites.namePlaceholder')}
|
|
183
|
+
onChange={(event) => {
|
|
184
|
+
setName(event.target.value)
|
|
185
|
+
if (nameError !== null) setNameError(null)
|
|
186
|
+
}}
|
|
187
|
+
onKeyDown={(event) => {
|
|
188
|
+
if (event.key === 'Enter') void save()
|
|
189
|
+
}}
|
|
190
|
+
className="h-8 min-w-0 flex-1 font-mono text-[11.5px]"
|
|
191
|
+
/>
|
|
192
|
+
<Button
|
|
193
|
+
size="sm"
|
|
194
|
+
data-testid="repeater-favorite-save"
|
|
195
|
+
disabled={!canSave}
|
|
196
|
+
onClick={() => void save()}
|
|
197
|
+
className="h-8 shrink-0 gap-1"
|
|
198
|
+
>
|
|
199
|
+
{saving ? (
|
|
200
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
201
|
+
) : (
|
|
202
|
+
<Save aria-hidden="true" className="h-3.5 w-3.5" />
|
|
203
|
+
)}
|
|
204
|
+
{saving
|
|
205
|
+
? t('retest.repeater.favorites.saving')
|
|
206
|
+
: t('retest.repeater.favorites.save')}
|
|
207
|
+
</Button>
|
|
208
|
+
</div>
|
|
209
|
+
{nameError !== null ? (
|
|
210
|
+
<p role="alert" className="mt-1.5 text-[10.5px] text-red-700">
|
|
211
|
+
{nameError}
|
|
212
|
+
</p>
|
|
213
|
+
) : request === null ? (
|
|
214
|
+
<p className="mt-1.5 text-[10px] leading-relaxed text-amber-800">
|
|
215
|
+
{t('retest.repeater.favorites.invalidRequest')}
|
|
216
|
+
</p>
|
|
217
|
+
) : (
|
|
218
|
+
<p className="mt-1.5 text-[10px] text-muted-foreground">
|
|
219
|
+
{t('retest.repeater.favorites.saveHint')}
|
|
220
|
+
</p>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
|
225
|
+
{loading && items.length === 0 ? (
|
|
226
|
+
<p className="flex h-[180px] items-center justify-center gap-2 text-[11.5px] text-muted-foreground">
|
|
227
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
228
|
+
{t('retest.repeater.favorites.loading')}
|
|
229
|
+
</p>
|
|
230
|
+
) : error !== null ? (
|
|
231
|
+
<div className="flex h-[180px] flex-col items-center justify-center gap-2 px-6 text-center">
|
|
232
|
+
<p role="alert" className="text-[11px] leading-relaxed text-red-700">
|
|
233
|
+
{t('retest.repeater.favorites.error', { error })}
|
|
234
|
+
</p>
|
|
235
|
+
<Button variant="outline" size="xs" onClick={() => void load()}>
|
|
236
|
+
{t('common.retry')}
|
|
237
|
+
</Button>
|
|
238
|
+
</div>
|
|
239
|
+
) : items.length === 0 ? (
|
|
240
|
+
<div className="flex h-[180px] flex-col items-center justify-center gap-1.5 text-center">
|
|
241
|
+
<Star aria-hidden="true" className="h-5 w-5 text-muted-foreground/60" />
|
|
242
|
+
<p className="text-[11.5px] text-muted-foreground">
|
|
243
|
+
{t('retest.repeater.favorites.empty')}
|
|
244
|
+
</p>
|
|
245
|
+
<p className="text-[10px] text-muted-foreground/80">
|
|
246
|
+
{t('retest.repeater.favorites.emptyHint')}
|
|
247
|
+
</p>
|
|
248
|
+
</div>
|
|
249
|
+
) : (
|
|
250
|
+
<ul className="flex flex-col gap-0.5">
|
|
251
|
+
{items.map((item) => (
|
|
252
|
+
<li
|
|
253
|
+
key={item.id}
|
|
254
|
+
className="flex items-stretch gap-1 rounded-md border border-transparent hover:border-border hover:bg-sidebar"
|
|
255
|
+
>
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
data-testid="repeater-collections-item"
|
|
259
|
+
data-collection-id={item.id}
|
|
260
|
+
disabled={vulnId === null}
|
|
261
|
+
onClick={() => loadItem(item)}
|
|
262
|
+
aria-label={t('retest.repeater.favorites.loadAria', {
|
|
263
|
+
name: item.name
|
|
264
|
+
})}
|
|
265
|
+
title={item.request.url}
|
|
266
|
+
className="flex min-w-0 flex-1 flex-col gap-1 rounded-md px-2.5 py-2 text-left transition-colors focus-visible:bg-accent-soft/40 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
267
|
+
>
|
|
268
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
269
|
+
<Star
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
className="h-3 w-3 shrink-0 text-accent"
|
|
272
|
+
/>
|
|
273
|
+
<span className="truncate text-[11.5px] font-medium text-foreground">
|
|
274
|
+
{item.name}
|
|
275
|
+
</span>
|
|
276
|
+
<span className="ml-auto shrink-0 font-mono text-[9.5px] tabular-nums text-muted-foreground">
|
|
277
|
+
{formatTime(item.createdAt)}
|
|
278
|
+
</span>
|
|
279
|
+
</span>
|
|
280
|
+
<span className="flex min-w-0 items-center gap-1.5 text-[9.5px] text-muted-foreground">
|
|
281
|
+
<span className="shrink-0 rounded border border-border bg-background px-1 py-0.5 font-mono">
|
|
282
|
+
{item.request.method}
|
|
283
|
+
</span>
|
|
284
|
+
<span className="truncate font-mono">{item.request.url}</span>
|
|
285
|
+
{item.response === null ? null : (
|
|
286
|
+
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
|
287
|
+
{item.response.status}
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
</span>
|
|
291
|
+
</button>
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
data-testid="repeater-collection-delete"
|
|
295
|
+
data-collection-id={item.id}
|
|
296
|
+
disabled={vulnId === null || deletingId !== null}
|
|
297
|
+
onClick={() => void remove(item)}
|
|
298
|
+
aria-label={t('retest.repeater.favorites.deleteAria', {
|
|
299
|
+
name: item.name
|
|
300
|
+
})}
|
|
301
|
+
className="my-1 flex w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40"
|
|
302
|
+
>
|
|
303
|
+
{deletingId === item.id ? (
|
|
304
|
+
<Loader2 aria-hidden="true" className="h-3.5 w-3.5 animate-spin" />
|
|
305
|
+
) : (
|
|
306
|
+
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
|
|
307
|
+
)}
|
|
308
|
+
</button>
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
</ul>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div className="flex items-center justify-between gap-2 border-t border-border px-4 py-2.5">
|
|
316
|
+
<span className="text-[9.5px] text-muted-foreground">
|
|
317
|
+
{t('retest.repeater.favorites.footerHint')}
|
|
318
|
+
</span>
|
|
319
|
+
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
|
320
|
+
{t('common.close')}
|
|
321
|
+
</Button>
|
|
322
|
+
</div>
|
|
323
|
+
</DialogPrimitive.Content>
|
|
324
|
+
</DialogPrimitive.Portal>
|
|
325
|
+
</DialogPrimitive.Root>
|
|
326
|
+
)
|
|
327
|
+
}
|