@actuate-media/cms-admin 0.43.0 → 0.45.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 (32) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/__tests__/views/email-settings.render.test.d.ts +2 -0
  3. package/dist/__tests__/views/email-settings.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/views/email-settings.render.test.js +184 -0
  5. package/dist/__tests__/views/email-settings.render.test.js.map +1 -0
  6. package/dist/__tests__/views/section-inspector-repeater.render.test.d.ts +2 -0
  7. package/dist/__tests__/views/section-inspector-repeater.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/views/section-inspector-repeater.render.test.js +68 -0
  9. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -0
  10. package/dist/actuate-admin.css +1 -1
  11. package/dist/views/Settings.d.ts.map +1 -1
  12. package/dist/views/Settings.js +5 -3
  13. package/dist/views/Settings.js.map +1 -1
  14. package/dist/views/page-editor/SectionInspector.d.ts +9 -0
  15. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  16. package/dist/views/page-editor/SectionInspector.js +38 -1
  17. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  18. package/dist/views/settings/EmailSettingsTab.d.ts +4 -0
  19. package/dist/views/settings/EmailSettingsTab.d.ts.map +1 -0
  20. package/dist/views/settings/EmailSettingsTab.js +75 -0
  21. package/dist/views/settings/EmailSettingsTab.js.map +1 -0
  22. package/dist/views/settings/useEmailSettings.d.ts +58 -0
  23. package/dist/views/settings/useEmailSettings.d.ts.map +1 -0
  24. package/dist/views/settings/useEmailSettings.js +209 -0
  25. package/dist/views/settings/useEmailSettings.js.map +1 -0
  26. package/package.json +2 -2
  27. package/src/__tests__/views/email-settings.render.test.tsx +225 -0
  28. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +90 -0
  29. package/src/views/Settings.tsx +13 -0
  30. package/src/views/page-editor/SectionInspector.tsx +40 -2
  31. package/src/views/settings/EmailSettingsTab.tsx +436 -0
  32. package/src/views/settings/useEmailSettings.ts +296 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -89,7 +89,7 @@
89
89
  "tailwindcss": "^4.0.0",
90
90
  "typescript": "^5.7.0",
91
91
  "vitest": "^4.1.0",
92
- "@actuate-media/cms-core": "0.61.0",
92
+ "@actuate-media/cms-core": "0.62.0",
93
93
  "@actuate-media/component-blocks": "0.2.1"
94
94
  },
95
95
  "scripts": {
@@ -0,0 +1,225 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ /**
6
+ * Settings > Email: provider picker + per-provider credential fields backed by
7
+ * GET/PUT /email/settings and POST /email/settings/test. Secrets are
8
+ * write-only — the API returns only `hasApiKey` / `apiKeyLast4`, never the key.
9
+ */
10
+
11
+ interface EmailSettingsData {
12
+ settings: Record<string, unknown>
13
+ envProvider: string | null
14
+ factoryAvailable: boolean
15
+ envSenderActive: boolean
16
+ platformAdapter: boolean
17
+ }
18
+
19
+ const EMPTY_RESPONSE: EmailSettingsData = {
20
+ settings: { provider: null, hasApiKey: false, hasSmtpPass: false },
21
+ envProvider: null,
22
+ factoryAvailable: true,
23
+ envSenderActive: false,
24
+ platformAdapter: false,
25
+ }
26
+
27
+ let getResponse: EmailSettingsData = EMPTY_RESPONSE
28
+ let putResponse: { settings: Record<string, unknown> } | null = null
29
+ let testResponse: { data?: unknown; error?: string; status: number } = {
30
+ data: { success: true, sentTo: 'admin@example.com' },
31
+ status: 200,
32
+ }
33
+
34
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: string }) => {
35
+ const method = options?.method ?? 'GET'
36
+ if (endpoint === '/email/settings' && method === 'GET') {
37
+ return { data: getResponse, status: 200 }
38
+ }
39
+ if (endpoint === '/email/settings' && method === 'PUT') {
40
+ return putResponse
41
+ ? { data: putResponse, status: 200 }
42
+ : { error: 'unexpected PUT', status: 500 }
43
+ }
44
+ if (endpoint === '/email/settings/test' && method === 'POST') {
45
+ return testResponse
46
+ }
47
+ return { data: {}, status: 200 }
48
+ })
49
+
50
+ vi.mock('../../lib/api.js', () => ({
51
+ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
52
+ ensureCsrfToken: vi.fn(async () => {}),
53
+ }))
54
+
55
+ const { EmailSettingsTab } = await import('../../views/settings/EmailSettingsTab.js')
56
+
57
+ function deepClone<T>(v: T): T {
58
+ return JSON.parse(JSON.stringify(v))
59
+ }
60
+
61
+ beforeEach(() => {
62
+ cmsApi.mockClear()
63
+ getResponse = deepClone(EMPTY_RESPONSE)
64
+ putResponse = null
65
+ testResponse = { data: { success: true, sentTo: 'admin@example.com' }, status: 200 }
66
+ })
67
+
68
+ describe('EmailSettingsTab', () => {
69
+ it('renders the provider picker with the unconfigured state', async () => {
70
+ render(<EmailSettingsTab role="ADMIN" />)
71
+ expect(await screen.findByRole('radio', { name: 'None' })).toBeTruthy()
72
+ expect(screen.getByRole('radio', { name: 'Resend' })).toBeTruthy()
73
+ expect(screen.getByRole('radio', { name: 'SMTP.com' })).toBeTruthy()
74
+ expect(screen.getByRole('radio', { name: 'Amazon SES' })).toBeTruthy()
75
+ expect(screen.getByRole('radio', { name: 'SMTP server' })).toBeTruthy()
76
+ expect(screen.getByText(/No email provider configured/)).toBeTruthy()
77
+ })
78
+
79
+ it('saves an SMTP.com configuration with the entered key (PUT payload contract)', async () => {
80
+ putResponse = {
81
+ settings: {
82
+ provider: 'smtpcom',
83
+ from: 'noreply@example.com',
84
+ channel: 'transactional',
85
+ hasApiKey: true,
86
+ apiKeyLast4: '-key',
87
+ hasSmtpPass: false,
88
+ },
89
+ }
90
+ render(<EmailSettingsTab role="ADMIN" />)
91
+ fireEvent.click(await screen.findByRole('radio', { name: 'SMTP.com' }))
92
+
93
+ fireEvent.change(screen.getByLabelText('Sender address'), {
94
+ target: { value: 'noreply@example.com' },
95
+ })
96
+ fireEvent.change(screen.getByLabelText('API key'), {
97
+ target: { value: 'smtpcom-secret-key' },
98
+ })
99
+ fireEvent.change(screen.getByLabelText('Sender channel'), {
100
+ target: { value: 'transactional' },
101
+ })
102
+
103
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
104
+
105
+ await waitFor(() => {
106
+ const put = cmsApi.mock.calls.find(
107
+ ([e, o]) => e === '/email/settings' && (o as any)?.method === 'PUT',
108
+ )
109
+ expect(put).toBeTruthy()
110
+ expect(JSON.parse((put![1] as any).body)).toEqual({
111
+ provider: 'smtpcom',
112
+ from: 'noreply@example.com',
113
+ apiKey: 'smtpcom-secret-key',
114
+ channel: 'transactional',
115
+ })
116
+ })
117
+
118
+ // After save the key input is cleared and the masked hint appears.
119
+ expect((screen.getByLabelText('API key') as HTMLInputElement).value).toBe('')
120
+ expect(await screen.findByText(/ends in -key/)).toBeTruthy()
121
+ })
122
+
123
+ it('omits the apiKey from the payload when a stored key is kept', async () => {
124
+ getResponse = {
125
+ ...deepClone(EMPTY_RESPONSE),
126
+ settings: {
127
+ provider: 'resend',
128
+ from: 'old@example.com',
129
+ hasApiKey: true,
130
+ apiKeyLast4: 'k3y9',
131
+ hasSmtpPass: false,
132
+ },
133
+ }
134
+ putResponse = {
135
+ settings: {
136
+ provider: 'resend',
137
+ from: 'new@example.com',
138
+ hasApiKey: true,
139
+ apiKeyLast4: 'k3y9',
140
+ hasSmtpPass: false,
141
+ },
142
+ }
143
+ render(<EmailSettingsTab role="ADMIN" />)
144
+ const from = await screen.findByLabelText('Sender address')
145
+ expect(await screen.findByText(/ends in k3y9/)).toBeTruthy()
146
+
147
+ fireEvent.change(from, { target: { value: 'new@example.com' } })
148
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
149
+
150
+ await waitFor(() => {
151
+ const put = cmsApi.mock.calls.find(
152
+ ([e, o]) => e === '/email/settings' && (o as any)?.method === 'PUT',
153
+ )
154
+ expect(put).toBeTruthy()
155
+ const body = JSON.parse((put![1] as any).body)
156
+ expect(body).toEqual({ provider: 'resend', from: 'new@example.com' })
157
+ expect(body).not.toHaveProperty('apiKey')
158
+ })
159
+ })
160
+
161
+ it('sends a test email and shows the result', async () => {
162
+ getResponse = {
163
+ ...deepClone(EMPTY_RESPONSE),
164
+ settings: {
165
+ provider: 'resend',
166
+ from: 'a@b.co',
167
+ hasApiKey: true,
168
+ apiKeyLast4: 'k3y9',
169
+ hasSmtpPass: false,
170
+ },
171
+ }
172
+ render(<EmailSettingsTab role="ADMIN" />)
173
+ fireEvent.click(await screen.findByRole('button', { name: /Send test email/ }))
174
+ await waitFor(() =>
175
+ expect(cmsApi).toHaveBeenCalledWith(
176
+ '/email/settings/test',
177
+ expect.objectContaining({ method: 'POST' }),
178
+ ),
179
+ )
180
+ expect(await screen.findByText(/Test email sent to admin@example.com/)).toBeTruthy()
181
+ })
182
+
183
+ it('surfaces a failed test send', async () => {
184
+ getResponse = {
185
+ ...deepClone(EMPTY_RESPONSE),
186
+ settings: {
187
+ provider: 'resend',
188
+ from: 'a@b.co',
189
+ hasApiKey: true,
190
+ hasSmtpPass: false,
191
+ },
192
+ }
193
+ testResponse = { error: 'SMTP.com API error (401): bad key', status: 502 }
194
+ render(<EmailSettingsTab role="ADMIN" />)
195
+ fireEvent.click(await screen.findByRole('button', { name: /Send test email/ }))
196
+ expect(await screen.findByText(/SMTP\.com API error \(401\)/)).toBeTruthy()
197
+ })
198
+
199
+ it('shows the env-managed notice and disables editing for non-admins', async () => {
200
+ getResponse = {
201
+ ...deepClone(EMPTY_RESPONSE),
202
+ envProvider: 'resend',
203
+ envSenderActive: true,
204
+ }
205
+ render(<EmailSettingsTab role="EDITOR" />)
206
+ expect(await screen.findByText(/read-only access/)).toBeTruthy()
207
+ expect(screen.getByText(/Environment variables currently configure/)).toBeTruthy()
208
+ expect((screen.getByRole('radio', { name: 'Resend' }) as HTMLButtonElement).disabled).toBe(true)
209
+ })
210
+
211
+ it('warns when the email plugin is missing for a configured provider', async () => {
212
+ getResponse = {
213
+ ...deepClone(EMPTY_RESPONSE),
214
+ factoryAvailable: false,
215
+ settings: {
216
+ provider: 'resend',
217
+ from: 'a@b.co',
218
+ hasApiKey: true,
219
+ hasSmtpPass: false,
220
+ },
221
+ }
222
+ render(<EmailSettingsTab role="ADMIN" />)
223
+ expect(await screen.findByText(/requires the email plugin/)).toBeTruthy()
224
+ })
225
+ })
@@ -0,0 +1,90 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ import { createSection } from '../../lib/page-editor-service.js'
6
+ import { SectionInspector, repeaterRowSummary } from '../../views/page-editor/SectionInspector.js'
7
+ import type { SectionFieldDef } from '../../views/page-editor/section-types.js'
8
+
9
+ /**
10
+ * Repeater rows derive a scannable header from their values ("Pro · $79/mo")
11
+ * instead of only the positional "Plan 2" label, so editors can find a row
12
+ * without expanding every form (Upchat feedback: "repeaters feel like
13
+ * spreadsheets").
14
+ */
15
+
16
+ const PLAN_FIELDS: SectionFieldDef[] = [
17
+ { key: 'name', label: 'Name', type: 'text' },
18
+ { key: 'price', label: 'Price', type: 'text' },
19
+ { key: 'description', label: 'Description', type: 'textarea' },
20
+ {
21
+ key: 'tier',
22
+ label: 'Tier',
23
+ type: 'select',
24
+ options: [{ label: 'Most popular', value: 'popular' }],
25
+ },
26
+ ]
27
+
28
+ describe('repeaterRowSummary', () => {
29
+ it('joins the first two short text values', () => {
30
+ expect(repeaterRowSummary(PLAN_FIELDS, { name: 'Pro', price: '$79/mo' })).toBe('Pro · $79/mo')
31
+ })
32
+
33
+ it('skips empty values and long-form fields', () => {
34
+ expect(
35
+ repeaterRowSummary(PLAN_FIELDS, {
36
+ name: ' ',
37
+ price: '$79/mo',
38
+ description: 'long prose that must never appear',
39
+ }),
40
+ ).toBe('$79/mo')
41
+ })
42
+
43
+ it('maps select values to their human label', () => {
44
+ expect(repeaterRowSummary(PLAN_FIELDS, { tier: 'popular' })).toBe('Most popular')
45
+ })
46
+
47
+ it('returns null for a blank row (caller falls back to "Plan N")', () => {
48
+ expect(repeaterRowSummary(PLAN_FIELDS, {})).toBeNull()
49
+ expect(repeaterRowSummary(PLAN_FIELDS, { name: '', price: '' })).toBeNull()
50
+ })
51
+
52
+ it('truncates very long summaries with an ellipsis', () => {
53
+ const summary = repeaterRowSummary(PLAN_FIELDS, { name: 'x'.repeat(100) })
54
+ expect(summary!.length).toBeLessThanOrEqual(60)
55
+ expect(summary!.endsWith('…')).toBe(true)
56
+ })
57
+ })
58
+
59
+ describe('SectionInspector repeater rows', () => {
60
+ function renderStatsInspector(stats: Array<Record<string, unknown>>) {
61
+ const section = createSection('stats')
62
+ section.content = { ...section.content, stats }
63
+ render(
64
+ <SectionInspector
65
+ section={section}
66
+ canEdit
67
+ onClose={vi.fn()}
68
+ onContentChange={vi.fn()}
69
+ onSettingsChange={vi.fn()}
70
+ onMetaChange={vi.fn()}
71
+ onToggleVisibility={vi.fn()}
72
+ />,
73
+ )
74
+ }
75
+
76
+ it('shows value-derived row headers instead of "Stat N"', () => {
77
+ renderStatsInspector([
78
+ { value: '99.99%', label: 'Uptime', description: '' },
79
+ { value: '3M+', label: 'API requests', description: '' },
80
+ ])
81
+ expect(screen.getByText('99.99% · Uptime')).toBeTruthy()
82
+ expect(screen.getByText('3M+ · API requests')).toBeTruthy()
83
+ expect(screen.queryByText('Stat 1')).toBeNull()
84
+ })
85
+
86
+ it('falls back to the positional label for blank rows', () => {
87
+ renderStatsInspector([{ value: '', label: '', description: '' }])
88
+ expect(screen.getByText('Stat 1')).toBeTruthy()
89
+ })
90
+ })
@@ -7,6 +7,7 @@ import {
7
7
  AlertTriangle,
8
8
  Download,
9
9
  Layers,
10
+ Mail,
10
11
  Users as UsersIcon,
11
12
  KeyRound,
12
13
  } from 'lucide-react'
@@ -18,6 +19,7 @@ import { RelationshipField } from '../fields/RelationshipField.js'
18
19
  import { GeneralSettingsTab } from './settings/GeneralSettingsTab.js'
19
20
  import { SeoSettingsTab } from './settings/SeoSettingsTab.js'
20
21
  import { AISettingsTab } from './settings/AISettingsTab.js'
22
+ import { EmailSettingsTab } from './settings/EmailSettingsTab.js'
21
23
  import { AppearanceTab } from './settings/AppearanceTab.js'
22
24
  import { SecurityTab } from './settings/SecurityTab.js'
23
25
  import { SettingsCard } from './settings/components.js'
@@ -85,6 +87,7 @@ export function Settings({
85
87
  'users',
86
88
  'api-keys',
87
89
  'seo',
90
+ 'email',
88
91
  'ai',
89
92
  'updates',
90
93
  ] as const,
@@ -214,6 +217,12 @@ export function Settings({
214
217
  <Tabs.Trigger value="seo" className={tabTriggerClass}>
215
218
  SEO
216
219
  </Tabs.Trigger>
220
+ <Tabs.Trigger value="email" className={tabTriggerClass}>
221
+ <span className="flex items-center gap-1.5">
222
+ <Mail className="h-4 w-4" />
223
+ Email
224
+ </span>
225
+ </Tabs.Trigger>
217
226
  <Tabs.Trigger value="ai" className={tabTriggerClass}>
218
227
  <span className="flex items-center gap-1.5">
219
228
  <Bot className="h-4 w-4" />
@@ -311,6 +320,10 @@ export function Settings({
311
320
  <SeoSettingsTab onNavigate={onNavigate} role={session?.user?.role} />
312
321
  </Tabs.Content>
313
322
 
323
+ <Tabs.Content value="email" className="space-y-4">
324
+ <EmailSettingsTab role={session?.user?.role} />
325
+ </Tabs.Content>
326
+
314
327
  <Tabs.Content value="ai" className="space-y-4">
315
328
  <AISettingsTab role={session?.user?.role} />
316
329
  </Tabs.Content>
@@ -525,6 +525,39 @@ function RelationshipField({
525
525
 
526
526
  type RowRecord = Record<string, unknown>
527
527
 
528
+ // Field types whose values are short enough to scan in a row header. Long-form
529
+ // fields (textarea, richText) and non-text values (media, url) stay out.
530
+ const SUMMARY_FIELD_TYPES = new Set(['text', 'select'])
531
+ const SUMMARY_MAX_LENGTH = 60
532
+
533
+ /**
534
+ * Derive a scannable one-line summary for a repeater row from its first two
535
+ * short text values — e.g. a pricing row renders as "Pro · $79/mo" instead of
536
+ * "Plan 2". Returns `null` when the row has no usable values yet, so the
537
+ * caller falls back to the positional "Plan 2" label.
538
+ */
539
+ export function repeaterRowSummary(fields: SectionFieldDef[], row: RowRecord): string | null {
540
+ const parts: string[] = []
541
+ for (const field of fields) {
542
+ if (parts.length >= 2) break
543
+ if (!SUMMARY_FIELD_TYPES.has(field.type)) continue
544
+ const raw = row[field.key]
545
+ const value = typeof raw === 'string' ? raw.trim() : typeof raw === 'number' ? String(raw) : ''
546
+ if (!value) continue
547
+ if (field.type === 'select') {
548
+ const option = field.options?.find((o) => o.value === value)
549
+ parts.push(option?.label ?? value)
550
+ } else {
551
+ parts.push(value)
552
+ }
553
+ }
554
+ if (parts.length === 0) return null
555
+ const joined = parts.join(' · ')
556
+ return joined.length > SUMMARY_MAX_LENGTH
557
+ ? `${joined.slice(0, SUMMARY_MAX_LENGTH - 1).trimEnd()}…`
558
+ : joined
559
+ }
560
+
528
561
  /**
529
562
  * Generic repeater editor: an orderable list of rows, each row a small form of
530
563
  * the repeater's sub-fields. Drives the generic `repeater` field type and the
@@ -665,6 +698,8 @@ function SortableRepeaterRow({
665
698
  zIndex: isDragging ? 10 : undefined,
666
699
  }
667
700
 
701
+ const summary = repeaterRowSummary(fields, row)
702
+
668
703
  return (
669
704
  <div
670
705
  ref={setNodeRef}
@@ -684,8 +719,11 @@ function SortableRepeaterRow({
684
719
  <GripVertical className="h-3.5 w-3.5" aria-hidden />
685
720
  </button>
686
721
  )}
687
- <span className="text-muted-foreground text-xs">
688
- {rowLabel} {index + 1}
722
+ <span
723
+ className="text-muted-foreground max-w-[200px] truncate text-xs"
724
+ title={summary ? `${rowLabel} ${index + 1}: ${summary}` : undefined}
725
+ >
726
+ {summary ?? `${rowLabel} ${index + 1}`}
689
727
  </span>
690
728
  </span>
691
729
  <button