@actuate-media/cms-admin 0.28.0 → 0.30.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 (73) hide show
  1. package/dist/AdminRoot.d.ts +13 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +22 -4
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/account-security.render.test.js +98 -0
  8. package/dist/__tests__/views/account-security.render.test.js.map +1 -0
  9. package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js +73 -0
  12. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
  13. package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
  16. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.js +1 -0
  20. package/dist/components/Breadcrumbs.js.map +1 -1
  21. package/dist/layout/Header.d.ts +3 -1
  22. package/dist/layout/Header.d.ts.map +1 -1
  23. package/dist/layout/Header.js +2 -2
  24. package/dist/layout/Header.js.map +1 -1
  25. package/dist/layout/Layout.d.ts +2 -1
  26. package/dist/layout/Layout.d.ts.map +1 -1
  27. package/dist/layout/Layout.js +2 -2
  28. package/dist/layout/Layout.js.map +1 -1
  29. package/dist/views/Login.d.ts +16 -1
  30. package/dist/views/Login.d.ts.map +1 -1
  31. package/dist/views/Login.js +55 -3
  32. package/dist/views/Login.js.map +1 -1
  33. package/dist/views/Profile.d.ts +12 -0
  34. package/dist/views/Profile.d.ts.map +1 -0
  35. package/dist/views/Profile.js +16 -0
  36. package/dist/views/Profile.js.map +1 -0
  37. package/dist/views/Settings.d.ts.map +1 -1
  38. package/dist/views/Settings.js +54 -51
  39. package/dist/views/Settings.js.map +1 -1
  40. package/dist/views/Users.d.ts.map +1 -1
  41. package/dist/views/Users.js +50 -7
  42. package/dist/views/Users.js.map +1 -1
  43. package/dist/views/profile/AccountSecurity.d.ts +2 -0
  44. package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
  45. package/dist/views/profile/AccountSecurity.js +117 -0
  46. package/dist/views/profile/AccountSecurity.js.map +1 -0
  47. package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
  48. package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
  49. package/dist/views/profile/useMfaEnrollment.js +103 -0
  50. package/dist/views/profile/useMfaEnrollment.js.map +1 -0
  51. package/dist/views/settings/AuthMfaCard.d.ts +2 -1
  52. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -1
  53. package/dist/views/settings/AuthMfaCard.js +3 -2
  54. package/dist/views/settings/AuthMfaCard.js.map +1 -1
  55. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  56. package/dist/views/settings/SecurityTab.js +1 -1
  57. package/dist/views/settings/SecurityTab.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/AdminRoot.tsx +46 -2
  60. package/src/__tests__/views/account-security.render.test.tsx +140 -0
  61. package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
  62. package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
  63. package/src/components/Breadcrumbs.tsx +1 -0
  64. package/src/layout/Header.tsx +10 -2
  65. package/src/layout/Layout.tsx +10 -1
  66. package/src/views/Login.tsx +247 -94
  67. package/src/views/Profile.tsx +54 -0
  68. package/src/views/Settings.tsx +142 -263
  69. package/src/views/Users.tsx +94 -12
  70. package/src/views/profile/AccountSecurity.tsx +406 -0
  71. package/src/views/profile/useMfaEnrollment.ts +120 -0
  72. package/src/views/settings/AuthMfaCard.tsx +19 -4
  73. package/src/views/settings/SecurityTab.tsx +1 -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
  }
@@ -31,7 +31,9 @@ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
31
31
  const { data, loading, error, refetch } = useApiData<any[]>('/users')
32
32
  const [showInviteDialog, setShowInviteDialog] = useState(false)
33
33
  const [inviteEmail, setInviteEmail] = useState('')
34
- const [inviteRole, setInviteRole] = useState('editor')
34
+ const [inviteName, setInviteName] = useState('')
35
+ const [inviteRole, setInviteRole] = useState('author')
36
+ const [inviting, setInviting] = useState(false)
35
37
  const [searchQuery, setSearchQuery] = useState('')
36
38
  const [filterRole, setFilterRole] = useState('all')
37
39
  const [sortConfig, setSortConfig] = useState<SortConfig<UserSortKey> | null>(null)
@@ -61,12 +63,60 @@ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
61
63
  return results
62
64
  }, [users, searchQuery, filterRole, sortConfig])
63
65
 
64
- const handleInvite = (e: FormEvent) => {
65
- e.preventDefault()
66
- toast.success(`Invitation sent to ${inviteEmail}`)
67
- setShowInviteDialog(false)
66
+ const resetInviteForm = () => {
68
67
  setInviteEmail('')
69
- setInviteRole('editor')
68
+ setInviteName('')
69
+ setInviteRole('author')
70
+ }
71
+
72
+ const handleInvite = async (e: FormEvent) => {
73
+ e.preventDefault()
74
+ if (inviting) return
75
+ setInviting(true)
76
+ try {
77
+ const res = await cmsApi<{
78
+ success: boolean
79
+ emailed?: boolean
80
+ inviteUrl?: string
81
+ user?: { email: string }
82
+ }>('/users/invite', {
83
+ method: 'POST',
84
+ body: JSON.stringify({
85
+ email: inviteEmail.trim(),
86
+ name: inviteName.trim() || undefined,
87
+ role: inviteRole,
88
+ }),
89
+ })
90
+
91
+ if (res.error) {
92
+ toast.error(res.error)
93
+ return
94
+ }
95
+
96
+ if (res.data?.emailed) {
97
+ toast.success(`Invitation sent to ${inviteEmail.trim()}`)
98
+ } else if (res.data?.inviteUrl) {
99
+ // No email provider configured (or the send failed): surface the link
100
+ // so the admin can share it manually rather than silently succeeding.
101
+ await navigator.clipboard?.writeText(res.data.inviteUrl).catch(() => {})
102
+ toast.success(
103
+ 'Invite created. Link copied — email isn’t configured, so share it manually.',
104
+ {
105
+ duration: 8000,
106
+ },
107
+ )
108
+ } else {
109
+ toast.success('Invitation created.')
110
+ }
111
+
112
+ setShowInviteDialog(false)
113
+ resetInviteForm()
114
+ refetch()
115
+ } catch {
116
+ toast.error('Could not create the invitation. Please try again.')
117
+ } finally {
118
+ setInviting(false)
119
+ }
70
120
  }
71
121
 
72
122
  const handleDelete = async (userId: number) => {
@@ -268,15 +318,23 @@ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
268
318
  <Dialog.Portal>
269
319
  <Dialog.Overlay className="fixed inset-0 z-50 bg-black/50" />
270
320
  <Dialog.Content className="fixed top-1/2 left-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-white p-6 shadow-lg">
271
- <Dialog.Title className="mb-4 text-lg font-semibold text-gray-900">
321
+ <Dialog.Title className="mb-1 text-lg font-semibold text-gray-900">
272
322
  Invite User
273
323
  </Dialog.Title>
324
+ <Dialog.Description className="mb-4 text-sm text-gray-500">
325
+ They’ll get a link to set a password and sign in. If email isn’t configured, you’ll
326
+ get a link to share.
327
+ </Dialog.Description>
274
328
  <form onSubmit={handleInvite} className="space-y-4">
275
329
  <div>
276
- <label className="mb-1 block text-sm font-medium text-gray-700">
330
+ <label
331
+ htmlFor="invite-email"
332
+ className="mb-1 block text-sm font-medium text-gray-700"
333
+ >
277
334
  Email Address
278
335
  </label>
279
336
  <input
337
+ id="invite-email"
280
338
  type="email"
281
339
  value={inviteEmail}
282
340
  onChange={(e) => setInviteEmail(e.target.value)}
@@ -286,13 +344,35 @@ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
286
344
  />
287
345
  </div>
288
346
  <div>
289
- <label className="mb-1 block text-sm font-medium text-gray-700">Role</label>
347
+ <label
348
+ htmlFor="invite-name"
349
+ className="mb-1 block text-sm font-medium text-gray-700"
350
+ >
351
+ Name <span className="font-normal text-gray-400">(optional)</span>
352
+ </label>
353
+ <input
354
+ id="invite-name"
355
+ type="text"
356
+ value={inviteName}
357
+ onChange={(e) => setInviteName(e.target.value)}
358
+ placeholder="Jane Doe"
359
+ 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"
360
+ />
361
+ </div>
362
+ <div>
363
+ <label
364
+ htmlFor="invite-role"
365
+ className="mb-1 block text-sm font-medium text-gray-700"
366
+ >
367
+ Role
368
+ </label>
290
369
  <select
370
+ id="invite-role"
291
371
  value={inviteRole}
292
372
  onChange={(e) => setInviteRole(e.target.value)}
293
373
  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"
294
374
  >
295
- <option value="viewer">Viewer</option>
375
+ <option value="author">Author</option>
296
376
  <option value="editor">Editor</option>
297
377
  <option value="admin">Admin</option>
298
378
  </select>
@@ -308,9 +388,11 @@ export function Users({ onNavigate, embedded = false }: UsersProps = {}) {
308
388
  </Dialog.Close>
309
389
  <button
310
390
  type="submit"
311
- className="rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700"
391
+ disabled={inviting || !inviteEmail.trim()}
392
+ className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
312
393
  >
313
- Send Invite
394
+ {inviting && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
395
+ {inviting ? 'Sending…' : 'Send Invite'}
314
396
  </button>
315
397
  </div>
316
398
  </form>