@actuate-media/cms-admin 0.28.0 → 0.30.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/dist/AdminRoot.d.ts +13 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +22 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/views/account-security.render.test.d.ts +2 -0
- package/dist/__tests__/views/account-security.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/account-security.render.test.js +98 -0
- package/dist/__tests__/views/account-security.render.test.js.map +1 -0
- package/dist/__tests__/views/login-mfa.render.test.d.ts +2 -0
- package/dist/__tests__/views/login-mfa.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/login-mfa.render.test.js +73 -0
- package/dist/__tests__/views/login-mfa.render.test.js.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
- package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
- package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +1 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/layout/Header.d.ts +3 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +2 -2
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.d.ts +2 -1
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +2 -2
- package/dist/layout/Layout.js.map +1 -1
- package/dist/views/Login.d.ts +16 -1
- package/dist/views/Login.d.ts.map +1 -1
- package/dist/views/Login.js +55 -3
- package/dist/views/Login.js.map +1 -1
- package/dist/views/Profile.d.ts +12 -0
- package/dist/views/Profile.d.ts.map +1 -0
- package/dist/views/Profile.js +16 -0
- package/dist/views/Profile.js.map +1 -0
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +54 -51
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +50 -7
- package/dist/views/Users.js.map +1 -1
- package/dist/views/profile/AccountSecurity.d.ts +2 -0
- package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
- package/dist/views/profile/AccountSecurity.js +117 -0
- package/dist/views/profile/AccountSecurity.js.map +1 -0
- package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
- package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
- package/dist/views/profile/useMfaEnrollment.js +103 -0
- package/dist/views/profile/useMfaEnrollment.js.map +1 -0
- package/dist/views/settings/AuthMfaCard.d.ts +2 -1
- package/dist/views/settings/AuthMfaCard.d.ts.map +1 -1
- package/dist/views/settings/AuthMfaCard.js +3 -2
- package/dist/views/settings/AuthMfaCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +1 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +3 -2
- package/src/AdminRoot.tsx +46 -2
- package/src/__tests__/views/account-security.render.test.tsx +140 -0
- package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
- package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/layout/Header.tsx +10 -2
- package/src/layout/Layout.tsx +10 -1
- package/src/views/Login.tsx +247 -94
- package/src/views/Profile.tsx +54 -0
- package/src/views/Settings.tsx +142 -263
- package/src/views/Users.tsx +94 -12
- package/src/views/profile/AccountSecurity.tsx +406 -0
- package/src/views/profile/useMfaEnrollment.ts +120 -0
- package/src/views/settings/AuthMfaCard.tsx +19 -4
- package/src/views/settings/SecurityTab.tsx +1 -0
package/src/AdminRoot.tsx
CHANGED
|
@@ -4,12 +4,13 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
|
|
|
4
4
|
import { toast } from 'sonner'
|
|
5
5
|
import { Layout } from './layout/Layout.js'
|
|
6
6
|
import { useAdminRouter } from './router/index.js'
|
|
7
|
-
import { ensureCsrfToken } from './lib/api.js'
|
|
7
|
+
import { cmsApi, ensureCsrfToken } from './lib/api.js'
|
|
8
8
|
import { Dashboard } from './views/Dashboard.js'
|
|
9
9
|
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,7 +57,23 @@ 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 }>
|
|
71
|
+
/**
|
|
72
|
+
* Clears the consumer-held session (e.g. `setSession(null)`) after the admin
|
|
73
|
+
* has revoked the server-side session. Server revocation + cookie clearing is
|
|
74
|
+
* handled inside the admin, so consumers only need to reset their local state.
|
|
75
|
+
*/
|
|
76
|
+
onLogout?: () => void | Promise<void>
|
|
60
77
|
captchaConfig?: { provider: 'recaptcha' | 'turnstile' | 'none'; siteKey: string | null }
|
|
61
78
|
}
|
|
62
79
|
|
|
@@ -68,11 +85,26 @@ function AdminShell({
|
|
|
68
85
|
setupRequired,
|
|
69
86
|
onSetupComplete,
|
|
70
87
|
onLogin,
|
|
88
|
+
onVerifyTotp,
|
|
89
|
+
onLogout,
|
|
71
90
|
captchaConfig,
|
|
72
91
|
}: AdminRootProps) {
|
|
73
92
|
const { currentPath, navigate, matchRoute, buildHref } = useAdminRouter(basePath, initialPath)
|
|
74
93
|
const [shortcutHelpOpen, setShortcutHelpOpen] = useState(false)
|
|
75
94
|
|
|
95
|
+
// Sign out: revoke the server-side session (and clear the cookie) first, then
|
|
96
|
+
// let the consumer drop its local session state. We clear local state even if
|
|
97
|
+
// the network call fails so a user is never stuck "logged in" in the UI.
|
|
98
|
+
const handleLogout = useCallback(async () => {
|
|
99
|
+
try {
|
|
100
|
+
await cmsApi('/auth/logout', { method: 'POST' })
|
|
101
|
+
} catch {
|
|
102
|
+
// Best-effort: proceed to clear client state regardless.
|
|
103
|
+
}
|
|
104
|
+
navigate(initialPath)
|
|
105
|
+
await onLogout?.()
|
|
106
|
+
}, [navigate, initialPath, onLogout])
|
|
107
|
+
|
|
76
108
|
// Open a preview route in a new browser tab so it behaves like a normal,
|
|
77
109
|
// shareable page (its own URL, no admin chrome — see the standalone branch
|
|
78
110
|
// below). The blank tab is opened synchronously inside the click gesture so
|
|
@@ -197,6 +229,7 @@ function AdminShell({
|
|
|
197
229
|
return (
|
|
198
230
|
<Login
|
|
199
231
|
onLogin={onLogin}
|
|
232
|
+
onVerifyTotp={onVerifyTotp}
|
|
200
233
|
onNavigate={navigate}
|
|
201
234
|
captchaConfig={captchaConfig}
|
|
202
235
|
branding={branding}
|
|
@@ -437,6 +470,10 @@ function AdminShell({
|
|
|
437
470
|
return <Settings onNavigate={navigate} config={config} session={session} />
|
|
438
471
|
}
|
|
439
472
|
|
|
473
|
+
if (matchRoute('/profile')) {
|
|
474
|
+
return <Profile onNavigate={navigate} session={session} />
|
|
475
|
+
}
|
|
476
|
+
|
|
440
477
|
// Page Builder routes — explicit /page-builder/* takes precedence over
|
|
441
478
|
// the collection-derived routing below, but we ALSO route a collection
|
|
442
479
|
// declared as `type: 'page'` through the Page Builder by default.
|
|
@@ -467,6 +504,7 @@ function AdminShell({
|
|
|
467
504
|
'widgets',
|
|
468
505
|
'users',
|
|
469
506
|
'settings',
|
|
507
|
+
'profile',
|
|
470
508
|
'page-builder',
|
|
471
509
|
'collections',
|
|
472
510
|
// `pages` is reserved so the Pages list/editor/tag routes above
|
|
@@ -577,7 +615,13 @@ function AdminShell({
|
|
|
577
615
|
}
|
|
578
616
|
|
|
579
617
|
return (
|
|
580
|
-
<Layout
|
|
618
|
+
<Layout
|
|
619
|
+
config={config}
|
|
620
|
+
session={session}
|
|
621
|
+
currentPath={currentPath}
|
|
622
|
+
onNavigate={navigate}
|
|
623
|
+
onLogout={handleLogout}
|
|
624
|
+
>
|
|
581
625
|
<ErrorBoundary>{renderView()}</ErrorBoundary>
|
|
582
626
|
{shortcutHelpOpen && <ShortcutHelp onClose={() => setShortcutHelpOpen(false)} />}
|
|
583
627
|
</Layout>
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: the shared "Save Changes" footer must only render on tabs that
|
|
7
|
+
* actually persist through `handleSave` (currently just Layout, which writes
|
|
8
|
+
* `defaultLayout` into the settings global). On self-managed tabs like SEO it
|
|
9
|
+
* was rendering a footer whose click toasted "Settings saved successfully!"
|
|
10
|
+
* while persisting none of the tab's edits — misleading, no-op UI.
|
|
11
|
+
*/
|
|
12
|
+
const SEO_CONFIG = {
|
|
13
|
+
static: { site: null },
|
|
14
|
+
overrides: { site: {}, collections: {} },
|
|
15
|
+
effective: { site: { siteUrl: 'https://example.com' } },
|
|
16
|
+
collections: [],
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
|
|
20
|
+
const method = options?.method ?? 'GET'
|
|
21
|
+
if (endpoint === '/globals/settings' && method === 'GET') {
|
|
22
|
+
return { data: { siteTitle: 'Actuate Media' }, status: 200 }
|
|
23
|
+
}
|
|
24
|
+
if (endpoint === '/seo/config' && method === 'GET') {
|
|
25
|
+
return { data: SEO_CONFIG, status: 200 }
|
|
26
|
+
}
|
|
27
|
+
if (endpoint.startsWith('/collections/')) {
|
|
28
|
+
return { data: { docs: [] }, status: 200 }
|
|
29
|
+
}
|
|
30
|
+
return { data: {}, status: 200 }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any) }))
|
|
34
|
+
|
|
35
|
+
const { Settings } = await import('../../views/Settings.js')
|
|
36
|
+
|
|
37
|
+
const LAYOUT_CONFIG = {
|
|
38
|
+
layout: { regions: { header: { collection: 'headers', label: 'Header' } } },
|
|
39
|
+
}
|
|
40
|
+
const SESSION = { user: { role: 'ADMIN' } }
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
cmsApi.mockClear()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('Settings shared save footer', () => {
|
|
47
|
+
it('does NOT render the global "Save Changes" footer on the self-managed SEO tab', async () => {
|
|
48
|
+
render(<Settings config={LAYOUT_CONFIG} session={SESSION} initialTab="seo" />)
|
|
49
|
+
|
|
50
|
+
// The SEO panel owns its own persistence ("Save SEO defaults").
|
|
51
|
+
expect(
|
|
52
|
+
(await screen.findAllByRole('button', { name: 'Save SEO defaults' })).length,
|
|
53
|
+
).toBeGreaterThan(0)
|
|
54
|
+
// The shared footer ("Save Changes") must be absent here.
|
|
55
|
+
expect(screen.queryByRole('button', { name: 'Save Changes' })).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('renders the global "Save Changes" footer on the Layout tab', async () => {
|
|
59
|
+
render(<Settings config={LAYOUT_CONFIG} session={SESSION} initialTab="layout" />)
|
|
60
|
+
|
|
61
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Save Changes' })).toBeTruthy())
|
|
62
|
+
})
|
|
63
|
+
})
|
package/src/layout/Header.tsx
CHANGED
|
@@ -24,9 +24,17 @@ export interface HeaderProps {
|
|
|
24
24
|
* on view-change.
|
|
25
25
|
*/
|
|
26
26
|
pageActions?: ReactNode
|
|
27
|
+
/** Signs the user out (revokes the server session + clears local state). */
|
|
28
|
+
onLogout?: () => void
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export function Header({
|
|
31
|
+
export function Header({
|
|
32
|
+
onToggleSidebar,
|
|
33
|
+
session,
|
|
34
|
+
onNavigate,
|
|
35
|
+
onLogout,
|
|
36
|
+
pageActions,
|
|
37
|
+
}: HeaderProps) {
|
|
30
38
|
const [showCommandPalette, setShowCommandPalette] = useState(false)
|
|
31
39
|
const [searchQuery, setSearchQuery] = useState('')
|
|
32
40
|
const { resolvedTheme, setTheme } = useTheme()
|
|
@@ -152,7 +160,7 @@ export function Header({ onToggleSidebar, session, onNavigate, pageActions }: He
|
|
|
152
160
|
<DropdownMenu.Separator className="bg-border my-1 h-px" />
|
|
153
161
|
<DropdownMenu.Item
|
|
154
162
|
className="text-destructive hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-3 py-2 text-sm outline-none"
|
|
155
|
-
onSelect={() =>
|
|
163
|
+
onSelect={() => onLogout?.()}
|
|
156
164
|
>
|
|
157
165
|
Logout
|
|
158
166
|
</DropdownMenu.Item>
|
package/src/layout/Layout.tsx
CHANGED
|
@@ -13,6 +13,7 @@ export interface LayoutProps {
|
|
|
13
13
|
session: any
|
|
14
14
|
currentPath: string
|
|
15
15
|
onNavigate: (path: string) => void
|
|
16
|
+
onLogout?: () => void
|
|
16
17
|
children: ReactNode
|
|
17
18
|
}
|
|
18
19
|
|
|
@@ -30,7 +31,14 @@ export interface LayoutProps {
|
|
|
30
31
|
* instead. The previous flex + `fixed`↔`static` toggle caused recurring
|
|
31
32
|
* sidebar-overlap bugs; the grid-based primitive is the contract.
|
|
32
33
|
*/
|
|
33
|
-
export function Layout({
|
|
34
|
+
export function Layout({
|
|
35
|
+
config,
|
|
36
|
+
session,
|
|
37
|
+
currentPath,
|
|
38
|
+
onNavigate,
|
|
39
|
+
onLogout,
|
|
40
|
+
children,
|
|
41
|
+
}: LayoutProps) {
|
|
34
42
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
|
35
43
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
|
36
44
|
|
|
@@ -63,6 +71,7 @@ export function Layout({ config, session, currentPath, onNavigate, children }: L
|
|
|
63
71
|
onToggleSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
|
64
72
|
session={session}
|
|
65
73
|
onNavigate={onNavigate}
|
|
74
|
+
onLogout={onLogout}
|
|
66
75
|
pageActions={pageActions}
|
|
67
76
|
/>
|
|
68
77
|
}
|