mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+281
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)]' />
|
||||
|
||||
Reference in New Issue
Block a user