@actuate-media/cms-admin 0.33.0 → 0.34.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 (46) hide show
  1. package/dist/__tests__/views/ai-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/ai-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/ai-settings.render.test.js +182 -0
  4. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -0
  5. package/dist/actuate-admin.css +1 -1
  6. package/dist/views/Settings.d.ts.map +1 -1
  7. package/dist/views/Settings.js +2 -46
  8. package/dist/views/Settings.js.map +1 -1
  9. package/dist/views/settings/AISettingsTab.d.ts +6 -0
  10. package/dist/views/settings/AISettingsTab.d.ts.map +1 -0
  11. package/dist/views/settings/AISettingsTab.js +138 -0
  12. package/dist/views/settings/AISettingsTab.js.map +1 -0
  13. package/dist/views/settings/AiFeaturesCard.d.ts +12 -0
  14. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -0
  15. package/dist/views/settings/AiFeaturesCard.js +14 -0
  16. package/dist/views/settings/AiFeaturesCard.js.map +1 -0
  17. package/dist/views/settings/CapabilityBadges.d.ts +5 -0
  18. package/dist/views/settings/CapabilityBadges.d.ts.map +1 -0
  19. package/dist/views/settings/CapabilityBadges.js +26 -0
  20. package/dist/views/settings/CapabilityBadges.js.map +1 -0
  21. package/dist/views/settings/ModelSelector.d.ts +12 -0
  22. package/dist/views/settings/ModelSelector.d.ts.map +1 -0
  23. package/dist/views/settings/ModelSelector.js +29 -0
  24. package/dist/views/settings/ModelSelector.js.map +1 -0
  25. package/dist/views/settings/MonthlyUsageCard.d.ts +14 -0
  26. package/dist/views/settings/MonthlyUsageCard.d.ts.map +1 -0
  27. package/dist/views/settings/MonthlyUsageCard.js +30 -0
  28. package/dist/views/settings/MonthlyUsageCard.js.map +1 -0
  29. package/dist/views/settings/ProviderConnectionCard.d.ts +13 -0
  30. package/dist/views/settings/ProviderConnectionCard.d.ts.map +1 -0
  31. package/dist/views/settings/ProviderConnectionCard.js +48 -0
  32. package/dist/views/settings/ProviderConnectionCard.js.map +1 -0
  33. package/dist/views/settings/useAiSettings.d.ts +143 -0
  34. package/dist/views/settings/useAiSettings.d.ts.map +1 -0
  35. package/dist/views/settings/useAiSettings.js +278 -0
  36. package/dist/views/settings/useAiSettings.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/__tests__/views/ai-settings.render.test.tsx +225 -0
  39. package/src/views/Settings.tsx +2 -139
  40. package/src/views/settings/AISettingsTab.tsx +411 -0
  41. package/src/views/settings/AiFeaturesCard.tsx +49 -0
  42. package/src/views/settings/CapabilityBadges.tsx +66 -0
  43. package/src/views/settings/ModelSelector.tsx +115 -0
  44. package/src/views/settings/MonthlyUsageCard.tsx +118 -0
  45. package/src/views/settings/ProviderConnectionCard.tsx +245 -0
  46. package/src/views/settings/useAiSettings.ts +448 -0
@@ -0,0 +1,225 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
4
+
5
+ /**
6
+ * Settings > AI reads three backends: /ai/settings (defaults + masked
7
+ * providers), /ai/models (catalog), /ai/usage/summary. Provider connection
8
+ * actions hit /ai/providers/*. Keys are never returned — only a masked last4.
9
+ */
10
+ const SETTINGS_RESPONSE = {
11
+ settings: {
12
+ defaultProviderId: 'p1',
13
+ defaultModelId: 'anthropic:claude-sonnet-4-latest',
14
+ features: [
15
+ {
16
+ key: 'ai.features.contentWriting',
17
+ label: 'Content writing suggestions',
18
+ description: 'Draft, rewrite, summarize content.',
19
+ enabled: true,
20
+ category: 'content',
21
+ requiredCapabilities: ['text'],
22
+ },
23
+ {
24
+ key: 'ai.features.redirectChainFlattening',
25
+ label: 'Redirect chain flattening',
26
+ description: 'Suggest single-hop redirects.',
27
+ enabled: false,
28
+ category: 'seo',
29
+ requiredCapabilities: ['text'],
30
+ },
31
+ ],
32
+ budget: { monthlyTokenLimit: 1000000 },
33
+ },
34
+ providers: [
35
+ {
36
+ id: 'p1',
37
+ provider: 'anthropic',
38
+ displayName: 'Anthropic (Claude)',
39
+ enabled: true,
40
+ status: 'connected',
41
+ apiKeyLast4: 'f12a',
42
+ apiKeySource: 'database',
43
+ keyManagedExternally: false,
44
+ hasKey: true,
45
+ createdAt: '2026-01-01T00:00:00.000Z',
46
+ updatedAt: '2026-01-01T00:00:00.000Z',
47
+ updatedBy: 'u1',
48
+ },
49
+ ],
50
+ defaultModel: null,
51
+ }
52
+
53
+ const MODELS = [
54
+ {
55
+ id: 'anthropic:claude-sonnet-4-latest',
56
+ provider: 'anthropic',
57
+ providerModelId: 'claude-sonnet-4-latest',
58
+ displayName: 'Claude Sonnet (balanced)',
59
+ status: 'available',
60
+ capabilities: { text: true, vision: true, toolCalling: true },
61
+ contextWindow: 200000,
62
+ inputModalities: ['text', 'image'],
63
+ outputModalities: ['text'],
64
+ pricing: { inputPerMillion: 3, outputPerMillion: 15 },
65
+ lastSyncedAt: '2026-01-01T00:00:00.000Z',
66
+ source: 'config',
67
+ },
68
+ ]
69
+
70
+ const USAGE = {
71
+ totalTokens: 412000,
72
+ promptTokens: 0,
73
+ completionTokens: 0,
74
+ monthlyTokenLimit: 1000000,
75
+ periodStart: '2026-06-01T00:00:00.000Z',
76
+ periodEnd: '2026-07-01T00:00:00.000Z',
77
+ runCount: 5,
78
+ }
79
+
80
+ // Mutable overrides so individual tests can tweak the loaded shape.
81
+ let settingsResponse: typeof SETTINGS_RESPONSE = SETTINGS_RESPONSE
82
+
83
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
84
+ const method = options?.method ?? 'GET'
85
+ if (endpoint === '/ai/settings' && method === 'GET')
86
+ return { data: settingsResponse, status: 200 }
87
+ if (endpoint === '/ai/settings' && method === 'PUT')
88
+ return { data: settingsResponse, status: 200 }
89
+ if (endpoint === '/ai/models' && method === 'GET')
90
+ return { data: { models: MODELS }, status: 200 }
91
+ if (endpoint === '/ai/usage/summary') return { data: USAGE, status: 200 }
92
+ if (endpoint === '/ai/providers/p1/test' && method === 'POST') {
93
+ return {
94
+ data: {
95
+ provider: settingsResponse.providers[0],
96
+ result: { ok: true, status: 'connected', message: 'Connection successful.' },
97
+ },
98
+ status: 200,
99
+ }
100
+ }
101
+ if (endpoint === '/ai/providers/p1/sync-models' && method === 'POST') {
102
+ return { data: { models: MODELS, fromFallback: false, warning: null }, status: 200 }
103
+ }
104
+ return { data: {}, status: 200 }
105
+ })
106
+
107
+ vi.mock('../../lib/api.js', () => ({
108
+ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
109
+ ensureCsrfToken: vi.fn(async () => {}),
110
+ }))
111
+
112
+ vi.mock('sonner', () => ({
113
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), loading: vi.fn() },
114
+ }))
115
+
116
+ const { AISettingsTab } = await import('../../views/settings/AISettingsTab.js')
117
+
118
+ function deepClone<T>(v: T): T {
119
+ return JSON.parse(JSON.stringify(v))
120
+ }
121
+
122
+ beforeEach(() => {
123
+ cmsApi.mockClear()
124
+ settingsResponse = deepClone(SETTINGS_RESPONSE)
125
+ })
126
+
127
+ describe('AISettingsTab', () => {
128
+ it('loads the default provider, model, and capability badges', async () => {
129
+ render(<AISettingsTab role="ADMIN" />)
130
+ expect(await screen.findByText('AI Assistant')).toBeTruthy()
131
+
132
+ const assistant = screen.getByRole('region', { name: 'AI Assistant' })
133
+ expect((within(assistant).getByLabelText('Default model') as HTMLSelectElement).value).toBe(
134
+ 'anthropic:claude-sonnet-4-latest',
135
+ )
136
+ // Capability badges render as text (not color-only).
137
+ expect(within(assistant).getByText('Vision')).toBeTruthy()
138
+ expect(within(assistant).getByText('200,000 token context')).toBeTruthy()
139
+ })
140
+
141
+ it('shows real monthly usage (never a fabricated number)', async () => {
142
+ render(<AISettingsTab role="ADMIN" />)
143
+ expect(await screen.findByText('412K tokens used')).toBeTruthy()
144
+ expect(screen.getByRole('progressbar').getAttribute('aria-valuenow')).toBe('41')
145
+ })
146
+
147
+ it('refreshes the model catalog via the provider sync endpoint', async () => {
148
+ render(<AISettingsTab role="ADMIN" />)
149
+ const assistant = await screen.findByRole('region', { name: 'AI Assistant' })
150
+ fireEvent.click(within(assistant).getByRole('button', { name: 'Refresh models' }))
151
+ await waitFor(() =>
152
+ expect(cmsApi).toHaveBeenCalledWith(
153
+ '/ai/providers/p1/sync-models',
154
+ expect.objectContaining({ method: 'POST' }),
155
+ ),
156
+ )
157
+ })
158
+
159
+ it('tests a provider connection', async () => {
160
+ render(<AISettingsTab role="ADMIN" />)
161
+ await screen.findByText('AI Assistant')
162
+ fireEvent.click(screen.getByRole('button', { name: 'Test connection' }))
163
+ await waitFor(() =>
164
+ expect(cmsApi).toHaveBeenCalledWith(
165
+ '/ai/providers/p1/test',
166
+ expect.objectContaining({ method: 'POST' }),
167
+ ),
168
+ )
169
+ })
170
+
171
+ it('toggles a feature and saves it to /ai/settings', async () => {
172
+ render(<AISettingsTab role="ADMIN" />)
173
+ await screen.findByText('AI Features')
174
+ fireEvent.click(screen.getByRole('switch', { name: 'Content writing suggestions' }))
175
+
176
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
177
+ expect(save.disabled).toBe(false)
178
+ fireEvent.click(save)
179
+
180
+ await waitFor(() =>
181
+ expect(cmsApi).toHaveBeenCalledWith(
182
+ '/ai/settings',
183
+ expect.objectContaining({ method: 'PUT' }),
184
+ ),
185
+ )
186
+ const putCall = cmsApi.mock.calls.find(
187
+ ([e, o]) => e === '/ai/settings' && (o as { method?: string })?.method === 'PUT',
188
+ )
189
+ const body = JSON.parse((putCall![1] as { body: string }).body)
190
+ const cw = body.features.find((f: { key: string }) => f.key === 'ai.features.contentWriting')
191
+ expect(cw.enabled).toBe(false)
192
+ })
193
+
194
+ it('is read-only for non-admins', async () => {
195
+ render(<AISettingsTab role="EDITOR" />)
196
+ expect(await screen.findByText(/read-only access to AI settings/i)).toBeTruthy()
197
+ const assistant = screen.getByRole('region', { name: 'AI Assistant' })
198
+ expect((within(assistant).getByLabelText('Default model') as HTMLSelectElement).disabled).toBe(
199
+ true,
200
+ )
201
+ expect(
202
+ (screen.getByRole('switch', { name: 'Content writing suggestions' }) as HTMLButtonElement)
203
+ .disabled,
204
+ ).toBe(true)
205
+ })
206
+
207
+ it('renders an env-managed key as read-only', async () => {
208
+ settingsResponse = deepClone(SETTINGS_RESPONSE)
209
+ settingsResponse.providers[0]!.keyManagedExternally = true
210
+ settingsResponse.providers[0]!.apiKeySource = 'environment'
211
+ render(<AISettingsTab role="ADMIN" />)
212
+ expect(await screen.findByText('Managed by environment')).toBeTruthy()
213
+ expect(screen.getByText(/set via an environment variable/i)).toBeTruthy()
214
+ })
215
+
216
+ it('warns when the configured default model is no longer available', async () => {
217
+ settingsResponse = deepClone(SETTINGS_RESPONSE)
218
+ settingsResponse.settings.defaultModelId = 'anthropic:ghost-model'
219
+ render(<AISettingsTab role="ADMIN" />)
220
+ await screen.findByText('AI Assistant')
221
+ expect(
222
+ await screen.findByText(/selected model is no longer offered by the provider/i),
223
+ ).toBeTruthy()
224
+ })
225
+ })
@@ -24,6 +24,7 @@ import { cmsApi } from '../lib/api.js'
24
24
  import { RelationshipField } from '../fields/RelationshipField.js'
25
25
  import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
26
26
  import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
27
+ import { AISettingsTab } from './settings/AISettingsTab.js'
27
28
  import { AppearanceTab } from './settings/AppearanceTab.js'
28
29
  import { SecurityTab } from './settings/SecurityTab.js'
29
30
  import { Users as UsersView } from './Users.js'
@@ -318,7 +319,7 @@ export function Settings({
318
319
  </Tabs.Content>
319
320
 
320
321
  <Tabs.Content value="ai" className="space-y-4">
321
- <AiStatusTab />
322
+ <AISettingsTab role={session?.user?.role} />
322
323
  </Tabs.Content>
323
324
 
324
325
  <Tabs.Content value="users">
@@ -350,144 +351,6 @@ export function Settings({
350
351
  )
351
352
  }
352
353
 
353
- // ---------------------------------------------------------------------------
354
- // AI Status (read-only)
355
- // ---------------------------------------------------------------------------
356
-
357
- interface AiStatus {
358
- installed: boolean
359
- configured: boolean
360
- provider: string | null
361
- }
362
-
363
- const PROVIDER_LABELS: Record<string, string> = {
364
- anthropic: 'Anthropic (Claude)',
365
- openai: 'OpenAI (GPT)',
366
- }
367
-
368
- /**
369
- * Honest, read-only view of the AI integration. AI is configured server-side
370
- * via the `@actuate-media/plugin-ai` plugin reading environment variables —
371
- * there is intentionally no API-key field here, because the dashboard never
372
- * stores provider keys. This panel just reports current status.
373
- */
374
- function AiStatusTab() {
375
- const { data, loading, error, refetch } = useApiData<AiStatus>('/ai/status')
376
-
377
- if (loading) {
378
- return (
379
- <div className="flex h-40 items-center justify-center">
380
- <Loader2 className="h-5 w-5 animate-spin text-gray-400" />
381
- </div>
382
- )
383
- }
384
-
385
- if (error) {
386
- return (
387
- <div className="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
388
- <AlertTriangle className="h-5 w-5 shrink-0 text-red-600" />
389
- <span className="flex-1 text-sm text-red-800">{error}</span>
390
- <button
391
- onClick={refetch}
392
- className="rounded-lg border border-red-300 px-3 py-1 text-sm text-red-700 transition-colors hover:bg-red-100"
393
- >
394
- Retry
395
- </button>
396
- </div>
397
- )
398
- }
399
-
400
- const installed = data?.installed ?? false
401
- const configured = data?.configured ?? false
402
- const providerLabel = data?.provider ? (PROVIDER_LABELS[data.provider] ?? data.provider) : null
403
-
404
- const status: { tone: 'ok' | 'warn' | 'off'; title: string; detail: string } = !installed
405
- ? {
406
- tone: 'off',
407
- title: 'AI features are not installed',
408
- detail:
409
- 'Add the @actuate-media/plugin-ai plugin to your CMS config to enable AI-powered content features.',
410
- }
411
- : configured
412
- ? {
413
- tone: 'ok',
414
- title: providerLabel ? `AI is active — ${providerLabel}` : 'AI is active',
415
- detail:
416
- 'A provider is configured and ready. AI-powered features in the editor and media library will use it.',
417
- }
418
- : {
419
- tone: 'warn',
420
- title: 'AI plugin installed, but no provider is configured',
421
- detail:
422
- 'Set a provider and API key where you register the plugin, e.g. aiPlugin({ provider: "anthropic", apiKey: process.env.ANTHROPIC_API_KEY }).',
423
- }
424
-
425
- const toneStyles: Record<typeof status.tone, string> = {
426
- ok: 'border-green-200 bg-green-50',
427
- warn: 'border-amber-200 bg-amber-50',
428
- off: 'border-gray-200 bg-gray-50',
429
- }
430
-
431
- return (
432
- <div className="space-y-4">
433
- <div className={`flex items-start gap-3 rounded-lg border p-4 ${toneStyles[status.tone]}`}>
434
- {status.tone === 'ok' ? (
435
- <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-green-600" />
436
- ) : status.tone === 'warn' ? (
437
- <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
438
- ) : (
439
- <Bot className="mt-0.5 h-5 w-5 shrink-0 text-gray-500" />
440
- )}
441
- <div className="flex-1">
442
- <h3 className="text-sm font-semibold text-gray-900">{status.title}</h3>
443
- <p className="mt-0.5 text-xs text-gray-600">{status.detail}</p>
444
- </div>
445
- </div>
446
-
447
- <div className="rounded-lg border border-gray-200 bg-white p-4">
448
- <h3 className="mb-1 text-sm font-semibold text-gray-900">How AI is configured</h3>
449
- <p className="mb-3 text-xs text-gray-500">
450
- AI is set up in code, not from this dashboard. Provider credentials live in environment
451
- variables and are never entered or stored here.
452
- </p>
453
- <ul className="space-y-1.5 text-xs text-gray-600">
454
- <li className="flex items-start gap-2">
455
- <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">
456
- 1
457
- </span>
458
- <span>
459
- Install the AI plugin:{' '}
460
- <code className="rounded bg-gray-100 px-1 font-mono">@actuate-media/plugin-ai</code>.
461
- </span>
462
- </li>
463
- <li className="flex items-start gap-2">
464
- <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">
465
- 2
466
- </span>
467
- <span>
468
- Set <code className="rounded bg-gray-100 px-1 font-mono">ANTHROPIC_API_KEY</code> or{' '}
469
- <code className="rounded bg-gray-100 px-1 font-mono">OPENAI_API_KEY</code> in your
470
- environment.
471
- </span>
472
- </li>
473
- <li className="flex items-start gap-2">
474
- <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-blue-100 text-[10px] font-bold text-blue-600">
475
- 3
476
- </span>
477
- <span>
478
- Register it in your CMS config:{' '}
479
- <code className="rounded bg-gray-100 px-1 font-mono">
480
- aiPlugin(&#123; provider, apiKey &#125;)
481
- </code>
482
- .
483
- </span>
484
- </li>
485
- </ul>
486
- </div>
487
- </div>
488
- )
489
- }
490
-
491
354
  // ---------------------------------------------------------------------------
492
355
  // Updates Panel
493
356
  // ---------------------------------------------------------------------------