From 9392bb9fba0667f75e98eb5d55e465ef963ca109 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 15:04:57 -0700 Subject: [PATCH 1/5] feat(admin): add password user creation --- .../components/admin/add-user-modal.test.tsx | 250 ++++++++++++++++++ .../components/admin/add-user-modal.tsx | 188 +++++++++++++ .../settings/components/admin/admin.tsx | 17 +- apps/sim/hooks/queries/admin-users.test.ts | 95 +++++++ apps/sim/hooks/queries/admin-users.ts | 38 +++ 5 files changed, 587 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx create mode 100644 apps/sim/hooks/queries/admin-users.test.ts 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 new file mode 100644 index 00000000000..fe210c44b73 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx @@ -0,0 +1,250 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const { addUserMutation, mockMutate, mockReset } = vi.hoisted(() => ({ + addUserMutation: { + current: { + isPending: false, + error: null as Error | null, + }, + }, + mockMutate: vi.fn(), + mockReset: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, + ChipModalHeader: ({ children }: { children: ReactNode }) =>

{children}

, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalError: ({ children }: { children: ReactNode }) => + children ?
{children}
: null, + ChipModalFooter: ({ + onCancel, + primaryAction, + }: { + onCancel: () => void + primaryAction: { label: ReactNode; onClick: () => void; disabled?: boolean } + }) => ( + + ), + ChipModalField: ({ + type, + inputType, + title, + value, + onChange, + options, + disabled, + error, + }: { + type: string + inputType?: string + title: string + value: string + onChange: (value: string) => void + options?: ReadonlyArray<{ value: string; label: string }> + disabled?: boolean + error?: ReactNode + }) => ( +
+ {title} + {type === 'dropdown' ? ( + + ) : ( + onChange(event.target.value)} + /> + )} + {error && {error}} +
+ ), +})) + +vi.mock('@/hooks/queries/admin-users', () => ({ + useAddUser: () => ({ + ...addUserMutation.current, + mutate: mockMutate, + reset: mockReset, + }), +})) + +import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal' +import type { AddUserInput, AdminUser } from '@/hooks/queries/admin-users' + +const CREATED_USER: AdminUser = { + id: 'user-1', + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + role: 'user', + banned: false, + banReason: null, +} + +let container: HTMLDivElement +let root: Root +let onCreated: ReturnType void>> +let onOpenChange: ReturnType void>> + +async function renderModal() { + await act(async () => { + root.render() + }) +} + +function field(label: string): HTMLInputElement | HTMLSelectElement { + const element = container.querySelector( + `[aria-label="${label}"]` + ) + if (!element) throw new Error(`No field labelled "${label}"`) + return element +} + +async function changeField(label: string, value: string) { + const element = field(label) + const valueSetter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(element), 'value')?.set + if (!valueSetter) throw new Error(`Field labelled "${label}" has no value setter`) + await act(async () => { + valueSetter.call(element, value) + element.dispatchEvent( + new Event(element instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true }) + ) + }) +} + +function buttonLabelled(text: string): HTMLButtonElement { + const button = [...container.querySelectorAll('button')].find( + (candidate) => candidate.textContent === text + ) + if (!button) throw new Error(`No button labelled "${text}"`) + return button +} + +async function fillRequiredFields() { + await changeField('Name', ' Canary Writer ') + await changeField('Email', ' Writer@Synthetics.Example.com ') + await changeField('Password', 'canary-password') +} + +describe('AddUserModal', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + onCreated = vi.fn() + onOpenChange = vi.fn() + addUserMutation.current = { isPending: false, error: null } + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() + }) + + it('requires a name, valid email, and eight-character password', async () => { + await renderModal() + + expect(buttonLabelled('Add user').disabled).toBe(true) + + await changeField('Name', 'Canary Writer') + await changeField('Email', 'not-an-email') + await changeField('Password', 'short') + + expect(buttonLabelled('Add user').disabled).toBe(true) + expect(container.textContent).toContain('Enter a valid email') + expect(container.textContent).toContain('Password must be at least 8 characters') + }) + + 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) + } + ) + await renderModal() + await fillRequiredFields() + + 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', + password: 'canary-password', + role: 'user', + emailVerified: true, + }, + { onSuccess: expect.any(Function) } + ) + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER) + }) + + it('supports platform-admin and unverified accounts', async () => { + mockMutate.mockImplementation( + (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { + options.onSuccess({ ...CREATED_USER, role: 'admin' }) + } + ) + await renderModal() + await fillRequiredFields() + await changeField('Platform role', 'admin') + await changeField('Email status', 'unverified') + + await act(async () => { + buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ role: 'admin', emailVerified: false }), + { onSuccess: expect.any(Function) } + ) + }) + + it('shows Better Auth failures without closing the modal', async () => { + addUserMutation.current = { + isPending: false, + error: new Error('A user with that email already exists'), + } + await renderModal() + + expect(container.textContent).toContain('A user with that email already exists') + expect(onOpenChange).not.toHaveBeenCalled() + expect(onCreated).not.toHaveBeenCalled() + }) +}) 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 new file mode 100644 index 00000000000..8ecd99e5e57 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx @@ -0,0 +1,188 @@ +'use client' + +import { useState } from 'react' +import { + ChipModal, + ChipModalBody, + ChipModalError, + ChipModalField, + ChipModalFooter, + ChipModalHeader, +} from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import { isValidEmailSyntax } from '@sim/utils/string' +import { type AddUserInput, type AdminUser, useAddUser } from '@/hooks/queries/admin-users' + +const ROLE_OPTIONS = [ + { value: 'user', label: 'User' }, + { value: 'admin', label: 'Platform admin' }, +] as const + +const EMAIL_STATUS_OPTIONS = [ + { value: 'verified', label: 'Verified' }, + { value: 'unverified', label: 'Unverified' }, +] as const + +interface AddUserModalProps { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (user: AdminUser) => void +} + +export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProps) { + const addUser = useAddUser() + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [role, setRole] = useState('user') + const [emailVerified, setEmailVerified] = useState(true) + + const normalizedName = name.trim() + const normalizedEmail = email.trim().toLowerCase() + const nameError = name.length > 0 && !normalizedName ? 'Name is required' : undefined + const emailError = + email.length > 0 && !isValidEmailSyntax(normalizedEmail) ? 'Enter a valid email' : undefined + const passwordError = + password.length > 0 && password.length < 8 + ? 'Password must be at least 8 characters' + : undefined + const canSubmit = + normalizedName.length > 0 && + isValidEmailSyntax(normalizedEmail) && + password.length >= 8 && + !addUser.isPending + + const reset = () => { + setName('') + setEmail('') + setPassword('') + setRole('user') + setEmailVerified(true) + addUser.reset() + } + + const handleClose = () => { + if (addUser.isPending) return + reset() + onOpenChange(false) + } + + const handleAddUser = () => { + if (!canSubmit) return + addUser.reset() + addUser.mutate( + { + name: normalizedName, + email: normalizedEmail, + password, + role, + emailVerified, + }, + { + onSuccess: (user) => { + reset() + onOpenChange(false) + onCreated(user) + }, + } + ) + } + + return ( + { + if (!next) handleClose() + }} + srTitle='Add user' + > + Add user + + { + setName(value) + addUser.reset() + }} + error={nameError} + placeholder='Canary Writer' + maxLength={100} + autoComplete='off' + disabled={addUser.isPending} + required + /> + { + setEmail(value) + addUser.reset() + }} + error={emailError} + placeholder='writer@synthetics.example.com' + autoComplete='off' + disabled={addUser.isPending} + required + /> + { + setPassword(value) + addUser.reset() + }} + error={passwordError} + hint='Better Auth creates a credential account with this password.' + placeholder='At least 8 characters' + autoComplete='new-password' + disabled={addUser.isPending} + required + /> + { + setRole(value as AddUserInput['role']) + addUser.reset() + }} + options={ROLE_OPTIONS} + align='start' + disabled={addUser.isPending} + required + /> + { + setEmailVerified(value === 'verified') + addUser.reset() + }} + options={EMAIL_STATUS_OPTIONS} + align='start' + hint='Verified users can sign in when email verification is required.' + disabled={addUser.isPending} + required + /> + + {addUser.error ? getErrorMessage(addUser.error, 'Failed to add user') : null} + + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index e456d250d1b..8eb7c145d76 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useQueryStates } from 'nuqs' import type { MothershipEnvironment } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' +import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal' import { adminParsers, adminUrlKeys, @@ -72,6 +73,7 @@ export function Admin() { const [banReason, setBanReason] = useState('') const [impersonatingUserId, setImpersonatingUserId] = useState(null) const [impersonationGuardError, setImpersonationGuardError] = useState(null) + const [isAddUserOpen, setIsAddUserOpen] = useState(false) const { data: usersData, @@ -370,7 +372,12 @@ export function Admin() {
-

User Management

+
+

User Management

+ +
+ { + setSearchInput(user.email) + setAdminParams({ q: user.email, offset: null }) + }} + /> ) } diff --git a/apps/sim/hooks/queries/admin-users.test.ts b/apps/sim/hooks/queries/admin-users.test.ts new file mode 100644 index 00000000000..7f6e197f79a --- /dev/null +++ b/apps/sim/hooks/queries/admin-users.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateUser } = vi.hoisted(() => ({ + mockCreateUser: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + client: { + admin: { + createUser: mockCreateUser, + }, + }, +})) + +import { addUser } from '@/hooks/queries/admin-users' + +describe('addUser', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('creates a Better Auth credential user with normalized identity fields', async () => { + mockCreateUser.mockResolvedValue({ + data: { + user: { + id: 'user-1', + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + role: 'user', + banned: false, + banReason: null, + }, + }, + error: null, + }) + + await expect( + addUser({ + name: ' Canary Writer ', + email: ' Writer@Synthetics.Example.com ', + password: 'canary-password', + role: 'user', + emailVerified: true, + }) + ).resolves.toEqual({ + id: 'user-1', + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + role: 'user', + banned: false, + banReason: null, + }) + expect(mockCreateUser).toHaveBeenCalledWith({ + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + password: 'canary-password', + role: 'user', + data: { emailVerified: true }, + }) + }) + + it('surfaces resolved Better Auth errors', async () => { + mockCreateUser.mockResolvedValue({ + data: null, + error: { message: 'A user with that email already exists' }, + }) + + await expect( + addUser({ + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + password: 'canary-password', + role: 'user', + emailVerified: true, + }) + ).rejects.toThrow('A user with that email already exists') + }) + + it('fails fast when Better Auth omits the created user', async () => { + mockCreateUser.mockResolvedValue({ data: null, error: null }) + + await expect( + addUser({ + name: 'Canary Writer', + email: 'writer@synthetics.example.com', + password: 'canary-password', + role: 'user', + emailVerified: true, + }) + ).rejects.toThrow('Better Auth did not return the created user') + }) +}) diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index afa3fa7c893..f85de408b54 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -24,6 +24,14 @@ export interface AdminUser { banReason: string | null } +export interface AddUserInput { + name: string + email: string + password: string + role: 'user' | 'admin' + emailVerified: boolean +} + interface AdminUserListData { users: AdminUser[] total: number @@ -47,6 +55,25 @@ function mapUser(u: { } } +export async function addUser({ + name, + email, + password, + role, + emailVerified, +}: AddUserInput): Promise { + const { data, error } = await client.admin.createUser({ + name: name.trim(), + email: email.trim().toLowerCase(), + password, + role, + data: { emailVerified }, + }) + if (error) throw new Error(error.message ?? 'Failed to add user') + if (!data?.user) throw new Error('Better Auth did not return the created user') + return mapUser(data.user) +} + async function fetchAdminUsers( offset: number, limit: number, @@ -127,6 +154,17 @@ export function useAdminUsers(offset: number, limit: number, searchQuery: string }) } +export function useAddUser() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: addUser, + onSettled: () => queryClient.invalidateQueries({ queryKey: adminUserKeys.lists() }), + onError: (error) => { + logger.error('Failed to add user', error) + }, + }) +} + export function useSetUserRole() { const queryClient = useQueryClient() return useMutation({ From 1e2bfb23a0ce1698fbd5bcec104a4fa279e72e81 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 15:13:49 -0700 Subject: [PATCH 2/5] fix(admin): restrict created users to normal role --- .../components/admin/add-user-modal.test.tsx | 14 +++++------ .../components/admin/add-user-modal.tsx | 23 +------------------ apps/sim/hooks/queries/admin-users.test.ts | 3 --- apps/sim/hooks/queries/admin-users.ts | 4 +--- 4 files changed, 8 insertions(+), 36 deletions(-) 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 fe210c44b73..5c260c08afa 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 @@ -204,7 +204,6 @@ describe('AddUserModal', () => { name: 'Canary Writer', email: 'writer@synthetics.example.com', password: 'canary-password', - role: 'user', emailVerified: true, }, { onSuccess: expect.any(Function) } @@ -213,15 +212,14 @@ describe('AddUserModal', () => { expect(onCreated).toHaveBeenCalledWith(CREATED_USER) }) - it('supports platform-admin and unverified accounts', async () => { + it('supports unverified accounts without exposing a platform-role control', async () => { mockMutate.mockImplementation( (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { - options.onSuccess({ ...CREATED_USER, role: 'admin' }) + options.onSuccess(CREATED_USER) } ) await renderModal() await fillRequiredFields() - await changeField('Platform role', 'admin') await changeField('Email status', 'unverified') await act(async () => { @@ -230,10 +228,10 @@ describe('AddUserModal', () => { await Promise.resolve() }) - expect(mockMutate).toHaveBeenCalledWith( - expect.objectContaining({ role: 'admin', emailVerified: false }), - { onSuccess: expect.any(Function) } - ) + expect(container.querySelector('[aria-label="Platform role"]')).toBeNull() + expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ emailVerified: false }), { + onSuccess: expect.any(Function), + }) }) it('shows Better Auth failures without closing the modal', async () => { 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 8ecd99e5e57..ac7972910ff 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 @@ -11,12 +11,7 @@ import { } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { isValidEmailSyntax } from '@sim/utils/string' -import { type AddUserInput, type AdminUser, useAddUser } from '@/hooks/queries/admin-users' - -const ROLE_OPTIONS = [ - { value: 'user', label: 'User' }, - { value: 'admin', label: 'Platform admin' }, -] as const +import { type AdminUser, useAddUser } from '@/hooks/queries/admin-users' const EMAIL_STATUS_OPTIONS = [ { value: 'verified', label: 'Verified' }, @@ -34,7 +29,6 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp const [name, setName] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') - const [role, setRole] = useState('user') const [emailVerified, setEmailVerified] = useState(true) const normalizedName = name.trim() @@ -56,7 +50,6 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp setName('') setEmail('') setPassword('') - setRole('user') setEmailVerified(true) addUser.reset() } @@ -75,7 +68,6 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp name: normalizedName, email: normalizedEmail, password, - role, emailVerified, }, { @@ -143,19 +135,6 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp disabled={addUser.isPending} required /> - { - setRole(value as AddUserInput['role']) - addUser.reset() - }} - options={ROLE_OPTIONS} - align='start' - disabled={addUser.isPending} - required - /> { name: ' Canary Writer ', email: ' Writer@Synthetics.Example.com ', password: 'canary-password', - role: 'user', emailVerified: true, }) ).resolves.toEqual({ @@ -73,7 +72,6 @@ describe('addUser', () => { name: 'Canary Writer', email: 'writer@synthetics.example.com', password: 'canary-password', - role: 'user', emailVerified: true, }) ).rejects.toThrow('A user with that email already exists') @@ -87,7 +85,6 @@ describe('addUser', () => { name: 'Canary Writer', email: 'writer@synthetics.example.com', password: 'canary-password', - role: 'user', emailVerified: true, }) ).rejects.toThrow('Better Auth did not return the created user') diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index f85de408b54..c15a0465bc4 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -28,7 +28,6 @@ export interface AddUserInput { name: string email: string password: string - role: 'user' | 'admin' emailVerified: boolean } @@ -59,14 +58,13 @@ export async function addUser({ name, email, password, - role, emailVerified, }: AddUserInput): Promise { const { data, error } = await client.admin.createUser({ name: name.trim(), email: email.trim().toLowerCase(), password, - role, + role: 'user', data: { emailVerified }, }) if (error) throw new Error(error.message ?? 'Failed to add user') From 712aeec0683556d4e7cb1bd6d1d23c66ecf77043 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 16:30:04 -0700 Subject: [PATCH 3/5] improvement(admin): refine add user action --- .../settings/components/admin/admin.tsx | 176 +++++++++--------- 1 file changed, 89 insertions(+), 87 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index 8eb7c145d76..1bb6b6fdd47 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useMemo, useRef, useState } from 'react' -import { Badge, Button, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn' +import { Badge, Button, Chip, ChipInput, ChipSelect, cn, Label, Search, Switch } from '@sim/emcn' import { getErrorMessage } from '@sim/utils/errors' import { useQueryStates } from 'nuqs' import type { MothershipEnvironment } from '@/lib/api/contracts' @@ -14,6 +14,7 @@ import { import { useRecentImpersonations } from '@/app/workspace/[workspaceId]/settings/components/admin/use-recent-impersonations' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { type AdminUser, useAdminUsers, @@ -371,99 +372,100 @@ export function Admin() {
-
-
-

User Management

- -
-
- setSearchInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder='Search by email or paste a user ID...' - className='min-w-0 flex-1' - /> - -
- - {usersError && ( -

- {getErrorMessage(usersError, 'Failed to fetch users')} -

- )} + setIsAddUserOpen(true)}>Add user} + > +
+
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + placeholder='Search by email or paste a user ID...' + className='min-w-0 flex-1' + /> + +
+ + {usersError && ( +

+ {getErrorMessage(usersError, 'Failed to fetch users')} +

+ )} - {(setUserRole.error || - banUser.error || - unbanUser.error || - impersonateUser.error || - impersonationGuardError) && ( -

- {impersonationGuardError || - (setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) - ?.message || - 'Action failed. Please try again.'} -

- )} + {(setUserRole.error || + banUser.error || + unbanUser.error || + impersonateUser.error || + impersonationGuardError) && ( +

+ {impersonationGuardError || + (setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error) + ?.message || + 'Action failed. Please try again.'} +

+ )} - {searchQuery.length > 0 && usersData ? ( - <> -
- {USER_TABLE_HEADER} + {searchQuery.length > 0 && usersData ? ( + <> +
+ {USER_TABLE_HEADER} - {usersData.users.length === 0 && ( - No users found. - )} + {usersData.users.length === 0 && ( + No users found. + )} - {usersData.users.map((u) => renderUserRow(u))} -
+ {usersData.users.map((u) => renderUserRow(u))} +
- {totalPages > 1 && ( -
- - Page {currentPage} of {totalPages} ({usersData.total} users) - -
- - + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} ({usersData.total} users) + +
+ + +
+ )} + + ) : ( + searchQuery.length === 0 && + recentUsers && + recentUsers.length > 0 && ( +
+ {USER_TABLE_HEADER} + {recentUsers.map((u) => renderUserRow(u))}
- )} - - ) : ( - searchQuery.length === 0 && - recentUsers && - recentUsers.length > 0 && ( -
- {USER_TABLE_HEADER} - {recentUsers.map((u) => renderUserRow(u))} -
- ) - )} -
+ ) + )} +
+ Date: Tue, 4 Aug 2026 16:30:19 -0700 Subject: [PATCH 4/5] fix(admin): prevent duplicate user creation --- .../components/admin/add-user-modal.test.tsx | 16 +++++++++++++++- .../settings/components/admin/add-user-modal.tsx | 11 ++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) 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 5c260c08afa..796d64babf6 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 @@ -206,12 +206,25 @@ describe('AddUserModal', () => { password: 'canary-password', emailVerified: true, }, - { onSuccess: expect.any(Function) } + { onSuccess: expect.any(Function), onSettled: expect.any(Function) } ) expect(onOpenChange).toHaveBeenCalledWith(false) expect(onCreated).toHaveBeenCalledWith(CREATED_USER) }) + it('ignores repeated submissions before the pending state renders', async () => { + await renderModal() + await fillRequiredFields() + + await act(async () => { + const addUserButton = buttonLabelled('Add user') + addUserButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + addUserButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mockMutate).toHaveBeenCalledTimes(1) + }) + it('supports unverified accounts without exposing a platform-role control', async () => { mockMutate.mockImplementation( (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { @@ -231,6 +244,7 @@ describe('AddUserModal', () => { expect(container.querySelector('[aria-label="Platform role"]')).toBeNull() expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ emailVerified: false }), { onSuccess: expect.any(Function), + onSettled: expect.any(Function), }) }) 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 ac7972910ff..204f0000d00 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 @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useRef, useState } from 'react' import { ChipModal, ChipModalBody, @@ -26,6 +26,7 @@ interface AddUserModalProps { export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProps) { const addUser = useAddUser() + const submissionInFlightRef = useRef(false) const [name, setName] = useState('') const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -55,13 +56,14 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp } const handleClose = () => { - if (addUser.isPending) return + if (submissionInFlightRef.current || addUser.isPending) return reset() onOpenChange(false) } const handleAddUser = () => { - if (!canSubmit) return + if (!canSubmit || submissionInFlightRef.current) return + submissionInFlightRef.current = true addUser.reset() addUser.mutate( { @@ -76,6 +78,9 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp onOpenChange(false) onCreated(user) }, + onSettled: () => { + submissionInFlightRef.current = false + }, } ) } From 44dfe92f1442b754b7ea4b4694f05792c35460a4 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 4 Aug 2026 16:39:16 -0700 Subject: [PATCH 5/5] fix(admin): reflect immediate submission state --- .../components/admin/add-user-modal.test.tsx | 23 ++++++++++++++++-- .../components/admin/add-user-modal.tsx | 24 ++++++++++++------- .../src/components/chip-modal/chip-modal.tsx | 13 +++++++++- 3 files changed, 48 insertions(+), 12 deletions(-) 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 796d64babf6..71883816a30 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 @@ -21,19 +21,36 @@ const { addUserMutation, mockMutate, mockReset } = vi.hoisted(() => ({ vi.mock('@sim/emcn', () => ({ ChipModal: ({ open, children }: { open: boolean; children: ReactNode }) => open ?
{children}
: null, - ChipModalHeader: ({ children }: { children: ReactNode }) =>

{children}

, + ChipModalHeader: ({ + children, + onClose, + closeDisabled, + }: { + children: ReactNode + onClose: () => void + closeDisabled?: boolean + }) => ( +
+

{children}

+ +
+ ), ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, ChipModalError: ({ children }: { children: ReactNode }) => children ?
{children}
: null, ChipModalFooter: ({ onCancel, + cancelDisabled, primaryAction, }: { onCancel: () => void + cancelDisabled?: boolean primaryAction: { label: ReactNode; onClick: () => void; disabled?: boolean } }) => (