@actuate-media/cms-admin 0.28.0 → 0.29.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 (50) hide show
  1. package/dist/AdminRoot.d.ts +7 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +7 -2
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/account-security.render.test.js +98 -0
  8. package/dist/__tests__/views/account-security.render.test.js.map +1 -0
  9. package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/login-mfa.render.test.js +73 -0
  12. package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
  13. package/dist/actuate-admin.css +1 -1
  14. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  15. package/dist/components/Breadcrumbs.js +1 -0
  16. package/dist/components/Breadcrumbs.js.map +1 -1
  17. package/dist/views/Login.d.ts +16 -1
  18. package/dist/views/Login.d.ts.map +1 -1
  19. package/dist/views/Login.js +55 -3
  20. package/dist/views/Login.js.map +1 -1
  21. package/dist/views/Profile.d.ts +12 -0
  22. package/dist/views/Profile.d.ts.map +1 -0
  23. package/dist/views/Profile.js +16 -0
  24. package/dist/views/Profile.js.map +1 -0
  25. package/dist/views/profile/AccountSecurity.d.ts +2 -0
  26. package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
  27. package/dist/views/profile/AccountSecurity.js +117 -0
  28. package/dist/views/profile/AccountSecurity.js.map +1 -0
  29. package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
  30. package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
  31. package/dist/views/profile/useMfaEnrollment.js +103 -0
  32. package/dist/views/profile/useMfaEnrollment.js.map +1 -0
  33. package/dist/views/settings/AuthMfaCard.d.ts +2 -1
  34. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -1
  35. package/dist/views/settings/AuthMfaCard.js +3 -2
  36. package/dist/views/settings/AuthMfaCard.js.map +1 -1
  37. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  38. package/dist/views/settings/SecurityTab.js +1 -1
  39. package/dist/views/settings/SecurityTab.js.map +1 -1
  40. package/package.json +3 -2
  41. package/src/AdminRoot.tsx +18 -0
  42. package/src/__tests__/views/account-security.render.test.tsx +140 -0
  43. package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
  44. package/src/components/Breadcrumbs.tsx +1 -0
  45. package/src/views/Login.tsx +247 -94
  46. package/src/views/Profile.tsx +54 -0
  47. package/src/views/profile/AccountSecurity.tsx +406 -0
  48. package/src/views/profile/useMfaEnrollment.ts +120 -0
  49. package/src/views/settings/AuthMfaCard.tsx +19 -4
  50. package/src/views/settings/SecurityTab.tsx +1 -0
@@ -36,6 +36,6 @@ export function SecurityTab({ role, onNavigate, }) {
36
36
  ? 'Ask an administrator to review the Security tab.'
37
37
  : (loadError ?? 'The security service could not be reached. Try again shortly.') })] })] }));
38
38
  }
39
- return (_jsxs("div", { className: "space-y-4 pb-4", children: [_jsx(SecurityStatusCard, { status: data.status, onNavigate: onNavigate }), issues.length > 0 && _jsx(SettingsValidationSummary, { issues: issues }), !canEdit && (_jsx("p", { className: "text-muted-foreground text-sm", children: "You have read-only access. Only administrators can change security settings." })), _jsx(AuthMfaCard, { form: form, setField: setField, canEdit: canEdit, viewerHasMfa: data.viewerHasMfa, withMfa: data.users.withMfa, totalAdmins: data.users.totalAdmins }), _jsx("div", { id: "security-sessions", children: _jsx(SessionPolicyCard, { form: form, setField: setField, canEdit: canEdit, sessionMaxAgeHours: data.sessionMaxAgeHours, activeSessions: data.activeSessions, revokeState: revokeState, onRevokeAll: () => void revokeAllOtherSessions() }) }), _jsx("div", { id: "security-ip-allowlist", children: _jsx(AccessProtectionCard, { ipAllowlist: data.ipAllowlist, secrets: data.secrets, csrfEnabled: data.csrfEnabled, uploadValidation: data.uploadValidation, sources: data.sources, onNavigate: onNavigate }) }), _jsx("div", { id: "security-audit", children: _jsx(SecretsAuditCard, { secrets: data.secrets, auditEnabled: data.auditEnabled, failedLogins24h: data.failedLogins24h, users: data.users, apiKeys: data.apiKeys, sources: data.sources, onNavigate: onNavigate }) }), saveState === 'error' && (_jsx("p", { className: "text-destructive text-sm", role: "alert", children: "Failed to save security settings. Please try again." })), _jsx(SettingsSaveBar, { dirty: dirty, canEdit: canEdit, hasErrors: hasErrors, saveState: saveState, onSave: () => void save(), onReset: reset })] }));
39
+ return (_jsxs("div", { className: "space-y-4 pb-4", children: [_jsx(SecurityStatusCard, { status: data.status, onNavigate: onNavigate }), issues.length > 0 && _jsx(SettingsValidationSummary, { issues: issues }), !canEdit && (_jsx("p", { className: "text-muted-foreground text-sm", children: "You have read-only access. Only administrators can change security settings." })), _jsx(AuthMfaCard, { form: form, setField: setField, canEdit: canEdit, viewerHasMfa: data.viewerHasMfa, withMfa: data.users.withMfa, totalAdmins: data.users.totalAdmins, onNavigate: onNavigate }), _jsx("div", { id: "security-sessions", children: _jsx(SessionPolicyCard, { form: form, setField: setField, canEdit: canEdit, sessionMaxAgeHours: data.sessionMaxAgeHours, activeSessions: data.activeSessions, revokeState: revokeState, onRevokeAll: () => void revokeAllOtherSessions() }) }), _jsx("div", { id: "security-ip-allowlist", children: _jsx(AccessProtectionCard, { ipAllowlist: data.ipAllowlist, secrets: data.secrets, csrfEnabled: data.csrfEnabled, uploadValidation: data.uploadValidation, sources: data.sources, onNavigate: onNavigate }) }), _jsx("div", { id: "security-audit", children: _jsx(SecretsAuditCard, { secrets: data.secrets, auditEnabled: data.auditEnabled, failedLogins24h: data.failedLogins24h, users: data.users, apiKeys: data.apiKeys, sources: data.sources, onNavigate: onNavigate }) }), saveState === 'error' && (_jsx("p", { className: "text-destructive text-sm", role: "alert", children: "Failed to save security settings. Please try again." })), _jsx(SettingsSaveBar, { dirty: dirty, canEdit: canEdit, hasErrors: hasErrors, saveState: saveState, onSave: () => void save(), onReset: reset })] }));
40
40
  }
41
41
  //# sourceMappingURL=SecurityTab.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"SecurityTab.js","sourceRoot":"","sources":["../../../src/views/settings/SecurityTab.tsx"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"SecurityTab.js","sourceRoot":"","sources":["../../../src/views/settings/SecurityTab.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/actuate-media/actuatecms.git",
@@ -58,6 +58,7 @@
58
58
  "cmdk": "^1.1.1",
59
59
  "lowlight": "^3.0.0",
60
60
  "lucide-react": "^1.7.0",
61
+ "qrcode.react": "^4.2.0",
61
62
  "react": "^19.2.0",
62
63
  "react-dom": "^19.2.0",
63
64
  "recharts": "^3.8.1",
@@ -87,7 +88,7 @@
87
88
  "tailwindcss": "^4.0.0",
88
89
  "typescript": "^5.7.0",
89
90
  "vitest": "^4.1.0",
90
- "@actuate-media/cms-core": "0.37.0",
91
+ "@actuate-media/cms-core": "0.38.0",
91
92
  "@actuate-media/component-blocks": "0.2.0"
92
93
  },
93
94
  "scripts": {
package/src/AdminRoot.tsx CHANGED
@@ -10,6 +10,7 @@ import { CollectionList } from './views/CollectionList.js'
10
10
  import { DocumentEdit } from './views/DocumentEdit.js'
11
11
  import { MediaBrowser } from './views/MediaBrowser.js'
12
12
  import { Settings } from './views/Settings.js'
13
+ import { Profile } from './views/Profile.js'
13
14
  import { Forms } from './views/Forms.js'
14
15
  import { FormEditor } from './views/FormEditor.js'
15
16
  import { FormSubmissions } from './views/FormSubmissions.js'
@@ -56,6 +57,16 @@ export interface AdminRootProps {
56
57
  email: string,
57
58
  password: string,
58
59
  captchaToken?: string,
60
+ ) => Promise<{
61
+ success: boolean
62
+ error?: string
63
+ requiresTOTP?: boolean
64
+ mfaPendingToken?: string
65
+ }>
66
+ /** Completes the TOTP second-factor step for accounts with MFA enabled. */
67
+ onVerifyTotp?: (
68
+ mfaPendingToken: string,
69
+ code: string,
59
70
  ) => Promise<{ success: boolean; error?: string }>
60
71
  captchaConfig?: { provider: 'recaptcha' | 'turnstile' | 'none'; siteKey: string | null }
61
72
  }
@@ -68,6 +79,7 @@ function AdminShell({
68
79
  setupRequired,
69
80
  onSetupComplete,
70
81
  onLogin,
82
+ onVerifyTotp,
71
83
  captchaConfig,
72
84
  }: AdminRootProps) {
73
85
  const { currentPath, navigate, matchRoute, buildHref } = useAdminRouter(basePath, initialPath)
@@ -197,6 +209,7 @@ function AdminShell({
197
209
  return (
198
210
  <Login
199
211
  onLogin={onLogin}
212
+ onVerifyTotp={onVerifyTotp}
200
213
  onNavigate={navigate}
201
214
  captchaConfig={captchaConfig}
202
215
  branding={branding}
@@ -437,6 +450,10 @@ function AdminShell({
437
450
  return <Settings onNavigate={navigate} config={config} session={session} />
438
451
  }
439
452
 
453
+ if (matchRoute('/profile')) {
454
+ return <Profile onNavigate={navigate} session={session} />
455
+ }
456
+
440
457
  // Page Builder routes — explicit /page-builder/* takes precedence over
441
458
  // the collection-derived routing below, but we ALSO route a collection
442
459
  // declared as `type: 'page'` through the Page Builder by default.
@@ -467,6 +484,7 @@ function AdminShell({
467
484
  'widgets',
468
485
  'users',
469
486
  'settings',
487
+ 'profile',
470
488
  'page-builder',
471
489
  'collections',
472
490
  // `pages` is reserved so the Pages list/editor/tag routes above
@@ -0,0 +1,140 @@
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
+ * Exercises the per-user MFA enrollment loop in AccountSecurity against a
7
+ * mocked auth API:
8
+ * GET /auth/me → current totpEnabled state
9
+ * POST /auth/totp/setup → provisioning URI + one-time backup codes
10
+ * POST /auth/totp/verify → flips MFA on
11
+ * POST /auth/totp/disable → turns MFA off
12
+ */
13
+ interface ApiCall {
14
+ endpoint: string
15
+ options?: { method?: string; headers?: Record<string, string>; body?: unknown }
16
+ }
17
+
18
+ let totpEnabled = false
19
+ const calls: ApiCall[] = []
20
+
21
+ const cmsApi = vi.fn(async (endpoint: string, options?: ApiCall['options']) => {
22
+ calls.push({ endpoint, options })
23
+ const method = options?.method ?? 'GET'
24
+ if (endpoint === '/auth/me') return { data: { totpEnabled }, status: 200 }
25
+ if (endpoint === '/auth/totp/setup' && method === 'POST') {
26
+ const pw = options?.headers?.['x-reauth-password']
27
+ if (!pw || pw === 'wrong') return { error: 'Re-authentication failed.', status: 401 }
28
+ return {
29
+ data: {
30
+ uri: 'otpauth://totp/Actuate%20CMS:admin@example.com?secret=ABCDEF',
31
+ secret: 'ABCDEF234567',
32
+ backupCodes: ['aaaa1111', 'bbbb2222', 'cccc3333', 'dddd4444'],
33
+ },
34
+ status: 200,
35
+ }
36
+ }
37
+ if (endpoint === '/auth/totp/verify' && method === 'POST') {
38
+ totpEnabled = true
39
+ return { data: { enabled: true }, status: 200 }
40
+ }
41
+ if (endpoint === '/auth/totp/disable' && method === 'POST') {
42
+ totpEnabled = false
43
+ return { data: { enabled: false }, status: 200 }
44
+ }
45
+ return { data: {}, status: 200 }
46
+ })
47
+
48
+ vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
49
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
50
+ vi.mock('qrcode.react', () => ({
51
+ QRCodeSVG: ({ value }: { value: string }) => <svg data-testid="qr" data-value={value} />,
52
+ }))
53
+
54
+ const { AccountSecurity } = await import('../../views/profile/AccountSecurity.js')
55
+
56
+ beforeEach(() => {
57
+ cmsApi.mockClear()
58
+ calls.length = 0
59
+ totpEnabled = false
60
+ })
61
+
62
+ describe('AccountSecurity — enrollment', () => {
63
+ it('walks through reauth → QR + backup codes → verify and enables MFA', async () => {
64
+ render(<AccountSecurity />)
65
+ await screen.findByText('Not set up')
66
+
67
+ fireEvent.click(screen.getByRole('button', { name: /Set up two-factor/i }))
68
+
69
+ // Reauth step: enter current password.
70
+ const pw = await screen.findByLabelText('Current password')
71
+ fireEvent.change(pw, { target: { value: 'correct-horse' } })
72
+ fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
73
+
74
+ // Setup posts the password as a reauth header.
75
+ await waitFor(() =>
76
+ expect(
77
+ calls.some(
78
+ (c) =>
79
+ c.endpoint === '/auth/totp/setup' &&
80
+ c.options?.headers?.['x-reauth-password'] === 'correct-horse',
81
+ ),
82
+ ).toBe(true),
83
+ )
84
+
85
+ // Enrollment step shows the QR, the manual key and the backup codes.
86
+ await screen.findByTestId('qr')
87
+ expect(screen.getByText('ABCDEF234567')).toBeTruthy()
88
+ expect(screen.getByText('aaaa1111')).toBeTruthy()
89
+
90
+ // Verify is gated on acknowledging the backup codes + a 6-digit code.
91
+ const verify = screen.getByRole('button', { name: /Verify & enable/i }) as HTMLButtonElement
92
+ expect(verify.disabled).toBe(true)
93
+
94
+ fireEvent.click(screen.getByLabelText(/saved my backup codes/i))
95
+ fireEvent.change(screen.getByLabelText('Authentication code'), { target: { value: '123456' } })
96
+ expect(verify.disabled).toBe(false)
97
+ fireEvent.click(verify)
98
+
99
+ await waitFor(() =>
100
+ expect(
101
+ calls.some(
102
+ (c) =>
103
+ c.endpoint === '/auth/totp/verify' && String(c.options?.body ?? '').includes('123456'),
104
+ ),
105
+ ).toBe(true),
106
+ )
107
+ await screen.findByText('Enabled')
108
+ })
109
+
110
+ it('surfaces a reauth failure without advancing to the QR step', async () => {
111
+ render(<AccountSecurity />)
112
+ await screen.findByText('Not set up')
113
+
114
+ fireEvent.click(screen.getByRole('button', { name: /Set up two-factor/i }))
115
+ const pw = await screen.findByLabelText('Current password')
116
+ fireEvent.change(pw, { target: { value: 'wrong' } })
117
+ fireEvent.click(screen.getByRole('button', { name: 'Continue' }))
118
+
119
+ await screen.findByText('Re-authentication failed.')
120
+ expect(screen.queryByTestId('qr')).toBeNull()
121
+ })
122
+ })
123
+
124
+ describe('AccountSecurity — disable', () => {
125
+ it('shows Enabled and disables with a password confirmation', async () => {
126
+ totpEnabled = true
127
+ render(<AccountSecurity />)
128
+ await screen.findByText('Enabled')
129
+
130
+ fireEvent.click(screen.getByRole('button', { name: /Disable two-factor/i }))
131
+ const pw = await screen.findByLabelText('Current password')
132
+ fireEvent.change(pw, { target: { value: 'correct-horse' } })
133
+ // The confirm button is the destructive one inside the panel.
134
+ const confirmButtons = screen.getAllByRole('button', { name: /Disable two-factor/i })
135
+ fireEvent.click(confirmButtons[confirmButtons.length - 1]!)
136
+
137
+ await waitFor(() => expect(calls.some((c) => c.endpoint === '/auth/totp/disable')).toBe(true))
138
+ await screen.findByText('Not set up')
139
+ })
140
+ })
@@ -0,0 +1,88 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+
5
+ import { Login } from '../../views/Login.js'
6
+
7
+ /**
8
+ * The login screen must advance to a second-factor challenge when the account
9
+ * has MFA enabled (`onLogin` returns `requiresTOTP` + `mfaPendingToken`), and
10
+ * must complete it via `onVerifyTotp` — including the backup-code fallback.
11
+ */
12
+ function fillCredentials() {
13
+ fireEvent.change(screen.getByLabelText('Email Address'), {
14
+ target: { value: 'admin@example.com' },
15
+ })
16
+ fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'pw' } })
17
+ }
18
+
19
+ describe('Login — MFA challenge', () => {
20
+ it('shows the TOTP step after the password step requires it', async () => {
21
+ const onLogin = vi.fn(async () => ({
22
+ success: false,
23
+ requiresTOTP: true,
24
+ mfaPendingToken: 'pending-token',
25
+ }))
26
+ const onVerifyTotp = vi.fn(async () => ({ success: true }))
27
+
28
+ render(<Login onLogin={onLogin} onVerifyTotp={onVerifyTotp} />)
29
+ fillCredentials()
30
+ fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
31
+
32
+ await screen.findByText('Two-factor authentication')
33
+
34
+ fireEvent.change(screen.getByLabelText('Authentication code'), { target: { value: '654321' } })
35
+ fireEvent.click(screen.getByRole('button', { name: 'Verify' }))
36
+
37
+ await waitFor(() => expect(onVerifyTotp).toHaveBeenCalledWith('pending-token', '654321'))
38
+ })
39
+
40
+ it('supports the backup-code fallback', async () => {
41
+ const onLogin = vi.fn(async () => ({
42
+ success: false,
43
+ requiresTOTP: true,
44
+ mfaPendingToken: 'pending-token',
45
+ }))
46
+ const onVerifyTotp = vi.fn(async () => ({ success: true }))
47
+
48
+ render(<Login onLogin={onLogin} onVerifyTotp={onVerifyTotp} />)
49
+ fillCredentials()
50
+ fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
51
+ await screen.findByText('Two-factor authentication')
52
+
53
+ fireEvent.click(screen.getByRole('button', { name: 'Use a backup code' }))
54
+ fireEvent.change(screen.getByLabelText('Backup code'), { target: { value: 'aaaa1111' } })
55
+ fireEvent.click(screen.getByRole('button', { name: 'Verify' }))
56
+
57
+ await waitFor(() => expect(onVerifyTotp).toHaveBeenCalledWith('pending-token', 'aaaa1111'))
58
+ })
59
+
60
+ it('shows an error returned from the verify step', async () => {
61
+ const onLogin = vi.fn(async () => ({
62
+ success: false,
63
+ requiresTOTP: true,
64
+ mfaPendingToken: 'pending-token',
65
+ }))
66
+ const onVerifyTotp = vi.fn(async () => ({ success: false, error: 'Invalid code' }))
67
+
68
+ render(<Login onLogin={onLogin} onVerifyTotp={onVerifyTotp} />)
69
+ fillCredentials()
70
+ fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
71
+ await screen.findByText('Two-factor authentication')
72
+
73
+ fireEvent.change(screen.getByLabelText('Authentication code'), { target: { value: '000000' } })
74
+ fireEvent.click(screen.getByRole('button', { name: 'Verify' }))
75
+
76
+ await screen.findByText('Invalid code')
77
+ })
78
+
79
+ it('does not show the challenge for a normal successful login', async () => {
80
+ const onLogin = vi.fn(async () => ({ success: true }))
81
+ render(<Login onLogin={onLogin} />)
82
+ fillCredentials()
83
+ fireEvent.click(screen.getByRole('button', { name: 'Sign In' }))
84
+
85
+ await waitFor(() => expect(onLogin).toHaveBeenCalled())
86
+ expect(screen.queryByText('Two-factor authentication')).toBeNull()
87
+ })
88
+ })
@@ -14,6 +14,7 @@ const LABEL_MAP: Record<string, string> = {
14
14
  users: 'Users',
15
15
  'script-tags': 'Script Tags',
16
16
  settings: 'Settings',
17
+ profile: 'Your Profile',
17
18
  collections: 'Collections',
18
19
  submissions: 'Submissions',
19
20
  new: 'New',