Skip to content

Commit 9e45939

Browse files
committed
feat(admin): provision a user with an emailed password reset instead of a set password
1 parent 82e654d commit 9e45939

7 files changed

Lines changed: 453 additions & 41 deletions

File tree

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,47 @@ 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: (user: AdminUser) => void }) => {
273+
options.onSuccess(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)
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+
270311
it('shows Better Auth failures without closing the modal', async () => {
271312
addUserMutation.current = {
272313
isPending: false,

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

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ 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
@@ -30,9 +37,11 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
3037
const [isSubmitting, setIsSubmitting] = useState(false)
3138
const [name, setName] = useState('')
3239
const [email, setEmail] = useState('')
40+
const [passwordMode, setPasswordMode] = useState<PasswordMode>('set')
3341
const [password, setPassword] = useState('')
3442
const [emailVerified, setEmailVerified] = useState(true)
3543

44+
const setsPassword = passwordMode === 'set'
3645
const normalizedName = name.trim()
3746
const normalizedEmail = email.trim().toLowerCase()
3847
const nameError = name.length > 0 && !normalizedName ? 'Name is required' : undefined
@@ -46,12 +55,13 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
4655
const canSubmit =
4756
normalizedName.length > 0 &&
4857
isValidEmailSyntax(normalizedEmail) &&
49-
password.length >= 8 &&
58+
(!setsPassword || password.length >= 8) &&
5059
!isSubmissionPending
5160

5261
const reset = () => {
5362
setName('')
5463
setEmail('')
64+
setPasswordMode('set')
5565
setPassword('')
5666
setEmailVerified(true)
5767
addUser.reset()
@@ -72,8 +82,8 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
7282
{
7383
name: normalizedName,
7484
email: normalizedEmail,
75-
password,
7685
emailVerified,
86+
...(setsPassword ? { password } : {}),
7787
},
7888
{
7989
onSuccess: (user) => {
@@ -131,21 +141,38 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
131141
required
132142
/>
133143
<ChipModalField
134-
type='input'
135-
inputType='password'
136-
title='Password'
137-
value={password}
144+
type='dropdown'
145+
title='Credentials'
146+
value={passwordMode}
138147
onChange={(value) => {
139-
setPassword(value)
148+
setPasswordMode(value as PasswordMode)
140149
addUser.reset()
141150
}}
142-
error={passwordError}
143-
hint='Better Auth creates a credential account with this password.'
144-
placeholder='At least 8 characters'
145-
autoComplete='new-password'
151+
options={PASSWORD_MODE_OPTIONS}
152+
align='start'
153+
hint={
154+
setsPassword ? undefined : 'They pick their own password from the emailed reset link.'
155+
}
146156
disabled={isSubmissionPending}
147157
required
148158
/>
159+
{setsPassword && (
160+
<ChipModalField
161+
type='input'
162+
inputType='password'
163+
title='Password'
164+
value={password}
165+
onChange={(value) => {
166+
setPassword(value)
167+
addUser.reset()
168+
}}
169+
error={passwordError}
170+
placeholder='At least 8 characters'
171+
autoComplete='new-password'
172+
disabled={isSubmissionPending}
173+
required
174+
/>
175+
)}
149176
<ChipModalField
150177
type='dropdown'
151178
title='Email status'

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

Lines changed: 35 additions & 4 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

@@ -162,6 +164,8 @@ export function Admin() {
162164
ids.add((unbanUser.variables as { userId: string }).userId)
163165
if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId)
164166
ids.add((impersonateUser.variables as { userId: string }).userId)
167+
if (sendPasswordReset.isPending && sendPasswordReset.variables?.userId)
168+
ids.add(sendPasswordReset.variables.userId)
165169
if (impersonatingUserId) ids.add(impersonatingUserId)
166170
return ids
167171
}, [
@@ -173,9 +177,19 @@ export function Admin() {
173177
unbanUser.variables,
174178
impersonateUser.isPending,
175179
impersonateUser.variables,
180+
sendPasswordReset.isPending,
181+
sendPasswordReset.variables,
176182
impersonatingUserId,
177183
])
178184

185+
/** Confirms the send in place, since nothing about the user row changes. */
186+
const resetPasswordLabel = (userId: string) => {
187+
if (sendPasswordReset.variables?.userId !== userId) return 'Reset password'
188+
if (sendPasswordReset.isPending) return 'Sending...'
189+
if (sendPasswordReset.isSuccess) return 'Reset sent'
190+
return 'Reset password'
191+
}
192+
179193
const renderUserRow = (u: AdminUser) => (
180194
<div key={u.id} className='flex flex-col gap-2 px-3 py-2 text-small'>
181195
<div className='flex items-center gap-3'>
@@ -187,9 +201,20 @@ export function Admin() {
187201
<span className='w-[55px]'>
188202
{u.banned ? <Badge variant='red'>Banned</Badge> : <Badge variant='green'>Active</Badge>}
189203
</span>
190-
<span className='flex w-[200px] justify-end gap-1'>
204+
<span className='flex w-[300px] justify-end gap-1'>
191205
{u.id !== session?.user?.id && (
192206
<>
207+
<Button
208+
variant='active'
209+
className='h-[28px] px-2 text-caption'
210+
onClick={() => {
211+
sendPasswordReset.reset()
212+
sendPasswordReset.mutate({ userId: u.id, email: u.email })
213+
}}
214+
disabled={pendingUserIds.has(u.id)}
215+
>
216+
{resetPasswordLabel(u.id)}
217+
</Button>
193218
<Button
194219
variant='active'
195220
className='h-[28px] px-2 text-caption'
@@ -401,11 +426,17 @@ export function Admin() {
401426
banUser.error ||
402427
unbanUser.error ||
403428
impersonateUser.error ||
429+
sendPasswordReset.error ||
404430
impersonationGuardError) && (
405431
<p className='text-[var(--text-error)] text-small'>
406432
{impersonationGuardError ||
407-
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
408-
?.message ||
433+
(
434+
setUserRole.error ||
435+
banUser.error ||
436+
unbanUser.error ||
437+
impersonateUser.error ||
438+
sendPasswordReset.error
439+
)?.message ||
409440
'Action failed. Please try again.'}
410441
</p>
411442
)}

apps/sim/hooks/queries/admin-users.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55

6-
const { mockCreateUser } = vi.hoisted(() => ({
6+
const { mockCreateUser, mockRequestJson } = vi.hoisted(() => ({
77
mockCreateUser: vi.fn(),
8+
mockRequestJson: vi.fn(),
89
}))
910

1011
vi.mock('@/lib/auth/auth-client', () => ({
@@ -15,11 +16,30 @@ vi.mock('@/lib/auth/auth-client', () => ({
1516
},
1617
}))
1718

19+
vi.mock('@/lib/api/client/request', () => ({
20+
requestJson: mockRequestJson,
21+
}))
22+
23+
vi.mock('@/lib/core/utils/urls', () => ({
24+
getBaseUrl: () => 'https://sim.test',
25+
}))
26+
27+
import { forgetPasswordContract } from '@/lib/api/contracts'
1828
import { addUser } from '@/hooks/queries/admin-users'
1929

30+
const CREATED_USER = {
31+
id: 'user-1',
32+
name: 'Canary Writer',
33+
email: 'writer@synthetics.example.com',
34+
role: 'user',
35+
banned: false,
36+
banReason: null,
37+
}
38+
2039
describe('addUser', () => {
2140
beforeEach(() => {
2241
vi.clearAllMocks()
42+
mockRequestJson.mockResolvedValue({ success: true })
2343
})
2444

2545
it('creates a Better Auth credential user with normalized identity fields', async () => {
@@ -59,6 +79,62 @@ describe('addUser', () => {
5979
role: 'user',
6080
data: { emailVerified: true },
6181
})
82+
expect(mockRequestJson).not.toHaveBeenCalled()
83+
})
84+
85+
it('omits the password and emails a reset link when no password is given', async () => {
86+
mockCreateUser.mockResolvedValue({ data: { user: CREATED_USER }, error: null })
87+
88+
await expect(
89+
addUser({
90+
name: 'Canary Writer',
91+
email: ' Writer@Synthetics.Example.com ',
92+
emailVerified: true,
93+
})
94+
).resolves.toEqual(CREATED_USER)
95+
96+
expect(mockCreateUser).toHaveBeenCalledWith({
97+
name: 'Canary Writer',
98+
email: 'writer@synthetics.example.com',
99+
role: 'user',
100+
data: { emailVerified: true },
101+
})
102+
expect(mockCreateUser.mock.calls[0][0]).not.toHaveProperty('password')
103+
expect(mockRequestJson).toHaveBeenCalledWith(forgetPasswordContract, {
104+
body: {
105+
email: 'writer@synthetics.example.com',
106+
redirectTo: 'https://sim.test/reset-password',
107+
},
108+
})
109+
})
110+
111+
it('never sends a reset email when the account was not created', async () => {
112+
mockCreateUser.mockResolvedValue({
113+
data: null,
114+
error: { message: 'A user with that email already exists' },
115+
})
116+
117+
await expect(
118+
addUser({
119+
name: 'Canary Writer',
120+
email: 'writer@synthetics.example.com',
121+
emailVerified: true,
122+
})
123+
).rejects.toThrow('A user with that email already exists')
124+
expect(mockRequestJson).not.toHaveBeenCalled()
125+
})
126+
127+
it('names the row-level recovery path when the reset email fails to send', async () => {
128+
mockCreateUser.mockResolvedValue({ data: { user: CREATED_USER }, error: null })
129+
mockRequestJson.mockRejectedValue(new Error('SMTP unavailable'))
130+
131+
await expect(
132+
addUser({
133+
name: 'Canary Writer',
134+
email: 'writer@synthetics.example.com',
135+
emailVerified: true,
136+
})
137+
).rejects.toThrow(/Account created, but the password reset email failed to send/)
62138
})
63139

64140
it('surfaces resolved Better Auth errors', async () => {

0 commit comments

Comments
 (0)