@actuate-media/cms-admin 0.20.0 → 0.21.1

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 (89) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +28 -4
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
  5. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  6. package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts +2 -0
  7. package/dist/__tests__/layout/sidebar-submenu.render.test.d.ts.map +1 -0
  8. package/dist/__tests__/layout/sidebar-submenu.render.test.js +60 -0
  9. package/dist/__tests__/layout/sidebar-submenu.render.test.js.map +1 -0
  10. package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
  11. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  12. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  13. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  14. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  15. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  17. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  20. package/dist/components/Breadcrumbs.js +4 -1
  21. package/dist/components/Breadcrumbs.js.map +1 -1
  22. package/dist/components/ErrorBoundary.d.ts +1 -1
  23. package/dist/components/seo/Drawer.d.ts.map +1 -1
  24. package/dist/components/seo/Drawer.js +5 -1
  25. package/dist/components/seo/Drawer.js.map +1 -1
  26. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  27. package/dist/components/seo/SeoEditorDrawer.js +11 -2
  28. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  29. package/dist/components/seo/primitives.d.ts.map +1 -1
  30. package/dist/components/seo/primitives.js +2 -1
  31. package/dist/components/seo/primitives.js.map +1 -1
  32. package/dist/layout/Sidebar.d.ts.map +1 -1
  33. package/dist/layout/Sidebar.js +105 -12
  34. package/dist/layout/Sidebar.js.map +1 -1
  35. package/dist/lib/seo-service.d.ts.map +1 -1
  36. package/dist/lib/seo-service.js +40 -4
  37. package/dist/lib/seo-service.js.map +1 -1
  38. package/dist/views/ApiKeys.d.ts +4 -1
  39. package/dist/views/ApiKeys.d.ts.map +1 -1
  40. package/dist/views/ApiKeys.js +5 -3
  41. package/dist/views/ApiKeys.js.map +1 -1
  42. package/dist/views/Dashboard.js +2 -2
  43. package/dist/views/Dashboard.js.map +1 -1
  44. package/dist/views/Forms.d.ts.map +1 -1
  45. package/dist/views/Forms.js +2 -1
  46. package/dist/views/Forms.js.map +1 -1
  47. package/dist/views/Settings.d.ts +4 -1
  48. package/dist/views/Settings.d.ts.map +1 -1
  49. package/dist/views/Settings.js +45 -6
  50. package/dist/views/Settings.js.map +1 -1
  51. package/dist/views/Users.d.ts +4 -1
  52. package/dist/views/Users.d.ts.map +1 -1
  53. package/dist/views/Users.js +5 -3
  54. package/dist/views/Users.js.map +1 -1
  55. package/dist/views/seo/AuditTab.js +2 -2
  56. package/dist/views/seo/AuditTab.js.map +1 -1
  57. package/dist/views/seo/ContentTab.js +1 -1
  58. package/dist/views/seo/ContentTab.js.map +1 -1
  59. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  60. package/dist/views/seo/RedirectsTab.js +8 -2
  61. package/dist/views/seo/RedirectsTab.js.map +1 -1
  62. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  63. package/dist/views/seo/TechnicalTab.js +7 -4
  64. package/dist/views/seo/TechnicalTab.js.map +1 -1
  65. package/dist/views/seo/useSeoResource.d.ts.map +1 -1
  66. package/dist/views/seo/useSeoResource.js +20 -7
  67. package/dist/views/seo/useSeoResource.js.map +1 -1
  68. package/package.json +2 -2
  69. package/src/AdminRoot.tsx +26 -4
  70. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  71. package/src/__tests__/layout/sidebar-submenu.render.test.tsx +86 -0
  72. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  73. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  74. package/src/components/Breadcrumbs.tsx +4 -1
  75. package/src/components/seo/Drawer.tsx +4 -0
  76. package/src/components/seo/SeoEditorDrawer.tsx +15 -3
  77. package/src/components/seo/primitives.tsx +4 -1
  78. package/src/layout/Sidebar.tsx +228 -19
  79. package/src/lib/seo-service.ts +41 -4
  80. package/src/views/ApiKeys.tsx +10 -5
  81. package/src/views/Dashboard.tsx +2 -2
  82. package/src/views/Forms.tsx +3 -1
  83. package/src/views/Settings.tsx +93 -40
  84. package/src/views/Users.tsx +10 -4
  85. package/src/views/seo/AuditTab.tsx +6 -6
  86. package/src/views/seo/ContentTab.tsx +3 -3
  87. package/src/views/seo/RedirectsTab.tsx +8 -2
  88. package/src/views/seo/TechnicalTab.tsx +25 -13
  89. package/src/views/seo/useSeoResource.ts +21 -7
package/src/AdminRoot.tsx CHANGED
@@ -13,8 +13,6 @@ import { Settings } from './views/Settings.js'
13
13
  import { Forms } from './views/Forms.js'
14
14
  import { FormEditor } from './views/FormEditor.js'
15
15
  import { FormSubmissions } from './views/FormSubmissions.js'
16
- import { Users } from './views/Users.js'
17
- import { ApiKeys } from './views/ApiKeys.js'
18
16
  import { SEO } from './views/SEO.js'
19
17
  import { ScriptTags } from './views/ScriptTags.js'
20
18
  import { ScriptTagEditor } from './views/ScriptTagEditor.js'
@@ -419,12 +417,14 @@ function AdminShell({
419
417
  return <Widgets onNavigate={navigate} />
420
418
  }
421
419
 
420
+ // Users + API Keys moved into the Settings view as tabs. The legacy
421
+ // top-level routes still resolve — they just land on the matching tab.
422
422
  if (matchRoute('/users')) {
423
- return <Users onNavigate={navigate} />
423
+ return <Settings onNavigate={navigate} config={config} initialTab="users" />
424
424
  }
425
425
 
426
426
  if (matchRoute('/api-keys')) {
427
- return <ApiKeys onNavigate={navigate} />
427
+ return <Settings onNavigate={navigate} config={config} initialTab="api-keys" />
428
428
  }
429
429
 
430
430
  if (matchRoute('/settings')) {
@@ -702,6 +702,28 @@ const ISOLATION_STYLE: React.CSSProperties = {
702
702
  export function AdminRoot(props: AdminRootProps) {
703
703
  const defaultDarkMode = props.config?.admin?.branding?.darkMode
704
704
 
705
+ // The admin is private application UI and must never be indexed. Ensure a
706
+ // `noindex, nofollow` robots meta tag is present while the admin is mounted,
707
+ // as defense-in-depth alongside the server `robots.txt` Disallow rule. This
708
+ // covers every consumer install (the admin page is typically a client
709
+ // component that can't export Next.js route metadata).
710
+ useEffect(() => {
711
+ if (typeof document === 'undefined') return
712
+ const PREV = document.querySelector<HTMLMetaElement>('meta[name="robots"]')
713
+ const prevContent = PREV?.getAttribute('content') ?? null
714
+ const meta = PREV ?? document.createElement('meta')
715
+ meta.setAttribute('name', 'robots')
716
+ meta.setAttribute('content', 'noindex, nofollow')
717
+ if (!PREV) document.head.appendChild(meta)
718
+ return () => {
719
+ if (prevContent === null) {
720
+ meta.remove()
721
+ } else {
722
+ meta.setAttribute('content', prevContent)
723
+ }
724
+ }
725
+ }, [])
726
+
705
727
  return (
706
728
  <div style={ISOLATION_STYLE} className="actuate-admin">
707
729
  <ThemeProvider defaultDarkMode={defaultDarkMode}>
@@ -156,9 +156,9 @@ describe('buildNavItems', () => {
156
156
  // Lands after the static Content destinations, before Settings.
157
157
  const menusIdx = items.findIndex((i) => i.path === '/navigations')
158
158
  const scriptIdx = items.findIndex((i) => i.path === '/script-tags')
159
- const usersIdx = items.findIndex((i) => i.path === '/users')
159
+ const settingsIdx = items.findIndex((i) => i.path === '/settings')
160
160
  expect(menusIdx).toBeGreaterThan(scriptIdx)
161
- expect(menusIdx).toBeLessThan(usersIdx)
161
+ expect(menusIdx).toBeLessThan(settingsIdx)
162
162
  })
163
163
 
164
164
  it('keeps standard Content/Settings entries after collections', () => {
@@ -172,8 +172,10 @@ describe('buildNavItems', () => {
172
172
  expect(paths).toContain('/forms')
173
173
  expect(paths).toContain('/seo')
174
174
  expect(paths).toContain('/script-tags')
175
- expect(paths).toContain('/users')
176
- expect(paths).toContain('/api-keys')
177
175
  expect(paths).toContain('/settings')
176
+ // Users + API Keys moved into Settings tabs — they are no longer
177
+ // standalone sidebar destinations.
178
+ expect(paths).not.toContain('/users')
179
+ expect(paths).not.toContain('/api-keys')
178
180
  })
179
181
  })
@@ -0,0 +1,86 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ import { Sidebar } from '../../layout/Sidebar.js'
6
+
7
+ /**
8
+ * Submenu rendering behaviour:
9
+ * - Expanded: parent children (e.g. Posts -> All Posts / Post Types) render
10
+ * inline beneath the parent.
11
+ * - Collapsed: children are hidden inline and instead revealed in a
12
+ * hover/focus flyout, so the 80px rail stays clean but sub-pages remain
13
+ * reachable.
14
+ */
15
+ function renderSidebar(collapsed: boolean) {
16
+ return render(
17
+ <Sidebar
18
+ collapsed={collapsed}
19
+ onToggleCollapse={() => {}}
20
+ currentPath="/"
21
+ onNavigate={() => {}}
22
+ />,
23
+ )
24
+ }
25
+
26
+ describe('Sidebar submenus', () => {
27
+ it('renders children inline when expanded', () => {
28
+ renderSidebar(false)
29
+ expect(screen.getByText('All Posts')).toBeTruthy()
30
+ expect(screen.getByText('Post Types')).toBeTruthy()
31
+ expect(screen.getByText('Saved Sections')).toBeTruthy()
32
+ expect(screen.getByText('Widgets')).toBeTruthy()
33
+ })
34
+
35
+ it('collapses and re-expands a parent submenu via the chevron toggle', () => {
36
+ renderSidebar(false)
37
+
38
+ // Children visible by default.
39
+ expect(screen.getByText('All Posts')).toBeTruthy()
40
+
41
+ // Collapse the Posts section.
42
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse Posts' }))
43
+ expect(screen.queryByText('All Posts')).toBeNull()
44
+
45
+ // Re-expand it.
46
+ fireEvent.click(screen.getByRole('button', { name: 'Expand Posts' }))
47
+ expect(screen.getByText('All Posts')).toBeTruthy()
48
+ })
49
+
50
+ it('keeps the active section expanded even when toggled closed', () => {
51
+ render(
52
+ <Sidebar
53
+ collapsed={false}
54
+ onToggleCollapse={() => {}}
55
+ currentPath="/posts"
56
+ onNavigate={() => {}}
57
+ />,
58
+ )
59
+
60
+ // Active section forces the submenu open; the chevron reports it as
61
+ // expanded and offers to collapse.
62
+ expect(screen.getByText('All Posts')).toBeTruthy()
63
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse Posts' }))
64
+ // Still visible because the section is the active route.
65
+ expect(screen.getByText('All Posts')).toBeTruthy()
66
+ })
67
+
68
+ it('hides children inline when collapsed and exposes them in a hover flyout', async () => {
69
+ renderSidebar(true)
70
+
71
+ // Inline children are not rendered in the collapsed rail.
72
+ expect(screen.queryByText('All Posts')).toBeNull()
73
+ expect(screen.queryByText('Post Types')).toBeNull()
74
+
75
+ // The parent is still reachable as an icon button labelled for AT.
76
+ const trigger = screen.getByRole('button', { name: 'Posts' })
77
+
78
+ // Hovering the parent opens the flyout (onMouseEnter lives on the wrapper).
79
+ fireEvent.mouseEnter(trigger.parentElement as HTMLElement)
80
+
81
+ await waitFor(() => {
82
+ expect(screen.getByText('All Posts')).toBeTruthy()
83
+ expect(screen.getByText('Post Types')).toBeTruthy()
84
+ })
85
+ })
86
+ })
@@ -0,0 +1,60 @@
1
+ // @vitest-environment node
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const cmsApi = vi.fn()
5
+ vi.mock('../../lib/api.js', () => ({
6
+ cmsApi: (...args: unknown[]) => cmsApi(...args),
7
+ }))
8
+
9
+ const {
10
+ fetchSeoOverview,
11
+ fetchSeoContentRecords,
12
+ fetchSeoIssues,
13
+ fetchRedirectRules,
14
+ updateSeoFields,
15
+ } = await import('../../lib/seo-service.js')
16
+
17
+ afterEach(() => {
18
+ cmsApi.mockReset()
19
+ })
20
+
21
+ /**
22
+ * Regression tests for the "swallowed read errors" blocker. `cmsApi` never
23
+ * throws — it returns `{ error }` on failure — so the SEO read fetchers must
24
+ * re-throw, otherwise `useSeoResource` can't distinguish "request failed" from
25
+ * "empty result" and renders a misleading fallback (e.g. a fake score of 0 /
26
+ * grade "Critical") with the error/retry UI permanently unreachable.
27
+ */
28
+ describe('SEO service read fetchers surface errors', () => {
29
+ it('fetchSeoOverview throws when the request fails (instead of returning a fake 0 score)', async () => {
30
+ cmsApi.mockResolvedValue({ error: 'Request failed (500)', status: 500 })
31
+ await expect(fetchSeoOverview()).rejects.toThrow('Request failed (500)')
32
+ })
33
+
34
+ it('fetchSeoContentRecords throws on error', async () => {
35
+ cmsApi.mockResolvedValue({ error: 'Network error', status: 0 })
36
+ await expect(fetchSeoContentRecords()).rejects.toThrow('Network error')
37
+ })
38
+
39
+ it('fetchSeoIssues throws on error', async () => {
40
+ cmsApi.mockResolvedValue({ error: 'Request failed (503)', status: 503 })
41
+ await expect(fetchSeoIssues()).rejects.toThrow('Request failed (503)')
42
+ })
43
+
44
+ it('fetchRedirectRules throws on error', async () => {
45
+ cmsApi.mockResolvedValue({ error: 'boom', status: 500 })
46
+ await expect(fetchRedirectRules()).rejects.toThrow('boom')
47
+ })
48
+
49
+ it('returns data normally on success', async () => {
50
+ cmsApi.mockResolvedValue({ data: { records: [{ id: 'r1' }] }, status: 200 })
51
+ await expect(fetchSeoContentRecords()).resolves.toEqual([{ id: 'r1' }])
52
+ })
53
+
54
+ it('mutations still return { error } rather than throwing', async () => {
55
+ cmsApi.mockResolvedValue({ error: 'forbidden', status: 403 })
56
+ await expect(updateSeoFields('page', 'doc-1', { metaTitle: 'x' })).resolves.toEqual({
57
+ error: 'forbidden',
58
+ })
59
+ })
60
+ })
@@ -0,0 +1,68 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { act, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ import { useSeoResource } from '../../views/seo/useSeoResource.js'
6
+
7
+ /**
8
+ * Regression test for the `useSeoResource` stale-response race: when the
9
+ * dependency (and therefore the fetcher) changes rapidly, a slow earlier
10
+ * request that resolves AFTER a faster later one must not clobber the newer
11
+ * result. We drive this with two deferred promises whose resolution order is
12
+ * inverted relative to the request order.
13
+ */
14
+
15
+ function Probe({ dep, fetcher }: { dep: number; fetcher: () => Promise<string> }) {
16
+ const { data, loading, error } = useSeoResource<string>(fetcher, [dep])
17
+ return (
18
+ <div>
19
+ <span data-testid="state">{loading ? 'loading' : (error ?? data ?? 'empty')}</span>
20
+ </div>
21
+ )
22
+ }
23
+
24
+ function deferred<T>() {
25
+ let resolve!: (value: T) => void
26
+ let reject!: (reason?: unknown) => void
27
+ const promise = new Promise<T>((res, rej) => {
28
+ resolve = res
29
+ reject = rej
30
+ })
31
+ return { promise, resolve, reject }
32
+ }
33
+
34
+ describe('useSeoResource', () => {
35
+ it('ignores a stale (earlier) response that resolves after the latest one', async () => {
36
+ const first = deferred<string>()
37
+ const second = deferred<string>()
38
+
39
+ // First render uses the "first" fetcher (slow), then we switch deps so the
40
+ // hook re-runs with the "second" fetcher (fast).
41
+ const firstFetcher = () => first.promise
42
+ const secondFetcher = () => second.promise
43
+
44
+ const { rerender } = render(<Probe dep={1} fetcher={firstFetcher} />)
45
+ rerender(<Probe dep={2} fetcher={secondFetcher} />)
46
+
47
+ // The newer request resolves FIRST.
48
+ await act(async () => {
49
+ second.resolve('NEW')
50
+ })
51
+ await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('NEW'))
52
+
53
+ // The stale earlier request resolves LATER — it must be ignored.
54
+ await act(async () => {
55
+ first.resolve('OLD')
56
+ })
57
+ expect(screen.getByTestId('state').textContent).toBe('NEW')
58
+ })
59
+
60
+ it('surfaces fetcher errors', async () => {
61
+ const d = deferred<string>()
62
+ render(<Probe dep={1} fetcher={() => d.promise} />)
63
+ await act(async () => {
64
+ d.reject(new Error('boom'))
65
+ })
66
+ await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('boom'))
67
+ })
68
+ })
@@ -36,7 +36,10 @@ export interface BreadcrumbsProps {
36
36
  }
37
37
 
38
38
  export function Breadcrumbs({ currentPath, onNavigate }: BreadcrumbsProps) {
39
- const segments = currentPath.split('/').filter(Boolean)
39
+ // Strip query string + hash so tab-driven routes (e.g. `/settings?tab=users`,
40
+ // `/seo?tab=audit`) don't leak the raw query into a crumb label.
41
+ const cleanPath = currentPath.split('?')[0]!.split('#')[0]!
42
+ const segments = cleanPath.split('/').filter(Boolean)
40
43
 
41
44
  if (segments.length === 0) return null
42
45
 
@@ -31,6 +31,10 @@ export function Drawer({
31
31
  <Dialog.Portal>
32
32
  <Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
33
33
  <Dialog.Content
34
+ // Radix auto-associates `aria-describedby` with the Dialog.Description
35
+ // when one is present; when it isn't, explicitly clear the attribute so
36
+ // Radix doesn't emit its "Missing Description" console warning.
37
+ {...(description ? {} : { 'aria-describedby': undefined })}
34
38
  className={`bg-card text-foreground fixed inset-y-0 right-0 z-50 flex w-full ${width} motion-safe:animate-in motion-safe:slide-in-from-right flex-col shadow-xl outline-none`}
35
39
  >
36
40
  <header className="border-border flex items-start justify-between gap-3 border-b px-5 py-4">
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { cloneElement, isValidElement, useEffect, useId, useState, type ReactElement } from 'react'
4
4
  import { Sparkles, Check } from 'lucide-react'
5
5
  import { toast } from 'sonner'
6
6
  import {
@@ -36,13 +36,25 @@ function Field({
36
36
  children: React.ReactNode
37
37
  hint?: React.ReactNode
38
38
  }) {
39
+ // Associate the visible label with its control for screen readers. We inject
40
+ // a generated id onto the single child input/textarea (unless it already has
41
+ // one) and point the <label htmlFor> at it.
42
+ const generatedId = useId()
43
+ let control = children
44
+ if (isValidElement(children) && (children.props as { id?: string }).id == null) {
45
+ control = cloneElement(children as ReactElement<{ id?: string }>, { id: generatedId })
46
+ }
47
+ const controlId = isValidElement(control) ? (control.props as { id?: string }).id : undefined
48
+
39
49
  return (
40
50
  <div className="space-y-1.5">
41
51
  <div className="flex items-center justify-between">
42
- <label className="text-foreground text-sm font-medium">{label}</label>
52
+ <label htmlFor={controlId} className="text-foreground text-sm font-medium">
53
+ {label}
54
+ </label>
43
55
  {hint}
44
56
  </div>
45
- {children}
57
+ {control}
46
58
  </div>
47
59
  )
48
60
  }
@@ -112,13 +112,16 @@ export function SeoStatusBadge({
112
112
  export function SeoScoreBar({ score, className = '' }: { score: number; className?: string }) {
113
113
  const grade = gradeFromScore(score)
114
114
  const fill = grade === 'good' ? 'bg-success' : grade === 'fair' ? 'bg-warning' : 'bg-destructive'
115
+ const clamped = Math.max(0, Math.min(100, Math.round(score)))
115
116
  return (
116
117
  <div
117
118
  className={`bg-muted h-2 w-full overflow-hidden rounded-full ${className}`}
118
119
  role="progressbar"
119
- aria-valuenow={score}
120
+ aria-label="SEO score"
121
+ aria-valuenow={clamped}
120
122
  aria-valuemin={0}
121
123
  aria-valuemax={100}
124
+ aria-valuetext={`${clamped} out of 100`}
122
125
  >
123
126
  <div
124
127
  className={`h-full rounded-full transition-[width] duration-300 motion-reduce:transition-none ${fill}`}