@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
@@ -144,6 +144,46 @@ export async function fetchSearchPerformance(
144
144
  return res.data?.rows ?? []
145
145
  }
146
146
 
147
+ // ─── Per-document search insight (synced GSC + CrUX) ─────────────────
148
+
149
+ export interface SearchInsightTopQuery {
150
+ query: string
151
+ clicks: number
152
+ impressions: number
153
+ position: number
154
+ }
155
+
156
+ export interface SearchInsight {
157
+ path: string
158
+ clicks28d: number
159
+ impressions28d: number
160
+ avgPosition28d: number | null
161
+ clicksPrev28d: number
162
+ impressionsPrev28d: number
163
+ avgPositionPrev28d: number | null
164
+ topQueries: SearchInsightTopQuery[]
165
+ lcpMs: number | null
166
+ inpMs: number | null
167
+ cls: number | null
168
+ cwvStatus: 'good' | 'needs-improvement' | 'poor' | null
169
+ lastSyncedAt: string | null
170
+ }
171
+
172
+ /**
173
+ * Synced search stats for one document. `insight` is null when Search Console
174
+ * isn't connected or no data has synced for the page yet — the editor pane
175
+ * renders a quiet hint instead of an error in that case.
176
+ */
177
+ export async function fetchSearchInsight(
178
+ entityId: string,
179
+ ): Promise<{ connected: boolean; insight: SearchInsight | null }> {
180
+ const res = await cmsApi<{ connected: boolean; insight: SearchInsight | null }>(
181
+ `/seo/search-insights/${encodeURIComponent(entityId)}`,
182
+ )
183
+ throwIfError(res)
184
+ return res.data ?? { connected: false, insight: null }
185
+ }
186
+
147
187
  // ─── Content SEO ─────────────────────────────────────────────────────
148
188
 
149
189
  /** Schema.org types available in document SEO editors. */
@@ -995,12 +1035,24 @@ export async function saveRedirectRecoverySettings(
995
1035
  export async function generate404RecoverySuggestions(): Promise<{
996
1036
  error?: string
997
1037
  autoApplied?: number
1038
+ /**
1039
+ * Set when AI governance vetoed a requested auto-apply (eligible plans were
1040
+ * stored as pending suggestions instead). Human-readable reason to surface.
1041
+ */
1042
+ governanceBlocked?: string
998
1043
  }> {
999
- const res = await cmsApi<{ created?: number; autoApplied?: number }>('/seo/redirects/suggest', {
1044
+ const res = await cmsApi<{
1045
+ created?: number
1046
+ autoApplied?: number
1047
+ governanceBlocked?: string | null
1048
+ }>('/seo/redirects/suggest', {
1000
1049
  method: 'POST',
1001
1050
  })
1002
1051
  if (res.error) return { error: res.error }
1003
- return { autoApplied: res.data?.autoApplied ?? 0 }
1052
+ return {
1053
+ autoApplied: res.data?.autoApplied ?? 0,
1054
+ governanceBlocked: res.data?.governanceBlocked ?? undefined,
1055
+ }
1004
1056
  }
1005
1057
 
1006
1058
  export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
@@ -1047,6 +1099,12 @@ export interface SeoAiResult {
1047
1099
  brandAlignment?: BrandAlignmentScoreResult
1048
1100
  /** True when the AI provider is not configured (graceful fallback). */
1049
1101
  unavailable?: boolean
1102
+ /**
1103
+ * Server-reported refusal (daily request cap, monthly budget, disabled
1104
+ * feature). Surface verbatim — the runtime crafts these messages to tell the
1105
+ * user which limit was hit and where to raise it.
1106
+ */
1107
+ error?: string
1050
1108
  }
1051
1109
 
1052
1110
  /**
@@ -1073,6 +1131,7 @@ export async function generateSeoField(
1073
1131
  body: JSON.stringify({ field, ...context }),
1074
1132
  })
1075
1133
  if (isAiUnavailable(res.status)) return { unavailable: true }
1134
+ if (res.error) return { error: res.error }
1076
1135
  return res.data ?? {}
1077
1136
  }
1078
1137
 
@@ -1082,6 +1141,7 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
1082
1141
  body: JSON.stringify({ issueId }),
1083
1142
  })
1084
1143
  if (isAiUnavailable(res.status)) return { unavailable: true }
1144
+ if (res.error) return { error: res.error }
1085
1145
  return res.data ?? {}
1086
1146
  }
1087
1147
 
@@ -1095,12 +1155,14 @@ export async function generateStructuredData(context: {
1095
1155
  body: JSON.stringify(context),
1096
1156
  })
1097
1157
  if (isAiUnavailable(res.status)) return { unavailable: true }
1158
+ if (res.error) return { error: res.error }
1098
1159
  return res.data ?? {}
1099
1160
  }
1100
1161
 
1101
1162
  export async function summarizeSeoAudit(): Promise<SeoAiResult> {
1102
1163
  const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
1103
1164
  if (isAiUnavailable(res.status)) return { unavailable: true }
1165
+ if (res.error) return { error: res.error }
1104
1166
  return res.data ?? {}
1105
1167
  }
1106
1168
 
@@ -8,7 +8,7 @@ import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
8
8
  import { RichTextField } from '../../fields/RichTextField.js'
9
9
  import type { PageSection } from '../../lib/page-editor-service.js'
10
10
  import type { SectionSettings } from './section-types.js'
11
- import { getSectionType } from './section-types.js'
11
+ import { getSectionType, isSectionTypeHideable } from './section-types.js'
12
12
 
13
13
  interface SectionInspectorProps {
14
14
  section: PageSection
@@ -62,6 +62,7 @@ export function SectionInspector({
62
62
  onPostBodyChange,
63
63
  }: SectionInspectorProps) {
64
64
  const def = getSectionType(section.sectionType)
65
+ const showVisibilityToggle = !def || isSectionTypeHideable(def)
65
66
  const bodySource = String(section.content.source ?? 'field')
66
67
  const editPostBodyField =
67
68
  section.sectionType === 'article-body' &&
@@ -193,25 +194,27 @@ export function SectionInspector({
193
194
  onChange={(e) => onMetaChange({ internalLabel: e.target.value })}
194
195
  />
195
196
  </div>
196
- <button
197
- type="button"
198
- onClick={onToggleVisibility}
199
- disabled={!canEdit}
200
- aria-pressed={!section.visible}
201
- className="border-border text-foreground hover:bg-accent flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm disabled:opacity-50"
202
- >
203
- <span className="flex items-center gap-2">
204
- {section.visible ? (
205
- <Eye className="h-4 w-4" aria-hidden />
206
- ) : (
207
- <EyeOff className="h-4 w-4" aria-hidden />
208
- )}
209
- {section.visible ? 'Visible on page' : 'Hidden from page'}
210
- </span>
211
- <span className="text-muted-foreground text-xs">
212
- {section.visible ? 'Hide' : 'Show'}
213
- </span>
214
- </button>
197
+ {showVisibilityToggle && (
198
+ <button
199
+ type="button"
200
+ onClick={onToggleVisibility}
201
+ disabled={!canEdit}
202
+ aria-pressed={!section.visible}
203
+ className="border-border text-foreground hover:bg-accent flex w-full items-center justify-between rounded-md border px-2.5 py-1.5 text-sm disabled:opacity-50"
204
+ >
205
+ <span className="flex items-center gap-2">
206
+ {section.visible ? (
207
+ <Eye className="h-4 w-4" aria-hidden />
208
+ ) : (
209
+ <EyeOff className="h-4 w-4" aria-hidden />
210
+ )}
211
+ {section.visible ? 'Visible on page' : 'Hidden from page'}
212
+ </span>
213
+ <span className="text-muted-foreground text-xs">
214
+ {section.visible ? 'Hide' : 'Show'}
215
+ </span>
216
+ </button>
217
+ )}
215
218
  </fieldset>
216
219
 
217
220
  {/* Appearance */}
@@ -32,6 +32,11 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
32
32
  import { adminPortalContainer } from '../../lib/portal-container.js'
33
33
  import type { PageSection } from '../../lib/page-editor-service.js'
34
34
  import { sectionIconFor } from './section-icons.js'
35
+ import {
36
+ getSectionType,
37
+ isSectionEffectivelyVisible,
38
+ isSectionTypeHideable,
39
+ } from './section-types.js'
35
40
 
36
41
  interface SectionsPanelProps {
37
42
  sections: PageSection[]
@@ -290,6 +295,9 @@ function SectionRow({
290
295
  // to view the read-only notice.
291
296
  const locked = section.unmanaged === true
292
297
  const Icon = sectionIconFor(section.sectionType)
298
+ const typeDef = getSectionType(section.sectionType)
299
+ const showVisibilityToggle = !typeDef || isSectionTypeHideable(typeDef)
300
+ const effectivelyVisible = isSectionEffectivelyVisible(section)
293
301
 
294
302
  return (
295
303
  <div
@@ -314,7 +322,7 @@ function SectionRow({
314
322
  <Icon className="h-4 w-4" aria-hidden />
315
323
  </span>
316
324
  <span
317
- className={`truncate text-sm ${section.visible ? 'text-foreground' : 'text-muted-foreground line-through'}`}
325
+ className={`truncate text-sm ${effectivelyVisible ? 'text-foreground' : 'text-muted-foreground line-through'}`}
318
326
  >
319
327
  {section.name}
320
328
  </span>
@@ -330,22 +338,24 @@ function SectionRow({
330
338
  </span>
331
339
  ) : (
332
340
  <>
333
- {/* Visibility survives the design-owned lockdown: `visible` is the one
334
- structural property the content model gives an editor. */}
335
- <button
336
- type="button"
337
- onClick={() => onToggleVisibility(section.id)}
338
- disabled={!canEdit}
339
- aria-pressed={!section.visible}
340
- aria-label={section.visible ? `Hide ${section.name}` : `Show ${section.name}`}
341
- className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
342
- >
343
- {section.visible ? (
344
- <Eye className="h-4 w-4" aria-hidden />
345
- ) : (
346
- <EyeOff className="h-4 w-4" aria-hidden />
347
- )}
348
- </button>
341
+ {/* Visibility survives the design-owned lockdown for hideable types.
342
+ hideable: false types omit the control entirely. */}
343
+ {showVisibilityToggle && (
344
+ <button
345
+ type="button"
346
+ onClick={() => onToggleVisibility(section.id)}
347
+ disabled={!canEdit}
348
+ aria-pressed={!section.visible}
349
+ aria-label={section.visible ? `Hide ${section.name}` : `Show ${section.name}`}
350
+ className="prenta-touch-target text-muted-foreground hover:text-foreground shrink-0 rounded p-1 disabled:opacity-40"
351
+ >
352
+ {section.visible ? (
353
+ <Eye className="h-4 w-4" aria-hidden />
354
+ ) : (
355
+ <EyeOff className="h-4 w-4" aria-hidden />
356
+ )}
357
+ </button>
358
+ )}
349
359
 
350
360
  {/* Disclosure. The row itself already toggles, but a visible chevron
351
361
  is what says the row opens at all — without one the list looks
@@ -13,6 +13,8 @@ export {
13
13
  getSectionTypesForScope,
14
14
  getAddableSectionTypesForScope,
15
15
  isSectionTypeAddable,
16
+ isSectionTypeHideable,
17
+ isSectionEffectivelyVisible,
16
18
  sectionTypeLabel,
17
19
  isKnownSectionType,
18
20
  resolveRepeaterField,
@@ -4,7 +4,7 @@ import { Puzzle } from 'lucide-react'
4
4
  import { fieldPathFromEventTarget } from '../../../lib/preview-bridge.js'
5
5
  import type { PageSection } from '../../../lib/page-editor-service.js'
6
6
  import { ErrorBoundary } from '../../../components/ErrorBoundary.js'
7
- import { getSectionType } from '../section-types.js'
7
+ import { getSectionType, isSectionEffectivelyVisible } from '../section-types.js'
8
8
  import { ADMIN_RENDER_OPTIONS, useAdminSectionRenderers } from './admin-renderers.js'
9
9
  import { backgroundClass, paddingClass, type PostRenderContext } from './parts.js'
10
10
  import { HeroBannerSection } from './HeroBannerSection.js'
@@ -204,7 +204,7 @@ export function SectionRenderer({
204
204
  // the editor's own view of it, and a hidden section that rendered solid here
205
205
  // would misreport what was live at that moment.
206
206
  const chromed = editable || reviewing
207
- const hidden = chromed && !section.visible
207
+ const hidden = chromed && !isSectionEffectivelyVisible(section)
208
208
  // Review mode is read-only. Click-to-select would edit the CURRENT page
209
209
  // while the canvas shows a past one.
210
210
  const interactive = editable && !reviewing
@@ -200,7 +200,7 @@ export function OverviewTab({
200
200
  <SeoEmptyState
201
201
  icon={<BarChart3 size={24} />}
202
202
  title="Search Console not connected"
203
- description="Add a Google Search Console verification token in Settings → SEO. Performance charts will appear here when the Search Analytics API is wired."
203
+ description="Connect a Google service account in Settings → SEO Search data, then run a sync. Real clicks, impressions, and positions will appear here."
204
204
  />
205
205
  ) : searchPerformance.length === 0 ? (
206
206
  <SeoEmptyState title="No search performance data" />
@@ -172,6 +172,12 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
172
172
  ? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
173
173
  : ''
174
174
  toast.success(`Generated 404 recovery suggestions.${autoMsg}`)
175
+ if (res.governanceBlocked) {
176
+ // AI governance vetoed the auto-apply (eligible plans were stored as
177
+ // pending suggestions). Tell the operator why, since the SEO module's
178
+ // "Auto-apply" toggle alone no longer decides.
179
+ toast.info(`Auto-apply skipped: ${res.governanceBlocked}`)
180
+ }
175
181
  refetch()
176
182
  }
177
183
 
@@ -122,6 +122,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
122
122
  canEdit={canEdit}
123
123
  onToggle={ai.toggleFeature}
124
124
  onSetFeatureModel={ai.setFeatureModel}
125
+ onSetGovernance={ai.setFeatureGovernance}
125
126
  />
126
127
  <BrandVoiceCard
127
128
  value={ai.form.brandVoice}
@@ -13,8 +13,8 @@ const SELECT_CLASS =
13
13
  * Global AI feature toggles with per-feature model routing. Each switch persists
14
14
  * (via the Save bar) to the shared `ai.features.*` flags; the optional model
15
15
  * picker overrides which model that feature uses (otherwise it inherits the
16
- * default). Enforcement across modules lands in a later phase; here we surface
17
- * and store the operator's intent.
16
+ * default). The governance flags (approval, bulk apply, daily cap) are enforced
17
+ * by the governed AI runtime and the auto-apply gates in core.
18
18
  */
19
19
  export function AiFeaturesCard({
20
20
  features,
@@ -23,6 +23,7 @@ export function AiFeaturesCard({
23
23
  canEdit,
24
24
  onToggle,
25
25
  onSetFeatureModel,
26
+ onSetGovernance,
26
27
  }: {
27
28
  features: AiFeatureView[]
28
29
  models: AiModelView[]
@@ -30,6 +31,14 @@ export function AiFeaturesCard({
30
31
  canEdit: boolean
31
32
  onToggle: (key: string, enabled: boolean) => void
32
33
  onSetFeatureModel: (key: string, modelId: string) => void
34
+ onSetGovernance: (
35
+ key: string,
36
+ patch: Partial<{
37
+ approvalRequired: boolean
38
+ allowBulkApply: boolean
39
+ maxRequestsPerDay: string
40
+ }>,
41
+ ) => void
33
42
  }) {
34
43
  return (
35
44
  <SettingsCard
@@ -49,6 +58,7 @@ export function AiFeaturesCard({
49
58
  canEdit={canEdit}
50
59
  onToggle={onToggle}
51
60
  onSetFeatureModel={onSetFeatureModel}
61
+ onSetGovernance={onSetGovernance}
52
62
  />
53
63
  ))}
54
64
  </div>
@@ -64,6 +74,7 @@ function FeatureRow({
64
74
  canEdit,
65
75
  onToggle,
66
76
  onSetFeatureModel,
77
+ onSetGovernance,
67
78
  }: {
68
79
  feature: AiFeatureView
69
80
  models: AiModelView[]
@@ -71,8 +82,17 @@ function FeatureRow({
71
82
  canEdit: boolean
72
83
  onToggle: (key: string, enabled: boolean) => void
73
84
  onSetFeatureModel: (key: string, modelId: string) => void
85
+ onSetGovernance: (
86
+ key: string,
87
+ patch: Partial<{
88
+ approvalRequired: boolean
89
+ allowBulkApply: boolean
90
+ maxRequestsPerDay: string
91
+ }>,
92
+ ) => void
74
93
  }) {
75
94
  const selectId = useId()
95
+ const capId = useId()
76
96
  const required = feature.requiredCapabilities ?? []
77
97
  const effective = effectiveFeatureModel(feature.modelId, defaultModelId, required, models)
78
98
  const defaultModel = models.find(
@@ -141,6 +161,43 @@ function FeatureRow({
141
161
  needs. Pick a compatible model.
142
162
  </p>
143
163
  )}
164
+
165
+ <div className="border-border mt-4 space-y-3 border-t pt-3">
166
+ <SettingsToggleRow
167
+ label="Require approval"
168
+ description="AI output must be human-approved before it is applied."
169
+ checked={feature.approvalRequired}
170
+ disabled={!canEdit}
171
+ onChange={(v) => onSetGovernance(feature.key, { approvalRequired: v })}
172
+ />
173
+ <SettingsToggleRow
174
+ label="Allow bulk apply"
175
+ description="Permit applying many AI results in one operation."
176
+ checked={feature.allowBulkApply}
177
+ disabled={!canEdit}
178
+ onChange={(v) => onSetGovernance(feature.key, { allowBulkApply: v })}
179
+ />
180
+ <div>
181
+ <label htmlFor={capId} className="text-muted-foreground mb-1 block text-sm">
182
+ Max requests per day
183
+ </label>
184
+ <input
185
+ id={capId}
186
+ type="text"
187
+ inputMode="numeric"
188
+ value={feature.maxRequestsPerDay}
189
+ placeholder="Unlimited"
190
+ disabled={!canEdit}
191
+ onChange={(e) =>
192
+ onSetGovernance(feature.key, { maxRequestsPerDay: e.target.value })
193
+ }
194
+ className={SELECT_CLASS}
195
+ />
196
+ <p className="text-muted-foreground mt-1 text-sm">
197
+ Site-wide daily cap for this feature. Leave empty for unlimited.
198
+ </p>
199
+ </div>
200
+ </div>
144
201
  </div>
145
202
  )}
146
203
  </div>