@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.
- package/CHANGELOG.md +17 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +10 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +35 -1
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/form-editor.render.test.js +1 -0
- package/dist/__tests__/views/form-editor.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +2 -0
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +2 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/components/SEOPanel.d.ts +8 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +7 -2
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.js +1 -1
- package/dist/components/forms/FormSchemaPane.js.map +1 -1
- package/dist/components/forms/NotificationsPanel.d.ts +4 -1
- package/dist/components/forms/NotificationsPanel.d.ts.map +1 -1
- package/dist/components/forms/NotificationsPanel.js +66 -12
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/ShortcodeGlossary.d.ts +5 -0
- package/dist/components/forms/ShortcodeGlossary.d.ts.map +1 -0
- package/dist/components/forms/ShortcodeGlossary.js +62 -0
- package/dist/components/forms/ShortcodeGlossary.js.map +1 -0
- package/dist/components/ui/ReauthDialog.d.ts +17 -0
- package/dist/components/ui/ReauthDialog.d.ts.map +1 -0
- package/dist/components/ui/ReauthDialog.js +34 -0
- package/dist/components/ui/ReauthDialog.js.map +1 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/forms-service.d.ts +9 -0
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +5 -0
- package/dist/lib/forms-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +14 -15
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +6 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +19 -1
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +4 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +1 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +22 -23
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +5 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/FormEditor.js +1 -1
- package/dist/views/FormEditor.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +3 -2
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -1
- package/dist/views/users/UserDetailDrawer.js +2 -30
- package/dist/views/users/UserDetailDrawer.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/pages-service-fetch.test.ts +11 -0
- package/src/__tests__/lib/post-editor-service.test.ts +46 -0
- package/src/__tests__/views/form-editor.render.test.tsx +1 -0
- package/src/__tests__/views/forms-list.render.test.tsx +2 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +2 -0
- package/src/components/SEOPanel.tsx +15 -3
- package/src/components/forms/FormSchemaPane.tsx +1 -1
- package/src/components/forms/NotificationsPanel.tsx +150 -20
- package/src/components/forms/ShortcodeGlossary.tsx +146 -0
- package/src/components/ui/ReauthDialog.tsx +93 -0
- package/src/components/ui/index.ts +2 -0
- package/src/index.ts +1 -1
- package/src/lib/forms-service.ts +12 -0
- package/src/lib/pages-service.ts +14 -15
- package/src/lib/post-editor-service.ts +17 -1
- package/src/lib/seo-service.ts +5 -0
- package/src/views/ApiKeys.tsx +48 -41
- package/src/views/DocumentEdit.tsx +5 -1
- package/src/views/FormEditor.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +3 -1
- 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'
|
package/src/lib/forms-service.ts
CHANGED
|
@@ -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> {
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -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({
|
|
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({
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
|
626
|
-
if (input.color !== undefined)
|
|
627
|
-
if (input.description !== undefined)
|
|
628
|
-
if (input.sortOrder !== undefined)
|
|
629
|
-
if (input.active !== undefined)
|
|
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({
|
|
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:
|
|
252
|
+
featuredImage: parseFeaturedImage(data.featuredImage),
|
|
237
253
|
body: dataStr(data, 'body'),
|
|
238
254
|
category: dataStr(data, 'category'),
|
|
239
255
|
tags: parseTags(data.tags),
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -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,
|
package/src/views/ApiKeys.tsx
CHANGED
|
@@ -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={
|
|
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
|
-
{
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
454
|
+
className="text-foreground block text-sm font-medium"
|
|
449
455
|
>
|
|
450
|
-
Confirm
|
|
456
|
+
Confirm it'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'll need to
|
|
519
|
+
We only store a hash of this key. If you lose it, you'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
|
-
'
|
|
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
|
|
package/src/views/FormEditor.tsx
CHANGED
|
@@ -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-
|
|
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>
|