@actuate-media/cms-admin 0.77.0 → 0.79.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.
Files changed (92) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/__tests__/components/attribution-rows.test.d.ts +2 -0
  3. package/dist/__tests__/components/attribution-rows.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/attribution-rows.test.js +82 -0
  5. package/dist/__tests__/components/attribution-rows.test.js.map +1 -0
  6. package/dist/__tests__/views/form-editor.render.test.js +1 -0
  7. package/dist/__tests__/views/form-editor.render.test.js.map +1 -1
  8. package/dist/__tests__/views/form-entries.render.test.js +20 -0
  9. package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
  10. package/dist/__tests__/views/form-submissions.render.test.js +12 -0
  11. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
  12. package/dist/__tests__/views/forms-list.render.test.js +2 -0
  13. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  14. package/dist/__tests__/views/redirects-pane.render.test.js +2 -0
  15. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  16. package/dist/components/forms/ExportMenu.d.ts +8 -0
  17. package/dist/components/forms/ExportMenu.d.ts.map +1 -0
  18. package/dist/components/forms/ExportMenu.js +32 -0
  19. package/dist/components/forms/ExportMenu.js.map +1 -0
  20. package/dist/components/forms/FormEntryDetailPane.d.ts +13 -0
  21. package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -1
  22. package/dist/components/forms/FormEntryDetailPane.js +74 -8
  23. package/dist/components/forms/FormEntryDetailPane.js.map +1 -1
  24. package/dist/components/forms/FormSchemaPane.js +1 -1
  25. package/dist/components/forms/FormSchemaPane.js.map +1 -1
  26. package/dist/components/forms/NotificationsPanel.d.ts +4 -1
  27. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
  28. package/dist/components/forms/NotificationsPanel.js +66 -12
  29. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  30. package/dist/components/forms/ShortcodeGlossary.d.ts +5 -0
  31. package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -0
  32. package/dist/components/forms/ShortcodeGlossary.js +62 -0
  33. package/dist/components/forms/ShortcodeGlossary.js.map +1 -0
  34. package/dist/components/forms/WebhooksPanel.d.ts.map +1 -1
  35. package/dist/components/forms/WebhooksPanel.js +1 -1
  36. package/dist/components/forms/WebhooksPanel.js.map +1 -1
  37. package/dist/components/ui/ReauthDialog.d.ts +17 -0
  38. package/dist/components/ui/ReauthDialog.d.ts.map +1 -0
  39. package/dist/components/ui/ReauthDialog.js +34 -0
  40. package/dist/components/ui/ReauthDialog.js.map +1 -0
  41. package/dist/components/ui/index.d.ts +2 -0
  42. package/dist/components/ui/index.d.ts.map +1 -1
  43. package/dist/components/ui/index.js +1 -0
  44. package/dist/components/ui/index.js.map +1 -1
  45. package/dist/lib/forms-service.d.ts +23 -0
  46. package/dist/lib/forms-service.d.ts.map +1 -1
  47. package/dist/lib/forms-service.js +42 -1
  48. package/dist/lib/forms-service.js.map +1 -1
  49. package/dist/lib/seo-service.d.ts +4 -0
  50. package/dist/lib/seo-service.d.ts.map +1 -1
  51. package/dist/lib/seo-service.js +1 -0
  52. package/dist/lib/seo-service.js.map +1 -1
  53. package/dist/views/ApiKeys.d.ts.map +1 -1
  54. package/dist/views/ApiKeys.js +22 -23
  55. package/dist/views/ApiKeys.js.map +1 -1
  56. package/dist/views/FormEditor.js +1 -1
  57. package/dist/views/FormEditor.js.map +1 -1
  58. package/dist/views/FormEntries.d.ts.map +1 -1
  59. package/dist/views/FormEntries.js +8 -36
  60. package/dist/views/FormEntries.js.map +1 -1
  61. package/dist/views/FormSubmissions.d.ts.map +1 -1
  62. package/dist/views/FormSubmissions.js +7 -36
  63. package/dist/views/FormSubmissions.js.map +1 -1
  64. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  65. package/dist/views/seo/RedirectsTab.js +3 -2
  66. package/dist/views/seo/RedirectsTab.js.map +1 -1
  67. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
  68. package/dist/views/users/UserDetailDrawer.js +2 -30
  69. package/dist/views/users/UserDetailDrawer.js.map +1 -1
  70. package/package.json +3 -3
  71. package/src/__tests__/components/attribution-rows.test.ts +93 -0
  72. package/src/__tests__/views/form-editor.render.test.tsx +1 -0
  73. package/src/__tests__/views/form-entries.render.test.tsx +29 -0
  74. package/src/__tests__/views/form-submissions.render.test.tsx +17 -0
  75. package/src/__tests__/views/forms-list.render.test.tsx +2 -0
  76. package/src/__tests__/views/redirects-pane.render.test.tsx +2 -0
  77. package/src/components/forms/ExportMenu.tsx +70 -0
  78. package/src/components/forms/FormEntryDetailPane.tsx +82 -8
  79. package/src/components/forms/FormSchemaPane.tsx +1 -1
  80. package/src/components/forms/NotificationsPanel.tsx +150 -20
  81. package/src/components/forms/ShortcodeGlossary.tsx +146 -0
  82. package/src/components/forms/WebhooksPanel.tsx +5 -1
  83. package/src/components/ui/ReauthDialog.tsx +93 -0
  84. package/src/components/ui/index.ts +2 -0
  85. package/src/lib/forms-service.ts +57 -1
  86. package/src/lib/seo-service.ts +5 -0
  87. package/src/views/ApiKeys.tsx +48 -41
  88. package/src/views/FormEditor.tsx +1 -1
  89. package/src/views/FormEntries.tsx +7 -46
  90. package/src/views/FormSubmissions.tsx +6 -46
  91. package/src/views/seo/RedirectsTab.tsx +3 -1
  92. package/src/views/users/UserDetailDrawer.tsx +1 -76
@@ -17,6 +17,7 @@ import { type FormEvent, useState } from 'react'
17
17
  import { toast } from 'sonner'
18
18
  import { useApiData } from '../lib/useApiData.js'
19
19
  import { cmsApi } from '../lib/api.js'
20
+ import { ReauthDialog } from '../components/ui/ReauthDialog.js'
20
21
  import { SettingsCard } from './settings/components.js'
21
22
 
22
23
  const DIALOG_INPUT_CLASS =
@@ -80,26 +81,11 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
80
81
  const { data, loading, error, refetch } = useApiData<ApiKeyRecord[]>('/api-keys')
81
82
  const [showCreate, setShowCreate] = useState(false)
82
83
  const [createdKey, setCreatedKey] = useState<{ key: string; record: ApiKeyRecord } | null>(null)
84
+ const [pendingDelete, setPendingDelete] = useState<{ id: string; name: string } | null>(null)
83
85
 
84
86
  const keys = data ?? []
85
87
  const rootPadding = embedded ? '' : 'p-4 sm:p-6'
86
88
 
87
- const handleRevoke = async (id: string, name: string) => {
88
- if (!confirm(`Revoke API key "${name}"? This cannot be undone.`)) return
89
- const password = prompt('Confirm your password to revoke this API key:')
90
- if (!password) return
91
- const res = await cmsApi(`/api-keys/${id}`, {
92
- method: 'DELETE',
93
- headers: { 'x-reauth-password': password },
94
- })
95
- if (res.error) {
96
- toast.error(res.error)
97
- } else {
98
- toast.success('API key revoked')
99
- refetch()
100
- }
101
- }
102
-
103
89
  if (loading) {
104
90
  return (
105
91
  <div className={`flex h-64 items-center justify-center ${embedded ? '' : 'p-4 sm:p-6'}`}>
@@ -193,10 +179,9 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
193
179
  </thead>
194
180
  <tbody className="divide-border divide-y">
195
181
  {keys.map((k) => {
196
- const revoked = !!k.revokedAt
197
182
  const expired = k.expiresAt && new Date(k.expiresAt).getTime() < Date.now()
198
183
  return (
199
- <tr key={k.id} className={revoked || expired ? 'opacity-60' : undefined}>
184
+ <tr key={k.id} className={expired ? 'opacity-60' : undefined}>
200
185
  <td className="px-4 py-3 text-sm">
201
186
  <div className="text-foreground font-medium">{k.name}</div>
202
187
  {k.user && (
@@ -218,11 +203,7 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
218
203
  {formatDate(k.expiresAt)}
219
204
  </td>
220
205
  <td className="px-4 py-3">
221
- {revoked ? (
222
- <span className="bg-destructive/10 text-destructive inline-flex rounded-full px-2 py-0.5 text-xs font-medium">
223
- Revoked
224
- </span>
225
- ) : expired ? (
206
+ {expired ? (
226
207
  <span className="bg-warning/10 text-warning inline-flex rounded-full px-2 py-0.5 text-xs font-medium">
227
208
  Expired
228
209
  </span>
@@ -233,17 +214,15 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
233
214
  )}
234
215
  </td>
235
216
  <td className="px-4 py-3 text-right">
236
- {!revoked && (
237
- <button
238
- type="button"
239
- onClick={() => handleRevoke(k.id, k.name)}
240
- className="hover:bg-destructive/10 rounded p-1.5 transition-colors"
241
- aria-label={`Revoke ${k.name}`}
242
- title="Revoke"
243
- >
244
- <Trash2 className="text-destructive h-4 w-4" aria-hidden="true" />
245
- </button>
246
- )}
217
+ <button
218
+ type="button"
219
+ onClick={() => setPendingDelete({ id: k.id, name: k.name })}
220
+ className="hover:bg-destructive/10 rounded p-1.5 transition-colors"
221
+ aria-label={`Delete ${k.name}`}
222
+ title="Delete"
223
+ >
224
+ <Trash2 className="text-destructive h-4 w-4" aria-hidden="true" />
225
+ </button>
247
226
  </td>
248
227
  </tr>
249
228
  )
@@ -266,6 +245,33 @@ export function ApiKeys({ embedded = false }: ApiKeysProps = {}) {
266
245
  />
267
246
 
268
247
  {createdKey && <RevealKeyDialog created={createdKey} onClose={() => setCreatedKey(null)} />}
248
+
249
+ <ReauthDialog
250
+ open={!!pendingDelete}
251
+ onClose={() => setPendingDelete(null)}
252
+ title="Delete API key"
253
+ description={
254
+ pendingDelete
255
+ ? `Permanently delete “${pendingDelete.name}”? Clients using this key will fail immediately. This cannot be undone.`
256
+ : 'Permanently delete this API key? This cannot be undone.'
257
+ }
258
+ confirmLabel="Delete key"
259
+ destructive
260
+ onSubmit={async (password) => {
261
+ if (!pendingDelete) return
262
+ const res = await cmsApi(`/api-keys/${pendingDelete.id}`, {
263
+ method: 'DELETE',
264
+ headers: { 'x-reauth-password': password },
265
+ })
266
+ if (res.error) {
267
+ toast.error(res.error)
268
+ return
269
+ }
270
+ toast.success('API key deleted')
271
+ setPendingDelete(null)
272
+ refetch()
273
+ }}
274
+ />
269
275
  </div>
270
276
  )
271
277
  }
@@ -442,24 +448,25 @@ function CreateApiKeyDialog({ open, onClose, onCreated }: CreateApiKeyDialogProp
442
448
  />
443
449
  </div>
444
450
 
445
- <div>
451
+ <div className="border-border bg-muted/30 space-y-2 rounded-md border p-3">
446
452
  <label
447
453
  htmlFor="apikey-password"
448
- className="text-foreground mb-1 block text-sm font-medium"
454
+ className="text-foreground block text-sm font-medium"
449
455
  >
450
- Confirm password
456
+ Confirm it&apos;s you
451
457
  </label>
458
+ <p className="text-muted-foreground text-sm">
459
+ Creating an API key is a sensitive action. Re-enter your password to continue.
460
+ </p>
452
461
  <input
453
462
  id="apikey-password"
454
463
  type="password"
455
464
  value={password}
456
465
  onChange={(e) => setPassword(e.target.value)}
466
+ autoComplete="current-password"
457
467
  className={DIALOG_INPUT_CLASS}
458
468
  required
459
469
  />
460
- <p className="text-muted-foreground mt-1 text-sm">
461
- Creating an API key is a sensitive action and requires re-authentication.
462
- </p>
463
470
  </div>
464
471
 
465
472
  <div className="flex items-center justify-end gap-2 pt-2">
@@ -509,7 +516,7 @@ function RevealKeyDialog({
509
516
  <div className="border-warning/30 bg-warning/10 mb-4 flex items-start gap-2 rounded-lg border p-4">
510
517
  <AlertTriangle className="text-warning mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
511
518
  <p className="text-foreground text-xs">
512
- We only store a hash of this key. If you lose it, you&apos;ll need to revoke and
519
+ We only store a hash of this key. If you lose it, you&apos;ll need to delete it and
513
520
  create a new one.
514
521
  </p>
515
522
  </div>
@@ -314,7 +314,7 @@ function ExistingFormEditor({
314
314
 
315
315
  <Tabs.Content value="notifications" tabIndex={-1}>
316
316
  <div className="border-border bg-card rounded-lg border p-4">
317
- <NotificationsPanel formId={formId} />
317
+ <NotificationsPanel formId={formId} fields={fields} />
318
318
  </div>
319
319
  </Tabs.Content>
320
320
 
@@ -2,13 +2,13 @@
2
2
 
3
3
  /**
4
4
  * All Entries — a cross-form submissions inbox. Same token-only table, search,
5
- * status pills (All / Unread / Starred / Archived), bulk actions, CSV export,
6
- * pagination, and inline detail pane as the per-form Submissions view, plus a
7
- * Form filter and a Form column so editors can triage every form from one
8
- * place.
5
+ * status pills (All / Unread / Starred / Archived), bulk actions, server-side
6
+ * CSV/Excel export, pagination, and inline detail pane as the per-form
7
+ * Submissions view, plus a Form filter and a Form column so editors can
8
+ * triage every form from one place.
9
9
  */
10
10
  import { useCallback, useEffect, useState } from 'react'
11
- import { Download, Inbox, Search } from 'lucide-react'
11
+ import { Inbox, Search } from 'lucide-react'
12
12
  import { toast } from 'sonner'
13
13
  import {
14
14
  bulkUpdateEntries,
@@ -25,6 +25,7 @@ import {
25
25
  import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
26
26
  import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
27
27
  import { EntriesTable } from '../components/forms/EntriesTable.js'
28
+ import { ExportMenu } from '../components/forms/ExportMenu.js'
28
29
  import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
29
30
  import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
30
31
  import {
@@ -33,7 +34,6 @@ import {
33
34
  FormsLoading,
34
35
  FormsPagination,
35
36
  SummaryChip,
36
- btnSecondary,
37
37
  } from '../components/forms/primitives.js'
38
38
 
39
39
  export interface FormEntriesProps {
@@ -64,37 +64,6 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
64
64
  }
65
65
  }
66
66
 
67
- function csvEscape(value: unknown): string {
68
- const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
69
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
70
- }
71
-
72
- function exportCsv(entries: FormSubmission[]) {
73
- const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
74
- const header = ['Entry', 'Form', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
75
- const lines = [header.map(csvEscape).join(',')]
76
- for (const e of entries) {
77
- const row = [
78
- e.entryNumber ?? '',
79
- e.formName ?? '',
80
- e.submittedAt,
81
- e.senderName ?? '',
82
- e.senderEmail ?? '',
83
- e.senderPhone ?? '',
84
- e.unread ? 'unread' : 'read',
85
- ...dataKeys.map((k) => e.data?.[k]),
86
- ]
87
- lines.push(row.map(csvEscape).join(','))
88
- }
89
- const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
90
- const url = URL.createObjectURL(blob)
91
- const a = document.createElement('a')
92
- a.href = url
93
- a.download = 'all-entries.csv'
94
- a.click()
95
- URL.revokeObjectURL(url)
96
- }
97
-
98
67
  export function FormEntries({ onNavigate }: FormEntriesProps) {
99
68
  const [entries, setEntries] = useState<FormSubmission[]>([])
100
69
  const [total, setTotal] = useState(0)
@@ -240,15 +209,7 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
240
209
  {total} {total === 1 ? 'submission' : 'submissions'} across your forms
241
210
  </p>
242
211
  </div>
243
- <button
244
- type="button"
245
- className={btnSecondary}
246
- onClick={() => exportCsv(entries)}
247
- disabled={entries.length === 0}
248
- >
249
- <Download className="h-4 w-4" aria-hidden />
250
- Export CSV
251
- </button>
212
+ <ExportMenu formId={scopedFormId} disabled={counts !== null && counts.total === 0} />
252
213
  </div>
253
214
 
254
215
  {counts && (
@@ -6,12 +6,12 @@
6
6
  *
7
7
  * Features: summary chips, search + status pills (All / Unread / Starred /
8
8
  * Archived), row selection with bulk actions (mark read, star, archive,
9
- * delete), per-row star toggle, CSV export, and an inline detail pane that
10
- * opens on row click (marking the entry read). Loading / empty /
11
- * filtered-empty / error states are all handled.
9
+ * delete), per-row star toggle, server-side CSV/Excel export, and an inline
10
+ * detail pane that opens on row click (marking the entry read). Loading /
11
+ * empty / filtered-empty / error states are all handled.
12
12
  */
13
13
  import { useCallback, useEffect, useState } from 'react'
14
- import { ArrowLeft, Download, Inbox, Search } from 'lucide-react'
14
+ import { ArrowLeft, Inbox, Search } from 'lucide-react'
15
15
  import { toast } from 'sonner'
16
16
  import {
17
17
  bulkUpdateEntries,
@@ -27,6 +27,7 @@ import {
27
27
  import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
28
28
  import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
29
29
  import { EntriesTable } from '../components/forms/EntriesTable.js'
30
+ import { ExportMenu } from '../components/forms/ExportMenu.js'
30
31
  import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
31
32
  import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
32
33
  import {
@@ -35,7 +36,6 @@ import {
35
36
  FormsLoading,
36
37
  FormsPagination,
37
38
  SummaryChip,
38
- btnSecondary,
39
39
  } from '../components/forms/primitives.js'
40
40
 
41
41
  export interface FormSubmissionsProps {
@@ -67,39 +67,8 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
67
67
  }
68
68
  }
69
69
 
70
- function csvEscape(value: unknown): string {
71
- const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
72
- return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
73
- }
74
-
75
- function exportCsv(entries: FormSubmission[], formSlug: string) {
76
- const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
77
- const header = ['Entry', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
78
- const lines = [header.map(csvEscape).join(',')]
79
- for (const e of entries) {
80
- const row = [
81
- e.entryNumber ?? '',
82
- e.submittedAt,
83
- e.senderName ?? '',
84
- e.senderEmail ?? '',
85
- e.senderPhone ?? '',
86
- e.unread ? 'unread' : 'read',
87
- ...dataKeys.map((k) => e.data?.[k]),
88
- ]
89
- lines.push(row.map(csvEscape).join(','))
90
- }
91
- const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
92
- const url = URL.createObjectURL(blob)
93
- const a = document.createElement('a')
94
- a.href = url
95
- a.download = `${formSlug || 'form'}-submissions.csv`
96
- a.click()
97
- URL.revokeObjectURL(url)
98
- }
99
-
100
70
  export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
101
71
  const [formName, setFormName] = useState<string>('')
102
- const [formSlug, setFormSlug] = useState<string>('')
103
72
  const [entries, setEntries] = useState<FormSubmission[]>([])
104
73
  const [total, setTotal] = useState(0)
105
74
  const [counts, setCounts] = useState<EntryCounts | null>(null)
@@ -119,7 +88,6 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
119
88
  .then((form) => {
120
89
  if (cancelled || !form) return
121
90
  setFormName(form.name)
122
- setFormSlug(form.slug)
123
91
  })
124
92
  .catch(() => {})
125
93
  return () => {
@@ -261,15 +229,7 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
261
229
  {total} {total === 1 ? 'submission' : 'submissions'}
262
230
  </p>
263
231
  </div>
264
- <button
265
- type="button"
266
- className={btnSecondary}
267
- onClick={() => exportCsv(entries, formSlug)}
268
- disabled={entries.length === 0}
269
- >
270
- <Download className="h-4 w-4" aria-hidden />
271
- Export CSV
272
- </button>
232
+ <ExportMenu formId={formId} disabled={counts !== null && counts.total === 0} />
273
233
  </div>
274
234
  </div>
275
235
 
@@ -53,12 +53,13 @@ function OverviewCards({ onRefetchKey }: { onRefetchKey: number }) {
53
53
  if (!data) return null
54
54
  const cards = [
55
55
  { label: 'Active redirects', value: data.activeRedirects },
56
+ { label: 'Never hit', value: data.neverHitRedirects },
56
57
  { label: 'Stale redirects', value: data.staleRedirects },
57
58
  { label: '404s tracked', value: data.notFoundCount },
58
59
  { label: 'Chains & loops', value: data.chainsAndLoops },
59
60
  ]
60
61
  return (
61
- <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
62
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-5">
62
63
  {cards.map((c) => (
63
64
  <div key={c.label} className="border-border bg-card rounded-lg border p-4">
64
65
  <p className="text-muted-foreground text-sm">{c.label}</p>
@@ -470,6 +471,7 @@ export function RedirectsTab() {
470
471
  label={r.enabled ? 'Enabled' : 'Disabled'}
471
472
  tone={r.enabled ? 'good' : 'neutral'}
472
473
  />
474
+ {r.neverHit && <SeoStatusBadge label="Never hit" tone="neutral" />}
473
475
  {r.stale && <SeoStatusBadge label="Stale" tone="fair" />}
474
476
  </span>
475
477
  </td>
@@ -39,6 +39,7 @@ import { Button } from '../../components/ui/Button.js'
39
39
  import { Skeleton } from '../../components/ui/Skeleton.js'
40
40
  import { EmptyState } from '../../components/ui/EmptyState.js'
41
41
  import { ConfirmDialog } from '../../components/ui/ConfirmDialog.js'
42
+ import { ReauthDialog } from '../../components/ui/ReauthDialog.js'
42
43
 
43
44
  // ---------------------------------------------------------------------------
44
45
  // Types (mirror cms-core serialized shapes)
@@ -151,82 +152,6 @@ const STATUS_META: Record<UserDetail['status'], { dot: string; text: string; lab
151
152
  },
152
153
  }
153
154
 
154
- // ---------------------------------------------------------------------------
155
- // Reauth dialog
156
- // ---------------------------------------------------------------------------
157
-
158
- function ReauthDialog({
159
- open,
160
- onClose,
161
- onSubmit,
162
- }: {
163
- open: boolean
164
- onClose: () => void
165
- onSubmit: (password: string) => Promise<void>
166
- }) {
167
- const [password, setPassword] = useState('')
168
- const [busy, setBusy] = useState(false)
169
-
170
- const submit = async () => {
171
- if (!password || busy) return
172
- setBusy(true)
173
- try {
174
- await onSubmit(password)
175
- setPassword('')
176
- } finally {
177
- setBusy(false)
178
- }
179
- }
180
-
181
- return (
182
- <Modal
183
- open={open}
184
- onClose={() => {
185
- setPassword('')
186
- onClose()
187
- }}
188
- title="Confirm it's you"
189
- actions={
190
- <>
191
- <Button
192
- variant="ghost"
193
- onClick={() => {
194
- setPassword('')
195
- onClose()
196
- }}
197
- >
198
- Cancel
199
- </Button>
200
- <Button variant="primary" loading={busy} disabled={!password || busy} onClick={submit}>
201
- Confirm
202
- </Button>
203
- </>
204
- }
205
- >
206
- <p className="text-muted-foreground mb-3 text-sm">
207
- This is a sensitive action. Re-enter your password to continue.
208
- </p>
209
- <label
210
- htmlFor="reauth-password"
211
- className="text-card-foreground mb-1 block text-sm font-medium"
212
- >
213
- Your password
214
- </label>
215
- <input
216
- id="reauth-password"
217
- type="password"
218
- value={password}
219
- onChange={(e) => setPassword(e.target.value)}
220
- onKeyDown={(e) => {
221
- if (e.key === 'Enter') submit()
222
- }}
223
- autoComplete="current-password"
224
- className="border-border bg-input-background text-foreground focus:ring-brand w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none"
225
- />
226
- </Modal>
227
- )
228
- }
229
-
230
155
  // ---------------------------------------------------------------------------
231
156
  // Transfer content modal
232
157
  // ---------------------------------------------------------------------------