Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ 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(() => ({
const {
addUserMutation,
mockMutate,
mockReset,
mockResetPassword,
mockToast,
resetPasswordMutation,
} = vi.hoisted(() => ({
addUserMutation: {
current: {
isPending: false,
Expand All @@ -16,6 +23,9 @@ const { addUserMutation, mockMutate, mockReset } = vi.hoisted(() => ({
},
mockMutate: vi.fn(),
mockReset: vi.fn(),
mockResetPassword: vi.fn(),
mockToast: { success: vi.fn(), error: vi.fn() },
resetPasswordMutation: { current: { isPending: false } },
}))

vi.mock('@sim/emcn', () => ({
Expand Down Expand Up @@ -58,6 +68,29 @@ vi.mock('@sim/emcn', () => ({
</button>
</footer>
),
Label: ({ htmlFor, children }: { htmlFor?: string; children: ReactNode }) => (
<label htmlFor={htmlFor}>{children}</label>
),
Switch: ({
id,
checked,
onCheckedChange,
disabled,
}: {
id?: string
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
}) => (
<input
id={id}
type='checkbox'
checked={checked}
disabled={disabled}
onChange={(event) => onCheckedChange(event.target.checked)}
/>
),
toast: mockToast,
ChipModalField: ({
type,
inputType,
Expand Down Expand Up @@ -114,6 +147,18 @@ vi.mock('@/hooks/queries/admin-users', () => ({
}),
}))

vi.mock('@/hooks/queries/user-profile', () => ({
useResetPassword: () => ({
mutateAsync: mockResetPassword,
isPending: resetPasswordMutation.current.isPending,
reset: vi.fn(),
}),
}))

vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: () => 'https://sim.test',
}))

import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal'
import type { AddUserInput, AdminUser } from '@/hooks/queries/admin-users'

Expand Down Expand Up @@ -157,6 +202,38 @@ async function changeField(label: string, value: string) {
})
}

/** Resolves a switch through its `<label for>` association, mirroring a real click on the label. */
async function toggleField(label: string) {
const labelElement = [...container.querySelectorAll('label')].find(
(candidate) => candidate.textContent === label
)
if (!labelElement?.htmlFor) throw new Error(`No label "${label}" bound to a control`)
const element = document.getElementById(labelElement.htmlFor) as HTMLInputElement | null
if (!element) throw new Error(`Label "${label}" points at a missing control`)
const checkedSetter = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(element),
'checked'
)?.set
if (!checkedSetter) throw new Error(`Field labelled "${label}" has no checked setter`)
await act(async () => {
checkedSetter.call(element, !element.checked)
element.dispatchEvent(new Event('click', { bubbles: true }))
element.dispatchEvent(new Event('change', { bubbles: true }))
})
}

/**
* Mirrors query-core's mutate-scoped callback contract: `onSuccess`'s return value is
* discarded (an async callback is never awaited) and `onSettled` follows synchronously.
*/
function succeedWithCreatedUser(
_input: AddUserInput,
options: { onSuccess: (user: AdminUser) => void | Promise<void>; onSettled: () => void }
) {
options.onSuccess(CREATED_USER)
options.onSettled()
}

function buttonLabelled(text: string): HTMLButtonElement {
const button = [...container.querySelectorAll('button')].find(
(candidate) => candidate.textContent === text
Expand All @@ -179,6 +256,11 @@ describe('AddUserModal', () => {
onCreated = vi.fn()
onOpenChange = vi.fn()
addUserMutation.current = { isPending: false, error: null }
resetPasswordMutation.current = { isPending: false }
mockResetPassword.mockResolvedValue({ success: true })
// vi.clearAllMocks() does not drop implementations, so re-arm the default (a
// request that never settles) rather than inheriting the previous test's.
mockMutate.mockImplementation(() => {})
})

afterEach(() => {
Expand All @@ -202,11 +284,7 @@ describe('AddUserModal', () => {
})

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)
}
)
mockMutate.mockImplementation(succeedWithCreatedUser)
await renderModal()
await fillRequiredFields()

Expand All @@ -227,6 +305,57 @@ describe('AddUserModal', () => {
)
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onCreated).toHaveBeenCalledWith(CREATED_USER)
expect(mockResetPassword).not.toHaveBeenCalled()
})

it('sends a password reset email when the toggle is on', async () => {
mockMutate.mockImplementation(succeedWithCreatedUser)
await renderModal()
await fillRequiredFields()
await toggleField('Send password reset email')

await act(async () => {
buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})

expect(mockResetPassword).toHaveBeenCalledWith({
email: 'writer@synthetics.example.com',
redirectTo: 'https://sim.test/reset-password',
})
expect(mockToast.success).toHaveBeenCalled()
expect(onCreated).toHaveBeenCalledWith(CREATED_USER)
})

it('keeps the submit path locked while the reset email is still in flight', async () => {
resetPasswordMutation.current = { isPending: true }
await renderModal()
await fillRequiredFields()

expect(buttonLabelled('Adding...').disabled).toBe(true)
expect(buttonLabelled('Close').disabled).toBe(true)
expect(buttonLabelled('Cancel').disabled).toBe(true)
})

it('still reports the created user when the reset email fails', async () => {
mockResetPassword.mockRejectedValue(new Error('SMTP unavailable'))
mockMutate.mockImplementation(succeedWithCreatedUser)
await renderModal()
await fillRequiredFields()
await toggleField('Send password reset email')

await act(async () => {
buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true }))
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})

expect(mockToast.error).toHaveBeenCalledWith(expect.stringContaining('SMTP unavailable'))
expect(onOpenChange).toHaveBeenCalledWith(false)
expect(onCreated).toHaveBeenCalledWith(CREATED_USER)
})

it('ignores repeated submissions before the pending state renders', async () => {
Expand All @@ -245,11 +374,7 @@ describe('AddUserModal', () => {
})

it('supports unverified accounts without exposing a platform-role control', async () => {
mockMutate.mockImplementation(
(_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => {
options.onSuccess(CREATED_USER)
}
)
mockMutate.mockImplementation(succeedWithCreatedUser)
await renderModal()
await fillRequiredFields()
await changeField('Email status', 'unverified')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
'use client'

import { useRef, useState } from 'react'
import { useId, useRef, useState } from 'react'
import {
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
Label,
Switch,
toast,
} from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
import { isValidEmailSyntax } from '@sim/utils/string'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { type AdminUser, useAddUser } from '@/hooks/queries/admin-users'
import { useResetPassword } from '@/hooks/queries/user-profile'

const EMAIL_STATUS_OPTIONS = [
{ value: 'verified', label: 'Verified' },
Expand All @@ -26,12 +31,15 @@ interface AddUserModalProps {

export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProps) {
const addUser = useAddUser()
const resetPassword = useResetPassword()
const resetEmailToggleId = useId()
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 [sendResetEmail, setSendResetEmail] = useState(false)

const normalizedName = name.trim()
const normalizedEmail = email.trim().toLowerCase()
Expand All @@ -42,7 +50,7 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
password.length > 0 && password.length < 8
? 'Password must be at least 8 characters'
: undefined
const isSubmissionPending = isSubmitting || addUser.isPending
const isSubmissionPending = isSubmitting || addUser.isPending || resetPassword.isPending
const canSubmit =
normalizedName.length > 0 &&
isValidEmailSyntax(normalizedEmail) &&
Expand All @@ -54,7 +62,9 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
setEmail('')
setPassword('')
setEmailVerified(true)
setSendResetEmail(false)
addUser.reset()
resetPassword.reset()
}

const handleClose = () => {
Expand All @@ -76,7 +86,20 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
emailVerified,
},
{
onSuccess: (user) => {
onSuccess: async (user) => {
if (sendResetEmail) {
try {
await resetPassword.mutateAsync({
email: normalizedEmail,
redirectTo: `${getBaseUrl()}/reset-password`,
})
toast.success(`Password reset email sent to ${normalizedEmail}`)
} catch (error) {
toast.error(
`User created, but the password reset email failed to send: ${getErrorMessage(error, 'Unknown error')}`
)
}
}
reset()
onOpenChange(false)
onCreated(user)
Expand Down Expand Up @@ -160,6 +183,18 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp
disabled={isSubmissionPending}
required
/>
<div className='flex items-center justify-between px-2'>
<Label htmlFor={resetEmailToggleId}>Send password reset email</Label>
<Switch
id={resetEmailToggleId}
checked={sendResetEmail}
disabled={isSubmissionPending}
onCheckedChange={(checked) => {
setSendResetEmail(checked)
addUser.reset()
}}
Comment on lines 183 to +195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Use the standard modal field wrapper

The new labeled switch is rendered as a raw row inside ChipModalBody, bypassing the required ChipModalField type='custom' structure and its standardized field layout and accessibility behavior.

Context Used: Component patterns and structure for React compone... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

/>
</div>
<ChipModalError>
{addUser.error ? getErrorMessage(addUser.error, 'Failed to add user') : null}
</ChipModalError>
Expand Down