From 9e4593998a7144cb9fabbb942349a3da2adae243 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 10:41:35 -0700 Subject: [PATCH 1/4] feat(admin): provision a user with an emailed password reset instead of a set password --- .../components/admin/add-user-modal.test.tsx | 41 +++++ .../components/admin/add-user-modal.tsx | 49 ++++-- .../settings/components/admin/admin.tsx | 39 ++++- apps/sim/hooks/queries/admin-users.test.ts | 78 ++++++++- apps/sim/hooks/queries/admin-users.ts | 56 ++++++- .../components/chip-modal/chip-modal.test.tsx | 74 ++++++++- .../src/components/chip-modal/chip-modal.tsx | 157 +++++++++++++++--- 7 files changed, 453 insertions(+), 41 deletions(-) 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 index 71883816a30..9ff60ea93d2 100644 --- 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 @@ -267,6 +267,47 @@ describe('AddUserModal', () => { }) }) + it('drops the password field and submits without one when emailing a reset link', async () => { + mockMutate.mockImplementation( + (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { + options.onSuccess(CREATED_USER) + } + ) + await renderModal() + await changeField('Name', 'Canary Writer') + await changeField('Email', 'writer@synthetics.example.com') + await changeField('Credentials', 'email') + + expect(container.querySelector('[aria-label="Password"]')).toBeNull() + expect(buttonLabelled('Add user').disabled).toBe(false) + + 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', + emailVerified: true, + }, + { onSuccess: expect.any(Function), onSettled: expect.any(Function) } + ) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER) + }) + + it('keeps a typed password across a round trip through the reset-link flow', async () => { + await renderModal() + await fillRequiredFields() + await changeField('Credentials', 'email') + await changeField('Credentials', 'set') + + expect((field('Password') as HTMLInputElement).value).toBe('canary-password') + expect(buttonLabelled('Add user').disabled).toBe(false) + }) + it('shows Better Auth failures without closing the modal', async () => { addUserMutation.current = { isPending: false, 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 index 89ebb362a34..0065502f7e5 100644 --- 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 @@ -18,6 +18,13 @@ const EMAIL_STATUS_OPTIONS = [ { value: 'unverified', label: 'Unverified' }, ] as const +const PASSWORD_MODE_OPTIONS = [ + { value: 'set', label: 'Set a password' }, + { value: 'email', label: 'Email a reset link' }, +] as const + +type PasswordMode = (typeof PASSWORD_MODE_OPTIONS)[number]['value'] + interface AddUserModalProps { open: boolean onOpenChange: (open: boolean) => void @@ -30,9 +37,11 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp const [isSubmitting, setIsSubmitting] = useState(false) const [name, setName] = useState('') const [email, setEmail] = useState('') + const [passwordMode, setPasswordMode] = useState('set') const [password, setPassword] = useState('') const [emailVerified, setEmailVerified] = useState(true) + const setsPassword = passwordMode === 'set' const normalizedName = name.trim() const normalizedEmail = email.trim().toLowerCase() const nameError = name.length > 0 && !normalizedName ? 'Name is required' : undefined @@ -46,12 +55,13 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp const canSubmit = normalizedName.length > 0 && isValidEmailSyntax(normalizedEmail) && - password.length >= 8 && + (!setsPassword || password.length >= 8) && !isSubmissionPending const reset = () => { setName('') setEmail('') + setPasswordMode('set') setPassword('') setEmailVerified(true) addUser.reset() @@ -72,8 +82,8 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp { name: normalizedName, email: normalizedEmail, - password, emailVerified, + ...(setsPassword ? { password } : {}), }, { onSuccess: (user) => { @@ -131,21 +141,38 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp required /> { - setPassword(value) + setPasswordMode(value as PasswordMode) addUser.reset() }} - error={passwordError} - hint='Better Auth creates a credential account with this password.' - placeholder='At least 8 characters' - autoComplete='new-password' + options={PASSWORD_MODE_OPTIONS} + align='start' + hint={ + setsPassword ? undefined : 'They pick their own password from the emailed reset link.' + } disabled={isSubmissionPending} required /> + {setsPassword && ( + { + setPassword(value) + addUser.reset() + }} + error={passwordError} + placeholder='At least 8 characters' + autoComplete='new-password' + disabled={isSubmissionPending} + required + /> + )} Email Role Status - Actions + Actions ) @@ -58,6 +59,7 @@ export function Admin() { const banUser = useBanUser() const unbanUser = useUnbanUser() const impersonateUser = useImpersonateUser() + const sendPasswordReset = useSendPasswordReset() const { recentEmails, recordImpersonation } = useRecentImpersonations() const { data: recentUsers } = useAdminUsersByEmails(recentEmails) @@ -162,6 +164,8 @@ export function Admin() { ids.add((unbanUser.variables as { userId: string }).userId) if (impersonateUser.isPending && (impersonateUser.variables as { userId?: string })?.userId) ids.add((impersonateUser.variables as { userId: string }).userId) + if (sendPasswordReset.isPending && sendPasswordReset.variables?.userId) + ids.add(sendPasswordReset.variables.userId) if (impersonatingUserId) ids.add(impersonatingUserId) return ids }, [ @@ -173,9 +177,19 @@ export function Admin() { unbanUser.variables, impersonateUser.isPending, impersonateUser.variables, + sendPasswordReset.isPending, + sendPasswordReset.variables, impersonatingUserId, ]) + /** Confirms the send in place, since nothing about the user row changes. */ + const resetPasswordLabel = (userId: string) => { + if (sendPasswordReset.variables?.userId !== userId) return 'Reset password' + if (sendPasswordReset.isPending) return 'Sending...' + if (sendPasswordReset.isSuccess) return 'Reset sent' + return 'Reset password' + } + const renderUserRow = (u: AdminUser) => (
@@ -187,9 +201,20 @@ export function Admin() { {u.banned ? Banned : Active} - + {u.id !== session?.user?.id && ( <> + + ) : undefined + } + {...aria} + /> + ) +} + /** * Internal renderer for {@link ChipModalField} `type='emails'`. Delegates the * chip lifecycle to {@link ChipEmailsInput} and adds only the field-level From 8dadbecc6ea7b756cdc78f85c7874cc10e00cc2d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 10:47:36 -0700 Subject: [PATCH 2/4] fix(emcn): keep focus on the input so the password Hide toggle actually masks --- .../components/chip-modal/chip-modal.test.tsx | 22 +++++++++++++ .../src/components/chip-modal/chip-modal.tsx | 31 ++++++++++++------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx index d8444b12c02..014a5bb6229 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx @@ -288,4 +288,26 @@ describe("ChipModalField inputType='password'", () => { expect(passwordInput().className).not.toContain(MASK_CLASS) expect(document.querySelector('[aria-label="Hide password"]')).not.toBeNull() }) + + it('hides a focused password instead of re-revealing it', () => { + mountPasswordField('hunter2-secret') + act(() => passwordInput().focus()) + + const toggle = document.querySelector('[aria-label="Hide password"]') + if (!toggle) throw new Error('Hide toggle did not render') + + // jsdom does not move focus on mousedown, so model what a browser does: the + // press blurs the input unless the handler prevents the default. Without the + // control's preventDefault that blur re-masks first, and the click then + // toggles back to revealed — leaving the password on screen. + act(() => { + const press = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) + toggle.dispatchEvent(press) + if (!press.defaultPrevented) passwordInput().blur() + toggle.click() + }) + + expect(passwordInput().className).toContain(MASK_CLASS) + expect(document.querySelector('[aria-label="Show password"]')).not.toBeNull() + }) }) diff --git a/packages/emcn/src/components/chip-modal/chip-modal.tsx b/packages/emcn/src/components/chip-modal/chip-modal.tsx index 079f59bb8ed..4909e62009b 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.tsx @@ -800,6 +800,20 @@ function handleSingleLineEnter( * is what stops a password manager autofilling the operator's own credentials * into a field that sets some other account's password. */ +interface ChipModalPasswordControlProps { + id: string + value: string + onChange: (value: string) => void + onKeyDown: (event: React.KeyboardEvent) => void + placeholder?: string + maxLength?: number + autoComplete?: string + disabled?: boolean + mono?: boolean + /** ARIA the owning {@link ChipModalField} derives from its own state. */ + aria: ChipModalFieldAria +} + function ChipModalPasswordControl({ id, value, @@ -811,18 +825,7 @@ function ChipModalPasswordControl({ disabled, mono, aria, -}: { - id: string - value: string - onChange: (value: string) => void - onKeyDown: (event: React.KeyboardEvent) => void - placeholder?: string - maxLength?: number - autoComplete?: string - disabled?: boolean - mono?: boolean - aria: ChipModalFieldAria -}) { +}: ChipModalPasswordControlProps) { const [revealed, setRevealed] = React.useState(false) return ( @@ -853,6 +856,10 @@ function ChipModalPasswordControl({ type='button' variant='ghost' disabled={disabled} + // Keep focus on the input: letting the button take it would fire the + // blur re-mask first, so the click would toggle back from `false` + // and "Hide" would leave a focused password on screen. + onMouseDown={(event) => event.preventDefault()} onClick={() => setRevealed((current) => !current)} className='size-6 flex-shrink-0 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]' aria-label={revealed ? 'Hide password' : 'Show password'} From 29fa87ad9d2430838cbe8a3653bf65f26cc79f73 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 10:59:45 -0700 Subject: [PATCH 3/4] fix(admin): surface a created user when only its reset email failed --- .../components/admin/add-user-modal.test.tsx | 44 ++++++++++++++----- .../components/admin/add-user-modal.tsx | 12 +++-- .../settings/components/admin/admin.tsx | 15 ++++++- apps/sim/hooks/queries/admin-users.test.ts | 17 +++---- apps/sim/hooks/queries/admin-users.ts | 41 ++++++++--------- 5 files changed, 84 insertions(+), 45 deletions(-) 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 index 9ff60ea93d2..bfa90c543fd 100644 --- 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 @@ -115,7 +115,7 @@ vi.mock('@/hooks/queries/admin-users', () => ({ })) import { AddUserModal } from '@/app/workspace/[workspaceId]/settings/components/admin/add-user-modal' -import type { AddUserInput, AdminUser } from '@/hooks/queries/admin-users' +import type { AddUserInput, AddUserResult, AdminUser } from '@/hooks/queries/admin-users' const CREATED_USER: AdminUser = { id: 'user-1', @@ -128,7 +128,7 @@ const CREATED_USER: AdminUser = { let container: HTMLDivElement let root: Root -let onCreated: ReturnType void>> +let onCreated: ReturnType void>> let onOpenChange: ReturnType void>> async function renderModal() { @@ -203,8 +203,8 @@ 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) + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER }) } ) await renderModal() @@ -226,7 +226,7 @@ describe('AddUserModal', () => { { onSuccess: expect.any(Function), onSettled: expect.any(Function) } ) expect(onOpenChange).toHaveBeenCalledWith(false) - expect(onCreated).toHaveBeenCalledWith(CREATED_USER) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER, undefined) }) it('ignores repeated submissions before the pending state renders', async () => { @@ -246,8 +246,8 @@ 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) + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER }) } ) await renderModal() @@ -269,8 +269,8 @@ describe('AddUserModal', () => { it('drops the password field and submits without one when emailing a reset link', async () => { mockMutate.mockImplementation( - (_input: AddUserInput, options: { onSuccess: (user: AdminUser) => void }) => { - options.onSuccess(CREATED_USER) + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER }) } ) await renderModal() @@ -295,7 +295,7 @@ describe('AddUserModal', () => { }, { onSuccess: expect.any(Function), onSettled: expect.any(Function) } ) - expect(onCreated).toHaveBeenCalledWith(CREATED_USER) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER, undefined) }) it('keeps a typed password across a round trip through the reset-link flow', async () => { @@ -308,6 +308,30 @@ describe('AddUserModal', () => { expect(buttonLabelled('Add user').disabled).toBe(false) }) + it('still hands the user back when only its reset email failed', async () => { + mockMutate.mockImplementation( + (_input: AddUserInput, options: { onSuccess: (result: AddUserResult) => void }) => { + options.onSuccess({ user: CREATED_USER, resetEmailError: 'SMTP unavailable' }) + } + ) + await renderModal() + await changeField('Name', 'Canary Writer') + await changeField('Email', 'writer@synthetics.example.com') + await changeField('Credentials', 'email') + + await act(async () => { + buttonLabelled('Add user').dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + await Promise.resolve() + }) + + // The account exists, so this closes like any other create — the host + // surfaces the user (and the reason) rather than stranding the operator in + // a modal whose form no longer maps to anything. + expect(onOpenChange).toHaveBeenCalledWith(false) + expect(onCreated).toHaveBeenCalledWith(CREATED_USER, 'SMTP unavailable') + }) + it('shows Better Auth failures without closing the modal', async () => { addUserMutation.current = { isPending: false, 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 index 0065502f7e5..9a09abbb8c5 100644 --- 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 @@ -28,7 +28,13 @@ type PasswordMode = (typeof PASSWORD_MODE_OPTIONS)[number]['value'] interface AddUserModalProps { open: boolean onOpenChange: (open: boolean) => void - onCreated: (user: AdminUser) => void + /** + * The account was created. `resetEmailError` is set when its provisioning + * reset email could not be sent — the account still exists, so the host is + * expected to surface the user (and report this) rather than treat it as a + * failed create. + */ + onCreated: (user: AdminUser, resetEmailError?: string) => void } export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProps) { @@ -86,10 +92,10 @@ export function AddUserModal({ open, onOpenChange, onCreated }: AddUserModalProp ...(setsPassword ? { password } : {}), }, { - onSuccess: (user) => { + onSuccess: ({ user, resetEmailError }) => { reset() onOpenChange(false) - onCreated(user) + onCreated(user, resetEmailError) }, onSettled: () => { submissionInFlightRef.current = false 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 3a0ee7d7edb..c7cd6f626af 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -77,6 +77,7 @@ export function Admin() { const [impersonatingUserId, setImpersonatingUserId] = useState(null) const [impersonationGuardError, setImpersonationGuardError] = useState(null) const [isAddUserOpen, setIsAddUserOpen] = useState(false) + const [provisionWarning, setProvisionWarning] = useState(null) const { data: usersData, @@ -208,6 +209,7 @@ export function Admin() { variant='active' className='h-[28px] px-2 text-caption' onClick={() => { + setProvisionWarning(null) sendPasswordReset.reset() sendPasswordReset.mutate({ userId: u.id, email: u.email }) }} @@ -441,6 +443,10 @@ export function Admin() {

)} + {provisionWarning && ( +

{provisionWarning}

+ )} + {searchQuery.length > 0 && usersData ? ( <>
@@ -500,9 +506,16 @@ export function Admin() { { + onCreated={(user, resetEmailError) => { + // Search for the new user either way: when the reset email failed, + // the recovery action named below lives on that user's row. setSearchInput(user.email) setAdminParams({ q: user.email, offset: null }) + setProvisionWarning( + resetEmailError + ? `Created ${user.email}, but the password reset email failed to send (${resetEmailError}). Use Reset password on their row to try again.` + : null + ) }} /> diff --git a/apps/sim/hooks/queries/admin-users.test.ts b/apps/sim/hooks/queries/admin-users.test.ts index ed367cd4176..a5522c3dfa4 100644 --- a/apps/sim/hooks/queries/admin-users.test.ts +++ b/apps/sim/hooks/queries/admin-users.test.ts @@ -64,14 +64,7 @@ describe('addUser', () => { password: 'canary-password', emailVerified: true, }) - ).resolves.toEqual({ - id: 'user-1', - name: 'Canary Writer', - email: 'writer@synthetics.example.com', - role: 'user', - banned: false, - banReason: null, - }) + ).resolves.toEqual({ user: CREATED_USER }) expect(mockCreateUser).toHaveBeenCalledWith({ name: 'Canary Writer', email: 'writer@synthetics.example.com', @@ -91,7 +84,7 @@ describe('addUser', () => { email: ' Writer@Synthetics.Example.com ', emailVerified: true, }) - ).resolves.toEqual(CREATED_USER) + ).resolves.toEqual({ user: CREATED_USER }) expect(mockCreateUser).toHaveBeenCalledWith({ name: 'Canary Writer', @@ -124,17 +117,19 @@ describe('addUser', () => { expect(mockRequestJson).not.toHaveBeenCalled() }) - it('names the row-level recovery path when the reset email fails to send', async () => { + it('still returns the created user when only the reset email fails', async () => { mockCreateUser.mockResolvedValue({ data: { user: CREATED_USER }, error: null }) mockRequestJson.mockRejectedValue(new Error('SMTP unavailable')) + // The account exists, so this must not surface as a failed create — the + // caller needs the user to reach its row's own "Reset password" action. await expect( addUser({ name: 'Canary Writer', email: 'writer@synthetics.example.com', emailVerified: true, }) - ).rejects.toThrow(/Account created, but the password reset email failed to send/) + ).resolves.toEqual({ user: CREATED_USER, resetEmailError: 'SMTP unavailable' }) }) it('surfaces resolved Better Auth errors', async () => { diff --git a/apps/sim/hooks/queries/admin-users.ts b/apps/sim/hooks/queries/admin-users.ts index 91cccae821d..1d141a46973 100644 --- a/apps/sim/hooks/queries/admin-users.ts +++ b/apps/sim/hooks/queries/admin-users.ts @@ -63,12 +63,24 @@ function mapUser(u: { } } +export interface AddUserResult { + user: AdminUser + /** + * Why the provisioning reset email could not be sent, when the account itself + * was created. Deliberately not a thrown error: the account exists, so + * re-submitting the form would only collide on the email. Callers finish the + * create — surfacing the user so its row, and that row's "Reset password" + * action, are reachable — and report this alongside. + */ + resetEmailError?: string +} + export async function addUser({ name, email, password, emailVerified, -}: AddUserInput): Promise { +}: AddUserInput): Promise { const normalizedEmail = email.trim().toLowerCase() const { data, error } = await client.admin.createUser({ name: name.trim(), @@ -80,26 +92,15 @@ export async function addUser({ 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') - if (!password) { - try { - await sendPasswordResetEmail(normalizedEmail) - } catch (resetError) { - /** - * The account exists at this point, so re-submitting the form would only - * collide on the email. Name the recovery path instead — the caller - * surfaces this verbatim, and the new user is already in the list behind - * the modal with its own "Reset password" action. - */ - throw new Error( - `Account created, but the password reset email failed to send (${getErrorMessage( - resetError, - 'unknown error' - )}). Use "Reset password" on the user's row to try again.` - ) - } - } + const user = mapUser(data.user) + if (password) return { user } - return mapUser(data.user) + try { + await sendPasswordResetEmail(normalizedEmail) + return { user } + } catch (resetError) { + return { user, resetEmailError: getErrorMessage(resetError, 'unknown error') } + } } /** Sends the standard password reset email, the same one the login page requests. */ From 6d04bce3e99fa52bd40ca169da364267c0c699f7 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Thu, 6 Aug 2026 11:06:02 -0700 Subject: [PATCH 4/4] test(emcn): cover keyboard activation of the password reveal toggle --- .../components/chip-modal/chip-modal.test.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx index 014a5bb6229..bc2407849dd 100644 --- a/packages/emcn/src/components/chip-modal/chip-modal.test.tsx +++ b/packages/emcn/src/components/chip-modal/chip-modal.test.tsx @@ -289,6 +289,42 @@ describe("ChipModalField inputType='password'", () => { expect(document.querySelector('[aria-label="Hide password"]')).not.toBeNull() }) + it('keeps the toggle label honest when a keyboard moves focus to it', () => { + mountPasswordField('hunter2-secret') + act(() => passwordInput().focus()) + + // Tabbing to the toggle blurs the input, which re-masks — so by the time a + // keyboard can activate the button it already reads "Show password", and + // activating it reveals. The label must never disagree with what is on + // screen, in either direction. + const focusedToggle = document.querySelector('[aria-label="Hide password"]') + if (!focusedToggle) throw new Error('Toggle should read "Hide password" while focused') + act(() => focusedToggle.focus()) + expect(passwordInput().className).toContain(MASK_CLASS) + + const toggle = document.querySelector('[aria-label="Show password"]') + if (!toggle) throw new Error('Toggle should read "Show password" once the input has blurred') + + // Enter/Space on a focused button dispatch a plain click, with no mousedown. + act(() => toggle.click()) + expect(passwordInput().className).not.toContain(MASK_CLASS) + expect(document.querySelector('[aria-label="Hide password"]')).not.toBeNull() + }) + + it('hides a revealed password on keyboard activation when the input is not focused', () => { + mountPasswordField('hunter2-secret') + + const reveal = document.querySelector('[aria-label="Show password"]') + if (!reveal) throw new Error('Reveal toggle did not render') + act(() => reveal.click()) + expect(passwordInput().className).not.toContain(MASK_CLASS) + + const hide = document.querySelector('[aria-label="Hide password"]') + if (!hide) throw new Error('Hide toggle did not render') + act(() => hide.click()) + expect(passwordInput().className).toContain(MASK_CLASS) + }) + it('hides a focused password instead of re-revealing it', () => { mountPasswordField('hunter2-secret') act(() => passwordInput().focus())