@actuate-media/cms-admin 0.78.0 → 0.79.1

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 (88) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/__tests__/lib/pages-service-fetch.test.js +10 -0
  3. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
  4. package/dist/__tests__/lib/post-editor-service.test.js +35 -1
  5. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  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/forms-list.render.test.js +2 -0
  9. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  10. package/dist/__tests__/views/redirects-pane.render.test.js +2 -0
  11. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  12. package/dist/components/SEOPanel.d.ts +8 -0
  13. package/dist/components/SEOPanel.d.ts.map +1 -1
  14. package/dist/components/SEOPanel.js +7 -2
  15. package/dist/components/SEOPanel.js.map +1 -1
  16. package/dist/components/forms/FormSchemaPane.js +1 -1
  17. package/dist/components/forms/FormSchemaPane.js.map +1 -1
  18. package/dist/components/forms/NotificationsPanel.d.ts +4 -1
  19. package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
  20. package/dist/components/forms/NotificationsPanel.js +66 -12
  21. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  22. package/dist/components/forms/ShortcodeGlossary.d.ts +5 -0
  23. package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -0
  24. package/dist/components/forms/ShortcodeGlossary.js +62 -0
  25. package/dist/components/forms/ShortcodeGlossary.js.map +1 -0
  26. package/dist/components/ui/ReauthDialog.d.ts +17 -0
  27. package/dist/components/ui/ReauthDialog.d.ts.map +1 -0
  28. package/dist/components/ui/ReauthDialog.js +34 -0
  29. package/dist/components/ui/ReauthDialog.js.map +1 -0
  30. package/dist/components/ui/index.d.ts +2 -0
  31. package/dist/components/ui/index.d.ts.map +1 -1
  32. package/dist/components/ui/index.js +1 -0
  33. package/dist/components/ui/index.js.map +1 -1
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/lib/forms-service.d.ts +9 -0
  39. package/dist/lib/forms-service.d.ts.map +1 -1
  40. package/dist/lib/forms-service.js +5 -0
  41. package/dist/lib/forms-service.js.map +1 -1
  42. package/dist/lib/pages-service.d.ts.map +1 -1
  43. package/dist/lib/pages-service.js +14 -15
  44. package/dist/lib/pages-service.js.map +1 -1
  45. package/dist/lib/post-editor-service.d.ts +6 -0
  46. package/dist/lib/post-editor-service.d.ts.map +1 -1
  47. package/dist/lib/post-editor-service.js +19 -1
  48. package/dist/lib/post-editor-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/DocumentEdit.d.ts.map +1 -1
  57. package/dist/views/DocumentEdit.js +5 -1
  58. package/dist/views/DocumentEdit.js.map +1 -1
  59. package/dist/views/FormEditor.js +1 -1
  60. package/dist/views/FormEditor.js.map +1 -1
  61. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  62. package/dist/views/seo/RedirectsTab.js +3 -2
  63. package/dist/views/seo/RedirectsTab.js.map +1 -1
  64. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
  65. package/dist/views/users/UserDetailDrawer.js +2 -30
  66. package/dist/views/users/UserDetailDrawer.js.map +1 -1
  67. package/package.json +3 -3
  68. package/src/__tests__/lib/pages-service-fetch.test.ts +11 -0
  69. package/src/__tests__/lib/post-editor-service.test.ts +46 -0
  70. package/src/__tests__/views/form-editor.render.test.tsx +1 -0
  71. package/src/__tests__/views/forms-list.render.test.tsx +2 -0
  72. package/src/__tests__/views/redirects-pane.render.test.tsx +2 -0
  73. package/src/components/SEOPanel.tsx +15 -3
  74. package/src/components/forms/FormSchemaPane.tsx +1 -1
  75. package/src/components/forms/NotificationsPanel.tsx +150 -20
  76. package/src/components/forms/ShortcodeGlossary.tsx +146 -0
  77. package/src/components/ui/ReauthDialog.tsx +93 -0
  78. package/src/components/ui/index.ts +2 -0
  79. package/src/index.ts +1 -1
  80. package/src/lib/forms-service.ts +12 -0
  81. package/src/lib/pages-service.ts +14 -15
  82. package/src/lib/post-editor-service.ts +17 -1
  83. package/src/lib/seo-service.ts +5 -0
  84. package/src/views/ApiKeys.tsx +48 -41
  85. package/src/views/DocumentEdit.tsx +5 -1
  86. package/src/views/FormEditor.tsx +1 -1
  87. package/src/views/seo/RedirectsTab.tsx +3 -1
  88. package/src/views/users/UserDetailDrawer.tsx +1 -76
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Copy-paste glossary of notification shortcodes (built-ins + form field keys).
5
+ * Used by the Notifications panel so admins never have to guess `{{token}}` names.
6
+ *
7
+ * Built-in keys must stay in sync with `FORM_NOTIFICATION_SHORTCODES` in
8
+ * `packages/cms-core/src/forms/notify.ts`.
9
+ */
10
+ import { useState } from 'react'
11
+ import { Check, Copy } from 'lucide-react'
12
+ import { toast } from 'sonner'
13
+ import type { FormField } from '../../lib/forms-service.js'
14
+
15
+ /** Mirrors cms-core `FORM_NOTIFICATION_SHORTCODES`. */
16
+ const BUILTIN_SHORTCODES = [
17
+ { key: 'formName', description: 'Form name' },
18
+ { key: 'siteName', description: 'Website name (SEO settings)' },
19
+ { key: 'siteUrl', description: 'Website URL (SEO settings)' },
20
+ { key: 'entryNumber', description: 'Submission number (e.g. #00042)' },
21
+ { key: 'senderName', description: 'Derived submitter name' },
22
+ { key: 'senderEmail', description: 'Derived submitter email' },
23
+ { key: 'submittedAt', description: 'Submission timestamp (ISO)' },
24
+ ] as const
25
+
26
+ function token(key: string): string {
27
+ return `{{${key}}}`
28
+ }
29
+
30
+ async function copyText(value: string, label: string) {
31
+ try {
32
+ await navigator.clipboard.writeText(value)
33
+ toast.success(`Copied ${label}`)
34
+ return true
35
+ } catch {
36
+ toast.error('Copy failed — your browser blocked clipboard access.')
37
+ return false
38
+ }
39
+ }
40
+
41
+ export function ShortcodeGlossary({ fields = [] }: { fields?: FormField[] }) {
42
+ const fieldTokens = fields.filter(
43
+ (f) => f.type !== 'honeypot' && f.type !== 'divider' && f.type !== 'richtext',
44
+ )
45
+
46
+ const allTokens = [
47
+ ...BUILTIN_SHORTCODES.map((s) => token(s.key)),
48
+ ...fieldTokens.map((f) => token(f.key)),
49
+ ].join('\n')
50
+
51
+ return (
52
+ <div className="border-border bg-muted/40 space-y-3 rounded-md border p-3">
53
+ <div className="flex items-start justify-between gap-3">
54
+ <div className="min-w-0">
55
+ <p className="text-foreground text-sm font-medium">Shortcode glossary</p>
56
+ <p className="text-muted-foreground text-xs">
57
+ Paste these into subject or message templates. Leave templates blank to use the built-in
58
+ email (all fields + attribution).
59
+ </p>
60
+ </div>
61
+ <CopyButton
62
+ label="Copy all"
63
+ ariaLabel="Copy all shortcodes"
64
+ value={allTokens}
65
+ successLabel="all shortcodes"
66
+ />
67
+ </div>
68
+
69
+ <ShortcodeGroup title="Built-in">
70
+ {BUILTIN_SHORTCODES.map((s) => (
71
+ <ShortcodeRow key={s.key} code={token(s.key)} description={s.description} />
72
+ ))}
73
+ </ShortcodeGroup>
74
+
75
+ {fieldTokens.length > 0 && (
76
+ <ShortcodeGroup title="Form fields">
77
+ {fieldTokens.map((f) => (
78
+ <ShortcodeRow key={f.id || f.key} code={token(f.key)} description={f.label || f.key} />
79
+ ))}
80
+ </ShortcodeGroup>
81
+ )}
82
+ </div>
83
+ )
84
+ }
85
+
86
+ function ShortcodeGroup({ title, children }: { title: string; children: React.ReactNode }) {
87
+ return (
88
+ <div>
89
+ <p className="text-muted-foreground mb-1.5 text-xs font-medium tracking-wide uppercase">
90
+ {title}
91
+ </p>
92
+ <ul className="border-border divide-border bg-background divide-y overflow-hidden rounded-md border">
93
+ {children}
94
+ </ul>
95
+ </div>
96
+ )
97
+ }
98
+
99
+ function ShortcodeRow({ code, description }: { code: string; description: string }) {
100
+ return (
101
+ <li className="flex items-center justify-between gap-2 px-2.5 py-1.5">
102
+ <div className="min-w-0">
103
+ <code className="text-foreground font-mono text-xs">{code}</code>
104
+ <span className="text-muted-foreground ml-2 text-xs">{description}</span>
105
+ </div>
106
+ <CopyButton label="Copy" ariaLabel={`Copy ${code}`} value={code} successLabel={code} />
107
+ </li>
108
+ )
109
+ }
110
+
111
+ function CopyButton({
112
+ label,
113
+ ariaLabel,
114
+ value,
115
+ successLabel,
116
+ }: {
117
+ label: string
118
+ ariaLabel: string
119
+ value: string
120
+ successLabel: string
121
+ }) {
122
+ const [copied, setCopied] = useState(false)
123
+
124
+ async function onCopy() {
125
+ const ok = await copyText(value, successLabel)
126
+ if (!ok) return
127
+ setCopied(true)
128
+ setTimeout(() => setCopied(false), 1500)
129
+ }
130
+
131
+ return (
132
+ <button
133
+ type="button"
134
+ onClick={() => void onCopy()}
135
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium focus-visible:ring-2 focus-visible:outline-none"
136
+ aria-label={ariaLabel}
137
+ >
138
+ {copied ? (
139
+ <Check className="h-3.5 w-3.5" aria-hidden />
140
+ ) : (
141
+ <Copy className="h-3.5 w-3.5" aria-hidden />
142
+ )}
143
+ {copied ? 'Copied' : label}
144
+ </button>
145
+ )
146
+ }
@@ -0,0 +1,93 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Modal } from './Modal.js'
5
+ import { Button } from './Button.js'
6
+
7
+ export interface ReauthDialogProps {
8
+ open: boolean
9
+ onClose: () => void
10
+ /** Called with the entered password. Resolve on success; throw or return without closing on failure. */
11
+ onSubmit: (password: string) => Promise<void>
12
+ title?: string
13
+ description?: string
14
+ confirmLabel?: string
15
+ /** When true, the confirm button uses the destructive (danger) variant. */
16
+ destructive?: boolean
17
+ }
18
+
19
+ /**
20
+ * Password re-auth modal for sensitive admin actions. Prefer this over
21
+ * `window.prompt()` so the flow stays in-theme and accessible.
22
+ */
23
+ export function ReauthDialog({
24
+ open,
25
+ onClose,
26
+ onSubmit,
27
+ title = "Confirm it's you",
28
+ description = 'This is a sensitive action. Re-enter your password to continue.',
29
+ confirmLabel = 'Confirm',
30
+ destructive = false,
31
+ }: ReauthDialogProps) {
32
+ const [password, setPassword] = useState('')
33
+ const [busy, setBusy] = useState(false)
34
+
35
+ const resetAndClose = () => {
36
+ setPassword('')
37
+ onClose()
38
+ }
39
+
40
+ const submit = async () => {
41
+ if (!password || busy) return
42
+ setBusy(true)
43
+ try {
44
+ await onSubmit(password)
45
+ setPassword('')
46
+ } finally {
47
+ setBusy(false)
48
+ }
49
+ }
50
+
51
+ return (
52
+ <Modal
53
+ open={open}
54
+ onClose={resetAndClose}
55
+ title={title}
56
+ actions={
57
+ <>
58
+ <Button variant="ghost" onClick={resetAndClose} disabled={busy}>
59
+ Cancel
60
+ </Button>
61
+ <Button
62
+ variant={destructive ? 'danger' : 'primary'}
63
+ loading={busy}
64
+ disabled={!password || busy}
65
+ onClick={submit}
66
+ >
67
+ {confirmLabel}
68
+ </Button>
69
+ </>
70
+ }
71
+ >
72
+ <p className="text-muted-foreground mb-3 text-sm">{description}</p>
73
+ <label
74
+ htmlFor="reauth-password"
75
+ className="text-card-foreground mb-1 block text-sm font-medium"
76
+ >
77
+ Your password
78
+ </label>
79
+ <input
80
+ id="reauth-password"
81
+ type="password"
82
+ value={password}
83
+ onChange={(e) => setPassword(e.target.value)}
84
+ onKeyDown={(e) => {
85
+ if (e.key === 'Enter') void submit()
86
+ }}
87
+ autoComplete="current-password"
88
+ autoFocus
89
+ 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"
90
+ />
91
+ </Modal>
92
+ )
93
+ }
@@ -28,6 +28,8 @@ export { Modal } from './Modal.js'
28
28
  export type { ModalProps } from './Modal.js'
29
29
  export { ConfirmDialog } from './ConfirmDialog.js'
30
30
  export type { ConfirmDialogProps } from './ConfirmDialog.js'
31
+ export { ReauthDialog } from './ReauthDialog.js'
32
+ export type { ReauthDialogProps } from './ReauthDialog.js'
31
33
  export { DataTable } from './DataTable.js'
32
34
  export type { DataTableProps } from './DataTable.js'
33
35
  export { Pagination } from './Pagination.js'
package/src/index.ts CHANGED
@@ -97,7 +97,7 @@ export type {
97
97
  SmartFolder,
98
98
  } from './components/FolderTree.js'
99
99
  export { TipTapEditor } from './components/TipTapEditor.js'
100
- export { SEOPanel } from './components/SEOPanel.js'
100
+ export { SEOPanel, resolveFocusKeyword } from './components/SEOPanel.js'
101
101
  export type { SEOData, SEOPanelProps } from './components/SEOPanel.js'
102
102
  export { LivePreview } from './components/LivePreview.js'
103
103
  export { VersionHistory } from './components/VersionHistory.js'
@@ -251,6 +251,18 @@ export async function setNotifyEnabled(
251
251
  return { data: res.data, error: res.error }
252
252
  }
253
253
 
254
+ /** Send a sample notification email to the first configured recipient. */
255
+ export async function testNotificationSettings(id: string): Promise<{
256
+ data?: { notified: number; autoresponded: boolean; skippedReason?: string }
257
+ error?: string
258
+ }> {
259
+ const res = await cmsApi<{ notified: number; autoresponded: boolean; skippedReason?: string }>(
260
+ `/forms/${encodeURIComponent(id)}/notifications/test`,
261
+ { method: 'POST', body: '{}' },
262
+ )
263
+ return { data: res.data, error: res.error }
264
+ }
265
+
254
266
  // ─── Embed ───────────────────────────────────────────────────────────────
255
267
 
256
268
  export async function fetchEmbedSettings(id: string): Promise<FormEmbedSettings> {
@@ -528,9 +528,11 @@ export async function movePage(
528
528
  id: string,
529
529
  parentPageId: string | null,
530
530
  ): Promise<{ error?: string }> {
531
+ // Flat field map — nested `{ data: { parentPageId } }` is stripped by
532
+ // field-access (undeclared `data` key) and silently no-ops.
531
533
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
532
534
  method: 'PUT',
533
- body: JSON.stringify({ data: { parentPageId } }),
535
+ body: JSON.stringify({ parentPageId }),
534
536
  })
535
537
  return res.error ? { error: res.error } : {}
536
538
  }
@@ -573,7 +575,7 @@ export async function bulkChangePageTags(
573
575
  p.primaryTagId && next.has(p.primaryTagId) ? p.primaryTagId : (tags[0] ?? null)
574
576
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
575
577
  method: 'PUT',
576
- body: JSON.stringify({ data: { tags, primaryTag } }),
578
+ body: JSON.stringify({ tags, primaryTag }),
577
579
  })
578
580
  return !res.error
579
581
  })
@@ -607,12 +609,10 @@ export async function createPageTag(input: PageTagInput): Promise<{ error?: stri
607
609
  title: input.name,
608
610
  slug,
609
611
  status: 'PUBLISHED',
610
- data: {
611
- color: input.color ?? 'gray',
612
- description: input.description ?? '',
613
- sortOrder: input.sortOrder ?? 0,
614
- active: input.active !== false,
615
- },
612
+ color: input.color ?? 'gray',
613
+ description: input.description ?? '',
614
+ sortOrder: input.sortOrder ?? 0,
615
+ active: input.active !== false,
616
616
  }),
617
617
  })
618
618
  return res.error ? { error: res.error } : {}
@@ -622,12 +622,11 @@ export async function updatePageTag(
622
622
  tagDocId: string,
623
623
  input: Partial<PageTagInput>,
624
624
  ): Promise<{ error?: string }> {
625
- const data: Record<string, unknown> = {}
626
- if (input.color !== undefined) data.color = input.color
627
- if (input.description !== undefined) data.description = input.description
628
- if (input.sortOrder !== undefined) data.sortOrder = input.sortOrder
629
- if (input.active !== undefined) data.active = input.active
630
- const body: Record<string, unknown> = { data }
625
+ const body: Record<string, unknown> = {}
626
+ if (input.color !== undefined) body.color = input.color
627
+ if (input.description !== undefined) body.description = input.description
628
+ if (input.sortOrder !== undefined) body.sortOrder = input.sortOrder
629
+ if (input.active !== undefined) body.active = input.active
631
630
  if (input.name !== undefined) body.title = input.name
632
631
  const res = await cmsApi(`/collections/${PAGE_TAGS_COLLECTION}/${encodeURIComponent(tagDocId)}`, {
633
632
  method: 'PUT',
@@ -677,7 +676,7 @@ export async function mergePageTags(source: PageTag, target: PageTag): Promise<{
677
676
  const primaryTag = p.primaryTagId === source.slug ? target.slug : p.primaryTagId
678
677
  const put = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(p.id)}`, {
679
678
  method: 'PUT',
680
- body: JSON.stringify({ data: { tags, primaryTag } }),
679
+ body: JSON.stringify({ tags, primaryTag }),
681
680
  })
682
681
  if (put.error) return { error: put.error }
683
682
  }
@@ -202,6 +202,22 @@ function dataStr(data: Record<string, unknown>, key: string): string {
202
202
  return typeof v === 'string' ? v : ''
203
203
  }
204
204
 
205
+ /**
206
+ * Media fields are stored denormalized as `{ id, url, alt, … }` after headless
207
+ * writes / admin picker. The editor state is a string (id or URL). Never coerce
208
+ * an object to `""` — that wiped featured images on the next save (#671).
209
+ */
210
+ export function parseFeaturedImage(raw: unknown): string {
211
+ if (typeof raw === 'string') return raw.trim()
212
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
213
+ const obj = raw as Record<string, unknown>
214
+ if (typeof obj.id === 'string' && obj.id.trim()) return obj.id.trim()
215
+ const url = obj.url ?? obj.publicUrl ?? obj.src
216
+ if (typeof url === 'string' && url.trim()) return url.trim()
217
+ }
218
+ return ''
219
+ }
220
+
205
221
  /**
206
222
  * Read stored tags into plain strings. The `tagsField` preset stores
207
223
  * `[{ tag: 'AI' }]` rows; older/hand-rolled configs may store `['AI']`
@@ -233,7 +249,7 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
233
249
  title: dataTitle || (doc.title ?? ''),
234
250
  slug: doc.slug ?? dataStr(data, 'slug'),
235
251
  excerpt: dataStr(data, 'excerpt'),
236
- featuredImage: dataStr(data, 'featuredImage'),
252
+ featuredImage: parseFeaturedImage(data.featuredImage),
237
253
  body: dataStr(data, 'body'),
238
254
  category: dataStr(data, 'category'),
239
255
  tags: parseTags(data.tags),
@@ -776,6 +776,9 @@ export interface RedirectRule {
776
776
  preserveQuery: boolean
777
777
  hits: number
778
778
  lastHitAt: string | null
779
+ /** True when lastHitAt is null — never recorded a public hit. */
780
+ neverHit: boolean
781
+ /** True when last hit was more than 60 days ago (never-hit is not stale). */
779
782
  stale: boolean
780
783
  source: string
781
784
  notes: string | null
@@ -786,6 +789,7 @@ export interface RedirectRule {
786
789
  export interface RedirectOverview {
787
790
  activeRedirects: number
788
791
  staleRedirects: number
792
+ neverHitRedirects: number
789
793
  notFoundCount: number
790
794
  notFoundHits: number
791
795
  chainsAndLoops: number
@@ -825,6 +829,7 @@ export async function fetchRedirectOverview(): Promise<RedirectOverview> {
825
829
  res.data ?? {
826
830
  activeRedirects: 0,
827
831
  staleRedirects: 0,
832
+ neverHitRedirects: 0,
828
833
  notFoundCount: 0,
829
834
  notFoundHits: 0,
830
835
  chainsAndLoops: 0,
@@ -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>
@@ -109,7 +109,7 @@ export function DocumentEdit({
109
109
  const SEO_FIELDS: (keyof SEOData)[] = [
110
110
  'metaTitle',
111
111
  'metaDescription',
112
- 'focusKeyphrase',
112
+ 'focusKeyword',
113
113
  'keyTakeaway',
114
114
  'canonical',
115
115
  'robotsPolicy',
@@ -148,6 +148,10 @@ export function DocumentEdit({
148
148
  ;(loadedSeo as any)[key] = docData[key]
149
149
  }
150
150
  }
151
+ // Legacy docs may only have `focusKeyphrase` — promote on load.
152
+ if (!loadedSeo.focusKeyword && typeof docData.focusKeyphrase === 'string') {
153
+ loadedSeo.focusKeyword = docData.focusKeyphrase
154
+ }
151
155
  setSeoData(loadedSeo)
152
156
  setInitialSeoData(loadedSeo)
153
157
 
@@ -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
 
@@ -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>