@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.
- package/dist/AdminRoot.d.ts +6 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +16 -3
- package/dist/AdminRoot.js.map +1 -1
- 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/__tests__/views/user-detail-drawer.render.test.d.ts +2 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js +113 -0
- package/dist/__tests__/views/user-detail-drawer.render.test.js.map +1 -0
- package/dist/__tests__/views/users.render.test.d.ts +2 -0
- package/dist/__tests__/views/users.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users.render.test.js +146 -0
- package/dist/__tests__/views/users.render.test.js.map +1 -0
- package/dist/actuate-admin.css +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/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 +1 -3
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +420 -56
- package/dist/views/Users.js.map +1 -1
- package/dist/views/users/UserDetailDrawer.d.ts +9 -0
- package/dist/views/users/UserDetailDrawer.d.ts.map +1 -0
- package/dist/views/users/UserDetailDrawer.js +236 -0
- package/dist/views/users/UserDetailDrawer.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +28 -2
- package/src/__tests__/views/settings-save-footer.render.test.tsx +63 -0
- package/src/__tests__/views/user-detail-drawer.render.test.tsx +119 -0
- package/src/__tests__/views/users.render.test.tsx +155 -0
- package/src/layout/Header.tsx +10 -2
- package/src/layout/Layout.tsx +10 -1
- package/src/views/Settings.tsx +142 -263
- package/src/views/Users.tsx +1187 -271
- 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
|
+
})
|
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
|
}
|