Skip to content

Commit fbd02bc

Browse files
feat(admin): add password user creation (#6267)
* feat(admin): add password user creation * fix(admin): restrict created users to normal role * improvement(admin): refine add user action * fix(admin): prevent duplicate user creation * fix(admin): reflect immediate submission state
1 parent bf5abc9 commit fbd02bc

6 files changed

Lines changed: 698 additions & 83 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
9+
10+
const { addUserMutation, mockMutate, mockReset } = vi.hoisted(() => ({
11+
addUserMutation: {
12+
current: {
13+
isPending: false,
14+
error: null as Error | null,
15+
},
16+
},
17+
mockMutate: vi.fn(),
18+
mockReset: vi.fn(),
19+
}))
20+
21+
vi.mock('@sim/emcn', () => ({
22+
ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) =>
23+
open ? <div role='dialog'>{children}</div> : null,
24+
ChipModalHeader: ({
25+
children,
26+
onClose,
27+
closeDisabled,
28+
}: {
29+
children: ReactNode
30+
onClose: () => void
31+
closeDisabled?: boolean
32+
}) => (
33+
<header>
34+
<h2>{children}</h2>
35+
<button type='button' onClick={onClose} disabled={closeDisabled}>
36+
Close
37+
</button>
38+
</header>
39+
),
40+
ChipModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
41+
ChipModalError: ({ children }: { children: ReactNode }) =>
42+
children ? <div role='alert'>{children}</div> : null,
43+
ChipModalFooter: ({
44+
onCancel,
45+
cancelDisabled,
46+
primaryAction,
47+
}: {
48+
onCancel: () => void
49+
cancelDisabled?: boolean
50+
primaryAction: { label: ReactNode; onClick: () => void; disabled?: boolean }
51+
}) => (
52+
<footer>
53+
<button type='button' onClick={onCancel} disabled={cancelDisabled}>
54+
Cancel
55+
</button>
56+
<button type='button' disabled={primaryAction.disabled} onClick={primaryAction.onClick}>
57+
{primaryAction.label}
58+
</button>
59+
</footer>
60+
),
61+
ChipModalField: ({
62+
type,
63+
inputType,
64+
title,
65+
value,
66+
onChange,
67+
options,
68+
disabled,
69+
error,
70+
}: {
71+
type: string
72+
inputType?: string
73+
title: string
74+
value: string
75+
onChange: (value: string) => void
76+
options?: ReadonlyArray<{ value: string; label: string }>
77+
disabled?: boolean
78+
error?: ReactNode
79+
}) => (
80+
<div>
81+
<span>{title}</span>
82+
{type === 'dropdown' ? (
83+
<select
84+
aria-label={title}
85+
value={value}
86+
disabled={disabled}
87+
onChange={(event) => onChange(event.target.value)}
88+
>
89+
{options?.map((option) => (
90+
<option key={option.value} value={option.value}>
91+
{option.label}
92+
</option>
93+
))}
94+
</select>
95+
) : (
96+
<input
97+
aria-label={title}
98+
type={inputType ?? (type === 'email' ? 'email' : 'text')}
99+
value={value}
100+
disabled={disabled}
101+
onChange={(event) => onChange(event.target.value)}
102+
/>
103+
)}
104+
{error && <span role='alert'>{error}</span>}
105+
</div>
106+
),
107+
}))
108+
109+
vi.mock('@/hooks/queries/admin-users', () => ({
110+
useAddUser: () => ({
111+
...addUserMutation.current,
112+
mutate: mockMutate,
113+
reset: mockReset,
114+
}),
115+
}))
116+
117+
import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal'
118+
import type { AddUserInput, AdminUser } from '@/hooks/queries/admin-users'
119+
120+
const CREATED_USER: AdminUser = {
121+
id: 'user-1',
122+
name: 'Canary Writer',
123+
email: 'writer@synthetics.example.com',
124+
role: 'user',
125+
banned: false,
126+
banReason: null,
127+
}
128+
129+
let container: HTMLDivElement
130+
let root: Root
131+
let onCreated: ReturnType<typeof vi.fn<(user: AdminUser) => void>>
132+
let onOpenChange: ReturnType<typeof vi.fn<(open: boolean) => void>>
133+
134+
async function renderModal() {
135+
await act(async () => {
136+
root.render(<AddUserModal open onOpenChange={onOpenChange} onCreated={onCreated} />)
137+
})
138+
}
139+
140+
function field(label: string): HTMLInputElement | HTMLSelectElement {
141+
const element = container.querySelector<HTMLInputElement | HTMLSelectElement>(
142+
`[aria-label="${label}"]`
143+
)
144+
if (!element) throw new Error(`No field labelled "${label}"`)
145+
return element
146+
}
147+
148+
async function changeField(label: string, value: string) {
149+
const element = field(label)
150+
const valueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')?.set
151+
if (!valueSetter) throw new Error(`Field labelled "${label}" has no value setter`)
152+
await act(async () => {
153+
valueSetter.call(element, value)
154+
element.dispatchEvent(
155+
new Event(element instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true })
156+
)
157+
})
158+
}
159+
160+
function buttonLabelled(text: string): HTMLButtonElement {
161+
const button = [...container.querySelectorAll('button')].find(
162+
(candidate) => candidate.textContent === text
163+
)
164+
if (!button) throw new Error(`No button labelled "${text}"`)
165+
return button
166+
}
167+
168+
async function fillRequiredFields() {
169+
await changeField('Name', ' Canary Writer ')
170+
await changeField('Email', ' Writer@Synthetics.Example.com ')
171+
await changeField('Password', 'canary-password')
172+
}
173+
174+
describe('AddUserModal', () => {
175+
beforeEach(() => {
176+
container = document.createElement('div')
177+
document.body.appendChild(container)
178+
root = createRoot(container)
179+
onCreated = vi.fn()
180+
onOpenChange = vi.fn()
181+
addUserMutation.current = { isPending: false, error: null }
182+
})
183+
184+
afterEach(() => {
185+
act(() => root.unmount())
186+
container.remove()
187+
vi.clearAllMocks()
188+
})
189+
190+
it('requires a name, valid email, and eight-character password', async () => {
191+
await renderModal()
192+
193+
expect(buttonLabelled('Add user').disabled).toBe(true)
194+
195+
await changeField('Name', 'Canary Writer')
196+
await changeField('Email', 'not-an-email')
197+
await changeField('Password', 'short')
198+
199+
expect(buttonLabelled('Add user').disabled).toBe(true)
200+
expect(container.textContent).toContain('Enter a valid email')
201+
expect(container.textContent).toContain('Password must be at least 8 characters')
202+
})
203+
204+
it('creates a verified credential user and returns it to the admin view', async () => {
205+
mockMutate.mockImplementation(
206+
(_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => {
207+
options.onSuccess(CREATED_USER)
208+
}
209+
)
210+
await renderModal()
211+
await fillRequiredFields()
212+
213+
await act(async () => {
214+
buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true }))
215+
await Promise.resolve()
216+
await Promise.resolve()
217+
})
218+
219+
expect(mockMutate).toHaveBeenCalledWith(
220+
{
221+
name: 'Canary Writer',
222+
email: 'writer@synthetics.example.com',
223+
password: 'canary-password',
224+
emailVerified: true,
225+
},
226+
{ onSuccess: expect.any(Function), onSettled: expect.any(Function) }
227+
)
228+
expect(onOpenChange).toHaveBeenCalledWith(false)
229+
expect(onCreated).toHaveBeenCalledWith(CREATED_USER)
230+
})
231+
232+
it('ignores repeated submissions before the pending state renders', async () => {
233+
await renderModal()
234+
await fillRequiredFields()
235+
236+
await act(async () => {
237+
const addUserButton = buttonLabelled('Add user')
238+
addUserButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
239+
addUserButton.dispatchEvent(new MouseEvent('click', { bubbles: true }))
240+
})
241+
242+
expect(mockMutate).toHaveBeenCalledTimes(1)
243+
expect(buttonLabelled('Close').disabled).toBe(true)
244+
expect(buttonLabelled('Cancel').disabled).toBe(true)
245+
})
246+
247+
it('supports unverified accounts without exposing a platform-role control', async () => {
248+
mockMutate.mockImplementation(
249+
(_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => {
250+
options.onSuccess(CREATED_USER)
251+
}
252+
)
253+
await renderModal()
254+
await fillRequiredFields()
255+
await changeField('Email status', 'unverified')
256+
257+
await act(async () => {
258+
buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true }))
259+
await Promise.resolve()
260+
await Promise.resolve()
261+
})
262+
263+
expect(container.querySelector('[aria-label="Platform role"]')).toBeNull()
264+
expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ emailVerified: false }), {
265+
onSuccess: expect.any(Function),
266+
onSettled: expect.any(Function),
267+
})
268+
})
269+
270+
it('shows Better Auth failures without closing the modal', async () => {
271+
addUserMutation.current = {
272+
isPending: false,
273+
error: new Error('A user with that email already exists'),
274+
}
275+
await renderModal()
276+
277+
expect(container.textContent).toContain('A user with that email already exists')
278+
expect(onOpenChange).not.toHaveBeenCalled()
279+
expect(onCreated).not.toHaveBeenCalled()
280+
})
281+
})

0 commit comments

Comments
 (0)