Skip to content

Commit 64b3472

Browse files
authored
feat(admin): provision a user with an emailed password reset instead of a set password (#6328)
* feat(admin): provision a user with an emailed password reset instead of a set password * fix(emcn): keep focus on the input so the password Hide toggle actually masks * fix(admin): surface a created user when only its reset email failed * test(emcn): cover keyboard activation of the password reveal toggle
1 parent 82e654d commit 64b3472

7 files changed

Lines changed: 575 additions & 59 deletions

File tree

apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ vi.mock('@/hooks/queries/admin-users', () => ({
115115
}))
116116

117117
import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal'
118-
import type { AddUserInput, AdminUser } from '@/hooks/queries/admin-users'
118+
import type { AddUserInput, AddUserResult, AdminUser } from '@/hooks/queries/admin-users'
119119

120120
const CREATED_USER: AdminUser = {
121121
id: 'user-1',
@@ -128,7 +128,7 @@ const CREATED_USER: AdminUser = {
128128

129129
let container: HTMLDivElement
130130
let root: Root
131-
let onCreated: ReturnType<typeof vi.fn<(user: AdminUser) => void>>
131+
let onCreated: ReturnType<typeof vi.fn<(user: AdminUser, resetEmailError?: string) => void>>
132132
let onOpenChange: ReturnType<typeof vi.fn<(open: boolean) => void>>
133133

134134
async function renderModal() {
@@ -203,8 +203,8 @@ describe('AddUserModal', () => {
203203

204204
it('creates a verified credential user and returns it to the admin view', async () => {
205205
mockMutate.mockImplementation(
206-
(_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => {
207-
options.onSuccess(CREATED_USER)
206+
(_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => {
207+
options.onSuccess({ user: CREATED_USER })
208208
}
209209
)
210210
await renderModal()
@@ -226,7 +226,7 @@ describe('AddUserModal', () => {
226226
{ onSuccess: expect.any(Function), onSettled: expect.any(Function) }
227227
)
228228
expect(onOpenChange).toHaveBeenCalledWith(false)
229-
expect(onCreated).toHaveBeenCalledWith(CREATED_USER)
229+
expect(onCreated).toHaveBeenCalledWith(CREATED_USER, undefined)
230230
})
231231

232232
it('ignores repeated submissions before the pending state renders', async () => {
@@ -246,8 +246,8 @@ describe('AddUserModal', () => {
246246

247247
it('supports unverified accounts without exposing a platform-role control', async () => {
248248
mockMutate.mockImplementation(
249-
(_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => {
250-
options.onSuccess(CREATED_USER)
249+
(_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => {
250+
options.onSuccess({ user: CREATED_USER })
251251
}
252252
)
253253
await renderModal()
@@ -267,6 +267,71 @@ describe('AddUserModal', () => {
267267
})
268268
})
269269

270+
it('drops the password field and submits without one when emailing a reset link', async () => {
271+
mockMutate.mockImplementation(
272+
(_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => {
273+
options.onSuccess({ user: CREATED_USER })
274+
}
275+
)
276+
await renderModal()
277+
await changeField('Name', 'Canary Writer')
278+
await changeField('Email', 'writer@synthetics.example.com')
279+
await changeField('Credentials', 'email')
280+
281+
expect(container.querySelector('[aria-label="Password"]')).toBeNull()
282+
expect(buttonLabelled('Add user').disabled).toBe(false)
283+
284+
await act(async () => {
285+
buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true }))
286+
await Promise.resolve()
287+
await Promise.resolve()
288+
})
289+
290+
expect(mockMutate).toHaveBeenCalledWith(
291+
{
292+
name: 'Canary Writer',
293+
email: 'writer@synthetics.example.com',
294+
emailVerified: true,
295+
},
296+
{ onSuccess: expect.any(Function), onSettled: expect.any(Function) }
297+
)
298+
expect(onCreated).toHaveBeenCalledWith(CREATED_USER, undefined)
299+
})
300+
301+
it('keeps a typed password across a round trip through the reset-link flow', async () => {
302+
await renderModal()
303+
await fillRequiredFields()
304+
await changeField('Credentials', 'email')
305+
await changeField('Credentials', 'set')
306+
307+
expect((field('Password') as HTMLInputElement).value).toBe('canary-password')
308+
expect(buttonLabelled('Add user').disabled).toBe(false)
309+
})
310+
311+
it('still hands the user back when only its reset email failed', async () => {
312+
mockMutate.mockImplementation(
313+
(_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => {
314+
options.onSuccess({ user: CREATED_USER, resetEmailError: 'SMTP unavailable' })
315+
}
316+
)
317+
await renderModal()
318+
await changeField('Name', 'Canary Writer')
319+
await changeField('Email', 'writer@synthetics.example.com')
320+
await changeField('Credentials', 'email')
321+
322+
await act(async () => {
323+
buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true }))
324+
await Promise.resolve()
325+
await Promise.resolve()
326+
})
327+
328+
// The account exists, so this closes like any other create — the host
329+
// surfaces the user (and the reason) rather than stranding the operator in
330+
// a modal whose form no longer maps to anything.
331+
expect(onOpenChange).toHaveBeenCalledWith(false)
332+
expect(onCreated).toHaveBeenCalledWith(CREATED_USER, 'SMTP unavailable')
333+
})
334+
270335
it('shows Better Auth failures without closing the modal', async () => {
271336
addUserMutation.current = {
272337
isPending: false,

apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,23 @@ const EMAIL_STATUS_OPTIONS = [
1818
{ value: 'unverified', label: 'Unverified' },
1919
] as const
2020

21+
const PASSWORD_MODE_OPTIONS = [
22+
{ value: 'set', label: 'Set a password' },
23+
{ value: 'email', label: 'Email a reset link' },
24+
] as const
25+
26+
type PasswordMode = (typeof PASSWORD_MODE_OPTIONS)[number]['value']
27+
2128
interface AddUserModalProps {
2229
open: boolean
2330
onOpenChange: (open: boolean) => void
24-
onCreated: (user: AdminUser) => void
31+
/**
32+
* The account was created. `resetEmailError` is set when its provisioning
33+
* reset email could not be sent — the account still exists, so the host is
34+
* expected to surface the user (and report this) rather than treat it as a
35+
* failed create.
36+
*/
37+
onCreated: (user: AdminUser, resetEmailError?: string) => void
2538
}
2639

2740
export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProps) {
@@ -30,9 +43,11 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
3043
const [isSubmitting, setIsSubmitting] = useState(false)
3144
const [name, setName] = useState('')
3245
const [email, setEmail] = useState('')
46+
const [passwordMode, setPasswordMode] = useState<PasswordMode>('set')
3347
const [password, setPassword] = useState('')
3448
const [emailVerified, setEmailVerified] = useState(true)
3549

50+
const setsPassword = passwordMode === 'set'
3651
const normalizedName = name.trim()
3752
const normalizedEmail = email.trim().toLowerCase()
3853
const nameError = name.length > 0 && !normalizedName ? 'Name is required' : undefined
@@ -46,12 +61,13 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
4661
const canSubmit =
4762
normalizedName.length > 0 &&
4863
isValidEmailSyntax(normalizedEmail) &&
49-
password.length >= 8 &&
64+
(!setsPassword || password.length >= 8) &&
5065
!isSubmissionPending
5166

5267
const reset = () => {
5368
setName('')
5469
setEmail('')
70+
setPasswordMode('set')
5571
setPassword('')
5672
setEmailVerified(true)
5773
addUser.reset()
@@ -72,14 +88,14 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
7288
{
7389
name: normalizedName,
7490
email: normalizedEmail,
75-
password,
7691
emailVerified,
92+
...(setsPassword ? { password } : {}),
7793
},
7894
{
79-
onSuccess: (user) => {
95+
onSuccess: ({ user, resetEmailError }) => {
8096
reset()
8197
onOpenChange(false)
82-
onCreated(user)
98+
onCreated(user, resetEmailError)
8399
},
84100
onSettled: () => {
85101
submissionInFlightRef.current = false
@@ -131,21 +147,38 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
131147
required
132148
/>
133149
<ChipModalField
134-
type='input'
135-
inputType='password'
136-
title='Password'
137-
value={password}
150+
type='dropdown'
151+
title='Credentials'
152+
value={passwordMode}
138153
onChange={(value) => {
139-
setPassword(value)
154+
setPasswordMode(value as PasswordMode)
140155
addUser.reset()
141156
}}
142-
error={passwordError}
143-
hint='Better Auth creates a credential account with this password.'
144-
placeholder='At least 8 characters'
145-
autoComplete='new-password'
157+
options={PASSWORD_MODE_OPTIONS}
158+
align='start'
159+
hint={
160+
setsPassword ? undefined : 'They pick their own password from the emailed reset link.'
161+
}
146162
disabled={isSubmissionPending}
147163
required
148164
/>
165+
{setsPassword && (
166+
<ChipModalField
167+
type='input'
168+
inputType='password'
169+
title='Password'
170+
value={password}
171+
onChange={(value) => {
172+
setPassword(value)
173+
addUser.reset()
174+
}}
175+
error={passwordError}
176+
placeholder='At least 8 characters'
177+
autoComplete='new-password'
178+
disabled={isSubmissionPending}
179+
required
180+
/>
181+
)}
149182
<ChipModalField
150183
type='dropdown'
151184
title='Email status'

apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
useAdminUsersByEmails,
2222
useBanUser,
2323
useImpersonateUser,
24+
useSendPasswordReset,
2425
useSetUserRole,
2526
useUnbanUser,
2627
} from '@/hooks/queries/admin-users'
@@ -36,7 +37,7 @@ const USER_TABLE_HEADER = (
3637
<span className='flex-1'>Email</span>
3738
<span className='w-[60px]'>Role</span>
3839
<span className='w-[55px]'>Status</span>
39-
<span className='w-[200px] text-right'>Actions</span>
40+
<span className='w-[300px] text-right'>Actions</span>
4041
</div>
4142
)
4243

@@ -58,6 +59,7 @@ export function Admin() {
5859
const banUser = useBanUser()
5960
const unbanUser = useUnbanUser()
6061
const impersonateUser = useImpersonateUser()
62+
const sendPasswordReset = useSendPasswordReset()
6163
const { recentEmails, recordImpersonation } = useRecentImpersonations()
6264
const { data: recentUsers } = useAdminUsersByEmails(recentEmails)
6365

@@ -75,6 +77,7 @@ export function Admin() {
7577
const [impersonatingUserId, setImpersonatingUserId] = useState<string | null>(null)
7678
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
7779
const [isAddUserOpen, setIsAddUserOpen] = useState(false)
80+
const [provisionWarning, setProvisionWarning] = useState<string | null>(null)
7881

7982
const {
8083
data: usersData,
@@ -162,6 +165,8 @@ export function Admin() {
162165
ids.add((unbanUser.variables as { userId: string }).userId)
163166
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
164167
ids.add((impersonateUser.variables as { userId: string }).userId)
168+
if (sendPasswordReset.isPending && sendPasswordReset.variables?.userId)
169+
ids.add(sendPasswordReset.variables.userId)
165170
if (impersonatingUserId) ids.add(impersonatingUserId)
166171
return ids
167172
}, [
@@ -173,9 +178,19 @@ export function Admin() {
173178
unbanUser.variables,
174179
impersonateUser.isPending,
175180
impersonateUser.variables,
181+
sendPasswordReset.isPending,
182+
sendPasswordReset.variables,
176183
impersonatingUserId,
177184
])
178185

186+
/** Confirms the send in place, since nothing about the user row changes. */
187+
const resetPasswordLabel = (userId: string) => {
188+
if (sendPasswordReset.variables?.userId !== userId) return 'Reset password'
189+
if (sendPasswordReset.isPending) return 'Sending...'
190+
if (sendPasswordReset.isSuccess) return 'Reset sent'
191+
return 'Reset password'
192+
}
193+
179194
const renderUserRow = (u: AdminUser) => (
180195
<div key={u.id} className='flex flex-col gap-2 px-3 py-2 text-small'>
181196
<div className='flex items-center gap-3'>
@@ -187,9 +202,21 @@ export function Admin() {
187202
<span className='w-[55px]'>
188203
{u.banned ? <Badge variant='red'>Banned</Badge> : <Badge variant='green'>Active</Badge>}
189204
</span>
190-
<span className='flex w-[200px] justify-end gap-1'>
205+
<span className='flex w-[300px] justify-end gap-1'>
191206
{u.id !== session?.user?.id && (
192207
<>
208+
<Button
209+
variant='active'
210+
className='h-[28px] px-2 text-caption'
211+
onClick={() => {
212+
setProvisionWarning(null)
213+
sendPasswordReset.reset()
214+
sendPasswordReset.mutate({ userId: u.id, email: u.email })
215+
}}
216+
disabled={pendingUserIds.has(u.id)}
217+
>
218+
{resetPasswordLabel(u.id)}
219+
</Button>
193220
<Button
194221
variant='active'
195222
className='h-[28px] px-2 text-caption'
@@ -401,15 +428,25 @@ export function Admin() {
401428
banUser.error ||
402429
unbanUser.error ||
403430
impersonateUser.error ||
431+
sendPasswordReset.error ||
404432
impersonationGuardError) && (
405433
<p className='text-[var(--text-error)] text-small'>
406434
{impersonationGuardError ||
407-
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
408-
?.message ||
435+
(
436+
setUserRole.error ||
437+
banUser.error ||
438+
unbanUser.error ||
439+
impersonateUser.error ||
440+
sendPasswordReset.error
441+
)?.message ||
409442
'Action failed. Please try again.'}
410443
</p>
411444
)}
412445

446+
{provisionWarning && (
447+
<p className='text-[var(--text-error)] text-small'>{provisionWarning}</p>
448+
)}
449+
413450
{searchQuery.length > 0 && usersData ? (
414451
<>
415452
<div className='flex flex-col gap-0.5'>
@@ -469,9 +506,16 @@ export function Admin() {
469506
<AddUserModal
470507
open={isAddUserOpen}
471508
onOpenChange={setIsAddUserOpen}
472-
onCreated={(user) => {
509+
onCreated={(user, resetEmailError) => {
510+
// Search for the new user either way: when the reset email failed,
511+
// the recovery action named below lives on that user's row.
473512
setSearchInput(user.email)
474513
setAdminParams({ q: user.email, offset: null })
514+
setProvisionWarning(
515+
resetEmailError
516+
? `Created ${user.email}, but the password reset email failed to send (${resetEmailError}). Use Reset password on their row to try again.`
517+
: null
518+
)
475519
}}
476520
/>
477521
</SettingsPanel>

0 commit comments

Comments
 (0)