@actuate-media/cms-admin 0.29.0 → 0.31.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 (46) hide show
  1. package/dist/AdminRoot.d.ts +6 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +16 -3
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/views/settings-save-footer.render.test.d.ts +2 -0
  6. package/dist/__tests__/views/settings-save-footer.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/settings-save-footer.render.test.js +53 -0
  8. package/dist/__tests__/views/settings-save-footer.render.test.js.map +1 -0
  9. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
  10. package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/user-detail-drawer.render.test.js +113 -0
  12. package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
  13. package/dist/__tests__/views/users.render.test.d.ts +2 -0
  14. package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/users.render.test.js +146 -0
  16. package/dist/__tests__/views/users.render.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/layout/Header.d.ts +3 -1
  19. package/dist/layout/Header.d.ts.map +1 -1
  20. package/dist/layout/Header.js +2 -2
  21. package/dist/layout/Header.js.map +1 -1
  22. package/dist/layout/Layout.d.ts +2 -1
  23. package/dist/layout/Layout.d.ts.map +1 -1
  24. package/dist/layout/Layout.js +2 -2
  25. package/dist/layout/Layout.js.map +1 -1
  26. package/dist/views/Settings.d.ts.map +1 -1
  27. package/dist/views/Settings.js +54 -51
  28. package/dist/views/Settings.js.map +1 -1
  29. package/dist/views/Users.d.ts +1 -3
  30. package/dist/views/Users.d.ts.map +1 -1
  31. package/dist/views/Users.js +420 -56
  32. package/dist/views/Users.js.map +1 -1
  33. package/dist/views/users/UserDetailDrawer.d.ts +9 -0
  34. package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
  35. package/dist/views/users/UserDetailDrawer.js +236 -0
  36. package/dist/views/users/UserDetailDrawer.js.map +1 -0
  37. package/package.json +2 -2
  38. package/src/AdminRoot.tsx +28 -2
  39. package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
  40. package/src/__tests__/views/user-detail-drawer.render.test.tsx +119 -0
  41. package/src/__tests__/views/users.render.test.tsx +155 -0
  42. package/src/layout/Header.tsx +10 -2
  43. package/src/layout/Layout.tsx +10 -1
  44. package/src/views/Settings.tsx +142 -263
  45. package/src/views/Users.tsx +1187 -271
  46. package/src/views/users/UserDetailDrawer.tsx +924 -0
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
4
+
5
+ /**
6
+ * Render tests for the User detail drawer: overview facts, lazy-loaded Sessions
7
+ * and Permissions tabs, and the security action surface.
8
+ */
9
+
10
+ const DETAIL = {
11
+ id: 'u1',
12
+ name: 'Priya Nair',
13
+ email: 'priya@actuatemedia.com',
14
+ avatarUrl: null,
15
+ initials: 'PN',
16
+ role: 'EDITOR',
17
+ roleName: 'Editor',
18
+ status: 'active',
19
+ mfaEnabled: true,
20
+ mfaRequired: false,
21
+ passwordSet: true,
22
+ forcePasswordReset: false,
23
+ failedLoginCount: 0,
24
+ lockedAt: null,
25
+ suspendedAt: null,
26
+ authProvider: null,
27
+ oauthProviders: [],
28
+ ssoOnly: false,
29
+ emailVerified: true,
30
+ lastLoginAt: new Date(Date.now() - 86400000).toISOString(),
31
+ lastActiveAt: new Date(Date.now() - 86400000).toISOString(),
32
+ acceptedInviteAt: new Date('2026-01-02').toISOString(),
33
+ createdAt: new Date('2026-01-01').toISOString(),
34
+ invitedBy: { id: 'o1', name: 'Dana Chen', email: 'dana@actuatemedia.com' },
35
+ externalDomain: false,
36
+ contentOwnedCount: 4,
37
+ isCurrentUser: false,
38
+ }
39
+
40
+ const SESSIONS = [
41
+ {
42
+ id: 's1',
43
+ deviceLabel: 'Chrome on macOS',
44
+ browser: 'Chrome',
45
+ os: 'macOS',
46
+ ipCountry: null,
47
+ createdAt: new Date(Date.now() - 3600000).toISOString(),
48
+ lastSeenAt: new Date().toISOString(),
49
+ expiresAt: new Date(Date.now() + 86400000).toISOString(),
50
+ revokedAt: null,
51
+ current: false,
52
+ active: true,
53
+ },
54
+ ]
55
+
56
+ const PERMISSIONS = {
57
+ role: 'EDITOR',
58
+ roleName: 'Editor',
59
+ permissions: ['dashboard.view', 'content.publish'],
60
+ }
61
+
62
+ const { cmsApi } = vi.hoisted(() => ({ cmsApi: vi.fn() }))
63
+ vi.mock('../../lib/api.js', () => ({ cmsApi }))
64
+
65
+ cmsApi.mockImplementation(async (endpoint?: string) => {
66
+ if (!endpoint) return { data: null, status: 200 }
67
+ if (endpoint.endsWith('/sessions')) return { data: SESSIONS, status: 200 }
68
+ if (endpoint.endsWith('/permissions')) return { data: PERMISSIONS, status: 200 }
69
+ if (endpoint.endsWith('/activity')) return { data: [], status: 200 }
70
+ if (endpoint.startsWith('/users/u1')) return { data: DETAIL, status: 200 }
71
+ return { data: null, status: 200 }
72
+ })
73
+
74
+ const { UserDetailDrawer } = await import('../../views/users/UserDetailDrawer.js')
75
+
76
+ // Radix Tabs activates on mousedown (button 0), not a bare click.
77
+ function selectTab(name: string) {
78
+ const tab = screen.getByRole('tab', { name })
79
+ fireEvent.mouseDown(tab, { button: 0 })
80
+ fireEvent.click(tab)
81
+ }
82
+
83
+ describe('UserDetailDrawer', () => {
84
+ beforeEach(() => cmsApi.mockClear())
85
+
86
+ it('renders overview facts for the user', async () => {
87
+ render(<UserDetailDrawer userId="u1" open onOpenChange={() => {}} />)
88
+ expect(await screen.findByText('Priya Nair')).toBeTruthy()
89
+ expect(screen.getByText('Content owned')).toBeTruthy()
90
+ expect(screen.getByText('4 items')).toBeTruthy()
91
+ expect(screen.getByText('Dana Chen')).toBeTruthy()
92
+ })
93
+
94
+ it('shows security actions on the Security tab', async () => {
95
+ render(<UserDetailDrawer userId="u1" open onOpenChange={() => {}} />)
96
+ await screen.findByText('Priya Nair')
97
+ selectTab('Security')
98
+ expect(await screen.findByText('Multi-factor authentication')).toBeTruthy()
99
+ expect(screen.getByRole('button', { name: 'Reset MFA' })).toBeTruthy()
100
+ expect(screen.getByRole('button', { name: 'Send reset' })).toBeTruthy()
101
+ expect(screen.getByRole('button', { name: 'Suspend' })).toBeTruthy()
102
+ })
103
+
104
+ it('lazy-loads the Sessions tab', async () => {
105
+ render(<UserDetailDrawer userId="u1" open onOpenChange={() => {}} />)
106
+ await screen.findByText('Priya Nair')
107
+ selectTab('Sessions')
108
+ expect(await screen.findByText('Chrome on macOS')).toBeTruthy()
109
+ expect(screen.getByRole('button', { name: 'Revoke all' })).toBeTruthy()
110
+ })
111
+
112
+ it('lazy-loads derived permissions', async () => {
113
+ render(<UserDetailDrawer userId="u1" open onOpenChange={() => {}} />)
114
+ await screen.findByText('Priya Nair')
115
+ selectTab('Permissions')
116
+ await waitFor(() => expect(screen.getByText('content.publish')).toBeTruthy())
117
+ expect(screen.getByText('dashboard.view')).toBeTruthy()
118
+ })
119
+ })
@@ -0,0 +1,155 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
4
+
5
+ /**
6
+ * Render tests for the rebuilt Users tab: dynamic metric cards, access-review
7
+ * banner, team table (members + pending invites), role filter pills, search,
8
+ * and the invite modal.
9
+ */
10
+
11
+ const MEMBERS = [
12
+ {
13
+ id: 'u1',
14
+ name: 'Dana Chen',
15
+ email: 'dana@actuatemedia.com',
16
+ initials: 'DC',
17
+ role: 'OWNER',
18
+ roleName: 'Owner',
19
+ status: 'active',
20
+ mfaEnabled: true,
21
+ externalDomain: false,
22
+ lastLoginAt: new Date().toISOString(),
23
+ lastActiveAt: new Date().toISOString(),
24
+ acceptedInviteAt: new Date().toISOString(),
25
+ createdAt: new Date().toISOString(),
26
+ isCurrentUser: true,
27
+ },
28
+ {
29
+ id: 'u2',
30
+ name: 'Priya Nair',
31
+ email: 'priya@actuatemedia.com',
32
+ initials: 'PN',
33
+ role: 'EDITOR',
34
+ roleName: 'Editor',
35
+ status: 'active',
36
+ mfaEnabled: true,
37
+ externalDomain: false,
38
+ lastLoginAt: new Date(Date.now() - 86400000).toISOString(),
39
+ lastActiveAt: new Date(Date.now() - 86400000).toISOString(),
40
+ acceptedInviteAt: new Date().toISOString(),
41
+ createdAt: new Date().toISOString(),
42
+ isCurrentUser: false,
43
+ },
44
+ ]
45
+
46
+ const INVITES = [
47
+ {
48
+ id: 'i1',
49
+ email: 'jordan@actuatemedia.com',
50
+ role: 'EDITOR',
51
+ roleName: 'Editor',
52
+ status: 'PENDING',
53
+ expiresAt: new Date(Date.now() + 5 * 86400000).toISOString(),
54
+ createdAt: new Date(Date.now() - 2 * 86400000).toISOString(),
55
+ expired: false,
56
+ },
57
+ ]
58
+
59
+ const STATS = {
60
+ members: 2,
61
+ pendingInvites: 1,
62
+ admins: 1,
63
+ seatsUsed: 3,
64
+ seatLimit: 10,
65
+ seatsAvailable: 7,
66
+ emailConfigured: true,
67
+ }
68
+
69
+ const REVIEW = [
70
+ {
71
+ id: 'inactive_user:u9',
72
+ userId: 'u9',
73
+ severity: 'high',
74
+ type: 'inactive_user',
75
+ title: 'Access review',
76
+ description: "Sara Lindqvist (sara@freelance.io) hasn't signed in for 94 days.",
77
+ recommendedAction: 'Revoke access',
78
+ },
79
+ ]
80
+
81
+ const { cmsApi } = vi.hoisted(() => ({ cmsApi: vi.fn() }))
82
+
83
+ vi.mock('../../lib/api.js', () => ({ cmsApi }))
84
+
85
+ cmsApi.mockImplementation(async (endpoint?: string) => {
86
+ if (!endpoint) return { data: null }
87
+ if (endpoint.startsWith('/users/stats')) return { data: STATS }
88
+ if (endpoint.startsWith('/users')) return { data: MEMBERS }
89
+ if (endpoint.startsWith('/invites')) return { data: INVITES }
90
+ if (endpoint.startsWith('/access-review')) return { data: REVIEW }
91
+ return { data: null }
92
+ })
93
+
94
+ const { Users } = await import('../../views/Users.js')
95
+
96
+ describe('Users tab', () => {
97
+ beforeEach(() => cmsApi.mockClear())
98
+
99
+ it('renders dynamic metric cards', async () => {
100
+ render(<Users embedded />)
101
+ expect(await screen.findByText('Members')).toBeTruthy()
102
+ expect(screen.getByText('Pending invites')).toBeTruthy()
103
+ expect(screen.getByText('Admins')).toBeTruthy()
104
+ expect(screen.getByText('Seats used')).toBeTruthy()
105
+ expect(screen.getByText('3 / 10')).toBeTruthy()
106
+ })
107
+
108
+ it('renders the access review banner with the AI badge', async () => {
109
+ render(<Users embedded />)
110
+ expect(await screen.findByText('Access review')).toBeTruthy()
111
+ expect(screen.getByText('AI')).toBeTruthy()
112
+ expect(screen.getByText(/hasn't signed in for 94 days/)).toBeTruthy()
113
+ })
114
+
115
+ it('renders members and pending invites in the team table', async () => {
116
+ render(<Users embedded />)
117
+ expect(await screen.findByText('Dana Chen')).toBeTruthy()
118
+ expect(screen.getByText('Priya Nair')).toBeTruthy()
119
+ // pending invite row
120
+ expect(screen.getByText('jordan@actuatemedia.com')).toBeTruthy()
121
+ expect(screen.getByText('Invited')).toBeTruthy()
122
+ expect(screen.getByText(/people have access/)).toBeTruthy()
123
+ })
124
+
125
+ it('filters by role pill', async () => {
126
+ render(<Users embedded />)
127
+ await screen.findByText('Dana Chen')
128
+ fireEvent.click(screen.getByRole('button', { name: 'Owner', pressed: false }))
129
+ await waitFor(() => expect(screen.queryByText('Priya Nair')).toBeNull())
130
+ expect(screen.getByText('Dana Chen')).toBeTruthy()
131
+ })
132
+
133
+ it('filters by search', async () => {
134
+ render(<Users embedded />)
135
+ await screen.findByText('Dana Chen')
136
+ fireEvent.change(screen.getByLabelText('Search team members'), { target: { value: 'priya' } })
137
+ await waitFor(() => expect(screen.queryByText('Dana Chen')).toBeNull())
138
+ expect(screen.getByText('Priya Nair')).toBeTruthy()
139
+ })
140
+
141
+ it('opens the invite modal with role radio cards', async () => {
142
+ render(<Users embedded />)
143
+ await screen.findByText('Dana Chen')
144
+ fireEvent.click(screen.getByRole('button', { name: /Invite user/ }))
145
+ const dialog = await screen.findByRole('dialog')
146
+ expect(within(dialog).getByText('Invite a user')).toBeTruthy()
147
+ expect(within(dialog).getByPlaceholderText('name@company.com')).toBeTruthy()
148
+ // Owner sees the advanced Owner role card too (current user is Owner).
149
+ expect(within(dialog).getByText('Owner')).toBeTruthy()
150
+ expect(within(dialog).getByText('Admin')).toBeTruthy()
151
+ expect(within(dialog).getByText('Editor')).toBeTruthy()
152
+ expect(within(dialog).getByText('Viewer')).toBeTruthy()
153
+ expect(within(dialog).getByRole('button', { name: 'Send invite' })).toBeTruthy()
154
+ })
155
+ })
@@ -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
  }