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..71883816a30 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.test.tsx @@ -0,0 +1,281 @@ +/** + * @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, + 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 } + }) => ( + + ), + 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', + emailVerified: true, + }, + { 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) + expect(buttonLabelled('Close').disabled).toBe(true) + expect(buttonLabelled('Cancel').disabled).toBe(true) + }) + + it('supports unverified accounts without exposing a platform-role control', async () => { + mockMutate.mockImplementation( + (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { + options.onSuccess(CREATED_USER) + } + ) + await renderModal() + await fillRequiredFields() + await changeField('Email status', 'unverified') + + await act(async () => { + buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + expect(container.querySelector('[aria-label="Platform role"]')).toBeNull() + expect(mockMutate).toHaveBeenCalledWith(expect.objectContaining({ emailVerified: false }), { + onSuccess: expect.any(Function), + onSettled: 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..89ebb362a34 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/add-user-modal.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useRef, 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 AdminUser, useAddUser } from '@/hooks/queries/admin-users' + +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 submissionInFlightRef = useRef(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + 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 isSubmissionPending = isSubmitting || addUser.isPending + const canSubmit = + normalizedName.length > 0 && + isValidEmailSyntax(normalizedEmail) && + password.length >= 8 && + !isSubmissionPending + + const reset = () => { + setName('') + setEmail('') + setPassword('') + setEmailVerified(true) + addUser.reset() + } + + const handleClose = () => { + if (submissionInFlightRef.current || isSubmissionPending) return + reset() + onOpenChange(false) + } + + const handleAddUser = () => { + if (!canSubmit || submissionInFlightRef.current) return + submissionInFlightRef.current = true + setIsSubmitting(true) + addUser.reset() + addUser.mutate( + { + name: normalizedName, + email: normalizedEmail, + password, + emailVerified, + }, + { + onSuccess: (user) => { + reset() + onOpenChange(false) + onCreated(user) + }, + onSettled: () => { + submissionInFlightRef.current = false + setIsSubmitting(false) + }, + } + ) + } + + return ( + { + if (!next) handleClose() + }} + srTitle='Add user' + > + + Add user + + + { + setName(value) + addUser.reset() + }} + error={nameError} + placeholder='Canary Writer' + maxLength={100} + autoComplete='off' + disabled={isSubmissionPending} + required + /> + { + setEmail(value) + addUser.reset() + }} + error={emailError} + placeholder='writer@synthetics.example.com' + autoComplete='off' + disabled={isSubmissionPending} + 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={isSubmissionPending} + required + /> + { + setEmailVerified(value === 'verified') + addUser.reset() + }} + options={EMAIL_STATUS_OPTIONS} + align='start' + hint='Verified users can sign in when email verification is required.' + disabled={isSubmissionPending} + 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..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,11 +1,12 @@ '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' import { useSession } from '@/lib/auth/auth-client' +import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal' import { adminParsers, adminUrlKeys, @@ -13,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, @@ -72,6 +74,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, @@ -369,94 +372,108 @@ 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))} -
- ) - )} -
+ ) + )} +
+ + { + 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-[""]' >