@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.
Files changed (73) hide show
  1. package/dist/AdminRoot.d.ts +13 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +22 -4
  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/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
  16. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  19. package/dist/components/Breadcrumbs.js +1 -0
  20. package/dist/components/Breadcrumbs.js.map +1 -1
  21. package/dist/layout/Header.d.ts +3 -1
  22. package/dist/layout/Header.d.ts.map +1 -1
  23. package/dist/layout/Header.js +2 -2
  24. package/dist/layout/Header.js.map +1 -1
  25. package/dist/layout/Layout.d.ts +2 -1
  26. package/dist/layout/Layout.d.ts.map +1 -1
  27. package/dist/layout/Layout.js +2 -2
  28. package/dist/layout/Layout.js.map +1 -1
  29. package/dist/views/Login.d.ts +16 -1
  30. package/dist/views/Login.d.ts.map +1 -1
  31. package/dist/views/Login.js +55 -3
  32. package/dist/views/Login.js.map +1 -1
  33. package/dist/views/Profile.d.ts +12 -0
  34. package/dist/views/Profile.d.ts.map +1 -0
  35. package/dist/views/Profile.js +16 -0
  36. package/dist/views/Profile.js.map +1 -0
  37. package/dist/views/Settings.d.ts.map +1 -1
  38. package/dist/views/Settings.js +54 -51
  39. package/dist/views/Settings.js.map +1 -1
  40. package/dist/views/Users.d.ts.map +1 -1
  41. package/dist/views/Users.js +50 -7
  42. package/dist/views/Users.js.map +1 -1
  43. package/dist/views/profile/AccountSecurity.d.ts +2 -0
  44. package/dist/views/profile/AccountSecurity.d.ts.map +1 -0
  45. package/dist/views/profile/AccountSecurity.js +117 -0
  46. package/dist/views/profile/AccountSecurity.js.map +1 -0
  47. package/dist/views/profile/useMfaEnrollment.d.ts +35 -0
  48. package/dist/views/profile/useMfaEnrollment.d.ts.map +1 -0
  49. package/dist/views/profile/useMfaEnrollment.js +103 -0
  50. package/dist/views/profile/useMfaEnrollment.js.map +1 -0
  51. package/dist/views/settings/AuthMfaCard.d.ts +2 -1
  52. package/dist/views/settings/AuthMfaCard.d.ts.map +1 -1
  53. package/dist/views/settings/AuthMfaCard.js +3 -2
  54. package/dist/views/settings/AuthMfaCard.js.map +1 -1
  55. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  56. package/dist/views/settings/SecurityTab.js +1 -1
  57. package/dist/views/settings/SecurityTab.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/AdminRoot.tsx +46 -2
  60. package/src/__tests__/views/account-security.render.test.tsx +140 -0
  61. package/src/__tests__/views/login-mfa.render.test.tsx +88 -0
  62. package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
  63. package/src/components/Breadcrumbs.tsx +1 -0
  64. package/src/layout/Header.tsx +10 -2
  65. package/src/layout/Layout.tsx +10 -1
  66. package/src/views/Login.tsx +247 -94
  67. package/src/views/Profile.tsx +54 -0
  68. package/src/views/Settings.tsx +142 -263
  69. package/src/views/Users.tsx +94 -12
  70. package/src/views/profile/AccountSecurity.tsx +406 -0
  71. package/src/views/profile/useMfaEnrollment.ts +120 -0
  72. package/src/views/settings/AuthMfaCard.tsx +19 -4
  73. 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 config={config} session={session} currentPath={currentPath} onNavigate={navigate}>
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
+ })
@@ -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',
@@ -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({ onToggleSidebar, session, onNavigate, pageActions }: HeaderProps) {
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={() => onNavigate('/logout')}
163
+ onSelect={() => onLogout?.()}
156
164
  >
157
165
  Logout
158
166
  </DropdownMenu.Item>
@@ -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({ config, session, currentPath, onNavigate, children }: LayoutProps) {
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
  }