@actuate-media/cms-admin 0.67.0 → 0.68.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 (146) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +29 -5
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  6. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  7. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  8. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  9. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  10. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  12. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  13. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  14. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  15. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  16. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  17. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  18. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  20. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  21. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  24. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  25. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  28. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  29. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  30. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  31. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  34. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  35. package/dist/actuate-admin.css +1 -1
  36. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  37. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  38. package/dist/components/collections/EntryDetailPane.js +92 -0
  39. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  40. package/dist/components/forms/FieldsPanel.js +1 -1
  41. package/dist/components/forms/FieldsPanel.js.map +1 -1
  42. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  43. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  44. package/dist/components/forms/FormSchemaPane.js +95 -0
  45. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  46. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  47. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  48. package/dist/components/seo/SeoEditorPane.js +175 -0
  49. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  50. package/dist/components/ui/Badge.d.ts +1 -1
  51. package/dist/components/ui/Button.d.ts +1 -1
  52. package/dist/components/ui/StatusBadge.d.ts +1 -1
  53. package/dist/hooks/useIdleLogout.d.ts +18 -0
  54. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  55. package/dist/hooks/useIdleLogout.js +66 -0
  56. package/dist/hooks/useIdleLogout.js.map +1 -0
  57. package/dist/lib/api.d.ts +1 -0
  58. package/dist/lib/api.d.ts.map +1 -1
  59. package/dist/lib/api.js +7 -1
  60. package/dist/lib/api.js.map +1 -1
  61. package/dist/lib/post-types-service.d.ts +44 -0
  62. package/dist/lib/post-types-service.d.ts.map +1 -0
  63. package/dist/lib/post-types-service.js +83 -0
  64. package/dist/lib/post-types-service.js.map +1 -0
  65. package/dist/views/CollectionList.d.ts.map +1 -1
  66. package/dist/views/CollectionList.js +23 -4
  67. package/dist/views/CollectionList.js.map +1 -1
  68. package/dist/views/Forms.d.ts.map +1 -1
  69. package/dist/views/Forms.js +25 -21
  70. package/dist/views/Forms.js.map +1 -1
  71. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  72. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  73. package/dist/views/Posts/PostTypeEditor.js +159 -0
  74. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  75. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  76. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  77. package/dist/views/Posts/PostTypesView.js +1 -1
  78. package/dist/views/Posts/PostTypesView.js.map +1 -1
  79. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  80. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  81. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  82. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  83. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  84. package/dist/views/page-builder/PageBuilder.js +51 -2
  85. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  86. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  87. package/dist/views/page-builder/PageTemplates.js +113 -6
  88. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  89. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  90. package/dist/views/seo/ContentTab.js +22 -6
  91. package/dist/views/seo/ContentTab.js.map +1 -1
  92. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  93. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  94. package/dist/views/settings/AccessProtectionCard.js +13 -10
  95. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  96. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  97. package/dist/views/settings/SecurityTab.js +3 -3
  98. package/dist/views/settings/SecurityTab.js.map +1 -1
  99. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  100. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  101. package/dist/views/settings/SessionPolicyCard.js +12 -5
  102. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  103. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  104. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  105. package/dist/views/settings/useSecuritySettings.js +47 -4
  106. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  107. package/package.json +2 -2
  108. package/src/AdminRoot.tsx +36 -7
  109. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  110. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  111. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  112. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  113. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  114. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  115. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  116. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  117. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  118. package/src/components/collections/EntryDetailPane.tsx +182 -0
  119. package/src/components/forms/FieldsPanel.tsx +3 -1
  120. package/src/components/forms/FormSchemaPane.tsx +192 -0
  121. package/src/components/seo/SeoEditorPane.tsx +533 -0
  122. package/src/hooks/useIdleLogout.ts +77 -0
  123. package/src/lib/api.ts +8 -2
  124. package/src/lib/post-types-service.ts +129 -0
  125. package/src/views/CollectionList.tsx +158 -110
  126. package/src/views/Forms.tsx +154 -126
  127. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  128. package/src/views/Posts/PostTypesView.tsx +5 -4
  129. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  130. package/src/views/page-builder/PageBuilder.tsx +69 -2
  131. package/src/views/page-builder/PageTemplates.tsx +340 -57
  132. package/src/views/seo/ContentTab.tsx +113 -80
  133. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  134. package/src/views/settings/SecurityTab.tsx +23 -2
  135. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  136. package/src/views/settings/useSecuritySettings.ts +78 -22
  137. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  138. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  139. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  140. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  141. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  142. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  143. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  144. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  145. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  146. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
@@ -0,0 +1,138 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import type { PageNode } from '@actuate-media/cms-core'
5
+
6
+ const { BuilderSectionsPanel } = await import('../../views/page-builder/BuilderSectionsPanel.js')
7
+
8
+ /** Minimal builder tree: a hero section plus an empty, hidden section. */
9
+ function makeTree(): PageNode {
10
+ return {
11
+ id: 'page1',
12
+ type: 'page',
13
+ children: [
14
+ {
15
+ id: 'sec1',
16
+ type: 'section',
17
+ settings: {},
18
+ children: [
19
+ {
20
+ id: 'cont1',
21
+ type: 'container',
22
+ settings: {},
23
+ children: [
24
+ {
25
+ id: 'row1',
26
+ type: 'row',
27
+ settings: {},
28
+ children: [
29
+ {
30
+ id: 'col1',
31
+ type: 'column',
32
+ settings: { width: 12 },
33
+ children: [
34
+ {
35
+ id: 'blk1',
36
+ type: 'block',
37
+ settings: { blockType: 'hero', variant: 'centered' },
38
+ data: {},
39
+ },
40
+ ],
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ },
46
+ ],
47
+ },
48
+ { id: 'sec2', type: 'section', settings: { visibility: 'hidden' }, children: [] },
49
+ ],
50
+ }
51
+ }
52
+
53
+ function renderPanel(over: Partial<Parameters<typeof BuilderSectionsPanel>[0]> = {}) {
54
+ const props = {
55
+ tree: makeTree(),
56
+ selectedNodeId: null,
57
+ collapsed: false,
58
+ onToggleCollapsed: vi.fn(),
59
+ onSelect: vi.fn(),
60
+ onReorder: vi.fn(),
61
+ onAddSection: vi.fn(),
62
+ ...over,
63
+ }
64
+ render(<BuilderSectionsPanel {...props} />)
65
+ return props
66
+ }
67
+
68
+ beforeEach(() => {
69
+ vi.clearAllMocks()
70
+ })
71
+
72
+ describe('BuilderSectionsPanel', () => {
73
+ it('lists the page sections in order with derived names and block counts', () => {
74
+ renderPanel()
75
+
76
+ // First section is named after its first block's catalog label.
77
+ expect(screen.getByText('Hero')).toBeTruthy()
78
+ expect(screen.getByText('1 block')).toBeTruthy()
79
+ // Empty section falls back to a positional name.
80
+ expect(screen.getByText('Section 2')).toBeTruthy()
81
+ expect(screen.getByText('Empty section')).toBeTruthy()
82
+ // Count chip in the header.
83
+ expect(screen.getByText('2')).toBeTruthy()
84
+ // Panel is an accessible nav landmark.
85
+ expect(screen.getByRole('navigation', { name: 'Page sections' })).toBeTruthy()
86
+ })
87
+
88
+ it('fires the selection callback when a section row is clicked', () => {
89
+ const props = renderPanel()
90
+
91
+ // `/^Hero/` excludes the drag handle ("Drag to reorder Hero").
92
+ fireEvent.click(screen.getByRole('button', { name: /^Hero/ }))
93
+ expect(props.onSelect).toHaveBeenCalledWith('sec1')
94
+ })
95
+
96
+ it('marks the selected section with aria-current', () => {
97
+ renderPanel({ selectedNodeId: 'sec1' })
98
+
99
+ const row = screen.getByRole('button', { name: /^Hero/ })
100
+ expect(row.getAttribute('aria-current')).toBe('true')
101
+ })
102
+
103
+ it('exposes keyboard-accessible drag handles per section', () => {
104
+ renderPanel()
105
+ expect(screen.getByRole('button', { name: 'Drag to reorder Hero' })).toBeTruthy()
106
+ expect(screen.getByRole('button', { name: 'Drag to reorder Section 2' })).toBeTruthy()
107
+ })
108
+
109
+ it('fires the add-section callback from the bottom affordance', () => {
110
+ const props = renderPanel()
111
+
112
+ fireEvent.click(screen.getByRole('button', { name: 'Add Section' }))
113
+ expect(props.onAddSection).toHaveBeenCalledTimes(1)
114
+ })
115
+
116
+ it('collapses to a rail with an expand toggle', () => {
117
+ const props = renderPanel({ collapsed: true })
118
+
119
+ // Section rows are gone; only the expand affordance remains.
120
+ expect(screen.queryByText('Hero')).toBeNull()
121
+ const toggle = screen.getByRole('button', { name: 'Expand sections panel' })
122
+ expect(toggle.getAttribute('aria-expanded')).toBe('false')
123
+
124
+ fireEvent.click(toggle)
125
+ expect(props.onToggleCollapsed).toHaveBeenCalledTimes(1)
126
+ })
127
+
128
+ it('shows a collapse toggle with aria-expanded when open', () => {
129
+ renderPanel()
130
+ const toggle = screen.getByRole('button', { name: 'Collapse sections panel' })
131
+ expect(toggle.getAttribute('aria-expanded')).toBe('true')
132
+ })
133
+
134
+ it('shows an empty message when the page has no sections', () => {
135
+ renderPanel({ tree: { id: 'page1', type: 'page', children: [] } })
136
+ expect(screen.getByText(/No sections yet/)).toBeTruthy()
137
+ })
138
+ })
@@ -0,0 +1,142 @@
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
+ const cmsApi = vi.fn()
6
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (...args: unknown[]) => cmsApi(...args) }))
7
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
8
+
9
+ const { PageTemplates } = await import('../../views/page-builder/PageTemplates.js')
10
+
11
+ function section(id: string) {
12
+ return { id, type: 'section', settings: {}, children: [] }
13
+ }
14
+
15
+ const TEMPLATES = [
16
+ {
17
+ id: 'builtin-landing',
18
+ name: 'Landing Page',
19
+ description: 'Hero, features cards, CTA, testimonials, and final CTA',
20
+ category: 'landing',
21
+ builtIn: true,
22
+ updatedAt: '2026-05-29T00:00:00.000Z',
23
+ thumbnail: null,
24
+ tree: { id: 'p1', type: 'page', children: [section('a'), section('b'), section('c')] },
25
+ },
26
+ {
27
+ id: 'custom-1',
28
+ name: 'Team Member',
29
+ description: 'Avatar hero, full bio, and contact links',
30
+ category: 'content',
31
+ builtIn: false,
32
+ updatedAt: '2026-06-10T00:00:00.000Z',
33
+ thumbnail: null,
34
+ tree: { id: 'p2', type: 'page', children: [section('a')] },
35
+ },
36
+ ]
37
+
38
+ beforeEach(() => {
39
+ cmsApi.mockReset()
40
+ cmsApi.mockImplementation(async (endpoint: string, options?: RequestInit) => {
41
+ if (endpoint === '/page-templates' && !options?.method) {
42
+ return { data: TEMPLATES, status: 200 }
43
+ }
44
+ return { data: {}, status: 200 }
45
+ })
46
+ })
47
+
48
+ describe('PageTemplates card grid', () => {
49
+ it('renders a card per template with name, category, and section-count meta', async () => {
50
+ render(<PageTemplates onNavigate={() => {}} />)
51
+
52
+ expect(await screen.findByText('Landing Page')).toBeTruthy()
53
+ expect(screen.getByText('Team Member')).toBeTruthy()
54
+ // Section counts derived from each template's tree.
55
+ expect(screen.getByText(/3 sections/)).toBeTruthy()
56
+ expect(screen.getByText(/1 section ·/)).toBeTruthy()
57
+ // Category labels from the design's palette.
58
+ expect(screen.getByText('Landing Page')).toBeTruthy()
59
+ expect(screen.getByText('Built-in')).toBeTruthy()
60
+ })
61
+
62
+ it('hides edit/delete actions on built-in templates (server rejects them)', async () => {
63
+ render(<PageTemplates onNavigate={() => {}} />)
64
+ await screen.findByText('Landing Page')
65
+
66
+ expect(screen.queryByRole('button', { name: 'Edit Landing Page' })).toBeNull()
67
+ expect(screen.queryByRole('button', { name: 'Delete Landing Page' })).toBeNull()
68
+ expect(screen.getByRole('button', { name: 'Edit Team Member' })).toBeTruthy()
69
+ expect(screen.getByRole('button', { name: 'Delete Team Member' })).toBeTruthy()
70
+ })
71
+
72
+ it('deletes a custom template only after the confirm dialog is accepted', async () => {
73
+ render(<PageTemplates onNavigate={() => {}} />)
74
+ await screen.findByText('Team Member')
75
+
76
+ fireEvent.click(screen.getByRole('button', { name: 'Delete Team Member' }))
77
+
78
+ // Confirm dialog is up; nothing deleted yet.
79
+ expect(await screen.findByText('Delete template?')).toBeTruthy()
80
+ expect(cmsApi).not.toHaveBeenCalledWith(
81
+ '/page-templates/custom-1',
82
+ expect.objectContaining({ method: 'DELETE' }),
83
+ )
84
+
85
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
86
+
87
+ await waitFor(() =>
88
+ expect(cmsApi).toHaveBeenCalledWith(
89
+ '/page-templates/custom-1',
90
+ expect.objectContaining({ method: 'DELETE' }),
91
+ ),
92
+ )
93
+ })
94
+
95
+ it('opens the edit modal seeded with the template and saves via PUT', async () => {
96
+ render(<PageTemplates onNavigate={() => {}} />)
97
+ await screen.findByText('Team Member')
98
+
99
+ fireEvent.click(screen.getByRole('button', { name: 'Edit Team Member' }))
100
+
101
+ const nameInput = (await screen.findByPlaceholderText('Template name')) as HTMLInputElement
102
+ expect(nameInput.value).toBe('Team Member')
103
+
104
+ fireEvent.change(nameInput, { target: { value: 'Team Bio' } })
105
+ fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
106
+
107
+ await waitFor(() =>
108
+ expect(cmsApi).toHaveBeenCalledWith(
109
+ '/page-templates/custom-1',
110
+ expect.objectContaining({ method: 'PUT' }),
111
+ ),
112
+ )
113
+ const putCall = cmsApi.mock.calls.find(
114
+ (c) => (c[1] as RequestInit | undefined)?.method === 'PUT',
115
+ )!
116
+ expect(JSON.parse((putCall[1] as RequestInit).body as string)).toMatchObject({
117
+ name: 'Team Bio',
118
+ category: 'content',
119
+ })
120
+ })
121
+
122
+ it('filters cards by search text', async () => {
123
+ render(<PageTemplates onNavigate={() => {}} />)
124
+ await screen.findByText('Team Member')
125
+
126
+ fireEvent.change(screen.getByPlaceholderText('Search templates…'), {
127
+ target: { value: 'landing' },
128
+ })
129
+
130
+ await waitFor(() => {
131
+ expect(screen.queryByText('Team Member')).toBeNull()
132
+ expect(screen.getByText('Landing Page')).toBeTruthy()
133
+ })
134
+ })
135
+
136
+ it('shows the empty state when no templates exist', async () => {
137
+ cmsApi.mockResolvedValue({ data: [], status: 200 })
138
+ render(<PageTemplates onNavigate={() => {}} />)
139
+
140
+ expect(await screen.findByText('No page templates yet')).toBeTruthy()
141
+ })
142
+ })
@@ -0,0 +1,116 @@
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
+ const cmsApiMock = vi.fn()
6
+ vi.mock('../../lib/api.js', () => ({
7
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
8
+ }))
9
+
10
+ const { PostTypeEditor } = await import('../../views/Posts/PostTypeEditor.js')
11
+
12
+ const COLLECTIONS = [
13
+ {
14
+ slug: 'blog',
15
+ labels: { singular: 'Blog Post', plural: 'Blog' },
16
+ type: 'post',
17
+ urlPrefix: 'blog',
18
+ description: 'Long-form articles',
19
+ icon: 'file-text',
20
+ color: 'purple',
21
+ group: 'Posts',
22
+ hidden: false,
23
+ fieldCount: 3,
24
+ fields: [
25
+ { name: 'title', type: 'text', required: true },
26
+ { name: 'body', type: 'richText', required: false },
27
+ { name: 'featuredImage', type: 'media', required: false },
28
+ ],
29
+ },
30
+ ]
31
+
32
+ const POSTS = {
33
+ posts: [],
34
+ total: 7,
35
+ page: 1,
36
+ pageSize: 1,
37
+ types: [
38
+ {
39
+ slug: 'blog',
40
+ singular: 'Blog Post',
41
+ plural: 'Blog',
42
+ description: 'Long-form articles',
43
+ urlPrefix: 'blog',
44
+ color: 'purple',
45
+ icon: 'file-text',
46
+ fieldCount: 3,
47
+ count: 7,
48
+ },
49
+ ],
50
+ authors: [],
51
+ }
52
+
53
+ function installMocks() {
54
+ cmsApiMock.mockImplementation((url: string) => {
55
+ if (url === '/collections') return { data: COLLECTIONS }
56
+ if (url.startsWith('/posts')) return { data: POSTS }
57
+ return { error: `unexpected url: ${url}` }
58
+ })
59
+ }
60
+
61
+ describe('PostTypeEditor', () => {
62
+ beforeEach(() => {
63
+ cmsApiMock.mockReset()
64
+ installMocks()
65
+ })
66
+
67
+ it('renders identity, post-count badge, and the live field list', async () => {
68
+ render(<PostTypeEditor slug="blog" onNavigate={vi.fn()} />)
69
+
70
+ await waitFor(() => expect(screen.getByText('Field Builder')).toBeTruthy())
71
+
72
+ // Toolbar: breadcrumb + count badge.
73
+ expect(screen.getByRole('navigation', { name: 'Breadcrumb' }).textContent).toContain('Blog')
74
+ expect(screen.getByText('7 posts')).toBeTruthy()
75
+
76
+ // Identity panel values.
77
+ expect(screen.getByText('Long-form articles')).toBeTruthy()
78
+ expect(screen.getByText('/api/cms/collections/blog/{id}')).toBeTruthy()
79
+
80
+ // Field rows with humanized labels, keys, and type badges.
81
+ expect(screen.getByText('Featured Image')).toBeTruthy()
82
+ expect(screen.getByText('featuredImage')).toBeTruthy()
83
+ expect(screen.getByText('Rich Text')).toBeTruthy()
84
+ expect(screen.getByText('Required')).toBeTruthy()
85
+ expect(screen.getAllByText('Optional')).toHaveLength(2)
86
+ })
87
+
88
+ it('generates a copyable field snippet and flags duplicate keys', async () => {
89
+ render(<PostTypeEditor slug="blog" onNavigate={vi.fn()} />)
90
+ await waitFor(() => expect(screen.getByText('Field Builder')).toBeTruthy())
91
+
92
+ fireEvent.click(screen.getByRole('button', { name: 'Add Field' }))
93
+ fireEvent.click(screen.getByRole('radio', { name: /Short Text/ }))
94
+
95
+ const nameInput = screen.getByLabelText('Field Name')
96
+ fireEvent.change(nameInput, { target: { value: 'Client Name' } })
97
+
98
+ // Auto-derived key + snippet preview.
99
+ expect((screen.getByLabelText('Field Key') as HTMLInputElement).value).toBe('clientName')
100
+ expect(screen.getByText(/clientName: \{ type: 'text', label: 'Client Name' \},/)).toBeTruthy()
101
+
102
+ // A name colliding with an existing field key blocks the snippet.
103
+ fireEvent.change(nameInput, { target: { value: 'Featured Image' } })
104
+ expect(screen.getByText(/already exists on this type/)).toBeTruthy()
105
+ expect(screen.queryByText('Copy snippet')).toBeNull()
106
+ })
107
+
108
+ it('shows a not-found notice for unknown slugs', async () => {
109
+ const onNavigate = vi.fn()
110
+ render(<PostTypeEditor slug="ghost" onNavigate={onNavigate} />)
111
+
112
+ await waitFor(() => expect(screen.getByText('No post type named "ghost"')).toBeTruthy())
113
+ fireEvent.click(screen.getByRole('button', { name: 'Back to Post Types' }))
114
+ expect(onNavigate).toHaveBeenCalledWith('/posts/types')
115
+ })
116
+ })
@@ -5,17 +5,23 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
5
5
  // The Security tab reads `/security` (computed posture + summaries + editable
6
6
  // policy), saves via PUT `/security`, and revokes sessions via POST.
7
7
  let securityData: any
8
+ /** When set, PUT /security returns this instead of echoing the body. */
9
+ let putResponseOverride:
10
+ | ((body: any) => { data?: any; error?: string; code?: string; status: number })
11
+ | null = null
8
12
 
9
13
  const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: unknown }) => {
10
14
  const method = options?.method ?? 'GET'
11
15
  if (endpoint === '/security' && method === 'GET') return { data: securityData, status: 200 }
12
16
  if (endpoint === '/security' && method === 'PUT') {
13
17
  const body = JSON.parse(String(options?.body ?? '{}'))
18
+ if (putResponseOverride) return putResponseOverride(body)
14
19
  securityData = {
15
20
  ...securityData,
16
21
  settings: {
17
22
  mfa: { required: body.mfa.required, gracePeriodDays: body.mfa.gracePeriodDays },
18
- session: { maxConcurrentSessions: body.session.maxConcurrentSessions },
23
+ session: body.session,
24
+ access: body.access,
19
25
  },
20
26
  }
21
27
  return { data: securityData, status: 200 }
@@ -33,7 +39,8 @@ function baseData(overrides: Record<string, unknown> = {}) {
33
39
  return {
34
40
  settings: {
35
41
  mfa: { required: false, gracePeriodDays: 7 },
36
- session: { maxConcurrentSessions: 5 },
42
+ session: { maxConcurrentSessions: 5, timeoutHours: 168, idleLogoutEnabled: false },
43
+ access: { ipAllowlist: [], auditLoggingEnabled: true },
37
44
  },
38
45
  status: {
39
46
  status: 'needs_attention',
@@ -75,6 +82,7 @@ function baseData(overrides: Record<string, unknown> = {}) {
75
82
  enabled: false,
76
83
  source: 'none',
77
84
  ranges: [],
85
+ configRanges: [],
78
86
  currentIp: '1.2.3.4',
79
87
  currentIpAllowed: null,
80
88
  },
@@ -102,6 +110,7 @@ function baseData(overrides: Record<string, unknown> = {}) {
102
110
  beforeEach(() => {
103
111
  cmsApi.mockClear()
104
112
  securityData = baseData()
113
+ putResponseOverride = null
105
114
  })
106
115
 
107
116
  describe('SecurityTab', () => {
@@ -110,9 +119,14 @@ describe('SecurityTab', () => {
110
119
 
111
120
  await screen.findByText('Security Status')
112
121
  expect(screen.getByText('Authentication & MFA')).toBeTruthy()
113
- expect(screen.getByText('Session Policy')).toBeTruthy()
114
- expect(screen.getByText('Access & Request Protection')).toBeTruthy()
122
+ expect(screen.getByText('Sessions')).toBeTruthy()
123
+ expect(screen.getByText('Access Control')).toBeTruthy()
115
124
  expect(screen.getByText('Secrets, Encryption & Audit')).toBeTruthy()
125
+ // New design controls are present.
126
+ expect(screen.getByLabelText(/Session timeout/)).toBeTruthy()
127
+ expect(screen.getByRole('switch', { name: 'Idle auto-logout' })).toBeTruthy()
128
+ expect(screen.getByLabelText(/Allowed IP addresses/)).toBeTruthy()
129
+ expect(screen.getByRole('switch', { name: 'Audit logging' })).toBeTruthy()
116
130
  // Status badge + warning surfaced.
117
131
  expect(screen.getByText('Needs attention')).toBeTruthy()
118
132
  expect(screen.getByText('MFA is not required in production')).toBeTruthy()
@@ -197,6 +211,97 @@ describe('SecurityTab', () => {
197
211
  await screen.findByText('Other sessions revoked.')
198
212
  })
199
213
 
214
+ it('persists the session timeout and idle auto-logout policy', async () => {
215
+ render(<SecurityTab role="ADMIN" />)
216
+ await screen.findByText('Security Status')
217
+
218
+ fireEvent.change(screen.getByLabelText(/Session timeout/), { target: { value: '8' } })
219
+ fireEvent.click(screen.getByRole('switch', { name: 'Idle auto-logout' }))
220
+
221
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
222
+ await waitFor(() =>
223
+ expect(cmsApi).toHaveBeenCalledWith(
224
+ '/security',
225
+ expect.objectContaining({
226
+ method: 'PUT',
227
+ body: expect.stringContaining('"timeoutHours":8'),
228
+ }),
229
+ ),
230
+ )
231
+ const putBody = JSON.parse(
232
+ String(cmsApi.mock.calls.find((c) => c[1]?.method === 'PUT')?.[1]?.body),
233
+ )
234
+ expect(putBody.session.idleLogoutEnabled).toBe(true)
235
+ })
236
+
237
+ it('persists the IP allowlist and audit toggle, flagging invalid entries client-side', async () => {
238
+ render(<SecurityTab role="ADMIN" />)
239
+ await screen.findByText('Security Status')
240
+
241
+ const textarea = screen.getByLabelText(/Allowed IP addresses/) as HTMLTextAreaElement
242
+ fireEvent.change(textarea, { target: { value: 'not-an-ip' } })
243
+ // Inline + summary validation blocks the save.
244
+ expect((await screen.findAllByText(/not-an-ip/)).length).toBeGreaterThan(0)
245
+ expect(
246
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
247
+ ).toBe(true)
248
+
249
+ fireEvent.change(textarea, { target: { value: '203.0.113.0/24\n1.2.3.4' } })
250
+ fireEvent.click(screen.getByRole('switch', { name: 'Audit logging' }))
251
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
252
+
253
+ await waitFor(() => expect(cmsApi).toHaveBeenCalledWith('/security', expect.anything()))
254
+ const putBody = JSON.parse(
255
+ String(cmsApi.mock.calls.find((c) => c[1]?.method === 'PUT')?.[1]?.body),
256
+ )
257
+ expect(putBody.access.ipAllowlist).toEqual(['203.0.113.0/24', '1.2.3.4'])
258
+ expect(putBody.access.auditLoggingEnabled).toBe(false)
259
+ })
260
+
261
+ it('surfaces the IP lockout guard as a confirm dialog and retries with confirmLockout', async () => {
262
+ let rejected = false
263
+ putResponseOverride = (body) => {
264
+ if (body.confirmLockout === true) {
265
+ putResponseOverride = null
266
+ securityData = {
267
+ ...securityData,
268
+ settings: {
269
+ ...securityData.settings,
270
+ session: body.session,
271
+ access: body.access,
272
+ },
273
+ }
274
+ return { data: securityData, status: 200 }
275
+ }
276
+ rejected = true
277
+ return {
278
+ error: 'Your current IP (1.2.3.4) is not in the allowlist — saving would lock you out.',
279
+ code: 'IP_LOCKOUT_RISK',
280
+ status: 409,
281
+ }
282
+ }
283
+
284
+ render(<SecurityTab role="ADMIN" />)
285
+ await screen.findByText('Security Status')
286
+
287
+ fireEvent.change(screen.getByLabelText(/Allowed IP addresses/), {
288
+ target: { value: '10.0.0.0/8' },
289
+ })
290
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
291
+
292
+ // The 409 turns into a confirm dialog, not a generic error.
293
+ const confirm = await screen.findByRole('button', { name: 'Save anyway' })
294
+ expect(rejected).toBe(true)
295
+ fireEvent.click(confirm)
296
+
297
+ await waitFor(() => {
298
+ const confirmed = cmsApi.mock.calls.filter(
299
+ (c) => c[1]?.method === 'PUT' && String(c[1]?.body).includes('"confirmLockout":true'),
300
+ )
301
+ expect(confirmed).toHaveLength(1)
302
+ })
303
+ })
304
+
200
305
  it('is read-only for non-admin roles', async () => {
201
306
  render(<SecurityTab role="EDITOR" />)
202
307
  await screen.findByText('Security Status')
@@ -208,5 +313,7 @@ describe('SecurityTab', () => {
208
313
  expect(toggle.disabled).toBe(true)
209
314
  const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
210
315
  expect(select.disabled).toBe(true)
316
+ const textarea = screen.getByLabelText(/Allowed IP addresses/) as HTMLTextAreaElement
317
+ expect(textarea.disabled).toBe(true)
211
318
  })
212
319
  })