@actuate-media/cms-admin 0.26.0 → 0.28.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 (105) hide show
  1. package/dist/__tests__/views/appearance-settings.render.test.d.ts +2 -0
  2. package/dist/__tests__/views/appearance-settings.render.test.d.ts.map +1 -0
  3. package/dist/__tests__/views/appearance-settings.render.test.js +95 -0
  4. package/dist/__tests__/views/appearance-settings.render.test.js.map +1 -0
  5. package/dist/__tests__/views/security-settings.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/security-settings.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/security-settings.render.test.js +175 -0
  8. package/dist/__tests__/views/security-settings.render.test.js.map +1 -0
  9. package/dist/actuate-admin.css +1 -1
  10. package/dist/components/ThemeProvider.d.ts +47 -4
  11. package/dist/components/ThemeProvider.d.ts.map +1 -1
  12. package/dist/components/ThemeProvider.js +173 -27
  13. package/dist/components/ThemeProvider.js.map +1 -1
  14. package/dist/components/ui/Badge.d.ts +1 -1
  15. package/dist/components/ui/Button.d.ts +1 -1
  16. package/dist/components/ui/Card.d.ts +2 -2
  17. package/dist/components/ui/Input.d.ts +1 -1
  18. package/dist/components/ui/Select.d.ts +1 -1
  19. package/dist/layout/Sidebar.d.ts.map +1 -1
  20. package/dist/layout/Sidebar.js +47 -8
  21. package/dist/layout/Sidebar.js.map +1 -1
  22. package/dist/views/Settings.d.ts.map +1 -1
  23. package/dist/views/Settings.js +5 -17
  24. package/dist/views/Settings.js.map +1 -1
  25. package/dist/views/settings/AccessProtectionCard.d.ts +10 -0
  26. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -0
  27. package/dist/views/settings/AccessProtectionCard.js +20 -0
  28. package/dist/views/settings/AccessProtectionCard.js.map +1 -0
  29. package/dist/views/settings/AdminThemeCard.d.ts +6 -0
  30. package/dist/views/settings/AdminThemeCard.d.ts.map +1 -0
  31. package/dist/views/settings/AdminThemeCard.js +31 -0
  32. package/dist/views/settings/AdminThemeCard.js.map +1 -0
  33. package/dist/views/settings/AppearanceTab.d.ts +7 -0
  34. package/dist/views/settings/AppearanceTab.d.ts.map +1 -0
  35. package/dist/views/settings/AppearanceTab.js +24 -0
  36. package/dist/views/settings/AppearanceTab.js.map +1 -0
  37. package/dist/views/settings/AuthMfaCard.d.ts +10 -0
  38. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -0
  39. package/dist/views/settings/AuthMfaCard.js +33 -0
  40. package/dist/views/settings/AuthMfaCard.js.map +1 -0
  41. package/dist/views/settings/BrandPreviewCard.d.ts +5 -0
  42. package/dist/views/settings/BrandPreviewCard.d.ts.map +1 -0
  43. package/dist/views/settings/BrandPreviewCard.js +18 -0
  44. package/dist/views/settings/BrandPreviewCard.js.map +1 -0
  45. package/dist/views/settings/BrandingCard.d.ts +6 -0
  46. package/dist/views/settings/BrandingCard.d.ts.map +1 -0
  47. package/dist/views/settings/BrandingCard.js +38 -0
  48. package/dist/views/settings/BrandingCard.js.map +1 -0
  49. package/dist/views/settings/SecretsAuditCard.d.ts +11 -0
  50. package/dist/views/settings/SecretsAuditCard.d.ts.map +1 -0
  51. package/dist/views/settings/SecretsAuditCard.js +22 -0
  52. package/dist/views/settings/SecretsAuditCard.js.map +1 -0
  53. package/dist/views/settings/SecurityStatusCard.d.ts +6 -0
  54. package/dist/views/settings/SecurityStatusCard.d.ts.map +1 -0
  55. package/dist/views/settings/SecurityStatusCard.js +35 -0
  56. package/dist/views/settings/SecurityStatusCard.js.map +1 -0
  57. package/dist/views/settings/SecurityTab.d.ts +5 -0
  58. package/dist/views/settings/SecurityTab.d.ts.map +1 -0
  59. package/dist/views/settings/SecurityTab.js +41 -0
  60. package/dist/views/settings/SecurityTab.js.map +1 -0
  61. package/dist/views/settings/SessionPolicyCard.d.ts +11 -0
  62. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -0
  63. package/dist/views/settings/SessionPolicyCard.js +21 -0
  64. package/dist/views/settings/SessionPolicyCard.js.map +1 -0
  65. package/dist/views/settings/TypographyMotionCard.d.ts +6 -0
  66. package/dist/views/settings/TypographyMotionCard.d.ts.map +1 -0
  67. package/dist/views/settings/TypographyMotionCard.js +8 -0
  68. package/dist/views/settings/TypographyMotionCard.js.map +1 -0
  69. package/dist/views/settings/securityPrimitives.d.ts +21 -0
  70. package/dist/views/settings/securityPrimitives.d.ts.map +1 -0
  71. package/dist/views/settings/securityPrimitives.js +31 -0
  72. package/dist/views/settings/securityPrimitives.js.map +1 -0
  73. package/dist/views/settings/useAppearanceSettings.d.ts +69 -0
  74. package/dist/views/settings/useAppearanceSettings.d.ts.map +1 -0
  75. package/dist/views/settings/useAppearanceSettings.js +344 -0
  76. package/dist/views/settings/useAppearanceSettings.js.map +1 -0
  77. package/dist/views/settings/useGeneralSettings.d.ts +4 -1
  78. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
  79. package/dist/views/settings/useGeneralSettings.js.map +1 -1
  80. package/dist/views/settings/useSecuritySettings.d.ts +37 -0
  81. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -0
  82. package/dist/views/settings/useSecuritySettings.js +154 -0
  83. package/dist/views/settings/useSecuritySettings.js.map +1 -0
  84. package/package.json +2 -2
  85. package/src/__tests__/views/appearance-settings.render.test.tsx +132 -0
  86. package/src/__tests__/views/security-settings.render.test.tsx +212 -0
  87. package/src/components/ThemeProvider.tsx +249 -36
  88. package/src/layout/Sidebar.tsx +81 -13
  89. package/src/styles/theme.css +32 -2
  90. package/src/views/Settings.tsx +5 -68
  91. package/src/views/settings/AccessProtectionCard.tsx +143 -0
  92. package/src/views/settings/AdminThemeCard.tsx +145 -0
  93. package/src/views/settings/AppearanceTab.tsx +81 -0
  94. package/src/views/settings/AuthMfaCard.tsx +118 -0
  95. package/src/views/settings/BrandPreviewCard.tsx +125 -0
  96. package/src/views/settings/BrandingCard.tsx +332 -0
  97. package/src/views/settings/SecretsAuditCard.tsx +129 -0
  98. package/src/views/settings/SecurityStatusCard.tsx +110 -0
  99. package/src/views/settings/SecurityTab.tsx +162 -0
  100. package/src/views/settings/SessionPolicyCard.tsx +123 -0
  101. package/src/views/settings/TypographyMotionCard.tsx +65 -0
  102. package/src/views/settings/securityPrimitives.tsx +75 -0
  103. package/src/views/settings/useAppearanceSettings.ts +513 -0
  104. package/src/views/settings/useGeneralSettings.ts +4 -1
  105. package/src/views/settings/useSecuritySettings.ts +197 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -87,7 +87,7 @@
87
87
  "tailwindcss": "^4.0.0",
88
88
  "typescript": "^5.7.0",
89
89
  "vitest": "^4.1.0",
90
- "@actuate-media/cms-core": "0.35.0",
90
+ "@actuate-media/cms-core": "0.37.0",
91
91
  "@actuate-media/component-blocks": "0.2.0"
92
92
  },
93
93
  "scripts": {
@@ -0,0 +1,132 @@
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
+ // The Appearance tab reads `/appearance` (settings + resolved brand), then on
6
+ // save read-modify-writes `/globals/settings`. Uploads POST `/media/upload`.
7
+ let appearanceData: any
8
+
9
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: unknown }) => {
10
+ const method = options?.method ?? 'GET'
11
+ if (endpoint === '/appearance' && method === 'GET') return { data: appearanceData, status: 200 }
12
+ if (endpoint === '/globals/settings' && method === 'GET')
13
+ return { data: { siteTitle: 'Actuate Media' }, status: 200 }
14
+ if (endpoint === '/globals/settings' && method === 'PUT') return { data: {}, status: 200 }
15
+ if (endpoint === '/media/upload' && method === 'POST')
16
+ return {
17
+ data: { id: 'new-logo', url: 'https://cdn.example.com/x.svg', mimeType: 'image/svg+xml' },
18
+ status: 200,
19
+ }
20
+ return { data: {}, status: 200 }
21
+ })
22
+
23
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
24
+
25
+ const { AppearanceTab } = await import('../../views/settings/AppearanceTab.js')
26
+
27
+ function baseData(overrides: Record<string, unknown> = {}) {
28
+ return {
29
+ appearance: {
30
+ adminColorModeDefault: 'system',
31
+ adminAccentColor: 'purple',
32
+ sidebarDensity: 'default',
33
+ contentFont: 'DM Sans',
34
+ reduceMotion: false,
35
+ },
36
+ brand: { mode: 'inheritPublicBrand' },
37
+ resolved: { admin: { initials: 'AM' } },
38
+ assets: [],
39
+ siteTitle: 'Actuate Media',
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ beforeEach(() => {
45
+ cmsApi.mockClear()
46
+ appearanceData = baseData()
47
+ })
48
+
49
+ describe('AppearanceTab', () => {
50
+ it('renders the branding/theme/typography cards with a clean (disabled) save bar', async () => {
51
+ render(<AppearanceTab role="ADMIN" />)
52
+
53
+ await screen.findByText('Branding')
54
+ expect(screen.getByText('Admin Theme')).toBeTruthy()
55
+ expect(screen.getByText('Typography & Motion')).toBeTruthy()
56
+ expect(screen.getByText('Brand Preview')).toBeTruthy()
57
+ expect(
58
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
59
+ ).toBe(true)
60
+ })
61
+
62
+ it('enables Save when the accent changes and persists appearance to the settings global', async () => {
63
+ render(<AppearanceTab role="ADMIN" />)
64
+ await screen.findByText('Branding')
65
+
66
+ fireEvent.click(screen.getByRole('radio', { name: 'Blue' }))
67
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
68
+ expect(save.disabled).toBe(false)
69
+
70
+ fireEvent.click(save)
71
+
72
+ await waitFor(() =>
73
+ expect(cmsApi).toHaveBeenCalledWith(
74
+ '/globals/settings',
75
+ expect.objectContaining({
76
+ method: 'PUT',
77
+ body: expect.stringContaining('"adminAccentColor":"blue"'),
78
+ }),
79
+ ),
80
+ )
81
+ })
82
+
83
+ it('warns when dark mode is the default but no dark-mode logo is set', async () => {
84
+ appearanceData = baseData({
85
+ appearance: {
86
+ adminColorModeDefault: 'dark',
87
+ adminAccentColor: 'purple',
88
+ sidebarDensity: 'default',
89
+ contentFont: 'DM Sans',
90
+ reduceMotion: false,
91
+ },
92
+ })
93
+ render(<AppearanceTab role="ADMIN" />)
94
+
95
+ expect(await screen.findByText(/no dark-mode logo is set/i)).toBeTruthy()
96
+ })
97
+
98
+ it('uploads a logo through the Media Center and enables Save', async () => {
99
+ render(<AppearanceTab role="ADMIN" />)
100
+ await screen.findByText('Branding')
101
+
102
+ const input = screen.getByLabelText('Primary Logo') as HTMLInputElement
103
+ const file = new File(
104
+ ['<svg viewBox="0 0 400 100" xmlns="http://www.w3.org/2000/svg"></svg>'],
105
+ 'logo.svg',
106
+ { type: 'image/svg+xml' },
107
+ )
108
+ fireEvent.change(input, { target: { files: [file] } })
109
+
110
+ await waitFor(() =>
111
+ expect(cmsApi).toHaveBeenCalledWith(
112
+ '/media/upload',
113
+ expect.objectContaining({ method: 'POST' }),
114
+ ),
115
+ )
116
+ await waitFor(() =>
117
+ expect(
118
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
119
+ ).toBe(false),
120
+ )
121
+ })
122
+
123
+ it('is read-only for non-admins', async () => {
124
+ render(<AppearanceTab role="EDITOR" />)
125
+
126
+ expect(await screen.findByText(/read-only access to appearance/i)).toBeTruthy()
127
+ // Upload control disabled.
128
+ expect(
129
+ (screen.getByRole('button', { name: /upload primary logo/i }) as HTMLButtonElement).disabled,
130
+ ).toBe(true)
131
+ })
132
+ })
@@ -0,0 +1,212 @@
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
+ // The Security tab reads `/security` (computed posture + summaries + editable
6
+ // policy), saves via PUT `/security`, and revokes sessions via POST.
7
+ let securityData: any
8
+
9
+ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string; body?: unknown }) => {
10
+ const method = options?.method ?? 'GET'
11
+ if (endpoint === '/security' && method === 'GET') return { data: securityData, status: 200 }
12
+ if (endpoint === '/security' && method === 'PUT') {
13
+ const body = JSON.parse(String(options?.body ?? '{}'))
14
+ securityData = {
15
+ ...securityData,
16
+ settings: {
17
+ mfa: { required: body.mfa.required, gracePeriodDays: body.mfa.gracePeriodDays },
18
+ session: { maxConcurrentSessions: body.session.maxConcurrentSessions },
19
+ },
20
+ }
21
+ return { data: securityData, status: 200 }
22
+ }
23
+ if (endpoint === '/security/sessions/revoke-all' && method === 'POST')
24
+ return { data: { revoked: 2 }, status: 200 }
25
+ return { data: {}, status: 200 }
26
+ })
27
+
28
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
29
+
30
+ const { SecurityTab } = await import('../../views/settings/SecurityTab.js')
31
+
32
+ function baseData(overrides: Record<string, unknown> = {}) {
33
+ return {
34
+ settings: {
35
+ mfa: { required: false, gracePeriodDays: 7 },
36
+ session: { maxConcurrentSessions: 5 },
37
+ },
38
+ status: {
39
+ status: 'needs_attention',
40
+ score: 88,
41
+ label: 'Needs attention',
42
+ description: 'Some recommended controls are missing.',
43
+ checks: [
44
+ { key: 'mfa', label: 'MFA coverage', status: 'warning', value: '1 of 2 admins' },
45
+ { key: 'csrf', label: 'CSRF protection', status: 'pass', value: 'Enabled' },
46
+ ],
47
+ warnings: [
48
+ {
49
+ id: 'mfa-not-required',
50
+ severity: 'warning',
51
+ category: 'auth',
52
+ title: 'MFA is not required in production',
53
+ description: 'Requiring MFA reduces account-takeover risk.',
54
+ },
55
+ ],
56
+ lastCheckedAt: '2026-06-01T00:00:00.000Z',
57
+ },
58
+ users: {
59
+ totalUsers: 3,
60
+ totalAdmins: 2,
61
+ withMfa: 1,
62
+ withoutMfa: 2,
63
+ adminsWithoutMfa: 1,
64
+ inactive: 0,
65
+ },
66
+ apiKeys: { active: 1, withoutExpiration: 0, broadScope: 0, staleUnused90d: 0 },
67
+ secrets: {
68
+ cmsSecret: 'pass',
69
+ encryptionKey: 'pass',
70
+ rateLimitBackend: 'distributed',
71
+ apiKeysHashed: true,
72
+ webhookSecretsEncrypted: true,
73
+ },
74
+ ipAllowlist: {
75
+ enabled: false,
76
+ source: 'none',
77
+ ranges: [],
78
+ currentIp: '1.2.3.4',
79
+ currentIpAllowed: null,
80
+ },
81
+ failedLogins24h: 0,
82
+ activeSessions: 2,
83
+ csrfEnabled: true,
84
+ uploadValidation: true,
85
+ auditEnabled: true,
86
+ sessionMaxAgeHours: 168,
87
+ viewerHasMfa: true,
88
+ environment: 'production',
89
+ sources: {
90
+ ipAllowlist: { source: 'config', editable: false },
91
+ csrf: { source: 'computed', editable: false },
92
+ rateLimiting: { source: 'environment', editable: false },
93
+ uploadValidation: { source: 'computed', editable: false },
94
+ cmsSecret: { source: 'environment', editable: false },
95
+ encryptionKey: { source: 'environment', editable: false },
96
+ audit: { source: 'computed', editable: false },
97
+ },
98
+ ...overrides,
99
+ }
100
+ }
101
+
102
+ beforeEach(() => {
103
+ cmsApi.mockClear()
104
+ securityData = baseData()
105
+ })
106
+
107
+ describe('SecurityTab', () => {
108
+ it('renders the status card and all security sections with a disabled save bar', async () => {
109
+ render(<SecurityTab role="ADMIN" />)
110
+
111
+ await screen.findByText('Security Status')
112
+ expect(screen.getByText('Authentication & MFA')).toBeTruthy()
113
+ expect(screen.getByText('Session Policy')).toBeTruthy()
114
+ expect(screen.getByText('Access & Request Protection')).toBeTruthy()
115
+ expect(screen.getByText('Secrets, Encryption & Audit')).toBeTruthy()
116
+ // Status badge + warning surfaced.
117
+ expect(screen.getByText('Needs attention')).toBeTruthy()
118
+ expect(screen.getByText('MFA is not required in production')).toBeTruthy()
119
+ expect(
120
+ (screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement).disabled,
121
+ ).toBe(true)
122
+ })
123
+
124
+ it('enables Save when concurrent-session cap changes and persists via PUT /security', async () => {
125
+ render(<SecurityTab role="ADMIN" />)
126
+ await screen.findByText('Security Status')
127
+
128
+ const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
129
+ fireEvent.change(select, { target: { value: '3' } })
130
+
131
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
132
+ expect(save.disabled).toBe(false)
133
+ fireEvent.click(save)
134
+
135
+ await waitFor(() =>
136
+ expect(cmsApi).toHaveBeenCalledWith(
137
+ '/security',
138
+ expect.objectContaining({
139
+ method: 'PUT',
140
+ body: expect.stringContaining('"maxConcurrentSessions":3'),
141
+ }),
142
+ ),
143
+ )
144
+ })
145
+
146
+ it('blocks enabling Require MFA (lockout-safe) when the acting admin has no MFA', async () => {
147
+ securityData = baseData({ viewerHasMfa: false })
148
+ render(<SecurityTab role="ADMIN" />)
149
+ await screen.findByText('Security Status')
150
+
151
+ const toggle = screen.getByRole('switch', {
152
+ name: 'Require MFA for admin users',
153
+ }) as HTMLButtonElement
154
+ // Toggle is disabled until the admin sets up their own MFA.
155
+ expect(toggle.disabled).toBe(true)
156
+ expect(screen.getByText(/Set up MFA on your own account/i)).toBeTruthy()
157
+ })
158
+
159
+ it('confirms before requiring MFA and then persists it (admin has MFA)', async () => {
160
+ render(<SecurityTab role="ADMIN" />)
161
+ await screen.findByText('Security Status')
162
+
163
+ fireEvent.click(screen.getByRole('switch', { name: 'Require MFA for admin users' }))
164
+ // Confirmation dialog appears; nothing saved yet.
165
+ const confirm = await screen.findByRole('button', { name: 'Require MFA' })
166
+ fireEvent.click(confirm)
167
+
168
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
169
+ expect(save.disabled).toBe(false)
170
+ fireEvent.click(save)
171
+
172
+ await waitFor(() =>
173
+ expect(cmsApi).toHaveBeenCalledWith(
174
+ '/security',
175
+ expect.objectContaining({
176
+ method: 'PUT',
177
+ body: expect.stringContaining('"required":true'),
178
+ }),
179
+ ),
180
+ )
181
+ })
182
+
183
+ it('revokes other sessions through a confirmation flow', async () => {
184
+ render(<SecurityTab role="ADMIN" />)
185
+ await screen.findByText('Security Status')
186
+
187
+ fireEvent.click(screen.getByRole('button', { name: 'Revoke other sessions' }))
188
+ const confirm = await screen.findByRole('button', { name: 'Revoke sessions' })
189
+ fireEvent.click(confirm)
190
+
191
+ await waitFor(() =>
192
+ expect(cmsApi).toHaveBeenCalledWith(
193
+ '/security/sessions/revoke-all',
194
+ expect.objectContaining({ method: 'POST' }),
195
+ ),
196
+ )
197
+ await screen.findByText('Other sessions revoked.')
198
+ })
199
+
200
+ it('is read-only for non-admin roles', async () => {
201
+ render(<SecurityTab role="EDITOR" />)
202
+ await screen.findByText('Security Status')
203
+
204
+ expect(screen.getByText(/read-only access/i)).toBeTruthy()
205
+ const toggle = screen.getByRole('switch', {
206
+ name: 'Require MFA for admin users',
207
+ }) as HTMLButtonElement
208
+ expect(toggle.disabled).toBe(true)
209
+ const select = screen.getByLabelText(/Maximum concurrent sessions/) as HTMLSelectElement
210
+ expect(select.disabled).toBe(true)
211
+ })
212
+ })