diff --git a/.changeset/heavy-pears-smile.md b/.changeset/heavy-pears-smile.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/heavy-pears-smile.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index ebe2f7b9fab..939241dfe69 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -188,6 +188,7 @@ import { Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; import { + AddPhoneFails as UserProfileAccountSectionAddPhoneFails, Default as UserProfileAccountSectionDefault, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, @@ -467,6 +468,7 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + AddPhoneFails: UserProfileAccountSectionAddPhoneFails, }; const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts new file mode 100644 index 00000000000..a01341182ac --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -0,0 +1,28 @@ +import type { UserProfileAccountSectionViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; + +interface FixtureOptions { + failAt?: UserProfileAddPhoneViewProps['step']; + onVerified?: (phoneNumber: string) => void; +} + +export function createUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< + UserProfileAccountSectionViewProps, + 'onSendPhoneCode' | 'onVerifyPhoneCode' +> { + return { + onSendPhoneCode: async () => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'phone') { + throw new Error('We couldn’t send a code. Try again.'); + } + }, + onVerifyPhoneCode: async (phoneNumber, code) => { + await new Promise(resolve => setTimeout(resolve, 700)); + if (failAt === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); + } + onVerified?.(phoneNumber); + }, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 9b8a9ce3eb8..6852be96684 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -13,6 +13,7 @@ import type { import { useMemo, useState } from 'react'; import { usePreviewImage } from './use-preview-image'; +import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useUserProfileEditNameFixture } from './user-profile-edit-name'; export interface UserProfileFixtureOptions { @@ -119,15 +120,9 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions emails, phones, onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), - onAddPhone: () => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]), + ...createUserProfileAddPhoneFixture({ + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }), onDeleteAccount: () => Promise.resolve(), onManageEmail: () => undefined, onManagePhone: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index e7a319005b4..532e38b6ce6 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -5,6 +5,9 @@ import * as Stories from './user-profile-account-section.stories'; Account details, profile image, email addresses, and phone numbers composed with `Section`. The `allowMultipleAccounts` flag controls whether contact methods appear inline or in dedicated sections. +In the multiple-account example, Add phone opens the flow using local state and simulated requests. +Entering or pasting six digits submits automatically. Use `000000` to see an incorrect-code error. + ## Single account + +## Add phone failure + +Add a phone number to see a failed send request while keeping the entered number. + + diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 47d47cc67cb..d9009069587 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -4,11 +4,13 @@ import type { UserProfilePhone, } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '@clerk/ui/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfileAddPhoneViewProps } from '@clerk/ui/mosaic/user-profile/user-profile-add-phone.view'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -24,9 +26,11 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, + failAt, failWith, }: { allowMultipleAccounts: boolean; + failAt?: UserProfileAddPhoneViewProps['step']; failWith?: UserProfileFormError; }) { const editName = useUserProfileEditNameFixture({ failWith }); @@ -40,8 +44,13 @@ function AccountSection({ ); const [phones, setPhones] = useState([ { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isVerified: true }] : []), ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); + const addPhone = createUserProfileAddPhoneFixture({ + failAt, + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + }); return ( - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]) - } + {...addPhone} + onProfilePictureChange={showFile} + onRemoveProfilePicture={clearImage} onManageEmail={() => undefined} onManagePhone={() => undefined} - onProfilePictureChange={showFile} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} - onRemoveProfilePicture={clearImage} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onUsernameChange={() => undefined} /> ); @@ -87,6 +88,15 @@ export function MultipleAccounts() { return ; } +export function AddPhoneFails() { + return ( + + ); +} + /** Every save is rejected, so the dialog shows both halves of a failure at once. */ export function EditNameFails() { return ( diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index ec593488fac..159807e0a28 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; import { usePreviewImage } from './fixtures/use-preview-image'; +import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; const providerIconUrl = (provider: string) => `https://img.clerk.com/static/${provider}.svg`; @@ -73,16 +74,9 @@ export function Default(_args: Record) { { id: `email_${Date.now()}`, value: `item${current.length + 1}@clerk.dev`, isVerified: true }, ]) } - onAddPhone={() => - setPhones(current => [ - ...current, - { - id: `phone_${Date.now()}`, - value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, - isVerified: true, - }, - ]) - } + {...createUserProfileAddPhoneFixture({ + onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + })} onConnectAccount={() => undefined} onDeleteAccount={() => Promise.resolve()} onManageEmail={() => undefined} @@ -96,7 +90,7 @@ export function Default(_args: Record) { onRemoveWeb3Wallet={() => undefined} onSetPrimaryWeb3Wallet={() => undefined} onSetPrimaryEmail={() => undefined} - onSetPrimaryPhone={() => undefined} + onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} onVerifyEmail={() => undefined} onVerifyPhone={() => undefined} onUsernameChange={() => undefined} diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx new file mode 100644 index 00000000000..73433cbb910 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.integration.test.tsx @@ -0,0 +1,41 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; + +describe('profile add phone', () => { + it.each([false, true])( + 'owns the dialog and returns focus with multiple accounts = %s', + async allowMultipleAccounts => { + const user = userEvent.setup(); + const onSend = vi.fn(() => Promise.resolve()); + const onVerify = vi.fn(() => Promise.resolve()); + render( + + + , + ); + const trigger = screen.getByRole('button', { name: 'Add phone number' }); + await user.click(trigger); + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); + await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); + await user.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); + expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }, + ); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx new file mode 100644 index 00000000000..2e1c4bc95b0 --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-add-phone.view.test.tsx @@ -0,0 +1,179 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAddPhoneViewProps } from '../user-profile-add-phone.view'; +import { UserProfileAddPhoneView } from '../user-profile-add-phone.view'; + +function renderView(overrides: Partial = {}) { + const props: UserProfileAddPhoneViewProps = { + open: true, + onOpenChange: vi.fn(), + step: 'phone', + phoneNumber: '+18018888181', + onPhoneNumberChange: vi.fn(), + code: '', + onCodeChange: vi.fn(), + onSubmit: vi.fn(), + onResend: vi.fn(), + ...overrides, + }; + return { + props, + ...render( + + + , + ), + }; +} + +function VerificationExample({ onSubmit }: Pick) { + const [code, setCode] = useState(''); + + return ( + + undefined} + step='verify' + phoneNumber='+18018888181' + onPhoneNumberChange={() => undefined} + code={code} + onCodeChange={setCode} + onSubmit={onSubmit} + onResend={() => undefined} + /> + + ); +} + +describe('UserProfileAddPhoneView', () => { + it.each(['typing', 'pasting'] as const)('automatically submits a complete code after %s', async method => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + render(); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); + + if (method === 'typing') { + await user.keyboard('12345'); + expect(onSubmit).not.toHaveBeenCalled(); + await user.keyboard('6'); + } else { + await user.paste('123456'); + } + + expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it('focuses the phone field and submits through the form or Send code', async () => { + const user = userEvent.setup(); + const { props } = renderView(); + + expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); + const phone = screen.getByRole('textbox', { name: 'Phone' }); + await waitFor(() => expect(phone).toHaveFocus()); + const phoneForm = phone.closest('form'); + if (!phoneForm) { + throw new Error('Phone form missing'); + } + expect(phoneForm).toHaveClass('cl-card-content'); + phoneForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Send code' })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + }); + + it('moves to verification inside the same dialog and submits the code', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView(); + const dialog = screen.getByRole('dialog'); + + rerender( + + + , + ); + + expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBe(dialog); + expect(screen.getByText('Enter the code sent to +1 (801) 888-8181')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Phone' })).not.toBeInTheDocument(); + const firstSlot = screen.getByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(firstSlot).toHaveFocus()); + const verifyForm = firstSlot.closest('form'); + if (!verifyForm) { + throw new Error('Verification form missing'); + } + expect(verifyForm).toHaveClass('cl-card-content'); + verifyForm.requestSubmit(); + expect(props.onSubmit).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Verify', exact: true })); + + expect(props.onSubmit).toHaveBeenCalledTimes(2); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('blocks submission and resend while verification is pending', async () => { + const user = userEvent.setup(); + const { props } = renderView({ step: 'verify', code: '123456', isPending: true }); + + for (const slot of screen.getAllByRole('textbox')) { + expect(slot).toBeDisabled(); + } + const verify = screen.getByRole('button', { name: 'Verify', exact: true }); + expect(verify).toHaveAttribute('aria-busy', 'true'); + await user.click(verify); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + + expect(props.onSubmit).not.toHaveBeenCalled(); + expect(props.onResend).not.toHaveBeenCalled(); + }); + + it.each(['phone', 'verify'] as const)('associates a %s error with its input', step => { + renderView({ step, errorMessage: 'Please try again.' }); + + const field = screen.getByRole('textbox', { name: step === 'phone' ? 'Phone' : 'Verification code' }); + expect(field).toHaveAttribute('aria-invalid', 'true'); + const describedControl = step === 'verify' ? screen.getByRole('group', { name: 'Verification code' }) : field; + expect(describedControl).toHaveAccessibleDescription('Please try again.'); + }); + + it('allows resending only after the countdown and the current request finish', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step: 'verify', resendSeconds: 12 }); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend (12)' })); + expect(props.onResend).not.toHaveBeenCalled(); + + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a code? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + + rerender( + + + , + ); + expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx new file mode 100644 index 00000000000..041aa9e524d --- /dev/null +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -0,0 +1,201 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../MosaicProvider'; +import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section/user-profile-account-section.view'; +import { UserProfileAccountSectionView } from '../user-profile-account-section/user-profile-account-section.view'; + +function renderPhone(overrides: Partial = {}) { + return render( + + + , + ); +} + +describe('phone actions', () => { + it('ignores backdrop clicks and allows Escape to cancel removal', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + const dialog = screen.getByRole('alertdialog', { name: 'Remove phone number?' }); + const backdrop = document.querySelector('.cl-dialog-backdrop'); + if (!backdrop) { + throw new Error('Expected a dialog backdrop'); + } + await user.click(backdrop); + expect(dialog).toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + }); + + it('closes confirmation before deletion finishes and prevents duplicate requests', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const onRemovePhone = vi.fn(() => pending); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + const dialog = screen.getByRole('alertdialog'); + const remove = within(dialog).getByRole('button', { name: 'Remove' }); + await user.click(remove); + await waitFor(() => expect(dialog).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(onRemovePhone).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); + + it('hides set primary while an update is pending', async () => { + const user = userEvent.setup(); + let finish = () => {}; + const pending = new Promise(resolve => { + finish = resolve; + }); + const onSetPrimaryPhone = vi.fn(() => pending); + renderPhone({ onSetPrimaryPhone, onRemovePhone: vi.fn() }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + expect(onSetPrimaryPhone).toHaveBeenCalledOnce(); + finish(); + await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument()); + }); + it.each([{ isDefault: true, isVerified: true }, { isDefault: false, isVerified: false }, { isDefault: false }])( + 'hides set primary for an ineligible phone: %j', + async flags => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + }, + ); + + it('updates the primary badge immediately without confirmation', async () => { + const user = userEvent.setup(); + function Example() { + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+18015550100', isVerified: true, isDefault: false }, + ]); + return ( + + + setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))) + } + /> + + ); + } + render(); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(screen.getByText('Primary')).toBeInTheDocument(); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage +1 (801) 555-0100' })).not.toBeInTheDocument(); + }); + + it('cancels removal without calling the mutation', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemovePhone).not.toHaveBeenCalled(); + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })).toHaveFocus(); + }); + + it('shows a failed removal in the account section and allows retry from the menu', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi + .fn() + .mockRejectedValueOnce(new Error('Cannot remove this phone.')) + .mockResolvedValue(undefined); + renderPhone({ onRemovePhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Cannot remove this phone.'); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + expect(onRemovePhone).toHaveBeenCalledTimes(2); + }); + + it('does not offer removal when it is forbidden', async () => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', isVerified: true, canRemove: false }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); + }); + it('shows a primary update error without opening a dialog', async () => { + const user = userEvent.setup(); + const onSetPrimaryPhone = vi.fn().mockRejectedValue(new Error('Unable to update primary phone.')); + renderPhone({ onSetPrimaryPhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryPhone).toHaveBeenCalledExactlyOnceWith('phone_1'); + expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary phone.'); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + it('requires confirmation before removing a phone number', async () => { + const user = userEvent.setup(); + const onRemovePhone = vi.fn(); + render( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).not.toHaveBeenCalled(); + const dialog = screen.getByRole('alertdialog', { name: 'Remove phone number?' }); + expect(dialog).toHaveTextContent('+1 (801) 555-0100'); + expect(within(dialog).queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Remove' })); + expect(onRemovePhone).toHaveBeenCalledExactlyOnceWith('phone_1'); + await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index a3627213e59..7905b273271 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -29,6 +29,19 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileProfilePanelView', () => { + it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { + renderView({ + allowMultipleAccounts, + phones: [{ id: 'phone_added', value: '+18015558181' }], + onManagePhone: vi.fn(), + }); + + expect(screen.getByText('+1 (801) 555-8181')).toBeInTheDocument(); + if (allowMultipleAccounts) { + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-8181' })).toBeInTheDocument(); + } + }); + it('composes the profile content without profile navigation', () => { renderView({ onProfilePictureChange: vi.fn(), @@ -49,7 +62,7 @@ describe('UserProfileProfilePanelView', () => { expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev')).toBeInTheDocument(); expect(screen.getByText('item1@clerk.dev').closest('.cl-section-item')).toHaveTextContent('Primary'); - expect(screen.getByText('+1 801-888-8181')).toBeInTheDocument(); + expect(screen.getByText('+1 (801) 888-8181')).toBeInTheDocument(); expect(screen.getByText('Profile picture')).toHaveClass('cl-section-label'); expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); expect(screen.getByText('Email')).toHaveClass('cl-section-label'); @@ -141,7 +154,8 @@ describe('UserProfileProfilePanelView', () => { renderView({ emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], onAddEmail: vi.fn(), - onAddPhone: vi.fn(), + onSendPhoneCode: () => Promise.resolve(), + onVerifyPhoneCode: () => Promise.resolve(), }); const accountSection = screen.getByRole('region', { name: 'Account' }); @@ -151,7 +165,7 @@ describe('UserProfileProfilePanelView', () => { expect(accountSection).not.toContainElement(emailSection); expect(accountSection).not.toContainElement(phoneSection); expect(emailSection).toHaveTextContent('item1@clerk.dev'); - expect(phoneSection).toHaveTextContent('+1 801-888-8181'); + expect(phoneSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(emailSection).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toHaveTextContent('Add'); }); @@ -167,7 +181,7 @@ describe('UserProfileProfilePanelView', () => { const accountSection = screen.getByRole('region', { name: 'Account' }); expect(accountSection).toHaveTextContent('item1@clerk.dev'); - expect(accountSection).toHaveTextContent('+1 801-888-8181'); + expect(accountSection).toHaveTextContent('+1 (801) 888-8181'); expect(within(accountSection).getByRole('button', { name: 'Update email' })).toBeInTheDocument(); expect(within(accountSection).getByRole('button', { name: 'Update phone number' })).toBeInTheDocument(); expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); @@ -194,7 +208,7 @@ describe('UserProfileProfilePanelView', () => { }); it('renders an actionable empty state when no phone number exists', () => { - renderView({ phones: [], onAddPhone: vi.fn() }); + renderView({ phones: [], onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve() }); const phoneSection = screen.getByRole('region', { name: 'Phone' }); const emptyState = within(phoneSection).getByText('No phone numbers added'); @@ -420,15 +434,21 @@ describe('UserProfileProfilePanelView', () => { await user.click(screen.getByRole('menuitem', { name: 'Verify' })); expect(onVerifyEmail).toHaveBeenCalledWith('email_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); expect(onVerifyPhone).toHaveBeenCalledWith('phone_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0100' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + expect(onRemovePhone).not.toHaveBeenCalled(); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove phone number?' })).getByRole('button', { + name: 'Remove', + }), + ); expect(onRemovePhone).toHaveBeenCalledWith('phone_unverified'); - await user.click(screen.getByRole('button', { name: 'Manage +1 801-555-0101' })); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0101' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index fe6b9ccc4b2..814f9cfcc84 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.messages.ts @@ -63,6 +63,14 @@ export const userProfileAccountSectionBase = { add: 'Add phone number', verify: 'Verify phone number', remove: 'Remove phone number', + primaryError: 'Unable to set the primary phone number. Try again.', + removeError: 'Unable to remove this phone number. Try again.', + removeDialog: { + title: 'Remove phone number?', + description: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', + confirm: 'Remove', + cancel: 'Cancel', + }, }, }; diff --git a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx index be6fe79a0ee..02f0df7223f 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-account-section/user-profile-account-section.view.tsx @@ -1,15 +1,22 @@ import type { FileRejection, FileRejectionReason } from '@clerk/headless/file-upload'; import { FileUpload } from '@clerk/headless/file-upload'; import * as stylex from '@stylexjs/stylex'; -import { useState } from 'react'; +import type { ReactNode } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { stringToFormattedPhoneString } from '../../../utils/phoneUtils'; import { Avatar } from '../../components/avatar'; import { Badge } from '../../components/badge'; import { Button } from '../../components/button'; +import { createConfirmHandle, Dialog } from '../../components/dialog'; import { Icon } from '../../components/icon'; import { Section } from '../../components/section'; +import { Text } from '../../components/text'; import type { UserProfileMenuAction } from '../user-profile-action-menu'; import { UserProfileActionMenu } from '../user-profile-action-menu'; +import type { UserProfileAddPhoneControllerOptions } from '../user-profile-add-phone.controller'; +import { useUserProfileAddPhoneController } from '../user-profile-add-phone.controller'; +import { UserProfileAddPhoneView } from '../user-profile-add-phone.view'; import { styles } from '../user-profile-profile-panel.styles'; import { fill, userProfileAccountSectionBase as m } from './user-profile-account-section.messages'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -72,11 +79,12 @@ export interface UserProfileAccountSectionViewProps { onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void; onRemoveEmail?: (id: string) => void; - onAddPhone?: () => void; + onSendPhoneCode?: (phoneNumber: string) => Promise; + onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; - onSetPrimaryPhone?: (id: string) => void; - onRemovePhone?: (id: string) => void; + onSetPrimaryPhone?: (id: string) => void | Promise; + onRemovePhone?: (id: string) => void | Promise; } export function UserProfileAccountSectionView({ @@ -101,12 +109,83 @@ export function UserProfileAccountSectionView({ onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onAddPhone, + onSendPhoneCode, + onVerifyPhoneCode, onManagePhone, onVerifyPhone, onSetPrimaryPhone, onRemovePhone, }: UserProfileAccountSectionViewProps) { + const addPhoneAction = + onSendPhoneCode && onVerifyPhoneCode ? ( + + ) : undefined; + const sectionRef = useRef(null); + const removeConfirm = useMemo(() => createConfirmHandle(), []); + const [phoneToRemove, setPhoneToRemove] = useState(); + const [removeError, setRemoveError] = useState(); + const removing = useRef(false); + const [isSettingPrimary, setIsSettingPrimary] = useState(false); + const [primaryError, setPrimaryError] = useState(); + const settingPrimary = useRef(false); + + const setPrimaryPhone = async (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) { + return; + } + settingPrimary.current = true; + setIsSettingPrimary(true); + setPrimaryError(undefined); + try { + await onSetPrimaryPhone(id); + } catch (error) { + setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError); + } finally { + settingPrimary.current = false; + setIsSettingPrimary(false); + } + }; + + const removePhone = async (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (!phone || phone.canRemove === false || !onRemovePhone || removing.current) { + return; + } + removing.current = true; + setPhoneToRemove(phone); + setRemoveError(undefined); + try { + const [beforePhone, afterPhone] = m.phone.removeDialog.description.split('{phoneNumber}'); + const confirmed = await removeConfirm.show({ + title: m.phone.removeDialog.title, + description: ( + <> + {beforePhone} + {stringToFormattedPhoneString(phone.value)} + {afterPhone} + + ), + actionLabel: m.phone.removeDialog.confirm, + cancelLabel: m.phone.removeDialog.cancel, + destructive: true, + }); + if (confirmed) { + await onRemovePhone(id); + } + } catch (error) { + setRemoveError(error instanceof Error ? error.message : m.phone.removeError); + } finally { + removing.current = false; + } + }; + const formattedPhones = phones.map(phone => ({ + ...phone, + value: stringToFormattedPhoneString(phone.value), + })); const initials = name .split(/\s+/) .map(part => part[0]) @@ -120,7 +199,12 @@ export function UserProfileAccountSectionView({ } + render={ +
+ } onReject={rejections => { setRejection(rejections[0]?.reason ?? null); onProfilePictureReject?.(rejections); @@ -209,10 +293,10 @@ export function UserProfileAccountSectionView({ ) : null} {!allowMultipleAccounts ? ( ) : null} @@ -232,16 +316,45 @@ export function UserProfileAccountSectionView({ ) : null} {allowMultipleAccounts ? ( void removePhone(id) : undefined} + onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} onVerify={onVerifyPhone} /> ) : null} + {primaryError ? ( + + {primaryError} + + ) : null} + {removeError ? ( + + {removeError} + + ) : null} + { + const buttons = Array.from(sectionRef.current?.querySelectorAll('button') ?? []); + const label = phoneToRemove ? `Manage ${stringToFormattedPhoneString(phoneToRemove.value)}` : ''; + return ( + buttons.find(button => button.getAttribute('aria-label') === label) ?? + buttons.find(button => button.getAttribute('aria-label') === 'Add phone number') ?? + buttons[0] ?? + false + ); + }} + /> ); } @@ -336,7 +449,34 @@ function EditName({ ); } +function AddPhone({ options, compact }: { options: UserProfileAddPhoneControllerOptions; compact: boolean }) { + const controller = useUserProfileAddPhoneController(options); + return ( + + {compact ? ( + + ) : null} + {compact ? m.add : m.phone.add} + + } + /> + ); +} + interface ContactSectionProps { + addAction?: ReactNode; kind: 'email' | 'phone'; label: string; items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; @@ -357,7 +497,7 @@ function ContactSection(props: ContactSectionProps) { ); } -function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectionProps) { +function SingleContactRow({ kind, label, items, onAdd, onManage, addAction }: ContactSectionProps) { const item = items[0]; const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; const emptyDescription = m[kind].empty; @@ -377,7 +517,9 @@ function SingleContactRow({ kind, label, items, onAdd, onManage }: ContactSectio {emptyDescription} )} - {onClick ? ( + {!item && addAction ? ( + {addAction} + ) : onClick ? ( + + + + + } + > + {m.verify.cancel} + + + {m.verify.submit} + + + + + )} + + + + + ); +} diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts index 8a3cc17a27c..fd3e6967fb3 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.styles.ts @@ -1,8 +1,11 @@ import * as stylex from '@stylexjs/stylex'; -import { space } from '../tokens.stylex'; +import { fontWeightVars, space } from '../tokens.stylex'; export const styles = stylex.create({ + confirmPhoneNumber: { + fontWeight: fontWeightVars['--cl-font-medium'], + }, contactValue: { gap: space['2'], alignItems: 'center', diff --git a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx index ffd3b4bf349..cd89043958d 100644 --- a/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-profile-profile-panel.view.tsx @@ -61,7 +61,8 @@ export function UserProfileProfilePanelView({ onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onAddPhone, + onSendPhoneCode, + onVerifyPhoneCode, onManagePhone, onVerifyPhone, onSetPrimaryPhone, @@ -92,7 +93,8 @@ export function UserProfileProfilePanelView({ phones={phones} username={username} onAddEmail={onAddEmail} - onAddPhone={onAddPhone} + onSendPhoneCode={onSendPhoneCode} + onVerifyPhoneCode={onVerifyPhoneCode} onManageEmail={onManageEmail} onManagePhone={onManagePhone} onProfilePictureChange={onProfilePictureChange}