diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx index 71883816a30..bfa90c543fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx @@ -115,7 +115,7 @@ vi.mock('@/hooks/queries/admin-users', () => ({ })) import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal' -import type { AddUserInput, AdminUser } from '@/hooks/queries/admin-users' +import type { AddUserInput, AddUserResult, AdminUser } from '@/hooks/queries/admin-users' const CREATED_USER: AdminUser = { id: 'user-1', @@ -128,7 +128,7 @@ const CREATED_USER: AdminUser = { let container: HTMLDivElement let root: Root -let onCreated: ReturnType void>> +let onCreated: ReturnType void>> let onOpenChange: ReturnType void>> async function renderModal() { @@ -203,8 +203,8 @@ describe('AddUserModal', () => { it('creates a verified credential user and returns it to the admin view', async () => { mockMutate.mockImplementation( - (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { - options.onSuccess(CREATED_USER) + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER }) } ) await renderModal() @@ -226,7 +226,7 @@ describe('AddUserModal', () => { { onSuccess: expect.any(Function), onSettled: expect.any(Function) } ) expect(onOpenChange).toHaveBeenCalledWith(false) - expect(onCreated).toHaveBeenCalledWith(CREATED_USER) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER, undefined) }) it('ignores repeated submissions before the pending state renders', async () => { @@ -246,8 +246,8 @@ describe('AddUserModal', () => { it('supports unverified accounts without exposing a platform-role control', async () => { mockMutate.mockImplementation( - (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { - options.onSuccess(CREATED_USER) + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER }) } ) await renderModal() @@ -267,6 +267,71 @@ describe('AddUserModal', () => { }) }) + it('drops the password field and submits without one when emailing a reset link', async () => { + mockMutate.mockImplementation( + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER }) + } + ) + await renderModal() + await changeField('Name', 'Canary Writer') + await changeField('Email', 'writer@synthetics.example.com') + await changeField('Credentials', 'email') + + expect(container.querySelector('[aria-label="Password"]')).toBeNull() + expect(buttonLabelled('Add user').disabled).toBe(false) + + await act(async () => { + buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockMutate).toHaveBeenCalledWith( + { + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + emailVerified: true, + }, + { onSuccess: expect.any(Function), onSettled: expect.any(Function) } + ) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER, undefined) + }) + + it('keeps a typed password across a round trip through the reset-link flow', async () => { + await renderModal() + await fillRequiredFields() + await changeField('Credentials', 'email') + await changeField('Credentials', 'set') + + expect((field('Password') as HTMLInputElement).value).toBe('canary-password') + expect(buttonLabelled('Add user').disabled).toBe(false) + }) + + it('still hands the user back when only its reset email failed', async () => { + mockMutate.mockImplementation( + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER, resetEmailError: 'SMTP unavailable' }) + } + ) + await renderModal() + await changeField('Name', 'Canary Writer') + await changeField('Email', 'writer@synthetics.example.com') + await changeField('Credentials', 'email') + + await act(async () => { + buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + // The account exists, so this closes like any other create — the host + // surfaces the user (and the reason) rather than stranding the operator in + // a modal whose form no longer maps to anything. + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER, 'SMTP unavailable') + }) + it('shows Better Auth failures without closing the modal', async () => { addUserMutation.current = { isPending: false, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx index 89ebb362a34..9a09abbb8c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx @@ -18,10 +18,23 @@ const EMAIL_STATUS_OPTIONS = [ { value: 'unverified', label: 'Unverified' }, ] as const +const PASSWORD_MODE_OPTIONS = [ + { value: 'set', label: 'Set a password' }, + { value: 'email', label: 'Email a reset link' }, +] as const + +type PasswordMode = (typeof PASSWORD_MODE_OPTIONS)[number]['value'] + interface AddUserModalProps { open: boolean onOpenChange: (open: boolean) => void - onCreated: (user: AdminUser) => void + /** + * The account was created. `resetEmailError` is set when its provisioning + * reset email could not be sent — the account still exists, so the host is + * expected to surface the user (and report this) rather than treat it as a + * failed create. + */ + onCreated: (user: AdminUser, resetEmailError?: string) => void } export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProps) { @@ -30,9 +43,11 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp const [isSubmitting, setIsSubmitting] = useState(false) const [name, setName] = useState('') const [email, setEmail] = useState('') + const [passwordMode, setPasswordMode] = useState('set') const [password, setPassword] = useState('') const [emailVerified, setEmailVerified] = useState(true) + const setsPassword = passwordMode === 'set' const normalizedName = name.trim() const normalizedEmail = email.trim().toLowerCase() const nameError = name.length > 0 && !normalizedName ? 'Name is required' : undefined @@ -46,12 +61,13 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp const canSubmit = normalizedName.length > 0 && isValidEmailSyntax(normalizedEmail) && - password.length >= 8 && + (!setsPassword || password.length >= 8) && !isSubmissionPending const reset = () => { setName('') setEmail('') + setPasswordMode('set') setPassword('') setEmailVerified(true) addUser.reset() @@ -72,14 +88,14 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp { name: normalizedName, email: normalizedEmail, - password, emailVerified, + ...(setsPassword ? { password } : {}), }, { - onSuccess: (user) => { + onSuccess: ({ user, resetEmailError }) => { reset() onOpenChange(false) - onCreated(user) + onCreated(user, resetEmailError) }, onSettled: () => { submissionInFlightRef.current = false @@ -131,21 +147,38 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp required /> { - setPassword(value) + setPasswordMode(value as PasswordMode) addUser.reset() }} - error={passwordError} - hint='Better Auth creates a credential account with this password.' - placeholder='At least 8 characters' - autoComplete='new-password' + options={PASSWORD_MODE_OPTIONS} + align='start' + hint={ + setsPassword ? undefined : 'They pick their own password from the emailed reset link.' + } disabled={isSubmissionPending} required /> + {setsPassword && ( + { + setPassword(value) + addUser.reset() + }} + error={passwordError} + placeholder='At least 8 characters' + autoComplete='new-password' + disabled={isSubmissionPending} + required + /> + )} Email Role Status - Actions + Actions ) @@ -58,6 +59,7 @@ export function Admin() { const banUser = useBanUser() const unbanUser = useUnbanUser() const impersonateUser = useImpersonateUser() + const sendPasswordReset = useSendPasswordReset() const { recentEmails, recordImpersonation } = useRecentImpersonations() const { data: recentUsers } = useAdminUsersByEmails(recentEmails) @@ -75,6 +77,7 @@ export function Admin() { const [impersonatingUserId, setImpersonatingUserId] = useState(null) const [impersonationGuardError, setImpersonationGuardError] = useState(null) const [isAddUserOpen, setIsAddUserOpen] = useState(false) + const [provisionWarning, setProvisionWarning] = useState(null) const { data: usersData, @@ -162,6 +165,8 @@ export function Admin() { ids.add((unbanUser.variables as { userId: string }).userId) if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId) ids.add((impersonateUser.variables as { userId: string }).userId) + if (sendPasswordReset.isPending && sendPasswordReset.variables?.userId) + ids.add(sendPasswordReset.variables.userId) if (impersonatingUserId) ids.add(impersonatingUserId) return ids }, [ @@ -173,9 +178,19 @@ export function Admin() { unbanUser.variables, impersonateUser.isPending, impersonateUser.variables, + sendPasswordReset.isPending, + sendPasswordReset.variables, impersonatingUserId, ]) + /** Confirms the send in place, since nothing about the user row changes. */ + const resetPasswordLabel = (userId: string) => { + if (sendPasswordReset.variables?.userId !== userId) return 'Reset password' + if (sendPasswordReset.isPending) return 'Sending...' + if (sendPasswordReset.isSuccess) return 'Reset sent' + return 'Reset password' + } + const renderUserRow = (u: AdminUser) => (
@@ -187,9 +202,21 @@ export function Admin() { {u.banned ? Banned : Active} - + {u.id !== session?.user?.id && ( <> + + ) : undefined + } + {...aria} + /> + ) +} + /** * Internal renderer for {@link ChipModalField} `type='emails'`. Delegates the * chip lifecycle to {@link ChipEmailsInput} and adds only the field-level