@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.

Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/client.js +428 -406
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -106
  7. package/src/cortex-ui/dsh/api.ts +5 -2
  8. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  9. package/src/cortex-ui/renderer/App.tsx +39 -0
  10. package/src/cortex-ui/renderer/components/asset/AssetDetail.tsx +9 -1
  11. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  12. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  13. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  14. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  16. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  17. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +333 -148
  18. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  19. package/src/cortex-ui/renderer/components/vuln/DirectoryPickerDialog.tsx +187 -0
  20. package/src/cortex-ui/renderer/components/vuln/ExportReportDialog.tsx +173 -59
  21. package/src/cortex-ui/renderer/components/vuln/VulnDetail.tsx +16 -4
  22. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  23. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  24. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  25. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  26. package/src/cortex-ui/renderer/i18n/messages/retest.ts +146 -2
  27. package/src/cortex-ui/renderer/i18n/messages/vuln.ts +26 -6
  28. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  29. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  30. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  31. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  32. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  33. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -0
  34. package/src/cortex-ui/renderer/lib/transport/httpClient.ts +1 -1
  35. package/src/cortex-ui/renderer/pages/DashboardPage.tsx +3 -3
  36. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  37. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  38. package/src/cortex-ui/shared/ipc.ts +3 -4
  39. package/src/cortex-ui/shared/types.ts +9 -0
  40. package/src/host/cortex/fs.js +72 -0
  41. package/src/host/cortex/index.js +49 -1
  42. package/src/host/cortex/repeater-store.js +411 -0
  43. package/src/host/cortex/repeater.js +15 -2
  44. package/src/host/cortex/settings.js +54 -0
  45. package/src/host/cortex/tools.js +2 -0
  46. package/src/host/cortex/vulns.js +36 -13
  47. package/src/host/miniapp/analyze.js +1078 -0
  48. package/src/host/miniapp/assets-import.js +294 -0
  49. package/src/host/miniapp/detect.js +426 -0
  50. package/src/host/miniapp/format.js +263 -0
  51. package/src/host/miniapp/roots.js +360 -0
  52. package/src/host/miniapp/routes.js +304 -0
  53. package/src/host/miniapp/service.js +559 -0
  54. package/src/host/miniapp/store.js +756 -0
  55. package/src/host/miniapp/tools.js +335 -0
  56. package/src/host/miniapp/unpack.js +1010 -0
  57. package/src/host/miniapp/wxapkg.js +416 -0
  58. 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
+ }