feat(admin): add password user creation (#6267)

* feat(admin): add password user creation

* fix(admin): restrict created users to normal role

* improvement(admin): refine add user action

* fix(admin): prevent duplicate user creation

* fix(admin): reflect immediate submission state
This commit is contained in:
Theodore Li
2026-08-04 19:48:10 -04:00
committed by GitHub
parent bf5abc9a40
commit fbd02bc176
6 changed files with 699 additions and 84 deletions
@@ -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 ? <div role='dialog'>{children}</div> : null,
ChipModalHeader: ({
children,
onClose,
closeDisabled,
}: {
children: ReactNode
onClose: () => void
closeDisabled?: boolean
}) => (
<header>
<h2>{children}</h2>
<button type='button' onClick={onClose} disabled={closeDisabled}>
Close
</button>
</header>
),
ChipModalBody: ({ children }: { children: ReactNode }) => <div>{children}</div>,
ChipModalError: ({ children }: { children: ReactNode }) =>
children ? <div role='alert'>{children}</div> : null,
ChipModalFooter: ({
onCancel,
cancelDisabled,
primaryAction,
}: {
onCancel: () => void
cancelDisabled?: boolean
primaryAction: { label: ReactNode; onClick: () => void; disabled?: boolean }
}) => (
<footer>
<button type='button' onClick={onCancel} disabled={cancelDisabled}>
Cancel
</button>
<button type='button' disabled={primaryAction.disabled} onClick={primaryAction.onClick}>
{primaryAction.label}
</button>
</footer>
),
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
}) => (
<div>
<span>{title}</span>
{type === 'dropdown' ? (
<select
aria-label={title}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
{options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<input
aria-label={title}
type={inputType ?? (type === 'email' ? 'email' : 'text')}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
/>
)}
{error && <span role='alert'>{error}</span>}
</div>
),
}))
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<typeof vi.fn<(user: AdminUser) => void>>
let onOpenChange: ReturnType<typeof vi.fn<(open: boolean) => void>>
async function renderModal() {
await act(async () => {
root.render(<AddUserModal open onOpenChange={onOpenChange} onCreated={onCreated} />)
})
}
function field(label: string): HTMLInputElement | HTMLSelectElement {
const element = container.querySelector<HTMLInputElement | HTMLSelectElement>(
`[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()
})
})
@@ -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 (
<ChipModal
open={open}
onOpenChange={(next) => {
if (!next) handleClose()
}}
srTitle='Add user'
>
<ChipModalHeader onClose={handleClose} closeDisabled={isSubmissionPending}>
Add user
</ChipModalHeader>
<ChipModalBody>
<ChipModalField
type='input'
title='Name'
value={name}
onChange={(value) => {
setName(value)
addUser.reset()
}}
error={nameError}
placeholder='Canary Writer'
maxLength={100}
autoComplete='off'
disabled={isSubmissionPending}
required
/>
<ChipModalField
type='email'
title='Email'
value={email}
onChange={(value) => {
setEmail(value)
addUser.reset()
}}
error={emailError}
placeholder='writer@synthetics.example.com'
autoComplete='off'
disabled={isSubmissionPending}
required
/>
<ChipModalField
type='input'
inputType='password'
title='Password'
value={password}
onChange={(value) => {
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
/>
<ChipModalField
type='dropdown'
title='Email status'
value={emailVerified ? 'verified' : 'unverified'}
onChange={(value) => {
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
/>
<ChipModalError>
{addUser.error ? getErrorMessage(addUser.error, 'Failed to add user') : null}
</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
cancelDisabled={isSubmissionPending}
primaryAction={{
label: isSubmissionPending ? 'Adding...' : 'Add user',
onClick: handleAddUser,
disabled: !canSubmit,
}}
/>
</ChipModal>
)
}
@@ -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<string | null>(null)
const [impersonationGuardError, setImpersonationGuardError] = useState<string | null>(null)
const [isAddUserOpen, setIsAddUserOpen] = useState(false)
const {
data: usersData,
@@ -369,94 +372,108 @@ export function Admin() {
<div className='h-px bg-[var(--border)]' />
<div className='flex flex-col gap-3'>
<p className='font-medium text-[var(--text-muted)] text-small'>User Management</p>
<div className='flex gap-2'>
<ChipInput
icon={Search}
value={searchInput}
onChange={(e) => 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'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
</div>
<SettingsSection
label='User management'
action={<Chip onClick={() => setIsAddUserOpen(true)}>Add user</Chip>}
>
<div className='flex flex-col gap-3'>
<div className='flex gap-2'>
<ChipInput
icon={Search}
value={searchInput}
onChange={(e) => 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'
/>
<Button variant='primary' onClick={handleSearch} disabled={usersLoading}>
{usersLoading ? 'Searching...' : 'Search'}
</Button>
</div>
{usersError && (
<p className='text-[var(--text-error)] text-small'>
{getErrorMessage(usersError, 'Failed to fetch users')}
</p>
)}
{usersError && (
<p className='text-[var(--text-error)] text-small'>
{getErrorMessage(usersError, 'Failed to fetch users')}
</p>
)}
{(setUserRole.error ||
banUser.error ||
unbanUser.error ||
impersonateUser.error ||
impersonationGuardError) && (
<p className='text-[var(--text-error)] text-small'>
{impersonationGuardError ||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
?.message ||
'Action failed. Please try again.'}
</p>
)}
{(setUserRole.error ||
banUser.error ||
unbanUser.error ||
impersonateUser.error ||
impersonationGuardError) && (
<p className='text-[var(--text-error)] text-small'>
{impersonationGuardError ||
(setUserRole.error || banUser.error || unbanUser.error || impersonateUser.error)
?.message ||
'Action failed. Please try again.'}
</p>
)}
{searchQuery.length > 0 && usersData ? (
<>
<div className='flex flex-col gap-0.5'>
{USER_TABLE_HEADER}
{searchQuery.length > 0 && usersData ? (
<>
<div className='flex flex-col gap-0.5'>
{USER_TABLE_HEADER}
{usersData.users.length === 0 && (
<SettingsEmptyState variant='inline'>No users found.</SettingsEmptyState>
)}
{usersData.users.length === 0 && (
<SettingsEmptyState variant='inline'>No users found.</SettingsEmptyState>
)}
{usersData.users.map((u) => renderUserRow(u))}
</div>
{totalPages > 1 && (
<div className='flex items-center justify-between text-[var(--text-secondary)] text-small'>
<span>
Page {currentPage} of {totalPages} ({usersData.total} users)
</span>
<div className='flex gap-1'>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() =>
setAdminParams((prev) => ({
offset: Math.max(0, prev.offset - PAGE_SIZE),
}))
}
disabled={usersOffset === 0 || usersLoading}
>
Previous
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() => setAdminParams((prev) => ({ offset: prev.offset + PAGE_SIZE }))}
disabled={usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading}
>
Next
</Button>
</div>
{usersData.users.map((u) => renderUserRow(u))}
</div>
)}
</>
) : (
searchQuery.length === 0 &&
recentUsers &&
recentUsers.length > 0 && (
<div className='flex flex-col gap-0.5'>
{USER_TABLE_HEADER}
{recentUsers.map((u) => renderUserRow(u))}
</div>
)
)}
</div>
{totalPages > 1 && (
<div className='flex items-center justify-between text-[var(--text-secondary)] text-small'>
<span>
Page {currentPage} of {totalPages} ({usersData.total} users)
</span>
<div className='flex gap-1'>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() =>
setAdminParams((prev) => ({
offset: Math.max(0, prev.offset - PAGE_SIZE),
}))
}
disabled={usersOffset === 0 || usersLoading}
>
Previous
</Button>
<Button
variant='active'
className='h-[28px] px-2 text-caption'
onClick={() =>
setAdminParams((prev) => ({ offset: prev.offset + PAGE_SIZE }))
}
disabled={usersOffset + PAGE_SIZE >= (usersData?.total ?? 0) || usersLoading}
>
Next
</Button>
</div>
</div>
)}
</>
) : (
searchQuery.length === 0 &&
recentUsers &&
recentUsers.length > 0 && (
<div className='flex flex-col gap-0.5'>
{USER_TABLE_HEADER}
{recentUsers.map((u) => renderUserRow(u))}
</div>
)
)}
</div>
</SettingsSection>
<AddUserModal
open={isAddUserOpen}
onOpenChange={setIsAddUserOpen}
onCreated={(user) => {
setSearchInput(user.email)
setAdminParams({ q: user.email, offset: null })
}}
/>
</SettingsPanel>
)
}
@@ -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')
})
})
+36
View File
@@ -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<AdminUser> {
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({
@@ -154,6 +154,8 @@ export interface ChipModalHeaderProps extends React.HTMLAttributes<HTMLDivElemen
icon?: React.ComponentType<{ className?: string }> | 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<HTMLDivElemen
*/
const ChipModalHeader = React.forwardRef<HTMLDivElement, ChipModalHeaderProps>(
(
{ className, children, icon: Icon = null, onClose, closeAriaLabel = 'Close', ...props },
{
className,
children,
icon: Icon = null,
onClose,
closeDisabled = false,
closeAriaLabel = 'Close',
...props
},
ref
) => (
<div ref={ref} className={cn('flex flex-col', className)} {...props}>
@@ -177,6 +187,7 @@ const ChipModalHeader = React.forwardRef<HTMLDivElement, ChipModalHeaderProps>(
type='button'
variant='ghost'
onClick={onClose}
disabled={closeDisabled}
className='relative size-[14px] flex-shrink-0 p-0 before:absolute before:inset-[-14px] before:content-[""]'
>
<X className='size-[14px] text-[var(--text-icon)]' />