@actuate-media/cms-admin 0.29.0 → 0.31.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/AdminRoot.d.ts +6 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -3
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
  8. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
  9. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/user-detail-drawer.render.test.js +113 -0
  12. package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
  13. package/dist/__tests__/views/users.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/users.render.test.js +146 -0
  16. package/dist/__tests__/views/users.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/layout/Header.d.ts +3 -1
  19. package/dist/layout/Header.d.ts.map +1 -1
  20. package/dist/layout/Header.js +2 -2
  21. package/dist/layout/Header.js.map +1 -1
  22. package/dist/layout/Layout.d.ts +2 -1
  23. package/dist/layout/Layout.d.ts.map +1 -1
  24. package/dist/layout/Layout.js +2 -2
  25. package/dist/layout/Layout.js.map +1 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +54 -51
  28. package/dist/views/Settings.js.map +1 -1
  29. package/dist/views/Users.d.ts +1 -3
  30. package/dist/views/Users.d.ts.map +1 -1
  31. package/dist/views/Users.js +420 -56
  32. package/dist/views/Users.js.map +1 -1
  33. package/dist/views/users/UserDetailDrawer.d.ts +9 -0
  34. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
  35. package/dist/views/users/UserDetailDrawer.js +236 -0
  36. package/dist/views/users/UserDetailDrawer.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/AdminRoot.tsx +28 -2
  39. package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
  40. package/src/__tests__/views/user-detail-drawer.render.test.tsx +119 -0
  41. package/src/__tests__/views/users.render.test.tsx +155 -0
  42. package/src/layout/Header.tsx +10 -2
  43. package/src/layout/Layout.tsx +10 -1
  44. package/src/views/Settings.tsx +142 -263
  45. package/src/views/Users.tsx +1187 -271
  46. package/src/views/users/UserDetailDrawer.tsx +924 -0
@@ -5,11 +5,6 @@ import {
5
5
  Bot,
6
6
  Eye,
7
7
  EyeOff,
8
- Image,
9
- FileCode2,
10
- BookOpen,
11
- Sparkles,
12
- MessageSquare,
13
8
  Loader2,
14
9
  AlertTriangle,
15
10
  Download,
@@ -44,8 +39,14 @@ export interface SettingsProps {
44
39
  initialTab?: string
45
40
  }
46
41
 
47
- /** Tabs that drive their own persistence (no global "Save Changes" footer). */
48
- const SELF_MANAGED_TABS = new Set(['general', 'appearance', 'security', 'users', 'api-keys'])
42
+ /**
43
+ * Tabs that persist through the shared footer's `handleSave` (which writes
44
+ * `defaultLayout` into the settings global). Every other tab owns its own
45
+ * saving, so the footer must NOT render there — otherwise it shows a
46
+ * "Settings saved successfully!" toast while persisting nothing relevant.
47
+ * This is an allowlist on purpose: new tabs default to self-managed.
48
+ */
49
+ const GLOBAL_SAVE_TABS = new Set(['layout'])
49
50
 
50
51
  function readSettingsTabFromUrl(): string | null {
51
52
  if (typeof window === 'undefined') return null
@@ -120,32 +121,8 @@ export function Settings({
120
121
  [normalizeTab, onNavigate],
121
122
  )
122
123
 
123
- // AI settings
124
- const [aiProvider, setAiProvider] = useState('anthropic')
125
- const [aiApiKey, setAiApiKey] = useState('')
126
- const [showApiKey, setShowApiKey] = useState(false)
127
- const [aiAltTags, setAiAltTags] = useState(true)
128
- const [aiMediaCategorize, setAiMediaCategorize] = useState(false)
129
- const [aiMetaDescriptions, setAiMetaDescriptions] = useState(true)
130
- const [aiReadability, setAiReadability] = useState(true)
131
- const [aiSchema, setAiSchema] = useState(true)
132
- const [aiBrandVoice, setAiBrandVoice] = useState(false)
133
- const [aiWritingAssistant, setAiWritingAssistant] = useState(true)
134
- const [aiContentScoring, setAiContentScoring] = useState(true)
135
- const [aiTranslation, setAiTranslation] = useState(false)
136
-
137
124
  useEffect(() => {
138
125
  if (data) {
139
- setAiProvider(data.aiProvider ?? 'anthropic')
140
- setAiAltTags(data.aiAltTags ?? true)
141
- setAiMediaCategorize(data.aiMediaCategorize ?? false)
142
- setAiMetaDescriptions(data.aiMetaDescriptions ?? true)
143
- setAiReadability(data.aiReadability ?? true)
144
- setAiSchema(data.aiSchema ?? true)
145
- setAiBrandVoice(data.aiBrandVoice ?? false)
146
- setAiWritingAssistant(data.aiWritingAssistant ?? true)
147
- setAiContentScoring(data.aiContentScoring ?? true)
148
- setAiTranslation(data.aiTranslation ?? false)
149
126
  if (data.defaultLayout && typeof data.defaultLayout === 'object') {
150
127
  setDefaultLayout(data.defaultLayout)
151
128
  }
@@ -162,16 +139,6 @@ export function Settings({
162
139
  method: 'PUT',
163
140
  body: JSON.stringify({
164
141
  ...(current.data ?? {}),
165
- aiProvider,
166
- aiAltTags,
167
- aiMediaCategorize,
168
- aiMetaDescriptions,
169
- aiReadability,
170
- aiSchema,
171
- aiBrandVoice,
172
- aiWritingAssistant,
173
- aiContentScoring,
174
- aiTranslation,
175
142
  ...layoutPayload,
176
143
  }),
177
144
  })
@@ -351,198 +318,7 @@ export function Settings({
351
318
  </Tabs.Content>
352
319
 
353
320
  <Tabs.Content value="ai" className="space-y-4">
354
- <div className="rounded-lg border border-gray-200 bg-white p-4">
355
- <h3 className="mb-1 text-sm font-semibold text-gray-900">AI Provider & API Key</h3>
356
- <p className="mb-4 text-xs text-gray-500">
357
- Connect an AI provider to enable intelligent content features
358
- </p>
359
- <div className="space-y-4">
360
- <div>
361
- <label className="mb-1 block text-sm font-medium text-gray-700">Provider</label>
362
- <select
363
- value={aiProvider}
364
- onChange={(e) => setAiProvider(e.target.value)}
365
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
366
- >
367
- <option value="anthropic">Anthropic (Claude)</option>
368
- <option value="openai">OpenAI (GPT)</option>
369
- <option value="google">Google (Gemini)</option>
370
- </select>
371
- <p className="mt-1 text-xs text-gray-500">
372
- {aiProvider === 'anthropic' &&
373
- 'Recommended. Best for content analysis, brand voice learning, and nuanced writing.'}
374
- {aiProvider === 'openai' &&
375
- 'Strong alternative. Good for general content generation and image understanding.'}
376
- {aiProvider === 'google' &&
377
- 'Multimodal. Excellent for image analysis and multilingual content.'}
378
- </p>
379
- </div>
380
- <div>
381
- <label className="mb-1 block text-sm font-medium text-gray-700">API Key</label>
382
- <div className="flex gap-2">
383
- <div className="relative flex-1">
384
- <input
385
- type={showApiKey ? 'text' : 'password'}
386
- value={aiApiKey}
387
- onChange={(e) => setAiApiKey(e.target.value)}
388
- placeholder={
389
- aiProvider === 'anthropic'
390
- ? 'sk-ant-...'
391
- : aiProvider === 'openai'
392
- ? 'sk-...'
393
- : 'AIza...'
394
- }
395
- className="w-full rounded-lg border border-gray-300 px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
396
- />
397
- <button
398
- type="button"
399
- onClick={() => setShowApiKey(!showApiKey)}
400
- className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
401
- >
402
- {showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
403
- </button>
404
- </div>
405
- <button
406
- type="button"
407
- onClick={() => {
408
- if (aiApiKey) toast.success('API key verified successfully')
409
- else toast.error('Please enter an API key first')
410
- }}
411
- className="rounded-lg border border-gray-300 px-4 py-2 text-sm transition-colors hover:bg-gray-50"
412
- >
413
- Verify
414
- </button>
415
- </div>
416
- <p className="mt-1 text-xs text-gray-500">
417
- Your key is encrypted at rest (AES-256-GCM) and never exposed to the client
418
- </p>
419
- </div>
420
- </div>
421
- </div>
422
-
423
- <div className="rounded-lg border border-gray-200 bg-white p-4">
424
- <div className="mb-1 flex items-center gap-2">
425
- <Image className="h-4 w-4 text-blue-600" />
426
- <h3 className="text-sm font-semibold text-gray-900">Media Intelligence</h3>
427
- </div>
428
- <p className="mb-4 text-xs text-gray-500">AI-powered image and media analysis</p>
429
- <div className="space-y-4">
430
- <ToggleSetting
431
- label="Auto Alt-Tag Generation"
432
- description="Scan uploaded images and automatically generate descriptive alt text for accessibility and SEO"
433
- checked={aiAltTags}
434
- onChange={setAiAltTags}
435
- />
436
- <ToggleSetting
437
- label="Media Auto-Categorization"
438
- description="Automatically tag and categorize uploaded media based on visual content analysis"
439
- checked={aiMediaCategorize}
440
- onChange={setAiMediaCategorize}
441
- />
442
- </div>
443
- </div>
444
-
445
- <div className="rounded-lg border border-gray-200 bg-white p-4">
446
- <div className="mb-1 flex items-center gap-2">
447
- <FileCode2 className="h-4 w-4 text-purple-600" />
448
- <h3 className="text-sm font-semibold text-gray-900">Content SEO</h3>
449
- </div>
450
- <p className="mb-4 text-xs text-gray-500">
451
- AI-driven SEO optimization for your content
452
- </p>
453
- <div className="space-y-4">
454
- <ToggleSetting
455
- label="Meta Description Generation"
456
- description="Auto-generate optimized meta descriptions by digesting page content"
457
- checked={aiMetaDescriptions}
458
- onChange={setAiMetaDescriptions}
459
- />
460
- <ToggleSetting
461
- label="Readability Analysis"
462
- description="Score content readability (Flesch-Kincaid, Gunning Fog) with improvement suggestions"
463
- checked={aiReadability}
464
- onChange={setAiReadability}
465
- />
466
- <ToggleSetting
467
- label="Schema.org Enrichment"
468
- description="Automatically detect content types and generate appropriate Schema.org JSON-LD markup"
469
- checked={aiSchema}
470
- onChange={setAiSchema}
471
- />
472
- </div>
473
- </div>
474
-
475
- <div className="rounded-lg border border-gray-200 bg-white p-4">
476
- <div className="mb-1 flex items-center gap-2">
477
- <MessageSquare className="h-4 w-4 text-green-600" />
478
- <h3 className="text-sm font-semibold text-gray-900">Brand Voice & Writing</h3>
479
- </div>
480
- <p className="mb-4 text-xs text-gray-500">
481
- AI that understands and writes in your brand&apos;s voice
482
- </p>
483
- <div className="space-y-4">
484
- <ToggleSetting
485
- label="Brand Voice Training"
486
- description="Analyze existing content to learn your brand's tone, style, and vocabulary"
487
- checked={aiBrandVoice}
488
- onChange={setAiBrandVoice}
489
- />
490
- <ToggleSetting
491
- label="AI Writing Assistant"
492
- description="In-editor AI helper for drafting, rewriting, and expanding content in your brand voice"
493
- checked={aiWritingAssistant}
494
- onChange={setAiWritingAssistant}
495
- />
496
- </div>
497
- </div>
498
-
499
- <div className="rounded-lg border border-gray-200 bg-white p-4">
500
- <div className="mb-1 flex items-center gap-2">
501
- <Sparkles className="h-4 w-4 text-yellow-600" />
502
- <h3 className="text-sm font-semibold text-gray-900">Content Quality</h3>
503
- </div>
504
- <p className="mb-4 text-xs text-gray-500">
505
- Automated quality scoring and content intelligence
506
- </p>
507
- <div className="space-y-4">
508
- <ToggleSetting
509
- label="Content Scoring"
510
- description="Automatically score content quality based on structure, readability, SEO, and completeness"
511
- checked={aiContentScoring}
512
- onChange={setAiContentScoring}
513
- />
514
- <ToggleSetting
515
- label="AI-Powered Translation"
516
- description="Translate content into target languages while preserving tone and meaning"
517
- checked={aiTranslation}
518
- onChange={setAiTranslation}
519
- />
520
- </div>
521
- </div>
522
-
523
- <div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4">
524
- <div className="mb-1 flex items-center gap-2">
525
- <BookOpen className="h-4 w-4 text-indigo-600" />
526
- <h3 className="text-sm font-semibold text-indigo-900">AI Usage This Month</h3>
527
- </div>
528
- <div className="mt-3 grid grid-cols-3 gap-4">
529
- <div>
530
- <div className="text-lg font-semibold text-indigo-900">0</div>
531
- <div className="text-xs text-indigo-700">API Calls</div>
532
- </div>
533
- <div>
534
- <div className="text-lg font-semibold text-indigo-900">0</div>
535
- <div className="text-xs text-indigo-700">Tokens Used</div>
536
- </div>
537
- <div>
538
- <div className="text-lg font-semibold text-indigo-900">$0.00</div>
539
- <div className="text-xs text-indigo-700">Estimated Cost</div>
540
- </div>
541
- </div>
542
- <p className="mt-2 text-xs text-indigo-600">
543
- Add an API key above to start using AI features
544
- </p>
545
- </div>
321
+ <AiStatusTab />
546
322
  </Tabs.Content>
547
323
 
548
324
  <Tabs.Content value="users">
@@ -558,7 +334,7 @@ export function Settings({
558
334
  </Tabs.Content>
559
335
  </Tabs.Root>
560
336
 
561
- {!SELF_MANAGED_TABS.has(activeTab) && (
337
+ {GLOBAL_SAVE_TABS.has(activeTab) && (
562
338
  <div className="mt-6 flex justify-end">
563
339
  <button
564
340
  type="button"
@@ -574,37 +350,140 @@ export function Settings({
574
350
  )
575
351
  }
576
352
 
577
- function ToggleSetting({
578
- label,
579
- description,
580
- checked,
581
- onChange,
582
- }: {
583
- label: string
584
- description: string
585
- checked: boolean
586
- onChange: (v: boolean) => void
587
- }) {
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
+
588
431
  return (
589
- <div className="flex items-center justify-between gap-4">
590
- <div className="flex-1">
591
- <label className="text-foreground text-sm font-medium">{label}</label>
592
- <p className="text-muted-foreground mt-0.5 text-xs">{description}</p>
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>
593
486
  </div>
594
- <button
595
- type="button"
596
- role="switch"
597
- aria-checked={checked}
598
- aria-label={label}
599
- onClick={() => onChange(!checked)}
600
- className={`focus:ring-primary relative h-6 w-11 shrink-0 rounded-full transition-colors focus:ring-2 focus:outline-none ${checked ? 'bg-primary' : 'bg-muted'}`}
601
- >
602
- <span
603
- className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
604
- checked ? 'translate-x-[22px]' : 'translate-x-0.5'
605
- }`}
606
- />
607
- </button>
608
487
  </div>
609
488
  )
610
489
  }