+
+ 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))}
-
- )
- )}
-
+ )
+ )}
+
+
+
{
+ 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..41fb1a43e10
--- /dev/null
+++ b/apps/sim/hooks/queries/admin-users.test.ts
@@ -0,0 +1,92 @@
+/**
+ * @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',
+ 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',
+ 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',
+ 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..c15a0465bc4 100644
--- a/apps/sim/hooks/queries/admin-users.ts
+++ b/apps/sim/hooks/queries/admin-users.ts
@@ -24,6 +24,13 @@ export interface AdminUser {
banReason: string | null
}
+export interface AddUserInput {
+ name: string
+ email: string
+ password: string
+ emailVerified: boolean
+}
+
interface AdminUserListData {
users: AdminUser[]
total: number
@@ -47,6 +54,24 @@ function mapUser(u: {
}
}
+export async function addUser({
+ name,
+ email,
+ password,
+ emailVerified,
+}: AddUserInput): Promise {
+ const { data, error } = await client.admin.createUser({
+ name: name.trim(),
+ email: email.trim().toLowerCase(),
+ password,
+ role: 'user',
+ 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 +152,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({
diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx
index e0d2da89f4b..85256fc2d46 100644
--- a/packages/emcn/src/components/chip-modal/chip-modal.tsx
+++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx
@@ -154,6 +154,8 @@ export interface ChipModalHeaderProps extends React.HTMLAttributes | null
/** Invoked when the trailing close button is activated. Always rendered. */
onClose: () => void
+ /** Disables the trailing close button while an operation is in flight. */
+ closeDisabled?: boolean
/** Accessible label for the close button. */
closeAriaLabel?: string
}
@@ -164,7 +166,15 @@ export interface ChipModalHeaderProps extends React.HTMLAttributes(
(
- { className, children, icon: Icon = null, onClose, closeAriaLabel = 'Close', ...props },
+ {
+ className,
+ children,
+ icon: Icon = null,
+ onClose,
+ closeDisabled = false,
+ closeAriaLabel = 'Close',
+ ...props
+ },
ref
) => (
@@ -177,6 +187,7 @@ const ChipModalHeader = React.forwardRef(
type='button'
variant='ghost'
onClick={onClose}
+ disabled={closeDisabled}
className='relative size-[14px] flex-shrink-0 p-0 before:absolute before:inset-[-14px] before:content-[""]'
>