@prenta/admin 1.10.1 → 1.12.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 (105) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -1
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
  6. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  7. package/dist/__tests__/lib/page-editor-service.test.js +23 -0
  8. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  9. package/dist/__tests__/lib/seo-service-errors.test.js +54 -1
  10. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  11. package/dist/__tests__/views/ai-settings.render.test.js +26 -8
  12. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  13. package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
  14. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  15. package/dist/__tests__/views/seo-settings.render.test.js +40 -2
  16. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  17. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
  18. package/dist/components/seo/SeoCopilotDrawer.js +4 -0
  19. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
  20. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  21. package/dist/components/seo/SeoEditorPane.js +8 -1
  22. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  23. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
  24. package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
  25. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
  26. package/dist/components/seo/SeoSearchStats.d.ts +4 -0
  27. package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
  28. package/dist/components/seo/SeoSearchStats.js +91 -0
  29. package/dist/components/seo/SeoSearchStats.js.map +1 -0
  30. package/dist/lib/editor-seo-content.d.ts.map +1 -1
  31. package/dist/lib/editor-seo-content.js +2 -2
  32. package/dist/lib/editor-seo-content.js.map +1 -1
  33. package/dist/lib/page-editor-service.d.ts.map +1 -1
  34. package/dist/lib/page-editor-service.js +2 -2
  35. package/dist/lib/page-editor-service.js.map +1 -1
  36. package/dist/lib/post-editor-service.d.ts.map +1 -1
  37. package/dist/lib/post-editor-service.js +3 -3
  38. package/dist/lib/post-editor-service.js.map +1 -1
  39. package/dist/lib/seo-service.d.ts +41 -0
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +22 -1
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/prenta-admin.css +1 -1
  44. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  45. package/dist/views/page-editor/SectionInspector.js +3 -2
  46. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  47. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  48. package/dist/views/page-editor/SectionsPanel.js +5 -1
  49. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  50. package/dist/views/page-editor/section-types.d.ts +1 -1
  51. package/dist/views/page-editor/section-types.d.ts.map +1 -1
  52. package/dist/views/page-editor/section-types.js +1 -1
  53. package/dist/views/page-editor/section-types.js.map +1 -1
  54. package/dist/views/page-editor/sections/SectionRenderer.js +2 -2
  55. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  56. package/dist/views/seo/OverviewTab.js +1 -1
  57. package/dist/views/seo/OverviewTab.js.map +1 -1
  58. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  59. package/dist/views/seo/RedirectsTab.js +6 -0
  60. package/dist/views/seo/RedirectsTab.js.map +1 -1
  61. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  62. package/dist/views/settings/AISettingsTab.js +1 -1
  63. package/dist/views/settings/AISettingsTab.js.map +1 -1
  64. package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
  65. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  66. package/dist/views/settings/AiFeaturesCard.js +7 -6
  67. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  68. package/dist/views/settings/SearchDataCard.d.ts +10 -0
  69. package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
  70. package/dist/views/settings/SearchDataCard.js +126 -0
  71. package/dist/views/settings/SearchDataCard.js.map +1 -0
  72. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  73. package/dist/views/settings/SeoSettingsTab.js +2 -1
  74. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  75. package/dist/views/settings/useAiSettings.d.ts +12 -0
  76. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  77. package/dist/views/settings/useAiSettings.js +29 -1
  78. package/dist/views/settings/useAiSettings.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/AdminRoot.tsx +4 -0
  81. package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
  82. package/src/__tests__/lib/page-editor-service.test.ts +23 -0
  83. package/src/__tests__/lib/seo-service-errors.test.ts +73 -0
  84. package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
  85. package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
  86. package/src/__tests__/views/seo-settings.render.test.tsx +55 -2
  87. package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
  88. package/src/components/seo/SeoEditorPane.tsx +13 -0
  89. package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
  90. package/src/components/seo/SeoSearchStats.tsx +183 -0
  91. package/src/lib/editor-seo-content.ts +6 -2
  92. package/src/lib/page-editor-service.ts +2 -1
  93. package/src/lib/post-editor-service.ts +5 -2
  94. package/src/lib/seo-service.ts +64 -2
  95. package/src/views/page-editor/SectionInspector.tsx +23 -20
  96. package/src/views/page-editor/SectionsPanel.tsx +27 -17
  97. package/src/views/page-editor/section-types.ts +2 -0
  98. package/src/views/page-editor/sections/SectionRenderer.tsx +2 -2
  99. package/src/views/seo/OverviewTab.tsx +1 -1
  100. package/src/views/seo/RedirectsTab.tsx +6 -0
  101. package/src/views/settings/AISettingsTab.tsx +1 -0
  102. package/src/views/settings/AiFeaturesCard.tsx +59 -2
  103. package/src/views/settings/SearchDataCard.tsx +390 -0
  104. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  105. package/src/views/settings/useAiSettings.ts +71 -2
@@ -0,0 +1,390 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useId, useState } from 'react'
4
+ import { AlertTriangle, CheckCircle2, KeyRound, Loader2, PlugZap, RefreshCw, X } from 'lucide-react'
5
+ import { toast } from 'sonner'
6
+ import { cmsApi } from '../../lib/api.js'
7
+ import { SettingsCard } from './components.js'
8
+
9
+ const INPUT_CLASS =
10
+ 'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
11
+ const LABEL_CLASS = 'mb-1 flex items-center gap-2 text-sm font-medium text-foreground'
12
+
13
+ interface SearchConsoleStatus {
14
+ propertyUrl: string
15
+ hasServiceAccountKey: boolean
16
+ serviceAccountEmail: string | null
17
+ hasCruxApiKey: boolean
18
+ connected: boolean
19
+ lastSyncAt: string | null
20
+ syncStatus: {
21
+ state: 'ok' | 'partial' | 'error' | 'not-configured'
22
+ message?: string
23
+ parts?: {
24
+ searchConsole?: { ok: boolean; message?: string; rowsUpserted?: number }
25
+ crux?: { ok: boolean; message?: string; pagesChecked?: number }
26
+ }
27
+ } | null
28
+ suggestedProperty: string | null
29
+ }
30
+
31
+ function KeyStoredBadge() {
32
+ return (
33
+ <span className="text-muted-foreground border-border bg-muted inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs">
34
+ <CheckCircle2 size={11} className="text-success" aria-hidden="true" />
35
+ Key stored
36
+ </span>
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Settings → SEO "Search data" card: Google Search Console service-account
42
+ * credentials, CrUX API key, connection test, and manual sync. Key material is
43
+ * write-only — the server stores it encrypted and never echoes it back, so the
44
+ * inputs stay empty with a "Key stored" badge once saved.
45
+ */
46
+ export function SearchDataCard({ canEdit }: { canEdit: boolean }) {
47
+ const propertyId = useId()
48
+ const keyId = useId()
49
+ const cruxId = useId()
50
+
51
+ const [status, setStatus] = useState<SearchConsoleStatus | null>(null)
52
+ const [loading, setLoading] = useState(true)
53
+ const [loadError, setLoadError] = useState<string | null>(null)
54
+
55
+ const [property, setProperty] = useState('')
56
+ const [serviceKey, setServiceKey] = useState('')
57
+ const [cruxKey, setCruxKey] = useState('')
58
+ const [saving, setSaving] = useState(false)
59
+ const [testing, setTesting] = useState(false)
60
+ const [syncing, setSyncing] = useState(false)
61
+
62
+ const load = useCallback(async () => {
63
+ setLoading(true)
64
+ setLoadError(null)
65
+ const res = await cmsApi<SearchConsoleStatus>('/seo/search-console')
66
+ if (res.data) {
67
+ setStatus(res.data)
68
+ setProperty(res.data.propertyUrl ?? '')
69
+ } else {
70
+ setLoadError(res.error ?? 'Failed to load search data settings')
71
+ }
72
+ setLoading(false)
73
+ }, [])
74
+
75
+ useEffect(() => {
76
+ void load()
77
+ }, [load])
78
+
79
+ const dirty =
80
+ property.trim() !== (status?.propertyUrl ?? '') ||
81
+ serviceKey.trim() !== '' ||
82
+ cruxKey.trim() !== ''
83
+
84
+ async function save(extra?: { serviceAccountKey?: string; cruxApiKey?: string }) {
85
+ setSaving(true)
86
+ const body: Record<string, string> = { propertyUrl: property.trim(), ...extra }
87
+ if (!extra) {
88
+ if (serviceKey.trim()) body.serviceAccountKey = serviceKey.trim()
89
+ if (cruxKey.trim()) body.cruxApiKey = cruxKey.trim()
90
+ }
91
+ const res = await cmsApi('/seo/search-console', {
92
+ method: 'PUT',
93
+ body: JSON.stringify(body),
94
+ })
95
+ setSaving(false)
96
+ if (res.error) {
97
+ toast.error(res.error)
98
+ return
99
+ }
100
+ toast.success('Search data settings saved')
101
+ setServiceKey('')
102
+ setCruxKey('')
103
+ await load()
104
+ }
105
+
106
+ async function testConnection() {
107
+ setTesting(true)
108
+ const res = await cmsApi<{ ok: boolean; code?: string; message?: string }>(
109
+ '/seo/search-console/test',
110
+ { method: 'POST' },
111
+ )
112
+ setTesting(false)
113
+ if (res.error) {
114
+ toast.error(res.error)
115
+ return
116
+ }
117
+ if (res.data?.ok) {
118
+ toast.success('Connected to Google Search Console')
119
+ } else {
120
+ toast.error(res.data?.message ?? 'Connection test failed')
121
+ }
122
+ await load()
123
+ }
124
+
125
+ async function syncNow() {
126
+ setSyncing(true)
127
+ const res = await cmsApi<{
128
+ status: NonNullable<SearchConsoleStatus['syncStatus']>
129
+ rowsUpserted: number
130
+ cruxChecked: number
131
+ }>('/seo/search-sync', { method: 'POST' })
132
+ setSyncing(false)
133
+ if (res.error) {
134
+ toast.error(res.error)
135
+ return
136
+ }
137
+ const state = res.data?.status.state
138
+ if (state === 'ok') {
139
+ toast.success(`Sync complete — ${res.data?.rowsUpserted ?? 0} rows updated`)
140
+ } else if (state === 'partial') {
141
+ toast.warning(res.data?.status.message ?? 'Sync finished with warnings')
142
+ } else if (state === 'not-configured') {
143
+ toast.error('Add a service account key and property first.')
144
+ } else {
145
+ toast.error(res.data?.status.message ?? 'Sync failed')
146
+ }
147
+ await load()
148
+ }
149
+
150
+ if (loading) {
151
+ return (
152
+ <SettingsCard
153
+ title="Search data"
154
+ description="Google Search Console and Core Web Vitals feed real search performance into the SEO workspace."
155
+ >
156
+ <div className="space-y-3" aria-busy="true" aria-live="polite">
157
+ <span className="sr-only">Loading search data settings…</span>
158
+ <div className="bg-muted h-9 w-full animate-pulse rounded" />
159
+ <div className="bg-muted h-20 w-full animate-pulse rounded" />
160
+ </div>
161
+ </SettingsCard>
162
+ )
163
+ }
164
+
165
+ if (loadError) {
166
+ return (
167
+ <SettingsCard
168
+ title="Search data"
169
+ description="Google Search Console and Core Web Vitals feed real search performance into the SEO workspace."
170
+ >
171
+ <div
172
+ role="alert"
173
+ className="border-destructive/30 bg-destructive/10 text-destructive flex items-center gap-3 rounded-lg border p-4 text-sm"
174
+ >
175
+ <AlertTriangle size={16} className="shrink-0" aria-hidden="true" />
176
+ <span className="flex-1">{loadError}</span>
177
+ <button
178
+ type="button"
179
+ onClick={() => void load()}
180
+ className="border-destructive/40 rounded-md border px-3 py-1 transition-colors hover:opacity-80"
181
+ >
182
+ Retry
183
+ </button>
184
+ </div>
185
+ </SettingsCard>
186
+ )
187
+ }
188
+
189
+ const lastSync = status?.lastSyncAt ? new Date(status.lastSyncAt).toLocaleString() : 'Never'
190
+
191
+ return (
192
+ <SettingsCard
193
+ title="Search data"
194
+ description="Connect Google Search Console (service account) and the CrUX API to feed real clicks, impressions, and Core Web Vitals into audits, quick wins, and AI suggestions."
195
+ >
196
+ <div className="space-y-4">
197
+ {/* Connection status */}
198
+ <div className="flex items-center justify-between gap-2 text-sm">
199
+ <span className="text-muted-foreground">Status</span>
200
+ {status?.connected ? (
201
+ <span className="text-success inline-flex items-center gap-1 font-medium">
202
+ <CheckCircle2 size={14} aria-hidden="true" />
203
+ Connected
204
+ </span>
205
+ ) : (
206
+ <span className="text-muted-foreground inline-flex items-center gap-1 font-medium">
207
+ <X size={14} aria-hidden="true" />
208
+ Not connected
209
+ </span>
210
+ )}
211
+ </div>
212
+
213
+ {/* Property */}
214
+ <div>
215
+ <label htmlFor={propertyId} className={LABEL_CLASS}>
216
+ Search Console property
217
+ </label>
218
+ <input
219
+ id={propertyId}
220
+ type="text"
221
+ autoComplete="off"
222
+ spellCheck={false}
223
+ value={property}
224
+ disabled={!canEdit}
225
+ placeholder={status?.suggestedProperty ?? 'sc-domain:example.com'}
226
+ onChange={(e) => setProperty(e.target.value)}
227
+ className={INPUT_CLASS}
228
+ />
229
+ <p className="text-muted-foreground mt-1 text-sm">
230
+ Domain property (<code>sc-domain:example.com</code>) or URL prefix (
231
+ <code>https://example.com/</code>) exactly as it appears in Search Console.
232
+ {status?.suggestedProperty && !property.trim() && canEdit && (
233
+ <>
234
+ {' '}
235
+ <button
236
+ type="button"
237
+ onClick={() => setProperty(status.suggestedProperty ?? '')}
238
+ className="text-brand hover:underline"
239
+ >
240
+ Use {status.suggestedProperty}
241
+ </button>
242
+ </>
243
+ )}
244
+ </p>
245
+ </div>
246
+
247
+ {/* Service account key */}
248
+ <div>
249
+ <label htmlFor={keyId} className={LABEL_CLASS}>
250
+ Service account key (JSON)
251
+ {status?.hasServiceAccountKey && <KeyStoredBadge />}
252
+ </label>
253
+ <textarea
254
+ id={keyId}
255
+ rows={3}
256
+ value={serviceKey}
257
+ disabled={!canEdit}
258
+ autoComplete="off"
259
+ spellCheck={false}
260
+ placeholder={
261
+ status?.hasServiceAccountKey
262
+ ? 'Paste a new key to replace the stored one'
263
+ : '{ "type": "service_account", "client_email": … }'
264
+ }
265
+ onChange={(e) => setServiceKey(e.target.value)}
266
+ className={`${INPUT_CLASS} resize-y font-mono text-sm`}
267
+ />
268
+ <p className="text-muted-foreground mt-1 text-sm">
269
+ {status?.serviceAccountEmail ? (
270
+ <>
271
+ Grant{' '}
272
+ <span className="text-foreground font-medium">{status.serviceAccountEmail}</span>{' '}
273
+ access in Search Console → Settings → Users and permissions.
274
+ </>
275
+ ) : (
276
+ 'Create a service account in Google Cloud, enable the Search Console API, and add its email as a user on the property.'
277
+ )}
278
+ </p>
279
+ {status?.hasServiceAccountKey && canEdit && (
280
+ <button
281
+ type="button"
282
+ onClick={() => void save({ serviceAccountKey: '' })}
283
+ className="text-muted-foreground hover:text-destructive mt-1 inline-flex items-center gap-1 rounded-md text-sm transition-colors"
284
+ >
285
+ <X size={13} aria-hidden="true" />
286
+ Remove stored key
287
+ </button>
288
+ )}
289
+ </div>
290
+
291
+ {/* CrUX key */}
292
+ <div>
293
+ <label htmlFor={cruxId} className={LABEL_CLASS}>
294
+ CrUX API key
295
+ {status?.hasCruxApiKey && <KeyStoredBadge />}
296
+ </label>
297
+ <input
298
+ id={cruxId}
299
+ type="password"
300
+ autoComplete="off"
301
+ value={cruxKey}
302
+ disabled={!canEdit}
303
+ placeholder={
304
+ status?.hasCruxApiKey ? 'Paste a new key to replace the stored one' : 'AIza…'
305
+ }
306
+ onChange={(e) => setCruxKey(e.target.value)}
307
+ className={INPUT_CLASS}
308
+ />
309
+ <p className="text-muted-foreground mt-1 text-sm">
310
+ Optional — collects real-user Core Web Vitals (LCP, INP, CLS) from the Chrome UX Report.
311
+ </p>
312
+ {status?.hasCruxApiKey && canEdit && (
313
+ <button
314
+ type="button"
315
+ onClick={() => void save({ cruxApiKey: '' })}
316
+ className="text-muted-foreground hover:text-destructive mt-1 inline-flex items-center gap-1 rounded-md text-sm transition-colors"
317
+ >
318
+ <X size={13} aria-hidden="true" />
319
+ Remove stored key
320
+ </button>
321
+ )}
322
+ </div>
323
+
324
+ {/* Actions */}
325
+ <div className="border-border flex flex-wrap items-center gap-2 border-t pt-4">
326
+ <button
327
+ type="button"
328
+ onClick={() => void save()}
329
+ disabled={!canEdit || saving || !dirty}
330
+ className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
331
+ >
332
+ {saving ? (
333
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
334
+ ) : (
335
+ <KeyRound size={14} aria-hidden="true" />
336
+ )}
337
+ Save credentials
338
+ </button>
339
+ <button
340
+ type="button"
341
+ onClick={() => void testConnection()}
342
+ disabled={!canEdit || testing || !status?.hasServiceAccountKey}
343
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
344
+ >
345
+ {testing ? (
346
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
347
+ ) : (
348
+ <PlugZap size={14} aria-hidden="true" />
349
+ )}
350
+ Test connection
351
+ </button>
352
+ <button
353
+ type="button"
354
+ onClick={() => void syncNow()}
355
+ disabled={!canEdit || syncing || !status?.connected}
356
+ className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-4 py-2 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
357
+ >
358
+ {syncing ? (
359
+ <Loader2 size={14} className="animate-spin" aria-hidden="true" />
360
+ ) : (
361
+ <RefreshCw size={14} aria-hidden="true" />
362
+ )}
363
+ Sync now
364
+ </button>
365
+ </div>
366
+
367
+ {/* Sync status */}
368
+ <div className="space-y-2 text-sm">
369
+ <div className="flex items-center justify-between gap-2">
370
+ <span className="text-muted-foreground">Last sync</span>
371
+ <span className="text-foreground font-medium">{lastSync}</span>
372
+ </div>
373
+ {status?.syncStatus && status.syncStatus.state !== 'ok' && status.syncStatus.message && (
374
+ <div
375
+ role="status"
376
+ className={
377
+ status.syncStatus.state === 'error'
378
+ ? 'border-destructive/30 bg-destructive/10 text-destructive flex items-start gap-2 rounded-md border p-3'
379
+ : 'border-warning/30 bg-warning/10 text-warning flex items-start gap-2 rounded-md border p-3'
380
+ }
381
+ >
382
+ <AlertTriangle size={14} className="mt-0.5 shrink-0" aria-hidden="true" />
383
+ <span>{status.syncStatus.message}</span>
384
+ </div>
385
+ )}
386
+ </div>
387
+ </div>
388
+ </SettingsCard>
389
+ )
390
+ }
@@ -16,6 +16,7 @@ import {
16
16
  import { siteUrlMatchesAdminOrigin } from '../../lib/preview-link.js'
17
17
  import { MediaPickerModal } from '../../components/MediaPickerModal.js'
18
18
  import { SEOConfigPanel } from '../../components/SEOConfigPanel.js'
19
+ import { SearchDataCard } from './SearchDataCard.js'
19
20
  import {
20
21
  ConfirmDangerousSettingDialog,
21
22
  SettingsCard,
@@ -149,6 +150,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
149
150
  <MetaDefaultsCard seo={seo} canEdit={canEdit} />
150
151
  <OrganizationCard seo={seo} canEdit={canEdit} />
151
152
  <SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
153
+ <SearchDataCard canEdit={canEdit} />
152
154
  <SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
153
155
 
154
156
  <details className="border-border bg-card group rounded-lg border">
@@ -86,6 +86,22 @@ export interface AiFeatureView {
86
86
  modelId?: string
87
87
  /** Per-feature provider connection override. Usually derived from the model. */
88
88
  providerId?: string
89
+ /** AI output must be human-approved before it is applied. */
90
+ approvalRequired: boolean
91
+ /** Permit applying many AI results in one operation. */
92
+ allowBulkApply: boolean
93
+ /** Site-wide daily request cap; empty string = unlimited (string-buffered input). */
94
+ maxRequestsPerDay: string
95
+ }
96
+
97
+ /** Raw feature shape on the wire (`GET /ai/settings`): governance fields optional, cap numeric. */
98
+ type AiFeatureWire = Omit<
99
+ AiFeatureView,
100
+ 'approvalRequired' | 'allowBulkApply' | 'maxRequestsPerDay'
101
+ > & {
102
+ approvalRequired?: boolean
103
+ allowBulkApply?: boolean
104
+ maxRequestsPerDay?: number | null
89
105
  }
90
106
 
91
107
  export interface AiUsageView {
@@ -143,7 +159,7 @@ interface AiSettingsResponse {
143
159
  settings: {
144
160
  defaultProviderId?: string
145
161
  defaultModelId?: string
146
- features: AiFeatureView[]
162
+ features: AiFeatureWire[]
147
163
  budget: { monthlyTokenLimit?: number }
148
164
  brandVoice?: {
149
165
  enabled?: boolean
@@ -172,6 +188,12 @@ function formFromResponse(res: AiSettingsResponse): AiSettingsForm {
172
188
  ...f,
173
189
  modelId: f.modelId ?? '',
174
190
  providerId: f.providerId ?? '',
191
+ approvalRequired: (f as { approvalRequired?: boolean }).approvalRequired !== false,
192
+ allowBulkApply: (f as { allowBulkApply?: boolean }).allowBulkApply === true,
193
+ maxRequestsPerDay:
194
+ typeof (f as { maxRequestsPerDay?: number }).maxRequestsPerDay === 'number'
195
+ ? String((f as { maxRequestsPerDay?: number }).maxRequestsPerDay)
196
+ : '',
175
197
  })),
176
198
  monthlyTokenLimit:
177
199
  typeof res.settings.budget.monthlyTokenLimit === 'number'
@@ -241,6 +263,15 @@ export interface UseAiSettings {
241
263
  toggleFeature: (key: string, enabled: boolean) => void
242
264
  /** Set a per-feature model override. Empty string reverts to the default model. */
243
265
  setFeatureModel: (key: string, modelId: string) => void
266
+ /** Patch per-feature governance controls (approval, bulk apply, daily cap). */
267
+ setFeatureGovernance: (
268
+ key: string,
269
+ patch: Partial<{
270
+ approvalRequired: boolean
271
+ allowBulkApply: boolean
272
+ maxRequestsPerDay: string
273
+ }>,
274
+ ) => void
244
275
  /** Patch one or more brand voice fields. */
245
276
  setBrandVoice: (patch: Partial<BrandVoiceForm>) => void
246
277
  /** Whether saves should trust the markdown document or quick-edit fields. */
@@ -374,6 +405,34 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
374
405
  setSaveState('idle')
375
406
  }, [])
376
407
 
408
+ const setFeatureGovernance = useCallback(
409
+ (
410
+ key: string,
411
+ patch: Partial<{
412
+ approvalRequired: boolean
413
+ allowBulkApply: boolean
414
+ maxRequestsPerDay: string
415
+ }>,
416
+ ) => {
417
+ setForm((prev) => ({
418
+ ...prev,
419
+ features: prev.features.map((f) =>
420
+ f.key === key
421
+ ? {
422
+ ...f,
423
+ ...patch,
424
+ ...(patch.maxRequestsPerDay !== undefined
425
+ ? { maxRequestsPerDay: patch.maxRequestsPerDay.replace(/[^\d]/g, '') }
426
+ : {}),
427
+ }
428
+ : f,
429
+ ),
430
+ }))
431
+ setSaveState('idle')
432
+ },
433
+ [],
434
+ )
435
+
377
436
  const setBrandVoice = useCallback((patch: Partial<BrandVoiceForm>) => {
378
437
  setForm((prev) => ({ ...prev, brandVoice: { ...prev.brandVoice, ...patch } }))
379
438
  setSaveState('idle')
@@ -474,7 +533,13 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
474
533
  return true
475
534
  return form.features.some((f, i) => {
476
535
  const base = baseline.features[i]
477
- return f.enabled !== base?.enabled || (f.modelId ?? '') !== (base?.modelId ?? '')
536
+ return (
537
+ f.enabled !== base?.enabled ||
538
+ (f.modelId ?? '') !== (base?.modelId ?? '') ||
539
+ f.approvalRequired !== base?.approvalRequired ||
540
+ f.allowBulkApply !== base?.allowBulkApply ||
541
+ f.maxRequestsPerDay !== base?.maxRequestsPerDay
542
+ )
478
543
  })
479
544
  }, [form, baseline])
480
545
 
@@ -492,6 +557,9 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
492
557
  key: f.key,
493
558
  enabled: f.enabled,
494
559
  modelId: f.modelId ? f.modelId : null,
560
+ approvalRequired: f.approvalRequired,
561
+ allowBulkApply: f.allowBulkApply,
562
+ maxRequestsPerDay: f.maxRequestsPerDay === '' ? null : Number(f.maxRequestsPerDay),
495
563
  })),
496
564
  budget: {
497
565
  monthlyTokenLimit:
@@ -661,6 +729,7 @@ export function useAiSettings(canEdit: boolean): UseAiSettings {
661
729
  setMonthlyTokenLimit,
662
730
  toggleFeature,
663
731
  setFeatureModel,
732
+ setFeatureGovernance,
664
733
  setBrandVoice,
665
734
  analyzeBrandVoice,
666
735
  scoreBrandVoice,