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 ? (