@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.
- package/CHANGELOG.md +28 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +59 -15
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +119 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/router/query-string.test.d.ts +2 -0
- package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
- package/dist/__tests__/router/query-string.test.js +44 -0
- package/dist/__tests__/router/query-string.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +33 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +99 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +23 -4
- package/dist/router/index.js.map +1 -1
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts +4 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +27 -5
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +2 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +22 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +5 -4
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +88 -18
- package/src/__tests__/hooks/useIdleLogout.test.tsx +188 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/router/query-string.test.ts +54 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +129 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/router/index.ts +25 -4
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/MediaBrowser.tsx +28 -5
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/SEO.tsx +2 -1
- package/src/views/Settings.tsx +7 -2
- package/src/views/Users.tsx +23 -2
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +25 -3
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- 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:
|
|
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('
|
|
114
|
-
expect(screen.getByText('Access
|
|
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
|
-
|
|
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"
|