@actuate-media/cms-admin 0.67.0 → 0.69.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 (180) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +59 -15
  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 +119 -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__/router/query-string.test.d.ts +2 -0
  14. package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
  15. package/dist/__tests__/router/query-string.test.js +44 -0
  16. package/dist/__tests__/router/query-string.test.js.map +1 -0
  17. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  18. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  19. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  20. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  21. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  22. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  24. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  25. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  26. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  28. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  29. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  30. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  31. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  32. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  33. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  34. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  35. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  36. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  37. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  38. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  39. package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
  40. package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
  41. package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
  42. package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
  43. package/dist/actuate-admin.css +1 -1
  44. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  45. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  46. package/dist/components/collections/EntryDetailPane.js +92 -0
  47. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  48. package/dist/components/forms/FieldsPanel.js +1 -1
  49. package/dist/components/forms/FieldsPanel.js.map +1 -1
  50. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  51. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  52. package/dist/components/forms/FormSchemaPane.js +95 -0
  53. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  54. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  55. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  56. package/dist/components/seo/SeoEditorPane.js +175 -0
  57. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  58. package/dist/components/ui/Badge.d.ts +1 -1
  59. package/dist/components/ui/Button.d.ts +1 -1
  60. package/dist/components/ui/StatusBadge.d.ts +1 -1
  61. package/dist/hooks/useIdleLogout.d.ts +33 -0
  62. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  63. package/dist/hooks/useIdleLogout.js +99 -0
  64. package/dist/hooks/useIdleLogout.js.map +1 -0
  65. package/dist/lib/api.d.ts +1 -0
  66. package/dist/lib/api.d.ts.map +1 -1
  67. package/dist/lib/api.js +7 -1
  68. package/dist/lib/api.js.map +1 -1
  69. package/dist/lib/post-types-service.d.ts +44 -0
  70. package/dist/lib/post-types-service.d.ts.map +1 -0
  71. package/dist/lib/post-types-service.js +83 -0
  72. package/dist/lib/post-types-service.js.map +1 -0
  73. package/dist/router/index.d.ts +7 -0
  74. package/dist/router/index.d.ts.map +1 -1
  75. package/dist/router/index.js +23 -4
  76. package/dist/router/index.js.map +1 -1
  77. package/dist/views/CollectionList.d.ts.map +1 -1
  78. package/dist/views/CollectionList.js +23 -4
  79. package/dist/views/CollectionList.js.map +1 -1
  80. package/dist/views/Forms.d.ts.map +1 -1
  81. package/dist/views/Forms.js +25 -21
  82. package/dist/views/Forms.js.map +1 -1
  83. package/dist/views/MediaBrowser.d.ts +4 -1
  84. package/dist/views/MediaBrowser.d.ts.map +1 -1
  85. package/dist/views/MediaBrowser.js +27 -5
  86. package/dist/views/MediaBrowser.js.map +1 -1
  87. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  88. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  89. package/dist/views/Posts/PostTypeEditor.js +159 -0
  90. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  91. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  92. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  93. package/dist/views/Posts/PostTypesView.js +1 -1
  94. package/dist/views/Posts/PostTypesView.js.map +1 -1
  95. package/dist/views/SEO.d.ts.map +1 -1
  96. package/dist/views/SEO.js +2 -1
  97. package/dist/views/SEO.js.map +1 -1
  98. package/dist/views/Settings.d.ts +4 -1
  99. package/dist/views/Settings.d.ts.map +1 -1
  100. package/dist/views/Settings.js +4 -3
  101. package/dist/views/Settings.js.map +1 -1
  102. package/dist/views/Users.d.ts +4 -1
  103. package/dist/views/Users.d.ts.map +1 -1
  104. package/dist/views/Users.js +22 -2
  105. package/dist/views/Users.js.map +1 -1
  106. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  107. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  108. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  109. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  110. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  111. package/dist/views/page-builder/PageBuilder.js +51 -2
  112. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  113. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  114. package/dist/views/page-builder/PageTemplates.js +113 -6
  115. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  116. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  117. package/dist/views/seo/ContentTab.js +22 -6
  118. package/dist/views/seo/ContentTab.js.map +1 -1
  119. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  120. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  121. package/dist/views/settings/AccessProtectionCard.js +13 -10
  122. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  123. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  124. package/dist/views/settings/SecurityTab.js +5 -4
  125. package/dist/views/settings/SecurityTab.js.map +1 -1
  126. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  127. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  128. package/dist/views/settings/SessionPolicyCard.js +12 -5
  129. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  130. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  131. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  132. package/dist/views/settings/useSecuritySettings.js +47 -4
  133. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  134. package/package.json +2 -2
  135. package/src/AdminRoot.tsx +88 -18
  136. package/src/__tests__/hooks/useIdleLogout.test.tsx +188 -0
  137. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  138. package/src/__tests__/router/query-string.test.ts +54 -0
  139. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  140. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  141. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  142. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  143. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  144. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  145. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  146. package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
  147. package/src/components/collections/EntryDetailPane.tsx +182 -0
  148. package/src/components/forms/FieldsPanel.tsx +3 -1
  149. package/src/components/forms/FormSchemaPane.tsx +192 -0
  150. package/src/components/seo/SeoEditorPane.tsx +533 -0
  151. package/src/hooks/useIdleLogout.ts +129 -0
  152. package/src/lib/api.ts +8 -2
  153. package/src/lib/post-types-service.ts +129 -0
  154. package/src/router/index.ts +25 -4
  155. package/src/views/CollectionList.tsx +158 -110
  156. package/src/views/Forms.tsx +154 -126
  157. package/src/views/MediaBrowser.tsx +28 -5
  158. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  159. package/src/views/Posts/PostTypesView.tsx +5 -4
  160. package/src/views/SEO.tsx +2 -1
  161. package/src/views/Settings.tsx +7 -2
  162. package/src/views/Users.tsx +23 -2
  163. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  164. package/src/views/page-builder/PageBuilder.tsx +69 -2
  165. package/src/views/page-builder/PageTemplates.tsx +340 -57
  166. package/src/views/seo/ContentTab.tsx +113 -80
  167. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  168. package/src/views/settings/SecurityTab.tsx +25 -3
  169. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  170. package/src/views/settings/useSecuritySettings.ts +78 -22
  171. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  172. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  173. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  174. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  175. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  176. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  177. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  178. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  179. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  180. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
@@ -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
  })
@@ -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
+ /**
6
+ * Content SEO tab with the inline editor pane (design split-pane pattern —
7
+ * previously the `SeoEditorDrawer` overlay). Pins: row click toggles the
8
+ * 380px pane, the pane loads the record through `fetchSeoContentRecord`, and
9
+ * Save flows through the same `updateSeoFields` service call.
10
+ */
11
+
12
+ function makeRecord(over: Record<string, unknown> = {}) {
13
+ return {
14
+ id: 'seo1',
15
+ entityType: 'page' as const,
16
+ entityId: 'p1',
17
+ title: 'Home',
18
+ url: 'https://example.com/',
19
+ canonicalUrl: null,
20
+ metaTitle: 'Home — Example',
21
+ metaDescription: 'Welcome to Example.',
22
+ focusKeyword: null,
23
+ keyTakeaway: null,
24
+ ogTitle: null,
25
+ ogDescription: null,
26
+ ogImageId: null,
27
+ ogImage: null,
28
+ twitterTitle: null,
29
+ twitterDescription: null,
30
+ twitterImageId: null,
31
+ noindex: false,
32
+ nofollow: false,
33
+ structuredDataType: 'WebPage',
34
+ structuredData: null,
35
+ llmsInclude: 'AUTO' as const,
36
+ llmsDescription: null,
37
+ llmsSection: null,
38
+ llmsOptional: false,
39
+ seoScore: 82,
40
+ seoGrade: 'good' as const,
41
+ updatedAt: '2026-06-01T00:00:00Z',
42
+ ...over,
43
+ }
44
+ }
45
+
46
+ const RECORDS = [
47
+ makeRecord(),
48
+ makeRecord({ id: 'seo2', entityType: 'post', entityId: 'p2', title: 'First Post' }),
49
+ ]
50
+
51
+ const fetchSeoContentRecords = vi.fn(async () => RECORDS)
52
+ const fetchSeoContentRecord = vi.fn(async (entityType: string, entityId: string) => {
53
+ return RECORDS.find((r) => r.entityType === entityType && r.entityId === entityId) ?? null
54
+ })
55
+ const updateSeoFields = vi.fn(async (_t: string, _i: string, _p: unknown) => ({}))
56
+
57
+ vi.mock('../../lib/seo-service.js', () => ({
58
+ fetchSeoContentRecords: () => fetchSeoContentRecords(),
59
+ fetchSeoContentRecord: (t: string, i: string) => fetchSeoContentRecord(t, i),
60
+ updateSeoFields: (t: string, i: string, p: unknown) => updateSeoFields(t, i, p),
61
+ generateSeoField: vi.fn(async () => ({ unavailable: true })),
62
+ SEO_SCHEMA_TYPES: ['Article', 'WebPage'],
63
+ applyContentSeoQuery: (
64
+ records: Array<{ entityType: string; title: string }>,
65
+ query: { search?: string; filter?: string },
66
+ ) =>
67
+ records.filter((r) => {
68
+ if (query.filter === 'pages' && r.entityType !== 'page') return false
69
+ if (query.filter === 'posts' && r.entityType !== 'post') return false
70
+ if (query.search && !r.title.toLowerCase().includes(query.search.toLowerCase())) return false
71
+ return true
72
+ }),
73
+ }))
74
+
75
+ vi.mock('../../lib/brand-voice-client.js', () => ({
76
+ fixBrandVoiceContent: vi.fn(async () => ({ ok: false })),
77
+ brandAlignmentTone: () => 'text-success',
78
+ brandAlignmentLabel: () => 'On brand',
79
+ }))
80
+
81
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
82
+
83
+ const { ContentTab } = await import('../../views/seo/ContentTab.js')
84
+
85
+ beforeEach(() => {
86
+ fetchSeoContentRecords.mockClear()
87
+ fetchSeoContentRecord.mockClear()
88
+ updateSeoFields.mockClear()
89
+ })
90
+
91
+ describe('Content SEO inline editor pane', () => {
92
+ it('opens the pane on row click and loads the record', async () => {
93
+ render(<ContentTab />)
94
+ const rowButton = await screen.findByRole('button', { name: /Home/ })
95
+ expect(rowButton.getAttribute('aria-expanded')).toBe('false')
96
+
97
+ fireEvent.click(rowButton)
98
+
99
+ expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
100
+ expect(rowButton.getAttribute('aria-expanded')).toBe('true')
101
+ await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('page', 'p1'))
102
+ // Loaded field values populate the form.
103
+ expect(await screen.findByDisplayValue('Home — Example')).toBeTruthy()
104
+ })
105
+
106
+ it('toggles the pane closed when the same row is clicked again', async () => {
107
+ render(<ContentTab />)
108
+ const rowButton = await screen.findByRole('button', { name: /Home/ })
109
+
110
+ fireEvent.click(rowButton)
111
+ expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
112
+
113
+ fireEvent.click(rowButton)
114
+ expect(screen.queryByRole('region', { name: 'SEO editor' })).toBeNull()
115
+ })
116
+
117
+ it('saves edited fields through updateSeoFields', async () => {
118
+ render(<ContentTab />)
119
+ fireEvent.click(await screen.findByRole('button', { name: /Home/ }))
120
+
121
+ const titleInput = await screen.findByDisplayValue('Home — Example')
122
+ fireEvent.change(titleInput, { target: { value: 'New Meta Title' } })
123
+ fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
124
+
125
+ await waitFor(() =>
126
+ expect(updateSeoFields).toHaveBeenCalledWith(
127
+ 'page',
128
+ 'p1',
129
+ expect.objectContaining({ metaTitle: 'New Meta Title' }),
130
+ ),
131
+ )
132
+ // The list refetches after a successful save.
133
+ await waitFor(() => expect(fetchSeoContentRecords.mock.calls.length).toBeGreaterThan(1))
134
+ })
135
+
136
+ it('opens the pane on mount for deep links', async () => {
137
+ render(<ContentTab initialEntityType="post" initialEntityId="p2" />)
138
+
139
+ await waitFor(() => expect(fetchSeoContentRecord).toHaveBeenCalledWith('post', 'p2'))
140
+ expect(screen.getByRole('region', { name: 'SEO editor' })).toBeTruthy()
141
+ })
142
+ })
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ /**
6
+ * Render tests for the `?selected=<userId>` deep link written by the command
7
+ * palette (`/users?selected=<id>`): once the member list loads, the matching
8
+ * member's detail drawer opens. Unknown ids are tolerated — no drawer, no crash.
9
+ */
10
+
11
+ const MEMBER = {
12
+ id: 'u1',
13
+ name: 'Priya Nair',
14
+ email: 'priya@actuatemedia.com',
15
+ initials: 'PN',
16
+ role: 'EDITOR',
17
+ roleName: 'Editor' as const,
18
+ status: 'active' as const,
19
+ mfaEnabled: true,
20
+ externalDomain: false,
21
+ lastLoginAt: null,
22
+ lastActiveAt: null,
23
+ acceptedInviteAt: new Date('2026-01-02').toISOString(),
24
+ createdAt: new Date('2026-01-01').toISOString(),
25
+ isCurrentUser: false,
26
+ }
27
+
28
+ const DETAIL = {
29
+ ...MEMBER,
30
+ avatarUrl: null,
31
+ mfaRequired: false,
32
+ passwordSet: true,
33
+ forcePasswordReset: false,
34
+ failedLoginCount: 0,
35
+ lockedAt: null,
36
+ suspendedAt: null,
37
+ authProvider: null,
38
+ oauthProviders: [],
39
+ ssoOnly: false,
40
+ emailVerified: true,
41
+ invitedBy: null,
42
+ contentOwnedCount: 4,
43
+ }
44
+
45
+ const STATS = {
46
+ members: 1,
47
+ pendingInvites: 0,
48
+ admins: 0,
49
+ seatsUsed: 1,
50
+ seatLimit: null,
51
+ seatsAvailable: null,
52
+ emailConfigured: true,
53
+ }
54
+
55
+ const { cmsApi } = vi.hoisted(() => ({ cmsApi: vi.fn() }))
56
+ vi.mock('../../lib/api.js', () => ({ cmsApi }))
57
+
58
+ cmsApi.mockImplementation(async (endpoint?: string) => {
59
+ if (!endpoint) return { data: null, status: 200 }
60
+ if (endpoint === '/invites') return { data: [], status: 200 }
61
+ if (endpoint === '/users/stats') return { data: STATS, status: 200 }
62
+ if (endpoint === '/access-review') return { data: [], status: 200 }
63
+ if (endpoint.startsWith('/users/u1')) return { data: DETAIL, status: 200 }
64
+ if (endpoint === '/users' || endpoint.startsWith('/users?'))
65
+ return { data: [MEMBER], status: 200 }
66
+ return { data: null, status: 200 }
67
+ })
68
+
69
+ const { Users } = await import('../../views/Users.js')
70
+
71
+ describe('Users ?selected= deep link', () => {
72
+ beforeEach(() => cmsApi.mockClear())
73
+
74
+ it('opens the detail drawer for the selected member once the list loads', async () => {
75
+ render(<Users currentPath="/users?selected=u1" />)
76
+
77
+ // "Content owned" only renders inside the detail drawer's overview tab.
78
+ expect(await screen.findByText('Content owned')).toBeTruthy()
79
+ expect(cmsApi.mock.calls.some(([endpoint]) => String(endpoint).startsWith('/users/u1'))).toBe(
80
+ true,
81
+ )
82
+ })
83
+
84
+ it('ignores a selected id that matches no member', async () => {
85
+ render(<Users currentPath="/users?selected=does-not-exist" />)
86
+
87
+ // List renders normally…
88
+ expect(await screen.findByText('priya@actuatemedia.com')).toBeTruthy()
89
+ // …but no drawer opens and no detail fetch is issued.
90
+ expect(screen.queryByText('Content owned')).toBeNull()
91
+ expect(
92
+ cmsApi.mock.calls.some(([endpoint]) => String(endpoint).includes('/users/does-not-exist')),
93
+ ).toBe(false)
94
+ })
95
+
96
+ it('renders without a drawer when there is no query at all', async () => {
97
+ render(<Users currentPath="/users" />)
98
+
99
+ expect(await screen.findByText('priya@actuatemedia.com')).toBeTruthy()
100
+ expect(screen.queryByText('Content owned')).toBeNull()
101
+ })
102
+ })
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Entry detail pane — lightweight read-only inspector shown beside the
5
+ * collection documents table (design's Entries split-pane, 400px). Surfaces
6
+ * the entry's key fields, status, and timestamps; the primary action opens
7
+ * the existing full editor route. Not an editor — editing stays in the
8
+ * dedicated editor views.
9
+ */
10
+ import { ExternalLink } from 'lucide-react'
11
+ import {
12
+ InspectorPaneBody,
13
+ InspectorPaneFooter,
14
+ InspectorPaneHeader,
15
+ InspectorPaneSection,
16
+ } from '../ui/InspectorPane.js'
17
+ import { StatusBadge, type StatusBadgeTone } from '../ui/StatusBadge.js'
18
+
19
+ interface FieldDef {
20
+ type?: string
21
+ label?: string
22
+ hidden?: boolean
23
+ options?: Array<{ label: string; value: string }>
24
+ }
25
+
26
+ const SKIPPED_FIELD_TYPES = new Set(['richText', 'blocks', 'json', 'media', 'relationship'])
27
+ const MAX_FIELDS = 8
28
+
29
+ function statusTone(status: string | undefined): StatusBadgeTone {
30
+ switch (status?.toUpperCase()) {
31
+ case 'PUBLISHED':
32
+ return 'success'
33
+ case 'SCHEDULED':
34
+ return 'warning'
35
+ case 'ARCHIVED':
36
+ return 'muted'
37
+ default:
38
+ return 'neutral'
39
+ }
40
+ }
41
+
42
+ function formatDateTime(value: unknown): string {
43
+ if (typeof value !== 'string' || !value) return '—'
44
+ const date = new Date(value)
45
+ if (Number.isNaN(date.getTime())) return '—'
46
+ return date.toLocaleDateString(undefined, {
47
+ month: 'short',
48
+ day: 'numeric',
49
+ year: 'numeric',
50
+ hour: 'numeric',
51
+ minute: '2-digit',
52
+ })
53
+ }
54
+
55
+ /** Human-readable display value for a scalar-ish field, or null to skip. */
56
+ function displayValue(raw: unknown, field: FieldDef): string | null {
57
+ if (raw == null || raw === '') return ''
58
+ if (typeof raw === 'boolean') return raw ? 'Yes' : 'No'
59
+ if (typeof raw === 'number') return String(raw)
60
+ if (typeof raw === 'string') {
61
+ const opt = field.options?.find((o) => o.value === raw)
62
+ return opt?.label ?? raw
63
+ }
64
+ if (Array.isArray(raw)) {
65
+ const tags = raw
66
+ .map((item) =>
67
+ typeof item === 'string'
68
+ ? item
69
+ : item && typeof item === 'object'
70
+ ? Object.values(item).find((v): v is string => typeof v === 'string')
71
+ : undefined,
72
+ )
73
+ .filter((v): v is string => Boolean(v))
74
+ if (tags.length > 0) return tags.join(', ')
75
+ return `${raw.length} item${raw.length === 1 ? '' : 's'}`
76
+ }
77
+ return null
78
+ }
79
+
80
+ /** Key fields worth showing in the pane: labelled scalars from the config. */
81
+ function keyFields(
82
+ fields: Record<string, FieldDef> | undefined,
83
+ data: Record<string, unknown>,
84
+ ): Array<{ name: string; label: string; value: string }> {
85
+ if (!fields || typeof fields !== 'object') return []
86
+ const out: Array<{ name: string; label: string; value: string }> = []
87
+ for (const [name, field] of Object.entries(fields)) {
88
+ if (!field || field.hidden) continue
89
+ if (field.type && SKIPPED_FIELD_TYPES.has(field.type)) continue
90
+ const value = displayValue(data[name], field)
91
+ if (value === null) continue
92
+ out.push({ name, label: field.label ?? name, value })
93
+ if (out.length >= MAX_FIELDS) break
94
+ }
95
+ return out
96
+ }
97
+
98
+ export interface EntryDetailPaneProps {
99
+ doc: Record<string, any>
100
+ /** Collection definition from the CMS config (untyped upstream). */
101
+ collection: any
102
+ singularLabel: string
103
+ onClose: () => void
104
+ onOpenEditor: () => void
105
+ }
106
+
107
+ export function EntryDetailPane({
108
+ doc,
109
+ collection,
110
+ singularLabel,
111
+ onClose,
112
+ onOpenEditor,
113
+ }: EntryDetailPaneProps) {
114
+ const title = doc.title || doc.name || `#${doc.id}`
115
+ const data = (doc.data ?? {}) as Record<string, unknown>
116
+ const fields = keyFields(collection?.fields as Record<string, FieldDef> | undefined, data)
117
+
118
+ return (
119
+ <>
120
+ <InspectorPaneHeader onClose={onClose} closeLabel={`Close ${singularLabel} details`}>
121
+ <div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">{title}</div>
122
+ <div className="mt-1 flex items-center gap-2">
123
+ <StatusBadge tone={statusTone(doc.status)}>{doc.status ?? 'DRAFT'}</StatusBadge>
124
+ <span className="text-[11px] text-[var(--muted)]">{singularLabel}</span>
125
+ </div>
126
+ </InspectorPaneHeader>
127
+
128
+ <InspectorPaneBody>
129
+ <InspectorPaneSection title="Fields">
130
+ {fields.length === 0 ? (
131
+ <p className="text-[12px] text-[var(--muted)]">No previewable fields.</p>
132
+ ) : (
133
+ <dl>
134
+ {fields.map((f) => (
135
+ <div key={f.name} className="mb-2.5 last:mb-0">
136
+ <dt className="text-[10.5px] font-semibold tracking-[0.5px] text-[var(--muted)] uppercase">
137
+ {f.label}
138
+ </dt>
139
+ {f.value ? (
140
+ <dd className="mt-0.5 text-[12.5px] break-words text-[var(--txt)]">
141
+ {f.value}
142
+ </dd>
143
+ ) : (
144
+ <dd className="mt-0.5 text-[12.5px] text-[var(--muted)] italic">
145
+ Not provided
146
+ </dd>
147
+ )}
148
+ </div>
149
+ ))}
150
+ </dl>
151
+ )}
152
+ </InspectorPaneSection>
153
+
154
+ <InspectorPaneSection title="Record">
155
+ <div className="flex items-center justify-between gap-3 py-1 text-[12px]">
156
+ <span className="text-[var(--muted)]">Created</span>
157
+ <span className="text-[var(--sub)]">{formatDateTime(doc.createdAt)}</span>
158
+ </div>
159
+ <div className="flex items-center justify-between gap-3 py-1 text-[12px]">
160
+ <span className="text-[var(--muted)]">Updated</span>
161
+ <span className="text-[var(--sub)]">{formatDateTime(doc.updatedAt)}</span>
162
+ </div>
163
+ <div className="flex items-center justify-between gap-3 py-1 text-[12px]">
164
+ <span className="text-[var(--muted)]">ID</span>
165
+ <span className="font-mono text-[11px] text-[var(--muted)]">{String(doc.id)}</span>
166
+ </div>
167
+ </InspectorPaneSection>
168
+ </InspectorPaneBody>
169
+
170
+ <InspectorPaneFooter>
171
+ <button
172
+ type="button"
173
+ onClick={onOpenEditor}
174
+ className="flex w-full items-center justify-center gap-1.5 rounded-[7px] bg-[var(--acc)] px-3 py-2 text-[13px] font-semibold text-white transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
175
+ >
176
+ <ExternalLink size={14} aria-hidden />
177
+ Open full editor
178
+ </button>
179
+ </InspectorPaneFooter>
180
+ </>
181
+ )
182
+ }
@@ -268,7 +268,9 @@ function FieldEditor({
268
268
 
269
269
  return (
270
270
  <div className="border-border space-y-3 border-t px-3 py-3">
271
- <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
271
+ {/* Stacked (not sm:grid-cols-2): this editor now lives in the 390px
272
+ schema inspector pane, where viewport-based two-column grids cramp. */}
273
+ <div className="grid grid-cols-1 gap-3">
272
274
  <Labeled label="Label">
273
275
  <input
274
276
  type="text"