Skip to content

Commit 9392bb9

Browse files
feat(admin): add password user creation
1 parent b98dd8b commit 9392bb9

5 files changed

Lines changed: 587 additions & 1 deletion

File tree

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

0 commit comments

Comments
 (0)