diff --git a/.changeset/mosaic-user-profile-split-account-section.md b/.changeset/mosaic-user-profile-split-account-section.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-split-account-section.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index 4c2859a384d..41a9282434c 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -430,7 +430,7 @@ branch on rather than two. A controller whose own state is a single boolean is the same layer with `useState` inside it — still no Clerk, still returning plain props. The async part goes to a shared hook (`useForm` here) rather than a hand-rolled pending flag. From -`features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts`: +`features/user-profile/user-profile-profile-section/user-profile-edit-username.controller.ts`: ```tsx export function useUserProfileEditUsernameController({ username = '', required = false, onSubmit }) { diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index bbd21bc04c6..c64f2589c4b 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -12,7 +12,7 @@ where its effects come from. Worked examples: - `packages/mosaic/src/features/user-button/user-button.controller.tsx` — wraps a model -- `packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts` +- `packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-username.controller.ts` — takes its one effect as a prop See `packages/mosaic/ARCHITECTURE.md` → "Controllers" for the layer contract. diff --git a/packages/mosaic/docs/models.md b/packages/mosaic/docs/models.md index d8cb788d0c9..cc2ba70ec2a 100644 --- a/packages/mosaic/docs/models.md +++ b/packages/mosaic/docs/models.md @@ -105,7 +105,7 @@ drops the fallback instead of holding the space open. Keep the two apart. - A callback that backs a form wraps its Clerk call in `save(run, fields, params)` from `utils/errors.ts`. It rethrows a Clerk error as a `SaveError` routed to the form fields it names, which the form renders - (`user-profile-account-section.model.ts`). + (`user-profile-profile-section.model.ts`). - No local UI state. What is open and what is in flight belong to the controller. - No React state machinery beyond the Clerk hooks themselves; the model is a derivation of what Clerk currently says. The exception is what to fetch: a diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index 1d6200f3a81..aff9457d7b6 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -44,11 +44,6 @@ "count": 1 } }, - "src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx": { - "sonarjs/cognitive-complexity": { - "count": 1 - } - }, "src/features/user-profile/user-profile-active-devices-section/user-profile-active-devices-section.model.ts": { "@typescript-eslint/no-restricted-imports": { "count": 1 diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx index 9bb21d8a73d..cd442eccc1f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { UserProfileContactListRowView } from '../user-profile-account-section/user-profile-contact-list-row.view'; +import { UserProfileContactListRowView } from '../user-profile-contact-list-row.view'; describe('UserProfileContactListRowView', () => { it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.utils.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact.utils.test.ts similarity index 95% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.utils.test.ts rename to packages/mosaic/src/features/user-profile/__tests__/user-profile-contact.utils.test.ts index e2c2e71331f..3c2604a96dc 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.utils.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact.utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { toContacts } from '../user-profile-account-section/user-profile-account-section.utils'; +import { toContacts } from '../user-profile-contact.utils'; type Contact = { id: string; verification: { status: 'verified' | 'unverified' | null; expireAt: Date | null } }; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx deleted file mode 100644 index e932abb968d..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; - -describe('UserProfileNameRowView', () => { - it('offers to add a name the user does not have yet', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - expect(screen.getByText('No name added')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Add name' })); - expect(screen.getByRole('dialog', { name: 'Add name' })).toBeInTheDocument(); - }); - - it('offers to edit a name the user has', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - expect(screen.getByText('Preston Booth')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Edit name' })); - expect(screen.getByRole('dialog', { name: 'Edit name' })).toBeInTheDocument(); - }); - it('names the connection managing the name in place of the edit action', () => { - render( - - - , - ); - - expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); - expect(screen.queryByRole('button')).not.toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx deleted file mode 100644 index 7c77b074811..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture-row.view.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -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 '../../../mosaic-provider'; -import { SaveError } from '../../../utils/errors'; -import type { UserProfilePictureRowViewProps } from '../user-profile-account-section/user-profile-picture-row.view'; -import { UserProfilePictureRowView } from '../user-profile-account-section/user-profile-picture-row.view'; - -function renderView(overrides: Partial = {}) { - return render( - - - , - ); -} - -describe('UserProfilePictureRowView', () => { - it('shows why removing the picture failed', async () => { - const user = userEvent.setup(); - renderView({ - hasImage: true, - onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), - }); - - await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - - await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent("This action couldn't be completed.")); - }); - - it('replaces a rejected pick with the result of a removal', async () => { - const user = userEvent.setup(); - const { container } = renderView({ - hasImage: true, - onChange: vi.fn(), - onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), - }); - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new Error('expected a file input'); - } - - const tooBig = new File(['x'], 'big.png', { type: 'image/png' }); - Object.defineProperty(tooBig, 'size', { value: 11 * 1024 * 1024 }); - await user.upload(input, tooBig); - const rejection = screen.getByRole('alert').textContent; - - await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); - await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - - await waitFor(() => expect(screen.getByRole('alert')).not.toHaveTextContent(rejection ?? '')); - expect(screen.getByRole('alert')).toHaveTextContent("This action couldn't be completed."); - }); - - it('offers Upload while the avatar is only a generated default', () => { - renderView({ - hasImage: false, - imageUrl: 'https://img.clerk.com/generated-default.png', - onChange: vi.fn(), - onRemove: vi.fn(), - }); - - expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); - }); - - it('offers change and remove in a menu once a profile picture is set', async () => { - const onChange = vi.fn(); - const onRemove = vi.fn(); - const user = userEvent.setup(); - renderView({ - hasImage: true, - imageUrl: 'https://example.com/avatar.png', - onChange, - onRemove, - }); - - expect(screen.queryByRole('button', { name: 'Upload' })).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); - - expect(screen.getByRole('menuitem', { name: 'Change avatar' })).toBeInTheDocument(); - await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - - expect(onRemove).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx index 96dd3aa7f12..2d613db4787 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-profile-panel.view.test.tsx @@ -16,44 +16,36 @@ function DeleteAccount() { return ; } -const props: UserProfileProfilePanelViewProps = { - allowMultipleAccounts: true, - name: 'Preston Booth', - username: 'prestonxyz', - emails: [ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, - ], - phones: [{ id: 'phone_1', value: '+1 801-888-8181', isDefault: false, isVerified: true }], -}; - -function renderView(overrides: Partial = {}) { +function renderView(overrides: UserProfileProfilePanelViewProps = {}) { return render( - + , ); } -const phoneVerifier = { sendCode: () => Promise.resolve(), verifyCode: () => Promise.resolve() }; - describe('UserProfileProfilePanelView', () => { - it('names the connection managing the name, as the section does on its own', () => { - renderView({ nameManagedBy: { name: 'Okta' }, onSubmitName: undefined }); + it('titles the panel Account without profile navigation', () => { + renderView(); - expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Edit name' })).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); + expect(screen.queryByRole('tab')).toBeNull(); }); - it('drops the rows the instance does not collect', () => { - renderView({ username: undefined, emails: undefined, phones: undefined }); + it('places the sections in the order of the composed profile page', () => { + renderView({ + dangerSlot:
, + web3WalletsSlot:
, + enterpriseAccountsSlot:
, + connectedAccountsSlot:
, + phoneSlot:
, + emailSlot:
, + profileSlot:
, + }); - expect(screen.queryByText('Username')).not.toBeInTheDocument(); - expect(screen.queryByText('item1@clerk.dev')).not.toBeInTheDocument(); - expect(screen.queryByText('+1 801-888-8181')).not.toBeInTheDocument(); + const order = ['profile', 'email', 'phone', 'connected', 'enterprise', 'web3', 'danger']; + const rendered = Array.from(document.querySelectorAll('[data-testid]'), node => node.getAttribute('data-testid')); + expect(rendered).toEqual(order); }); it('keeps the final wallet confirmation mounted until removal settles', async () => { @@ -79,7 +71,6 @@ describe('UserProfileProfilePanelView', () => { rerender( { await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); - it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { - renderView({ - allowMultipleAccounts, - phones: [{ id: 'phone_added', value: '+18015558181', isDefault: false, isVerified: true }], - onRemovePhone: 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(() => Promise.resolve()), - onSubmitName: () => Promise.resolve(), - onSubmitUsername: () => Promise.resolve(), - }); - - expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); - expect(screen.getByRole('region', { name: 'Account' })).toContainElement( - screen.getByRole('group', { name: 'Profile' }), - ); - expect(screen.getByRole('heading', { level: 3, name: 'Profile' })).toHaveClass('cl-section-title'); - expect(screen.getByText('Name', { selector: '.cl-section-label > *' })).toBeInTheDocument(); - expect(screen.getByText('Username', { selector: '.cl-section-label > *' })).toBeInTheDocument(); - expect(screen.getByText('Preston Booth')).toHaveClass('cl-section-description'); - expect(screen.getByText('prestonxyz')).toHaveClass('cl-section-description'); - expect(screen.getByRole('button', { name: 'Edit name' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Edit username' })).toBeInTheDocument(); - 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('Profile picture', { selector: '.cl-section-label > *' })).toBeInTheDocument(); - expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toHaveClass('cl-section-description'); - expect(screen.getByRole('heading', { level: 3, name: 'Email' })).toHaveClass('cl-section-title'); - expect(screen.getByRole('heading', { level: 3, name: 'Phone' })).toHaveClass('cl-section-title'); - expect(screen.getByText('item1@clerk.dev').closest('.cl-section-description')).not.toBeNull(); - expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument(); - const profilePicture = screen.getByText('Profile picture').closest('.cl-section-item'); - expect(profilePicture?.querySelector('.cl-section-media')).toHaveAttribute('data-size', 'lg'); - expect(profilePicture?.querySelector('.cl-avatar')).toHaveAttribute('data-size', 'fit'); - expect(screen.queryByRole('tab')).toBeNull(); - expect(screen.queryByRole('heading', { name: 'User Profile' })).toBeNull(); - }); - - it('uploads the picked file when no profile picture is set', async () => { - const onProfilePictureChange = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - const { container } = renderView({ - onProfilePictureChange, - onRemoveProfilePicture: vi.fn(() => Promise.resolve()), - }); - - expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); - - const input = container.querySelector('input[type="file"]'); - if (!input) { - throw new Error('File picker not found'); - } - const file = new File(['avatar'], 'avatar.png', { type: 'image/png' }); - await user.upload(input, file); - - expect(onProfilePictureChange).toHaveBeenCalledWith(file); - }); - - it('turns away a file past the size the row advertises', async () => { - const onProfilePictureChange = vi.fn(() => Promise.resolve()); - const onProfilePictureReject = vi.fn(); - const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); - - const oversized = new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' }); - const input = container.querySelector('input[type="file"]'); - if (!input) { - throw new Error('File picker not found'); - } - await user.upload(input, oversized); - - expect(onProfilePictureChange).not.toHaveBeenCalled(); - expect(onProfilePictureReject).toHaveBeenCalledWith([{ file: oversized, reason: 'size' }]); - expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); - expect(screen.getByText('Recommend size 1:1, up to 10MB.')).toBeInTheDocument(); - }); - - it('clears the rejection once an acceptable file is picked', async () => { - const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve()) }); - const input = container.querySelector('input[type="file"]'); - if (!input) { - throw new Error('File picker not found'); - } - - await user.upload(input, new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' })); - expect(screen.getByRole('alert')).toBeInTheDocument(); - - await user.upload(input, new File(['small'], 'small.png', { type: 'image/png' })); - expect(screen.queryByRole('alert')).toBeNull(); - }); - - it('nests both contact types as groups inside Account when multiple accounts are allowed', () => { - renderView({ - emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], - onAddEmail: vi.fn(), - onCreatePhone: () => Promise.resolve(phoneVerifier), - getPhoneVerifier: () => phoneVerifier, - }); - - const accountSection = screen.getByRole('region', { name: 'Account' }); - const emailSection = within(accountSection).getByRole('group', { name: 'Email' }); - const phoneSection = within(accountSection).getByRole('group', { name: 'Phone' }); - - expect(screen.queryByRole('region', { name: 'Email' })).not.toBeInTheDocument(); - expect(screen.queryByRole('region', { name: 'Phone' })).not.toBeInTheDocument(); - expect(within(emailSection).getByRole('heading', { level: 3, name: 'Email' })).toBeInTheDocument(); - expect(emailSection.querySelector('.cl-section-header')).toHaveTextContent('Email'); - expect(within(emailSection).getByRole('list')).toContainElement(screen.getByText('item1@clerk.dev')); - expect(within(emailSection).getAllByRole('listitem')).toHaveLength(1); - 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'); - }); - - it('keeps both contact types inside Account when multiple accounts are not allowed', () => { - renderView({ - allowMultipleAccounts: false, - emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], - onManageEmail: vi.fn(), - onManagePhone: vi.fn(), - }); - - const accountSection = screen.getByRole('region', { name: 'Account' }); - - expect(accountSection).toHaveTextContent('item1@clerk.dev'); - 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('group', { name: 'Email' })).not.toBeInTheDocument(); - expect(screen.queryByRole('group', { name: 'Phone' })).not.toBeInTheDocument(); - }); - - it('forwards inline contact update and add actions', async () => { - const onAddEmail = vi.fn(); - const onManagePhone = vi.fn(); - const user = userEvent.setup(); - renderView({ - allowMultipleAccounts: false, - emails: [], - onAddEmail, - onManagePhone, - }); - - expect(screen.getByText('No email addresses added')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Add email' })); - await user.click(screen.getByRole('button', { name: 'Update phone number' })); - - expect(onAddEmail).toHaveBeenCalledOnce(); - expect(onManagePhone).toHaveBeenCalledWith('phone_1'); - }); - - it('renders an actionable empty state when no phone number exists', () => { - renderView({ - phones: [], - onCreatePhone: () => Promise.resolve(phoneVerifier), - getPhoneVerifier: () => phoneVerifier, - }); - - const phoneSection = screen.getByRole('group', { name: 'Phone' }); - const emptyState = within(phoneSection).getByText('No phone numbers added'); - - expect(emptyState.closest('.cl-section-items')).not.toBeNull(); - expect(emptyState.closest('.cl-section-item')).not.toContainElement(within(phoneSection).getByText('Phone')); - expect(within(phoneSection).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); - }); - it('renders the danger zone when provided', () => { renderView({ dangerSlot: , @@ -362,158 +176,4 @@ describe('UserProfileProfilePanelView', () => { expect(screen.getByRole('button', { name: 'Connect Coinbase Wallet' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Manage Coinbase Wallet' })).toBeVisible(); }); - - it('renders safely before profile data is available', () => { - render( - - - , - ); - - expect(screen.getByRole('region', { name: 'Account' })).toBeInTheDocument(); - }); - - it('forwards profile and contact actions', async () => { - const onAddEmail = vi.fn(); - const onRemoveEmail = vi.fn(); - renderView({ onSubmitName: () => Promise.resolve(), onAddEmail, onRemoveEmail }); - const user = userEvent.setup(); - - await user.click(screen.getByRole('button', { name: 'Add email' })); - await user.click(screen.getByRole('button', { name: 'Manage item2@clerk.dev' })); - expect(onRemoveEmail).not.toHaveBeenCalled(); - await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); - expect(onRemoveEmail).not.toHaveBeenCalled(); - await user.click(within(screen.getByRole('alertdialog')).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); - // Last: the edit-name dialog is modal, so the rest of the panel goes inert once it opens. - await user.click(screen.getByRole('button', { name: 'Edit name' })); - - expect(screen.getByRole('dialog', { name: 'Edit name' })).toBeInTheDocument(); - expect(onAddEmail).toHaveBeenCalledOnce(); - expect(onRemoveEmail).toHaveBeenCalledWith('email_2'); - }); - - it('drives the edit-name dialog from the section, seeded with the saved name', async () => { - const onSubmitName = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ firstName: 'Preston', lastName: 'Booth', onSubmitName }); - - await user.click(screen.getByRole('button', { name: 'Edit name' })); - const dialog = screen.getByRole('dialog', { name: 'Edit name' }); - expect(within(dialog).getByLabelText('First name')).toHaveValue('Preston'); - expect(within(dialog).getByLabelText('Last name')).toHaveValue('Booth'); - - await user.clear(within(dialog).getByLabelText('Last name')); - await user.type(within(dialog).getByLabelText('Last name'), 'Barton'); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitName).toHaveBeenCalledWith({ firstName: 'Preston', lastName: 'Barton' }); - await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit name' })).not.toBeInTheDocument()); - }); - - it('drives the edit-username dialog from the section, seeded with the saved username', async () => { - const onSubmitUsername = vi.fn(() => Promise.resolve()); - const user = userEvent.setup(); - renderView({ username: 'prestonxyz', onSubmitUsername }); - - await user.click(screen.getByRole('button', { name: 'Edit username' })); - const dialog = screen.getByRole('dialog', { name: 'Edit username' }); - expect(within(dialog).getByLabelText('Username')).toHaveValue('prestonxyz'); - - await user.clear(within(dialog).getByLabelText('Username')); - await user.type(within(dialog).getByLabelText('Username'), 'preston'); - await user.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(onSubmitUsername).toHaveBeenCalledWith('preston'); - await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Edit username' })).not.toBeInTheDocument()); - }); - - it('matches the existing conditional contact actions', async () => { - const onVerifyEmail = vi.fn(); - const onSetPrimaryEmail = vi.fn(); - const onRemoveEmail = vi.fn(); - const onVerifyPhone = vi.fn(); - const onSetPrimaryPhone = vi.fn(); - const onRemovePhone = vi.fn(); - const user = userEvent.setup(); - - renderView({ - emails: [ - { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, - { id: 'email_secondary', value: 'secondary@clerk.dev', isDefault: false, isVerified: true }, - { id: 'email_unverified', value: 'unverified@clerk.dev', isDefault: false, isVerified: false }, - ], - phones: [ - { id: 'phone_unverified', value: '+1 801-555-0100', isDefault: false, isVerified: false }, - { id: 'phone_secondary', value: '+1 801-555-0101', isDefault: false, isVerified: true }, - ], - onVerifyEmail, - onSetPrimaryEmail, - onRemoveEmail, - onVerifyPhone, - onSetPrimaryPhone, - onRemovePhone, - }); - - await user.click(screen.getByRole('button', { name: 'Manage primary@clerk.dev' })); - await user.click(screen.getByRole('menuitem', { name: 'Complete verification' })); - expect(onVerifyEmail).toHaveBeenCalledWith('email_primary'); - - await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); - await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); - expect(onSetPrimaryEmail).toHaveBeenCalledWith('email_secondary'); - - await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); - const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); - expect(removeEmail).toHaveAttribute('data-color', 'negative'); - await user.click(removeEmail); - expect(onRemoveEmail).not.toHaveBeenCalled(); - await user.click( - within(screen.getByRole('alertdialog', { name: 'Remove email address?' })).getByRole('button', { - name: 'Remove', - }), - ); - expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); - - await user.click(screen.getByRole('button', { name: 'Manage unverified@clerk.dev' })); - 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('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('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('menuitem', { name: 'Set as primary' })); - expect(onSetPrimaryPhone).toHaveBeenCalledWith('phone_secondary'); - }); - - it('hides action triggers when immutable items have no available actions', () => { - renderView({ - emails: [ - { - id: 'email_immutable', - value: 'immutable@clerk.dev', - isDefault: true, - isVerified: true, - }, - ], - phones: [], - onVerifyEmail: vi.fn(), - onSetPrimaryEmail: vi.fn(), - }); - - expect(screen.queryByRole('button', { name: 'Manage immutable@clerk.dev' })).not.toBeInTheDocument(); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx deleted file mode 100644 index 80eb4b6969d..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileUsernameRowView } from '../user-profile-account-section/user-profile-username-row.view'; - -describe('UserProfileUsernameRowView', () => { - it('offers to add a username the user does not have yet', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - expect(screen.getByText('No username added')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Add username' })); - expect(screen.getByRole('dialog', { name: 'Add username' })).toBeInTheDocument(); - }); - - it('offers to edit a username the user has', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - expect(screen.getByText('prestonxyz')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Edit username' })); - expect(screen.getByRole('dialog', { name: 'Edit username' })).toBeInTheDocument(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx index 429f4444d76..97bd72979ac 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile.view.test.tsx @@ -8,7 +8,7 @@ import type { UserProfileViewProps } from '../user-profile.view'; import { UserProfileView } from '../user-profile.view'; const pages: UserProfileViewProps['pages'] = { - account: { name: 'Preston Booth', username: 'prestonxyz' }, + account: {}, security: {}, billing: { subscription: { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx deleted file mode 100644 index 94712c9f943..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.feature.test.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import type { EmailAddressJSON, EnterpriseAccountConnectionJSON, PhoneNumberJSON } from '@clerk/shared/types'; -import { act, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; -import { describe, expect, it, vi } from 'vitest'; - -import { - type FakeFapiSeed, - fapiUrl, - holdRequests, - serveFapi, - VERIFICATION_CODE, - verifyEmailOutOfBand, - worker, -} from '../../../__tests__/feature/fake-fapi'; -import type { FapiAttributeOverrides } from '../../../__tests__/feature/fapi'; -import { - fapiClient, - fapiEmailAddress, - fapiEnterpriseAccount, - fapiEnvironment, - fapiPhoneNumber, - fapiSession, - fapiUser, - fapiVerification, -} from '../../../__tests__/feature/fapi'; -import { renderWithClerk } from '../../../__tests__/feature/render'; -import { UserProfileAccountSection } from './user-profile-account-section'; - -const HOME = fapiPhoneNumber({ - id: 'idn_home', - phone_number: '+18015550100', - verification: fapiVerification('phone_code', { status: 'verified' }), -}); -const WORK = fapiPhoneNumber({ - id: 'idn_work', - phone_number: '+18015550199', - verification: fapiVerification('phone_code', { status: 'verified' }), -}); -const HOME_LABEL = '+1 (801) 555-0100'; -const WORK_LABEL = '+1 (801) 555-0199'; - -function signedIn(phones: PhoneNumberJSON[] = [], overrides: Partial = {}): FakeFapiSeed { - const user = fapiUser({ - id: 'user_1', - first_name: 'Alice', - last_name: 'Smith', - username: 'alicesmith', - email_addresses: [fapiEmailAddress({ id: 'idn_email' })], - phone_numbers: phones, - primary_phone_number_id: phones[0]?.id ?? null, - }); - return { - environment: fapiEnvironment({ attributes: { phone_number: { enabled: true } } }), - client: fapiClient([fapiSession({ id: 'sess_1', user })]), - ...overrides, - }; -} - -function withPhoneAttribute(overrides: FapiAttributeOverrides['phone_number']): FakeFapiSeed { - return signedIn([HOME, WORK], { environment: fapiEnvironment({ attributes: { phone_number: overrides } }) }); -} - -async function renderSection(seed: FakeFapiSeed = signedIn()) { - const fapi = serveFapi(seed); - const view = await renderWithClerk(); - return { ...view, fapi, actor: userEvent.setup() }; -} - -type Actor = ReturnType; - -const row = () => screen.getByRole('group', { name: 'Phone' }); -const listed = () => - within(row()) - .queryAllByText(/^\+\d/) - .map(node => node.textContent); - -async function manage(actor: Actor, label: string, action: string) { - await actor.click(within(row()).getByRole('button', { name: `Manage ${label}` })); - await actor.click(await screen.findByRole('menuitem', { name: action })); -} - -async function addPhone(actor: Actor, digits: string) { - await actor.click(screen.getByRole('button', { name: 'Add phone number' })); - await actor.type(screen.getByRole('textbox', { name: 'Phone' }), digits); - await actor.click(screen.getByRole('button', { name: 'Send code' })); -} - -async function enterCode(actor: Actor, code: string) { - const input = await screen.findByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(input).not.toHaveAttribute('aria-disabled')); - await actor.click(input); - await actor.keyboard(code); -} - -describe('the user profile phone numbers', () => { - it('lists the primary number first and marks it', async () => { - await renderSection(signedIn([HOME, WORK])); - - expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]); - expect(within(row()).getByText(HOME_LABEL).parentElement).toHaveTextContent('Primary'); - }); - - it('says so when no number has been added', async () => { - await renderSection(); - - expect(row()).toHaveTextContent('No phone numbers added'); - }); - - it('adds a number, verifies the code it was sent, and lists it', async () => { - const { actor } = await renderSection(); - - await addPhone(actor, '8015550100'); - await enterCode(actor, VERIFICATION_CODE); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(listed()).toEqual([HOME_LABEL]); - expect(row()).not.toHaveTextContent('Unverified'); - }); - - it('starts the country on the one the instance located the user in', async () => { - const { actor } = await renderSection(signedIn([], { country: 'de' })); - - await actor.click(screen.getByRole('button', { name: 'Add phone number' })); - - expect(screen.getByRole('button', { name: 'Country, Germany' })).toBeInTheDocument(); - expect(screen.getByText('+49')).toBeInTheDocument(); - }); - - it('keeps the dialog open and says the code was wrong', async () => { - const { actor } = await renderSection(); - - await addPhone(actor, '8015550100'); - await enterCode(actor, '000000'); - - expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); - expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBeInTheDocument(); - }); - - it('holds the resend behind a countdown while the code is still fresh', async () => { - const { actor } = await renderSection(); - - await addPhone(actor, '8015550100'); - - expect(await screen.findByRole('button', { name: /Didn’t receive a code\? Resend \(\d+\)/ })).toBeDisabled(); - }); - - it('verifies a number that was left unverified', async () => { - const pending = fapiPhoneNumber({ id: 'idn_new', phone_number: '+18015550199' }); - const { actor } = await renderSection(signedIn([HOME, pending])); - - expect(within(row()).getByText(WORK_LABEL).parentElement).toHaveTextContent('Unverified'); - await manage(actor, WORK_LABEL, 'Verify phone number'); - await enterCode(actor, VERIFICATION_CODE); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - await waitFor(() => expect(within(row()).getByText(WORK_LABEL).parentElement).not.toHaveTextContent('Unverified')); - }); - - it('makes another verified number the primary one', async () => { - const { actor } = await renderSection(signedIn([HOME, WORK])); - - await manage(actor, WORK_LABEL, 'Set as primary'); - - await waitFor(() => expect(listed()).toEqual([WORK_LABEL, HOME_LABEL])); - expect(within(row()).getByText(WORK_LABEL).parentElement).toHaveTextContent('Primary'); - }); - - it('removes a number once the removal is confirmed', async () => { - const { actor } = await renderSection(signedIn([HOME, WORK])); - - await manage(actor, WORK_LABEL, 'Remove phone number'); - const dialog = await screen.findByRole('alertdialog', { name: 'Remove phone number?' }); - expect(dialog).toHaveTextContent(WORK_LABEL); - await actor.click(within(dialog).getByRole('button', { name: 'Remove' })); - - await waitFor(() => expect(listed()).toEqual([HOME_LABEL])); - }); - - it('explains a refused removal in the instance’s own copy', async () => { - const { actor } = await renderSection(signedIn([HOME, WORK])); - const destroy = holdRequests('post', '/v1/me/phone_numbers/:id'); - - await manage(actor, WORK_LABEL, 'Remove phone number'); - const dialog = await screen.findByRole('alertdialog', { name: 'Remove phone number?' }); - await actor.click(within(dialog).getByRole('button', { name: 'Remove' })); - await waitFor(() => expect(destroy.requests).toHaveLength(1)); - destroy.fail('action_blocked', 'Raw server sentence.'); - - expect(await within(dialog).findByText(/Please try again later or contact support/)).toBeInTheDocument(); - expect(dialog).not.toHaveTextContent('Raw server sentence.'); - expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]); - }); - - it('hides the row when the instance does not collect phone numbers', async () => { - await renderSection(withPhoneAttribute({ enabled: false })); - - expect(screen.queryByRole('group', { name: 'Phone' })).not.toBeInTheDocument(); - }); - - it('lists immutable numbers without offering to add or remove one', async () => { - const { actor } = await renderSection(withPhoneAttribute({ enabled: true, immutable: true })); - - expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]); - expect(screen.queryByRole('button', { name: 'Add phone number' })).not.toBeInTheDocument(); - await actor.click(within(row()).getByRole('button', { name: `Manage ${WORK_LABEL}` })); - - expect(await screen.findByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument(); - expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); - }); -}); - -const VERIFIED = fapiVerification('email_code', { status: 'verified' }); -const PRIMARY = fapiEmailAddress({ id: 'idn_primary', email_address: 'alice@example.com', verification: VERIFIED }); - -function signedInWithEmails(emails: EmailAddressJSON[], environment = fapiEnvironment()): FakeFapiSeed { - const user = fapiUser({ - id: 'user_1', - first_name: 'Alice', - last_name: 'Smith', - username: 'alicesmith', - email_addresses: emails, - primary_email_address_id: emails[0]?.id ?? null, - }); - return { environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }; -} - -const verifiesByLink = fapiEnvironment({ attributes: { email_address: { verifications: ['email_link'] } } }); - -const emailRow = () => screen.getByRole('group', { name: 'Email' }); -const emailsListed = () => - within(emailRow()) - .queryAllByText(/@/) - .map(node => node.textContent); - -async function manageEmail(actor: Actor, label: string, action: string) { - await actor.click(within(emailRow()).getByRole('button', { name: `Manage ${label}` })); - await actor.click(await screen.findByRole('menuitem', { name: action })); -} - -describe('the user profile email addresses', () => { - it('adds an address and verifies the code it was sent', async () => { - const { actor } = await renderSection(signedInWithEmails([])); - expect(emailRow()).toHaveTextContent('No email addresses added'); - - await actor.click(screen.getByRole('button', { name: 'Add email' })); - await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); - await actor.click(screen.getByRole('button', { name: 'Continue' })); - await enterCode(actor, VERIFICATION_CODE); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(emailsListed()).toEqual(['new@example.com']); - expect(emailRow()).not.toHaveTextContent('Unverified'); - }); - - it('holds the code field until the code has been sent', async () => { - const { actor } = await renderSection(signedInWithEmails([])); - const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); - - await actor.click(screen.getByRole('button', { name: 'Add email' })); - await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); - await actor.click(screen.getByRole('button', { name: 'Continue' })); - await waitFor(() => expect(prepare.requests).toHaveLength(1)); - - expect(await screen.findByRole('textbox', { name: 'Verification code' })).toHaveAttribute('aria-disabled', 'true'); - - prepare.release(); - await enterCode(actor, VERIFICATION_CODE); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(emailsListed()).toEqual(['new@example.com']); - }); - - it('verifies an address left unverified', async () => { - const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); - const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending])); - expect(within(emailRow()).getByText('pending@example.com').parentElement).toHaveTextContent('Unverified'); - - await manageEmail(actor, 'pending@example.com', 'Verify'); - await enterCode(actor, VERIFICATION_CODE); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - await waitFor(() => - expect(within(emailRow()).getByText('pending@example.com').parentElement).not.toHaveTextContent('Unverified'), - ); - }); - - it('waits for the emailed link when the instance verifies by link, then closes once it is opened', async () => { - const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); - const { actor, fapi } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); - const poll = holdRequests('get', '/v1/me/email_addresses/:id'); - - await manageEmail(actor, 'pending@example.com', 'Verify'); - const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - await waitFor(() => expect(dialog).toHaveTextContent('Open the link we sent to pending@example.com')); - expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); - await waitFor(() => expect(poll.requests).toHaveLength(1)); - - verifyEmailOutOfBand(fapi, 'idn_pending'); - poll.release(); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('points the emailed link at the user profile on the host origin', async () => { - const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); - const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); - const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); - - await manageEmail(actor, 'pending@example.com', 'Verify'); - - await waitFor(() => expect(prepare.requests).toHaveLength(1)); - const [request] = prepare.requests; - if (!request) { - throw new Error('expected a prepare_verification request'); - } - const body = new URLSearchParams(await request.text()); - expect(body.get('strategy')).toBe('email_link'); - expect(body.get('redirect_url')).toBe(new URL('/user-profile#/verify', window.location.origin).href); - - prepare.release(); - }); - - it('leaves the address unverified when the link dialog is dismissed', async () => { - const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); - const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); - - await manageEmail(actor, 'pending@example.com', 'Verify'); - const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - await actor.click(within(dialog).getByRole('button', { name: 'Cancel' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(within(emailRow()).getByText('pending@example.com').parentElement).toHaveTextContent('Unverified'); - }); - - it('sends the user to the identity provider for an address that matches an SSO connection, even when the instance verifies by link', async () => { - const sso = fapiEmailAddress({ - id: 'idn_sso', - email_address: 'alice@acme.co', - matches_sso_connection: true, - }); - const { actor, clerk } = await renderSection(signedInWithEmails([PRIMARY, sso], verifiesByLink)); - const windowNavigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); - - await manageEmail(actor, 'alice@acme.co', 'Verify'); - const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - expect(dialog).toHaveTextContent('acme.co'); - expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); - await actor.click(within(dialog).getByRole('button', { name: 'Connect' })); - - await waitFor(() => expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.acme.co/sso'))); - }); - - it('does not offer to add the username as an address', async () => { - const { actor } = await renderSection( - signedInWithEmails([], fapiEnvironment({ attributes: { username: { enabled: true } } })), - ); - - await actor.click(screen.getByRole('button', { name: 'Add email' })); - await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'alicesmith'); - - expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); - }); - - it('hides the row when the instance does not collect email addresses', async () => { - await renderSection( - signedInWithEmails( - [], - fapiEnvironment({ - attributes: { email_address: { enabled: false, used_for_first_factor: false, first_factors: [] } }, - }), - ), - ); - - expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument(); - }); -}); - -function fileInput(container: Element): HTMLInputElement { - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new Error('expected a file input'); - } - return input; -} - -function failsWith(path: string, error: Record, status: number) { - worker.use(http.post(fapiUrl(path), () => HttpResponse.json({ errors: [error] }, { status }))); -} - -describe('the user profile name, username and picture', () => { - it('saves an edited name and closes the dialog', async () => { - const { actor } = await renderSection(); - - await actor.click(screen.getByRole('button', { name: 'Edit name' })); - const dialog = screen.getByRole('dialog', { name: 'Edit name' }); - await actor.clear(within(dialog).getByLabelText('First name')); - await actor.type(within(dialog).getByLabelText('First name'), 'Alicia'); - await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByText('Alicia Smith')).toBeInTheDocument(); - }); - - it('keeps the username dialog open on the error the server names it for', async () => { - const { actor } = await renderSection(); - failsWith( - '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/v1/me', - { - code: 'form_identifier_exists', - message: 'Taken', - long_message: 'That username is taken. Please try another.', - meta: { param_name: 'username' }, - }, - 422, - ); - - await actor.click(screen.getByRole('button', { name: 'Edit username' })); - const dialog = screen.getByRole('dialog', { name: 'Edit username' }); - await actor.type(within(dialog).getByLabelText('Username'), '2'); - await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - expect(await within(dialog).findByText('That username is taken. Please try another.')).toBeInTheDocument(); - }); - - it('uploads a picked picture and then offers to change or remove it', async () => { - const { actor, container } = await renderSection(); - - expect(screen.queryByRole('button', { name: 'Manage profile picture' })).not.toBeInTheDocument(); - await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); - - await actor.click(await screen.findByRole('button', { name: 'Manage profile picture' })); - expect(await screen.findByRole('menuitem', { name: 'Remove avatar' })).toBeInTheDocument(); - }); - - it('says why an upload was refused', async () => { - const { actor, container } = await renderSection(); - failsWith('/v1/me/profile_image', { code: 'avatar_file_size_exceeded', message: 'Too large' }, 413); - - await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); - - await waitFor(() => - expect(screen.getByRole('alert')).toHaveTextContent( - 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', - ), - ); - }); -}); - -describe('a contact row the user cannot add to', () => { - it('is left out when it has nothing to list', async () => { - await renderSection({ - environment: fapiEnvironment({ - attributes: { - email_address: { enabled: true, immutable: true }, - phone_number: { enabled: true, immutable: true }, - }, - }), - client: fapiClient([fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) })]), - }); - - expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument(); - expect(screen.queryByRole('group', { name: 'Phone' })).not.toBeInTheDocument(); - }); -}); - -describe('a user signed in through an enterprise connection', () => { - function signedInThroughSso(connection: Partial = {}): FakeFapiSeed { - const user = fapiUser({ - id: 'user_1', - first_name: 'Alice', - last_name: 'Smith', - email_addresses: [fapiEmailAddress({ id: 'idn_primary', email_address: 'alice@acme.co' })], - phone_numbers: [HOME], - primary_phone_number_id: HOME.id, - enterprise_accounts: [ - fapiEnterpriseAccount({ id: 'eac_1', email_address: 'alice@acme.co' }, { name: 'Acme Corp', ...connection }), - ], - }); - return { - environment: fapiEnvironment({ - attributes: { phone_number: { enabled: true } }, - user_settings: { - enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, - }, - }), - client: fapiClient([fapiSession({ id: 'sess_1', user })]), - }; - } - - it('hands the name to the connection instead of offering to edit it', async () => { - await renderSection(signedInThroughSso()); - - expect(await screen.findByText('Managed by Acme Corp')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Edit name' })).not.toBeInTheDocument(); - }); - - it('stops the user adding contacts when the connection disables additional identifications', async () => { - await renderSection(signedInThroughSso({ disable_additional_identifications: true })); - - expect(await screen.findByText('alice@acme.co')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add email' })).not.toBeInTheDocument(); - expect(within(row()).queryByRole('button', { name: 'Add phone number' })).not.toBeInTheDocument(); - }); - - it('still lets the user add contacts when the connection allows them', async () => { - await renderSection(signedInThroughSso({ disable_additional_identifications: false })); - - expect(await screen.findByRole('button', { name: 'Add email' })).toBeInTheDocument(); - expect(within(row()).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); - }); -}); - -describe('switching the active user', () => { - function signedInAsBoth(): FakeFapiSeed { - const alice = fapiUser({ - id: 'user_1', - first_name: 'Alice', - last_name: 'Smith', - email_addresses: [fapiEmailAddress({ id: 'idn_alice' })], - }); - const bob = fapiUser({ - id: 'user_2', - first_name: 'Bob', - last_name: 'Jones', - email_addresses: [fapiEmailAddress({ id: 'idn_bob' })], - }); - return { - environment: fapiEnvironment(), - client: fapiClient([fapiSession({ id: 'sess_1', user: alice }), fapiSession({ id: 'sess_2', user: bob })]), - }; - } - - it('shows the newly active account and drops the draft the other one left open', async () => { - const { actor, clerk } = await renderSection(signedInAsBoth()); - - await actor.click(screen.getByRole('button', { name: 'Edit name' })); - const dialog = screen.getByRole('dialog', { name: 'Edit name' }); - await actor.clear(within(dialog).getByLabelText('First name')); - await actor.type(within(dialog).getByLabelText('First name'), 'Alicia'); - - await act(() => clerk.setActive({ session: 'sess_2' })); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(await screen.findByText('Bob Jones')).toBeInTheDocument(); - expect(screen.queryByText(/Alic/)).not.toBeInTheDocument(); - }); -}); - -describe('reverification', () => { - it.todo('confirms it is the user before an email address is added, then adds it'); - it.todo('confirms it is the user before an email address becomes the primary one'); - it.todo('confirms it is the user before a phone number is added, then adds it'); - it.todo('confirms it is the user before a phone number becomes the primary one'); - it.todo('confirms it is the user before the username changes'); - it.todo('leaves the add dialog open and untouched when the confirmation is dismissed'); -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts deleted file mode 100644 index 1ca6daa3bb6..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { buildURL } from '@clerk/shared/internal/clerk-js/url'; -import { getFullName } from '@clerk/shared/internal/clerk-js/user'; -import { useClerk, useUser } from '@clerk/shared/react'; -import type { - AttributeData, - Attributes, - EmailAddressResource, - EnterpriseAccountResource, - PhoneNumberResource, - UserResource, -} from '@clerk/shared/types'; - -import { toCountryIso } from '../../../components/phone-input'; -import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment'; -import type { MosaicRouter } from '../../../hooks/use-mosaic-router'; -import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; -import type { MessageValues } from '../../../localization'; -import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/errors'; -import type { UserProfileManagedBy } from '../user-profile-managed-by'; -import type { - UserProfileEmailVerification, - UserProfileEmailVerifier, - UserProfileNameAttribute, - UserProfilePhoneVerifier, -} from './user-profile-account-section.types'; -import { isAttributeAvailable, toContactAccess, toContacts } from './user-profile-account-section.utils'; -import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; -import type { UserProfileAddEmailField } from './user-profile-add-email.controller'; -import type { UserProfileAddPhoneField } from './user-profile-add-phone.controller'; -import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; - -type UserProfileAccountSectionData = Pick< - UserProfileAccountSectionViewProps, - | 'allowMultipleAccounts' - | 'name' - | 'imageUrl' - | 'hasImage' - | 'firstName' - | 'lastName' - | 'firstNameAttribute' - | 'lastNameAttribute' - | 'nameManagedBy' - | 'username' - | 'usernameRequired' - | 'emails' - | 'phones' - | 'defaultPhoneCountry' - | 'onCreateEmail' - | 'getEmailVerifier' - | 'onSetPrimaryEmail' - | 'onRemoveEmail' - | 'onCreatePhone' - | 'getPhoneVerifier' - | 'onSetPrimaryPhone' - | 'onRemovePhone' - | 'onProfilePictureChange' - | 'onRemoveProfilePicture' - | 'onSubmitName' - | 'onSubmitUsername' ->; - -export type UserProfileAccountSectionModel = - | { status: 'loading' } - | { status: 'hidden' } - | (UserProfileAccountSectionData & { status: 'ready'; userId: string }); - -const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; -const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; -const ADD_EMAIL_FIELDS: readonly UserProfileAddEmailField[] = ['emailAddress', 'code']; -const ADD_PHONE_FIELDS: readonly UserProfileAddPhoneField[] = ['phoneNumber', 'code']; - -function byId(items: T[], id: string, kind: string): T { - const item = items.find(item => item.id === id); - if (!item) { - throw new Error(`No ${kind} with id ${id}`); - } - return item; -} - -/* - TODO: pick this base the way the routing mode says to. Legacy `buildVerificationRedirectUrl` only - falls back to `displayConfig.userProfileUrl` under virtual routing, and builds a path instead of a - hash when the host routes by path — Mosaic has no routing yet, so this always hashes onto the - instance's profile URL and sends a path-routed host's user back to the wrong place. #9843 adds - `MosaicRoutingProvider`; wire this to it once that lands. - - Whatever base wins, nothing in Mosaic serves the `/verify` the link lands on: there is no route and - no equivalent of legacy's `VerificationSuccessPage`, so an opened link is handled by whatever - clerk-js already mounts there. That page has to come with the routing work, not after it. -*/ -function verifyRedirectUrl(userProfileUrl: string): string { - return buildURL({ base: userProfileUrl, hashPath: '/verify' }, { stringify: true }); -} - -function startEmailVerification( - email: EmailAddressResource, - linkRedirectUrl: string | undefined, - router: MosaicRouter, -): UserProfileEmailVerification { - if (email.matchesSsoConnection) { - return { - method: 'sso', - /* - Prepared on the click rather than on entering the step, so the redirect URL the server - answers with is in hand by the time we navigate. Preparing on entry leaves the button live - before the response lands, and an early click has nowhere to go. - - TODO: carry the mounting mode back from the IdP. Legacy appends `appendModalState` to this - redirect when the profile is mounted as a modal, so returning from the provider reopens the - modal on the step the user left. Mosaic has no modal mode to encode yet; whoever adds one has - to encode it here too, or the user comes back to a closed dialog and a lost flow. - */ - connect: () => - save(async () => { - const prepared = await email.prepareVerification({ - strategy: 'enterprise_sso', - redirectUrl: window.location.href, - }); - const url = prepared.verification.externalVerificationRedirectURL; - if (!url) { - throw new Error('Enterprise SSO verification did not return a redirect URL'); - } - await router.navigate(url.href); - }), - }; - } - if (linkRedirectUrl === undefined) { - return { method: 'code', sent: save(() => email.prepareVerification({ strategy: 'email_code' })) }; - } - const { startEmailLinkFlow, cancelEmailLinkFlow } = email.createEmailLinkFlow(); - return { - method: 'link', - verified: save(() => startEmailLinkFlow({ redirectUrl: linkRedirectUrl })), - cancel: cancelEmailLinkFlow, - }; -} - -function toEmailVerifier( - email: EmailAddressResource, - linkRedirectUrl: string | undefined, - router: MosaicRouter, -): UserProfileEmailVerifier { - return { - start: () => startEmailVerification(email, linkRedirectUrl, router), - verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS), - }; -} - -function canAddIdentifications(user: UserResource, enterpriseSSOEnabled: boolean): boolean { - return ( - !enterpriseSSOEnabled || - !user.enterpriseAccounts.some( - account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, - ) - ); -} - -function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { - if (!account) { - return undefined; - } - const connection = account.enterpriseConnection; - return { name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, '') }; -} - -function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { - return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; -} - -function toPhoneVerifier(phone: PhoneNumberResource): UserProfilePhoneVerifier { - return { - sendCode: () => save(() => phone.prepareVerification()), - verifyCode: code => save(() => phone.attemptVerification({ code }), ADD_PHONE_FIELDS), - }; -} - -type SaveAsUser = (run: (current: UserResource) => Promise) => Promise; - -type ContactActions = { - user: UserResource; - access: ReturnType; - currentUser: () => UserResource; - saveAsUser: SaveAsUser; -}; - -function toEmailProps( - { user, access, currentUser, saveAsUser }: ContactActions, - verifierFor: (email: EmailAddressResource) => UserProfileEmailVerifier, -): Pick< - UserProfileAccountSectionData, - 'emails' | 'onCreateEmail' | 'getEmailVerifier' | 'onSetPrimaryEmail' | 'onRemoveEmail' -> { - if (!access.show) { - return {}; - } - return { - emails: toContacts(user.emailAddresses, user.primaryEmailAddressId, email => email.emailAddress), - onCreateEmail: access.canCreate - ? async emailAddress => { - const request = currentUser().createEmailAddress({ email: emailAddress }); - await save(() => request, ADD_EMAIL_FIELDS); - return verifierFor(await request); - } - : undefined, - getEmailVerifier: id => verifierFor(byId(user.emailAddresses, id, 'email address')), - onSetPrimaryEmail: id => saveAsUser(current => current.update({ primaryEmailAddressId: id })), - onRemoveEmail: access.canRemove - ? id => saveAsUser(current => byId(current.emailAddresses, id, 'email address').destroy()) - : undefined, - }; -} - -function toPhoneProps({ - user, - access, - currentUser, - saveAsUser, -}: ContactActions): Pick< - UserProfileAccountSectionData, - 'phones' | 'onCreatePhone' | 'getPhoneVerifier' | 'onSetPrimaryPhone' | 'onRemovePhone' -> { - if (!access.show) { - return {}; - } - return { - phones: toContacts(user.phoneNumbers, user.primaryPhoneNumberId, phone => phone.phoneNumber), - onCreatePhone: access.canCreate - ? async phoneNumber => { - const request = currentUser().createPhoneNumber({ phoneNumber }); - await save(() => request, ADD_PHONE_FIELDS); - return toPhoneVerifier(await request); - } - : undefined, - getPhoneVerifier: id => toPhoneVerifier(byId(user.phoneNumbers, id, 'phone number')), - onSetPrimaryPhone: id => saveAsUser(current => current.update({ primaryPhoneNumberId: id })), - onRemovePhone: access.canRemove - ? id => saveAsUser(current => byId(current.phoneNumbers, id, 'phone number').destroy()) - : undefined, - }; -} - -export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { - const { isLoaded, user } = useUser(); - const clerk = useClerk(); - const environment = useMosaicEnvironment(); - const router = useMosaicRouter(); - - if (!isLoaded || !environment) { - return { status: 'loading' }; - } - - if (!user) { - return { status: 'hidden' }; - } - - const userId = user.id; - - const currentUser = (): UserResource => { - const current = clerk.user; - if (!current || current.id !== userId) { - throw new SaveError({ global: UNEXPECTED_ERROR }); - } - return current; - }; - - const saveAsUser = ( - run: (current: UserResource) => Promise, - fields: readonly TField[] = [], - params?: MessageValues, - ): Promise => save(() => run(currentUser()), fields, params); - - const { usernameSettings, enterpriseSSO } = environment.userSettings; - const attributes: Partial = environment.userSettings.attributes; - const usernameAttribute = attributes.username; - const usernameImmutable = Boolean(usernameAttribute?.immutable); - const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); - const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); - const canAddMore = canAddIdentifications(user, enterpriseSSO.enabled); - const emailAccess = toContactAccess(attributes.email_address, user.emailAddresses.length, canAddMore); - const phoneAccess = toContactAccess(attributes.phone_number, user.phoneNumbers.length, canAddMore); - const verifiesEmailByLink = Boolean(attributes.email_address?.verifications.includes('email_link')); - const linkRedirectUrl = verifiesEmailByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; - const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); - - return { - status: 'ready', - userId, - allowMultipleAccounts: true, - name: getFullName(user), - firstName: user.firstName ?? '', - lastName: user.lastName ?? '', - firstNameAttribute: toNameAttribute(attributes.first_name), - lastNameAttribute: toNameAttribute(attributes.last_name), - nameManagedBy, - imageUrl: user.imageUrl, - hasImage: user.hasImage, - username: showUsername ? (user.username ?? '') : undefined, - usernameRequired: Boolean(usernameAttribute?.required), - ...toEmailProps({ user, access: emailAccess, currentUser, saveAsUser }, verifierFor), - ...toPhoneProps({ user, access: phoneAccess, currentUser, saveAsUser }), - defaultPhoneCountry: toCountryIso(clerk.__internal_country), - onProfilePictureChange: file => saveAsUser(current => current.setProfileImage({ file })), - onRemoveProfilePicture: user.hasImage - ? () => saveAsUser(current => current.setProfileImage({ file: null })) - : undefined, - onSubmitName: nameManagedBy - ? undefined - : value => - saveAsUser(current => current.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), - onSubmitUsername: - showUsername && !usernameImmutable - ? username => - saveAsUser(current => current.update({ username }), USERNAME_FIELDS, { - min_length: usernameSettings.min_length, - max_length: usernameSettings.max_length, - }) - : undefined, - }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx deleted file mode 100644 index 8271ba43f3d..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useUserProfileAccountSectionModel } from './user-profile-account-section.model'; -import { UserProfileAccountSectionView } from './user-profile-account-section.view'; - -export function UserProfileAccountSection() { - const model = useUserProfileAccountSectionModel(); - - if (model.status !== 'ready') { - return null; - } - - const { status: _status, userId, ...viewProps } = model; - - return ( - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts deleted file mode 100644 index 12c3559b055..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { FormSubmitError } from '../../../components/form'; - -/** Plain data, so nothing downstream of the model imports a Clerk error. */ -export interface UserProfileFormError { - /** Rendered in the dialog's negative banner. */ - message?: string; - /** Rendered under the named control, which is also marked invalid. */ - fields?: Partial>; -} - -/** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ -export interface UserProfileNameAttribute { - /** @default true */ - enabled?: boolean; - /** @default false */ - required?: boolean; -} - -/** What a save rejects with when the failure names a control; a plain `Error` shows only the banner. */ -export class UserProfileSaveError extends FormSubmitError> { - constructor(message: string, fields?: Partial>) { - super({ message, fields }); - this.name = 'UserProfileSaveError'; - } -} - -export interface UserProfileContact { - id: string; - value: string; - isDefault: boolean; - isVerified: boolean; -} - -export type UserProfileEmail = UserProfileContact; - -export type UserProfilePhone = UserProfileContact; - -export type UserProfileEmailVerification = - | { method: 'code'; sent: Promise } - | { method: 'link'; verified: Promise; cancel: () => void } - | { method: 'sso'; connect: () => Promise }; - -export interface UserProfileEmailVerifier { - start: () => UserProfileEmailVerification; - verifyCode: (code: string) => Promise; -} - -export interface UserProfilePhoneVerifier { - sendCode: () => Promise; - verifyCode: (code: string) => Promise; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx deleted file mode 100644 index 20af7c144db..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import type { CountryIso } from '../../../components/phone-input'; -import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; -import type { FileRejection } from '../../../primitives/file-upload'; -import type { UserProfileManagedBy } from '../user-profile-managed-by'; -import type { - UserProfileEmail, - UserProfileEmailVerifier, - UserProfileNameAttribute, - UserProfilePhone, - UserProfilePhoneVerifier, -} from './user-profile-account-section.types'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; -import { UserProfileEmailRowView } from './user-profile-email-row.view'; -import { UserProfileNameRowView } from './user-profile-name-row.view'; -import { UserProfilePhoneRowView } from './user-profile-phone-row.view'; -import { UserProfilePictureRowView } from './user-profile-picture-row.view'; -import { UserProfileUsernameRowView } from './user-profile-username-row.view'; - -export type { UserProfileEmail, UserProfilePhone } from './user-profile-account-section.types'; - -export interface UserProfileAccountSectionViewProps { - allowMultipleAccounts?: boolean; - imageUrl?: string; - /** - * Whether `imageUrl` is a picture the user uploaded. Clerk's image service always returns a URL — - * a generated initials avatar when none was uploaded — so the row cannot tell the two apart from - * `imageUrl` alone. Supplied from `user.hasImage`. - */ - hasImage?: boolean; - name: string; - username?: string; - usernameRequired?: boolean; - /** Passed alongside `name`, which cannot be split back into its two halves. */ - firstName?: string; - lastName?: string; - firstNameAttribute?: UserProfileNameAttribute; - lastNameAttribute?: UserProfileNameAttribute; - nameManagedBy?: UserProfileManagedBy; - /** Left out when the instance does not collect the attribute, which drops the row. */ - emails?: UserProfileEmail[]; - phones?: UserProfilePhone[]; - defaultPhoneCountry?: CountryIso; - onProfilePictureChange?: (file: File) => Promise; - onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => Promise; - onSubmitName?: (value: UserProfileEditNameValue) => Promise; - onSubmitUsername?: (username: string) => Promise; - onAddEmail?: () => void; - onCreateEmail?: (emailAddress: string) => Promise; - getEmailVerifier?: (id: string) => UserProfileEmailVerifier; - onManageEmail?: (id: string) => void; - onVerifyEmail?: (id: string) => void; - onSetPrimaryEmail?: (id: string) => void | Promise; - onRemoveEmail?: (id: string) => void | Promise; - onCreatePhone?: (phoneNumber: string) => Promise; - getPhoneVerifier?: (id: string) => UserProfilePhoneVerifier; - onManagePhone?: (id: string) => void; - onVerifyPhone?: (id: string) => void; - onSetPrimaryPhone?: (id: string) => void | Promise; - onRemovePhone?: (id: string) => void | Promise; -} - -export function UserProfileAccountSectionView({ - allowMultipleAccounts = false, - imageUrl, - hasImage = false, - name, - username, - usernameRequired = false, - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - nameManagedBy, - emails, - phones, - defaultPhoneCountry, - onProfilePictureChange, - onProfilePictureReject, - onRemoveProfilePicture, - onSubmitName, - onSubmitUsername, - onAddEmail, - onCreateEmail, - getEmailVerifier, - onManageEmail, - onVerifyEmail, - onSetPrimaryEmail, - onRemoveEmail, - onCreatePhone, - getPhoneVerifier, - onManagePhone, - onVerifyPhone, - onSetPrimaryPhone, - onRemovePhone, -}: UserProfileAccountSectionViewProps) { - const m = useMessages('userProfileAccountSection'); - const showName = firstNameAttribute?.enabled !== false || lastNameAttribute?.enabled !== false; - const phoneRow = phones ? ( - - ) : null; - const emailRow = emails ? ( - - ) : null; - - return ( - - - - - {m.sectionTitle} - - - - - {showName ? ( - - ) : null} - {username !== undefined ? ( - - ) : null} - {!allowMultipleAccounts ? emailRow : null} - {!allowMultipleAccounts ? phoneRow : null} - - - {allowMultipleAccounts ? emailRow : null} - {allowMultipleAccounts ? phoneRow : null} - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx deleted file mode 100644 index 8cdd32bd18e..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-row.view.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; -import type { ReactNode } from 'react'; - -import { Badge } from '../../../components/badge'; -import { Button } from '../../../components/button'; -import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; -import { truncationStyles } from '../../../styles/typography.styles'; -import { styles } from '../user-profile-profile-panel.styles'; - -export interface UserProfileContactRowViewProps { - kind: 'email' | 'phone'; - label: string; - items: Array<{ id: string; value: string; isDefault?: boolean }>; - onAdd?: () => void; - onManage?: (id: string) => void; - addAction?: ReactNode; -} - -export function UserProfileContactRowView({ - kind, - label, - items, - onAdd, - onManage, - addAction, -}: UserProfileContactRowViewProps) { - const m = useMessages('userProfileAccountSection'); - const item = items[0]; - const onClick = item ? (onManage ? () => onManage(item.id) : undefined) : onAdd; - const emptyDescription = m[kind].empty; - const actionLabel = item ? m[kind].update : m[kind].add; - - return ( - - - - {label} - {item ? ( - - {item.value} - {item.isDefault ? {m.primary} : null} - - ) : ( - {emptyDescription} - )} - - {!item && addAction ? ( - {addAction} - ) : onClick ? ( - - - - ) : null} - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.controller.ts deleted file mode 100644 index b533cef81cd..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.controller.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { usePendingAction } from '../../../hooks/use-pending-action'; -import type { UserProfileEmail, UserProfileEmailVerifier } from './user-profile-account-section.types'; -import type { UserProfileAddEmailController } from './user-profile-add-email.controller'; -import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; - -export interface UserProfileEmailRowControllerOptions { - emails: UserProfileEmail[]; - username?: string; - onCreateEmail?: (emailAddress: string) => Promise; - getEmailVerifier?: (id: string) => UserProfileEmailVerifier; - onVerifyEmail?: (id: string) => void; - onSetPrimaryEmail?: (id: string) => void | Promise; -} - -export interface UserProfileEmailRowController { - verification: UserProfileAddEmailController | undefined; - error: string | undefined; - onVerify: ((id: string) => void) | undefined; - onSetPrimary: ((id: string) => void) | undefined; -} - -export function useUserProfileEmailRowController({ - emails, - username, - onCreateEmail, - getEmailVerifier, - onVerifyEmail, - onSetPrimaryEmail, -}: UserProfileEmailRowControllerOptions): UserProfileEmailRowController { - const verification = useUserProfileAddEmailController({ username, onCreate: onCreateEmail }); - const setPrimary = usePendingAction(); - - const verifyInDialog = (id: string) => { - const email = emails.find(email => email.id === id); - const verifier = getEmailVerifier?.(id); - if (email && verifier) { - verification.onVerifyEmail(email.value, verifier); - } - }; - - return { - verification: getEmailVerifier ? verification : undefined, - error: setPrimary.error, - onVerify: getEmailVerifier ? verifyInDialog : onVerifyEmail, - onSetPrimary: onSetPrimaryEmail ? id => void setPrimary.run(id, () => onSetPrimaryEmail(id)) : undefined, - }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx deleted file mode 100644 index 580eec5c7b0..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-email-row.view.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useMemo, useRef } from 'react'; - -import { Confirmation } from '../../../blocks/confirmation'; -import { Button } from '../../../components/button'; -import { Dialog } from '../../../components/dialog'; -import { Icon } from '../../../components/icon'; -import { Section } from '../../../components/section'; -import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; -import { fill, useMessages } from '../../../localization'; -import type { UserProfileEmail, UserProfileEmailVerifier } from './user-profile-account-section.types'; -import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; -import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; -import { UserProfileContactRowView } from './user-profile-contact-row.view'; -import { useUserProfileEmailRowController } from './user-profile-email-row.controller'; - -export interface UserProfileEmailRowViewProps { - emails: UserProfileEmail[]; - username?: string; - allowMultipleAccounts?: boolean; - onAddEmail?: () => void; - onCreateEmail?: (emailAddress: string) => Promise; - getEmailVerifier?: (id: string) => UserProfileEmailVerifier; - onManageEmail?: (id: string) => void; - onVerifyEmail?: (id: string) => void; - onSetPrimaryEmail?: (id: string) => void | Promise; - onRemoveEmail?: (id: string) => void | Promise; -} - -export function UserProfileEmailRowView({ - emails, - username, - allowMultipleAccounts = false, - onAddEmail, - onCreateEmail, - getEmailVerifier, - onManageEmail, - onVerifyEmail, - onSetPrimaryEmail, - onRemoveEmail, -}: UserProfileEmailRowViewProps) { - const m = useMessages('userProfileAccountSection'); - const row = useRef(null); - const removalFocus = useListRemovalFocus({ - ids: emails.map(email => email.id), - onRemove: onRemoveEmail, - fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, - }); - const { verification, error, onVerify, onSetPrimary } = useUserProfileEmailRowController({ - emails, - username, - onCreateEmail, - getEmailVerifier, - onVerifyEmail, - onSetPrimaryEmail, - }); - const verificationDialog = useMemo(() => Dialog.createHandle(), []); - const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); - const verifyingId = useRef(undefined); - const addEmailLabel = ( - <> - {allowMultipleAccounts ? ( - - ) : null} - {allowMultipleAccounts ? m.add : m.email.add} - - ); - const addEmailAction = - verification && onCreateEmail ? ( - - } - > - {addEmailLabel} - - ) : onAddEmail ? ( - - ) : undefined; - - const dialog = verification ? ( - { - const id = verifyingId.current; - verifyingId.current = undefined; - return id ? removalFocus.trigger(id) : null; - }} - /> - ) : null; - - if (!allowMultipleAccounts) { - return ( - <> - - {dialog} - - ); - } - - return ( - <> - removeEmailConfirmation.open(email) : undefined} - onSetPrimary={onSetPrimary} - onVerify={ - onVerify - ? id => { - verifyingId.current = id; - onVerify(id); - } - : undefined - } - > - {error} - - {dialog} - {onRemoveEmail ? ( - - fill(email.isVerified ? m.email.removeDialog.verifiedDescription : m.email.removeDialog.description, { - emailAddress: email.value, - }) - } - actionLabel={m.email.removeDialog.confirm} - cancelLabel={m.email.removeDialog.cancel} - finalFocus={removalFocus.finalFocus} - onConfirm={email => removalFocus.remove(email.id)} - /> - ) : null} - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx deleted file mode 100644 index f4c9cf82c13..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-name-row.view.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { Button } from '../../../components/button'; -import { Section } from '../../../components/section'; -import { fill, useMessages } from '../../../localization'; -import type { UserProfileManagedBy } from '../user-profile-managed-by'; -import { UserProfileManagedByLabel } from '../user-profile-managed-by'; -import type { UserProfileNameAttribute } from './user-profile-account-section.types'; -import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; -import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; - -export interface UserProfileNameRowViewProps { - name: string; - firstName?: string; - lastName?: string; - firstNameAttribute?: UserProfileNameAttribute; - lastNameAttribute?: UserProfileNameAttribute; - managedBy?: UserProfileManagedBy; - onSubmit?: (value: UserProfileEditNameValue) => Promise; -} - -export function UserProfileNameRowView({ - name, - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - managedBy, - onSubmit, -}: UserProfileNameRowViewProps) { - const m = useMessages('userProfileAccountSection'); - - return ( - - - - {m.name.label} - {name || m.name.empty} - - {onSubmit ? ( - - - - ) : managedBy ? ( - - ) : null} - - - ); -} - -function EditName({ - isSet, - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - onSubmit, -}: { - isSet: boolean; - firstName?: string; - lastName?: string; - firstNameAttribute?: UserProfileNameAttribute; - lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise; -}) { - const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); - - return ( - - {isSet ? m.name.edit : m.name.add} - - } - /> - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.controller.ts deleted file mode 100644 index 22580bea7ab..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { stringToFormattedPhoneString } from '@clerk/shared/phone'; - -import { usePendingAction } from '../../../hooks/use-pending-action'; -import type { UserProfilePhone, UserProfilePhoneVerifier } from './user-profile-account-section.types'; -import type { UserProfileAddPhoneController } from './user-profile-add-phone.controller'; -import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; - -export interface UserProfilePhoneRowControllerOptions { - phones: UserProfilePhone[]; - onCreatePhone?: (phoneNumber: string) => Promise; - getPhoneVerifier?: (id: string) => UserProfilePhoneVerifier; - onVerifyPhone?: (id: string) => void; - onSetPrimaryPhone?: (id: string) => void | Promise; -} - -export interface UserProfilePhoneRowController { - phones: UserProfilePhone[]; - verification: UserProfileAddPhoneController | undefined; - error: string | undefined; - onVerify: ((id: string) => void) | undefined; - onSetPrimary: ((id: string) => void) | undefined; -} - -export function useUserProfilePhoneRowController({ - phones, - onCreatePhone, - getPhoneVerifier, - onVerifyPhone, - onSetPrimaryPhone, -}: UserProfilePhoneRowControllerOptions): UserProfilePhoneRowController { - const verification = useUserProfileAddPhoneController({ onCreate: onCreatePhone }); - const setPrimary = usePendingAction(); - - const verifyInDialog = (id: string) => { - const phone = phones.find(phone => phone.id === id); - const verifier = getPhoneVerifier?.(id); - if (phone && verifier) { - verification.onVerifyPhone(phone.value, verifier); - } - }; - - return { - phones: phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value) })), - verification: getPhoneVerifier ? verification : undefined, - error: setPrimary.error, - onVerify: getPhoneVerifier ? verifyInDialog : onVerifyPhone, - onSetPrimary: onSetPrimaryPhone ? id => void setPrimary.run(id, () => onSetPrimaryPhone(id)) : undefined, - }; -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx deleted file mode 100644 index 0582f793d3b..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-phone-row.view.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useMemo, useRef } from 'react'; - -import { Confirmation } from '../../../blocks/confirmation'; -import { Button } from '../../../components/button'; -import { Dialog } from '../../../components/dialog'; -import { Icon } from '../../../components/icon'; -import type { CountryIso } from '../../../components/phone-input'; -import { Section } from '../../../components/section'; -import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; -import { fill, useMessages } from '../../../localization'; -import type { UserProfilePhone, UserProfilePhoneVerifier } from './user-profile-account-section.types'; -import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog'; -import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; -import { UserProfileContactRowView } from './user-profile-contact-row.view'; -import { useUserProfilePhoneRowController } from './user-profile-phone-row.controller'; - -export interface UserProfilePhoneRowViewProps { - phones: UserProfilePhone[]; - defaultPhoneCountry?: CountryIso; - allowMultipleAccounts?: boolean; - onCreatePhone?: (phoneNumber: string) => Promise; - getPhoneVerifier?: (id: string) => UserProfilePhoneVerifier; - onManagePhone?: (id: string) => void; - onVerifyPhone?: (id: string) => void; - onSetPrimaryPhone?: (id: string) => void | Promise; - onRemovePhone?: (id: string) => void | Promise; -} - -export function UserProfilePhoneRowView({ - phones, - defaultPhoneCountry, - allowMultipleAccounts = false, - onCreatePhone, - getPhoneVerifier, - onManagePhone, - onVerifyPhone, - onSetPrimaryPhone, - onRemovePhone, -}: UserProfilePhoneRowViewProps) { - const m = useMessages('userProfileAccountSection'); - const row = useRef(null); - const removalFocus = useListRemovalFocus({ - ids: phones.map(phone => phone.id), - onRemove: onRemovePhone, - fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, - }); - const { - phones: items, - verification, - error, - onVerify, - onSetPrimary, - } = useUserProfilePhoneRowController({ - phones, - onCreatePhone, - getPhoneVerifier, - onVerifyPhone, - onSetPrimaryPhone, - }); - const verificationDialog = useMemo(() => Dialog.createHandle(), []); - const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []); - const verifyingId = useRef(undefined); - const addPhoneAction = - verification && onCreatePhone ? ( - - } - > - {allowMultipleAccounts ? ( - - ) : null} - {allowMultipleAccounts ? m.add : m.phone.add} - - ) : undefined; - - const dialog = verification ? ( - { - const id = verifyingId.current; - verifyingId.current = undefined; - return id ? removalFocus.trigger(id) : null; - }} - /> - ) : null; - - if (!allowMultipleAccounts) { - return ( - <> - - {dialog} - - ); - } - - return ( - <> - removePhoneConfirmation.open(phone) : undefined} - onSetPrimary={onSetPrimary} - onVerify={ - onVerify - ? id => { - verifyingId.current = id; - onVerify(id); - } - : undefined - } - > - {error} - - {dialog} - {onRemovePhone ? ( - - fill(phone.isVerified ? m.phone.removeDialog.verifiedDescription : m.phone.removeDialog.description, { - phoneNumber: phone.value, - }) - } - actionLabel={m.phone.removeDialog.confirm} - cancelLabel={m.phone.removeDialog.cancel} - finalFocus={removalFocus.finalFocus} - onConfirm={phone => removalFocus.remove(phone.id)} - /> - ) : null} - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx deleted file mode 100644 index 48d0b78df0a..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-username-row.view.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Button } from '../../../components/button'; -import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; -import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; -import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; - -export interface UserProfileUsernameRowViewProps { - username: string; - required?: boolean; - onSubmit?: (username: string) => Promise; -} - -export function UserProfileUsernameRowView({ username, required = false, onSubmit }: UserProfileUsernameRowViewProps) { - const m = useMessages('userProfileAccountSection'); - return ( - - - - {m.username.label} - {username || m.username.empty} - - {onSubmit ? ( - - - - ) : null} - - - ); -} - -function EditUsername({ - username, - required, - onSubmit, -}: { - username: string; - required: boolean; - onSubmit: (username: string) => Promise; -}) { - const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileEditUsernameController({ username, required, onSubmit }); - const isSet = Boolean(username); - - return ( - - {isSet ? m.username.edit : m.username.add} - - } - /> - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx similarity index 84% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx index 83a8d4242c7..89015bf5451 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx @@ -1,16 +1,16 @@ import * as stylex from '@stylexjs/stylex'; import type { ReactNode, Ref } from 'react'; -import type { ActionMenuAction } from '../../../components/action-menu'; -import { ActionMenu } from '../../../components/action-menu'; -import { Badge } from '../../../components/badge'; -import { Button } from '../../../components/button'; -import { Icon } from '../../../components/icon'; -import { Section } from '../../../components/section'; -import { fill, useMessages } from '../../../localization'; -import { truncationStyles } from '../../../styles/typography.styles'; -import { styles } from '../user-profile-profile-panel.styles'; -import type { UserProfileContact } from './user-profile-account-section.types'; +import type { ActionMenuAction } from '../../components/action-menu'; +import { ActionMenu } from '../../components/action-menu'; +import { Badge } from '../../components/badge'; +import { Button } from '../../components/button'; +import { Icon } from '../../components/icon'; +import { Section } from '../../components/section'; +import { fill, useMessages } from '../../localization'; +import { truncationStyles } from '../../styles/typography.styles'; +import type { UserProfileContact } from './user-profile-contact.types'; +import { styles } from './user-profile-profile-panel.styles'; export interface UserProfileContactListRowViewProps { rowRef?: Ref; @@ -39,7 +39,7 @@ export function UserProfileContactListRowView({ triggerRef, children, }: UserProfileContactListRowViewProps) { - const m = useMessages('userProfileAccountSection'); + const m = useMessages('userProfileContact'); const emptyDescription = m[kind].empty; return ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts similarity index 56% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts index 85b9c96a097..0ab5a633b04 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-contact.messages.ts @@ -2,47 +2,10 @@ * Email and phone keep separate keys rather than sharing one templated string: a locale that * inflects around the noun cannot build either from the other. */ -export const userProfileAccountSectionMessages = { - sectionLabel: 'Account', - sectionTitle: 'Profile', - picture: { - label: 'Profile picture', - description: 'Recommend size 1:1, up to 10MB.', - upload: 'Upload', - manage: 'Manage profile picture', - change: 'Change avatar', - remove: 'Remove avatar', - }, - name: { - label: 'Name', - empty: 'No name added', - edit: 'Edit name', - add: 'Add name', - managedBy: 'Managed by {name}', - - dialogTitle: 'Edit name', - addDialogTitle: 'Add name', - firstNameLabel: 'First name', - lastNameLabel: 'Last name', - cancel: 'Cancel', - save: 'Save changes', - }, - username: { - label: 'Username', - empty: 'No username added', - edit: 'Edit username', - add: 'Add username', - - dialogTitle: 'Edit username', - addDialogTitle: 'Add username', - fieldLabel: 'Username', - cancel: 'Cancel', - save: 'Save changes', - }, +export const userProfileContactMessages = { primary: 'Primary', unverified: 'Unverified', add: 'Add', - manage: 'Manage', setPrimary: 'Set as primary', completeVerification: 'Complete verification', @@ -50,7 +13,6 @@ export const userProfileAccountSectionMessages = { email: { label: 'Email', empty: 'No email addresses added', - update: 'Update email', add: 'Add email', verify: 'Verify', remove: 'Remove email', @@ -65,7 +27,6 @@ export const userProfileAccountSectionMessages = { phone: { label: 'Phone', empty: 'No phone numbers added', - update: 'Update phone number', add: 'Add phone number', verify: 'Verify phone number', remove: 'Remove phone number', diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact.types.ts b/packages/mosaic/src/features/user-profile/user-profile-contact.types.ts new file mode 100644 index 00000000000..1f178b0f450 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-contact.types.ts @@ -0,0 +1,25 @@ +export interface UserProfileContact { + id: string; + value: string; + isDefault: boolean; + isVerified: boolean; +} + +export type UserProfileEmail = UserProfileContact; + +export type UserProfilePhone = UserProfileContact; + +export type UserProfileEmailVerification = + | { method: 'code'; sent: Promise } + | { method: 'link'; verified: Promise; cancel: () => void } + | { method: 'sso'; connect: () => Promise }; + +export interface UserProfileEmailVerifier { + start: () => UserProfileEmailVerification; + verifyCode: (code: string) => Promise; +} + +export interface UserProfilePhoneVerifier { + sendCode: () => Promise; + verifyCode: (code: string) => Promise; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts b/packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts similarity index 70% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts rename to packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts index ae2c51786a9..2c720a69a7e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-contact.utils.ts @@ -1,10 +1,7 @@ -import type { AttributeData, VerificationResource } from '@clerk/shared/types'; +import type { AttributeData, UserResource, VerificationResource } from '@clerk/shared/types'; -import type { UserProfileContact } from './user-profile-account-section.types'; - -export function isAttributeAvailable(attribute: AttributeData | undefined): boolean { - return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); -} +import { isAttributeAvailable } from './user-profile.utils'; +import type { UserProfileContact } from './user-profile-contact.types'; export interface UserProfileContactAccess { show: boolean; @@ -23,6 +20,23 @@ export function toContactAccess( return { show: available && (canCreate || count > 0), canCreate, canRemove: mutable }; } +export function canAddIdentifications(user: UserResource, enterpriseSSOEnabled: boolean): boolean { + return ( + !enterpriseSSOEnabled || + !user.enterpriseAccounts.some( + account => account.active && account.enterpriseConnection?.disableAdditionalIdentifications, + ) + ); +} + +export function byId(items: T[], id: string, kind: string): T { + const item = items.find(item => item.id === id); + if (!item) { + throw new Error(`No ${kind} with id ${id}`); + } + return item; +} + type Identification = { id: string; verification: Pick }; export function sortByVerification(items: T[], primaryId: string | null): T[] { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts similarity index 98% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts rename to packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts index ae6b30748e6..2b022c9949c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts @@ -2,9 +2,9 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { SaveError } from '../../../utils/errors'; -import type { UserProfileEmailVerification, UserProfileEmailVerifier } from './user-profile-account-section.types'; -import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; +import { SaveError } from '../../../../utils/errors'; +import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../../user-profile-contact.types'; +import { useUserProfileAddEmailController } from '../user-profile-add-email.controller'; const sentCode = (): UserProfileEmailVerification => ({ method: 'code', sent: Promise.resolve() }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.dialog.test.tsx similarity index 97% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.dialog.test.tsx index 65b0542dc86..b2fd295b191 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.dialog.test.tsx @@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileAddEmailDialogProps } from '../user-profile-account-section/user-profile-add-email.dialog'; -import { UserProfileAddEmailDialog } from '../user-profile-account-section/user-profile-add-email.dialog'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import type { UserProfileAddEmailDialogProps } from '../user-profile-add-email.dialog'; +import { UserProfileAddEmailDialog } from '../user-profile-add-email.dialog'; function renderView(overrides: Partial = {}) { const props: UserProfileAddEmailDialogProps = { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx similarity index 58% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx index df84c987fa2..cd0fa69240d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-actions.test.tsx @@ -4,26 +4,35 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { SaveError } from '../../../utils/errors'; -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'; +import { clerkApiError } from '../../../../__tests__/clerk-errors'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import { SaveError } from '../../../../utils/errors'; +import { useUserProfileEmailSectionController } from '../user-profile-email-section.controller'; +import type { ReadyEmailSectionModel } from '../user-profile-email-section.types'; +import { UserProfileEmailSectionView } from '../user-profile-email-section.view'; const codeVerifier = { start: () => ({ method: 'code', sent: Promise.resolve() }) as const, verifyCode: () => Promise.resolve(), }; -function renderEmail(overrides: Partial = {}) { +type EmailSectionProps = Partial>; + +function EmailSection({ + emails = [{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true }], + getEmailVerifier = () => codeVerifier, + onSetPrimaryEmail = () => Promise.resolve(), + ...rest +}: EmailSectionProps) { + const controller = useUserProfileEmailSectionController({ emails, getEmailVerifier, onSetPrimaryEmail, ...rest }); + return ; +} + +function renderEmail(overrides: EmailSectionProps = {}) { return render( - , @@ -34,7 +43,10 @@ describe('email actions', () => { it('keeps the email when removal is canceled with Escape', async () => { const user = userEvent.setup(); const onRemoveEmail = vi.fn(); - renderEmail({ onRemoveEmail }); + renderEmail({ + emails: [{ id: 'email_1', value: 'test@example.com', isDefault: true, isVerified: true }], + onRemoveEmail, + }); const trigger = screen.getByRole('button', { name: 'Manage test@example.com' }); trigger.focus(); @@ -56,15 +68,14 @@ describe('email actions', () => { ]); return ( - Promise.resolve(codeVerifier)} - getEmailVerifier={() => codeVerifier} - onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} + onRemoveEmail={id => { + setEmails(current => current.filter(email => email.id !== id)); + return Promise.resolve(); + }} /> ); @@ -143,4 +154,61 @@ describe('email actions', () => { expect(dialog).not.toHaveTextContent('sign in'); } }); + + it('renders the emails as a group with an Add action', () => { + renderEmail({ + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], + onCreateEmail: () => Promise.resolve(codeVerifier), + }); + + const group = screen.getByRole('group', { name: 'Email' }); + expect(within(group).getByRole('heading', { name: 'Email' })).toHaveClass('cl-section-title'); + expect(within(group).getByRole('list')).toContainElement(screen.getByText('item1@clerk.dev')); + expect(screen.getByText('item1@clerk.dev').closest('.cl-section-item')).toHaveTextContent('Primary'); + expect(within(group).getByRole('button', { name: 'Add email' })).toHaveTextContent('Add'); + }); + + it('offers set primary and remove where each applies', async () => { + const user = userEvent.setup(); + const onSetPrimaryEmail = vi.fn().mockResolvedValue(undefined); + const onRemoveEmail = vi.fn().mockResolvedValue(undefined); + renderEmail({ + emails: [ + { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_secondary', value: 'secondary@clerk.dev', isDefault: false, isVerified: true }, + ], + onSetPrimaryEmail, + onRemoveEmail, + }); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(onSetPrimaryEmail).toHaveBeenCalledWith('email_secondary'); + + await user.click(screen.getByRole('button', { name: 'Manage secondary@clerk.dev' })); + const removeEmail = screen.getByRole('menuitem', { name: 'Remove email' }); + expect(removeEmail).toHaveAttribute('data-color', 'negative'); + await user.click(removeEmail); + await user.click( + within(screen.getByRole('alertdialog', { name: 'Remove email address?' })).getByRole('button', { + name: 'Remove', + }), + ); + expect(onRemoveEmail).toHaveBeenCalledWith('email_secondary'); + }); + + it.each([ + ['email_primary', 'primary@clerk.dev', true, 'Complete verification'], + ['email_unverified', 'unverified@clerk.dev', false, 'Verify'], + ])('opens verification for %s', async (id, value, isDefault, action) => { + const user = userEvent.setup(); + const getEmailVerifier = vi.fn(() => codeVerifier); + renderEmail({ emails: [{ id, value, isDefault, isVerified: false }], getEmailVerifier }); + + await user.click(screen.getByRole('button', { name: `Manage ${value}` })); + await user.click(screen.getByRole('menuitem', { name: action })); + + expect(getEmailVerifier).toHaveBeenCalledExactlyOnceWith(id); + expect(await screen.findByRole('dialog', { name: 'Verify your email' })).toBeInTheDocument(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx new file mode 100644 index 00000000000..711b3713e39 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx @@ -0,0 +1,251 @@ +import type { EmailAddressJSON, EnterpriseAccountConnectionJSON } from '@clerk/shared/types'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { + type FakeFapiSeed, + holdRequests, + serveFapi, + VERIFICATION_CODE, + verifyEmailOutOfBand, +} from '../../../../__tests__/feature/fake-fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileEmailSection } from '../user-profile-email-section'; + +const VERIFIED = fapiVerification('email_code', { status: 'verified' }); +const PRIMARY = fapiEmailAddress({ id: 'idn_primary', email_address: 'alice@example.com', verification: VERIFIED }); + +function signedInWithEmails(emails: EmailAddressJSON[], environment = fapiEnvironment()): FakeFapiSeed { + const user = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + username: 'alicesmith', + email_addresses: emails, + primary_email_address_id: emails[0]?.id ?? null, + }); + return { environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }; +} + +const verifiesByLink = fapiEnvironment({ attributes: { email_address: { verifications: ['email_link'] } } }); + +async function renderSection(seed: FakeFapiSeed) { + const fapi = serveFapi(seed); + const view = await renderWithClerk(); + return { ...view, fapi, actor: userEvent.setup() }; +} + +type Actor = ReturnType; + +const emailRow = () => screen.getByRole('group', { name: 'Email' }); +const emailsListed = () => + within(emailRow()) + .queryAllByText(/@/) + .map(node => node.textContent); + +async function manageEmail(actor: Actor, label: string, action: string) { + await actor.click(within(emailRow()).getByRole('button', { name: `Manage ${label}` })); + await actor.click(await screen.findByRole('menuitem', { name: action })); +} + +async function enterCode(actor: Actor, code: string) { + const input = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(input).not.toHaveAttribute('aria-disabled')); + await actor.click(input); + await actor.keyboard(code); +} + +describe('the user profile email section', () => { + it('adds an address and verifies the code it was sent', async () => { + const { actor } = await renderSection(signedInWithEmails([])); + expect(emailRow()).toHaveTextContent('No email addresses added'); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await actor.click(screen.getByRole('button', { name: 'Continue' })); + await enterCode(actor, VERIFICATION_CODE); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(emailsListed()).toEqual(['new@example.com']); + expect(emailRow()).not.toHaveTextContent('Unverified'); + }); + + it('holds the code field until the code has been sent', async () => { + const { actor } = await renderSection(signedInWithEmails([])); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await actor.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + expect(await screen.findByRole('textbox', { name: 'Verification code' })).toHaveAttribute('aria-disabled', 'true'); + + prepare.release(); + await enterCode(actor, VERIFICATION_CODE); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(emailsListed()).toEqual(['new@example.com']); + }); + + it('verifies an address left unverified', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending])); + expect(within(emailRow()).getByText('pending@example.com').parentElement).toHaveTextContent('Unverified'); + + await manageEmail(actor, 'pending@example.com', 'Verify'); + await enterCode(actor, VERIFICATION_CODE); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => + expect(within(emailRow()).getByText('pending@example.com').parentElement).not.toHaveTextContent('Unverified'), + ); + }); + + it('waits for the emailed link when the instance verifies by link, then closes once it is opened', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor, fapi } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); + const poll = holdRequests('get', '/v1/me/email_addresses/:id'); + + await manageEmail(actor, 'pending@example.com', 'Verify'); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await waitFor(() => expect(dialog).toHaveTextContent('Open the link we sent to pending@example.com')); + expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); + await waitFor(() => expect(poll.requests).toHaveLength(1)); + + verifyEmailOutOfBand(fapi, 'idn_pending'); + poll.release(); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('points the emailed link at the user profile on the host origin', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + + await manageEmail(actor, 'pending@example.com', 'Verify'); + + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + const [request] = prepare.requests; + if (!request) { + throw new Error('expected a prepare_verification request'); + } + const body = new URLSearchParams(await request.text()); + expect(body.get('strategy')).toBe('email_link'); + expect(body.get('redirect_url')).toBe(new URL('/user-profile#/verify', window.location.origin).href); + + prepare.release(); + }); + + it('leaves the address unverified when the link dialog is dismissed', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending], verifiesByLink)); + + await manageEmail(actor, 'pending@example.com', 'Verify'); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await actor.click(within(dialog).getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(within(emailRow()).getByText('pending@example.com').parentElement).toHaveTextContent('Unverified'); + }); + + it('sends the user to the identity provider for an address that matches an SSO connection, even when the instance verifies by link', async () => { + const sso = fapiEmailAddress({ + id: 'idn_sso', + email_address: 'alice@acme.co', + matches_sso_connection: true, + }); + const { actor, clerk } = await renderSection(signedInWithEmails([PRIMARY, sso], verifiesByLink)); + const windowNavigate = vi.spyOn(clerk, '__internal_windowNavigate').mockImplementation(() => {}); + + await manageEmail(actor, 'alice@acme.co', 'Verify'); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + expect(dialog).toHaveTextContent('acme.co'); + expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); + await actor.click(within(dialog).getByRole('button', { name: 'Connect' })); + + await waitFor(() => expect(windowNavigate).toHaveBeenCalledWith(new URL('https://idp.acme.co/sso'))); + }); + + it('does not offer to add the username as an address', async () => { + const { actor } = await renderSection( + signedInWithEmails([], fapiEnvironment({ attributes: { username: { enabled: true } } })), + ); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'alicesmith'); + + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + }); + + it('is left out when the instance does not collect email addresses', async () => { + await renderSection( + signedInWithEmails( + [], + fapiEnvironment({ + attributes: { email_address: { enabled: false, used_for_first_factor: false, first_factors: [] } }, + }), + ), + ); + + expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument(); + }); + + it('is left out when it is immutable and has nothing to list', async () => { + await renderSection( + signedInWithEmails([], fapiEnvironment({ attributes: { email_address: { enabled: true, immutable: true } } })), + ); + + expect(screen.queryByRole('group', { name: 'Email' })).not.toBeInTheDocument(); + }); +}); + +describe('the email section for a user signed in through an enterprise connection', () => { + function signedInThroughSso(connection: Partial): FakeFapiSeed { + const user = fapiUser({ + id: 'user_1', + email_addresses: [fapiEmailAddress({ id: 'idn_primary', email_address: 'alice@acme.co' })], + enterprise_accounts: [ + fapiEnterpriseAccount({ id: 'eac_1', email_address: 'alice@acme.co' }, { name: 'Acme Corp', ...connection }), + ], + }); + return { + environment: fapiEnvironment({ + user_settings: { + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }; + } + + it('stops the user adding an address when the connection disables additional identifications', async () => { + await renderSection(signedInThroughSso({ disable_additional_identifications: true })); + + expect(await screen.findByText('alice@acme.co')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add email' })).not.toBeInTheDocument(); + }); + + it('still lets the user add an address when the connection allows it', async () => { + await renderSection(signedInThroughSso({ disable_additional_identifications: false })); + + expect(await screen.findByRole('button', { name: 'Add email' })).toBeInTheDocument(); + }); +}); + +describe('email reverification', () => { + it.todo('confirms it is the user before an email address is added, then adds it'); + it.todo('confirms it is the user before an email address becomes the primary one'); + it.todo('leaves the add dialog open and untouched when the confirmation is dismissed'); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts similarity index 99% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts index 7514afb35c4..7e2b69a780e 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts @@ -7,7 +7,7 @@ import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; import type { FormError } from '../../../utils/errors'; import { toFormError } from '../../../utils/errors'; -import type { UserProfileEmailVerification, UserProfileEmailVerifier } from './user-profile-account-section.types'; +import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../user-profile-contact.types'; import type { UserProfileAddEmailDialogProps } from './user-profile-add-email.dialog'; export type UserProfileAddEmailField = 'emailAddress' | 'code'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.dialog.tsx similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.dialog.tsx diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts new file mode 100644 index 00000000000..6cd1052ead5 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.controller.ts @@ -0,0 +1,30 @@ +import { usePendingAction } from '../../../hooks/use-pending-action'; +import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; +import type { ReadyEmailSectionModel, UserProfileEmailSectionViewProps } from './user-profile-email-section.types'; + +export function useUserProfileEmailSectionController({ + emails, + username, + onCreateEmail, + getEmailVerifier, + onSetPrimaryEmail, + onRemoveEmail, +}: Omit): UserProfileEmailSectionViewProps { + const verification = useUserProfileAddEmailController({ username, onCreate: onCreateEmail }); + const setPrimary = usePendingAction(); + + return { + emails, + canAdd: Boolean(onCreateEmail), + verification, + error: setPrimary.error, + onVerify: id => { + const email = emails.find(email => email.id === id); + if (email) { + verification.onVerifyEmail(email.value, getEmailVerifier(id)); + } + }, + onSetPrimary: id => void setPrimary.run(id, () => onSetPrimaryEmail(id)), + onRemove: onRemoveEmail, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts new file mode 100644 index 00000000000..773ace0c222 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.model.ts @@ -0,0 +1,125 @@ +import { buildURL } from '@clerk/shared/internal/clerk-js/url'; +import type { EmailAddressResource } from '@clerk/shared/types'; + +import type { MosaicRouter } from '../../../hooks/use-mosaic-router'; +import { useMosaicRouter } from '../../../hooks/use-mosaic-router'; +import { save } from '../../../utils/errors'; +import type { UserProfileEmailVerification, UserProfileEmailVerifier } from '../user-profile-contact.types'; +import { byId, canAddIdentifications, toContactAccess, toContacts } from '../user-profile-contact.utils'; +import { useUserProfileUserModel } from '../user-profile-user.model'; +import type { UserProfileAddEmailField } from './user-profile-add-email.controller'; +import type { UserProfileEmailSectionModel } from './user-profile-email-section.types'; + +const ADD_EMAIL_FIELDS: readonly UserProfileAddEmailField[] = ['emailAddress', 'code']; + +/* + TODO: pick this base the way the routing mode says to. Legacy `buildVerificationRedirectUrl` only + falls back to `displayConfig.userProfileUrl` under virtual routing, and builds a path instead of a + hash when the host routes by path — Mosaic has no routing yet, so this always hashes onto the + instance's profile URL and sends a path-routed host's user back to the wrong place. #9843 adds + `MosaicRoutingProvider`; wire this to it once that lands. + + Whatever base wins, nothing in Mosaic serves the `/verify` the link lands on: there is no route and + no equivalent of legacy's `VerificationSuccessPage`, so an opened link is handled by whatever + clerk-js already mounts there. That page has to come with the routing work, not after it. +*/ +function verifyRedirectUrl(userProfileUrl: string): string { + return buildURL({ base: userProfileUrl, hashPath: '/verify' }, { stringify: true }); +} + +function startEmailVerification( + email: EmailAddressResource, + linkRedirectUrl: string | undefined, + router: MosaicRouter, +): UserProfileEmailVerification { + if (email.matchesSsoConnection) { + return { + method: 'sso', + /* + Prepared on the click rather than on entering the step, so the redirect URL the server + answers with is in hand by the time we navigate. Preparing on entry leaves the button live + before the response lands, and an early click has nowhere to go. + + TODO: carry the mounting mode back from the IdP. Legacy appends `appendModalState` to this + redirect when the profile is mounted as a modal, so returning from the provider reopens the + modal on the step the user left. Mosaic has no modal mode to encode yet; whoever adds one has + to encode it here too, or the user comes back to a closed dialog and a lost flow. + */ + connect: () => + save(async () => { + const prepared = await email.prepareVerification({ + strategy: 'enterprise_sso', + redirectUrl: window.location.href, + }); + const url = prepared.verification.externalVerificationRedirectURL; + if (!url) { + throw new Error('Enterprise SSO verification did not return a redirect URL'); + } + await router.navigate(url.href); + }), + }; + } + if (linkRedirectUrl === undefined) { + return { method: 'code', sent: save(() => email.prepareVerification({ strategy: 'email_code' })) }; + } + const { startEmailLinkFlow, cancelEmailLinkFlow } = email.createEmailLinkFlow(); + return { + method: 'link', + verified: save(() => startEmailLinkFlow({ redirectUrl: linkRedirectUrl })), + cancel: cancelEmailLinkFlow, + }; +} + +function toEmailVerifier( + email: EmailAddressResource, + linkRedirectUrl: string | undefined, + router: MosaicRouter, +): UserProfileEmailVerifier { + return { + start: () => startEmailVerification(email, linkRedirectUrl, router), + verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS), + }; +} + +export function useUserProfileEmailSectionModel(): UserProfileEmailSectionModel { + const model = useUserProfileUserModel(); + const router = useMosaicRouter(); + if (model.status !== 'ready') { + return model; + } + + const { user, environment, currentUser, saveAsUser } = model; + const { attributes, enterpriseSSO } = environment.userSettings; + const access = toContactAccess( + attributes.email_address, + user.emailAddresses.length, + canAddIdentifications(user, enterpriseSSO.enabled), + ); + + if (!access.show) { + return { status: 'hidden' }; + } + + const verifiesByLink = Boolean(attributes.email_address?.verifications.includes('email_link')); + const linkRedirectUrl = verifiesByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; + const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); + + return { + status: 'ready', + userId: user.id, + emails: toContacts(user.emailAddresses, user.primaryEmailAddressId, email => email.emailAddress), + username: user.username ?? undefined, + onCreateEmail: access.canCreate + ? async emailAddress => { + const request = currentUser().createEmailAddress({ email: emailAddress }); + await save(() => request, ADD_EMAIL_FIELDS); + return verifierFor(await request); + } + : undefined, + getEmailVerifier: id => verifierFor(byId(user.emailAddresses, id, 'email address')), + onSetPrimaryEmail: id => saveAsUser(current => current.update({ primaryEmailAddressId: id })), + onRemoveEmail: access.canRemove + ? id => saveAsUser(current => byId(current.emailAddresses, id, 'email address').destroy()) + : undefined, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.tsx new file mode 100644 index 00000000000..dca3c30f156 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.tsx @@ -0,0 +1,24 @@ +import { useUserProfileEmailSectionController } from './user-profile-email-section.controller'; +import { useUserProfileEmailSectionModel } from './user-profile-email-section.model'; +import type { ReadyEmailSectionModel } from './user-profile-email-section.types'; +import { UserProfileEmailSectionView } from './user-profile-email-section.view'; + +export function UserProfileEmailSection() { + const model = useUserProfileEmailSectionModel(); + + if (model.status !== 'ready') { + return null; + } + + return ( + + ); +} + +function Emails({ model }: { model: ReadyEmailSectionModel }) { + const controller = useUserProfileEmailSectionController(model); + return ; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts new file mode 100644 index 00000000000..5b8ac79dcbb --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.types.ts @@ -0,0 +1,28 @@ +import type { UserProfileEmail, UserProfileEmailVerifier } from '../user-profile-contact.types'; +import type { UserProfileAddEmailController } from './user-profile-add-email.controller'; + +export interface UserProfileEmailSectionViewProps { + emails: UserProfileEmail[]; + canAdd: boolean; + verification: UserProfileAddEmailController; + error: string | undefined; + onVerify: (id: string) => void; + onSetPrimary: (id: string) => void; + onRemove?: (id: string) => Promise; +} + +export type UserProfileEmailSectionModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + emails: UserProfileEmail[]; + username?: string; + onCreateEmail?: (emailAddress: string) => Promise; + getEmailVerifier: (id: string) => UserProfileEmailVerifier; + onSetPrimaryEmail: (id: string) => Promise; + onRemoveEmail?: (id: string) => Promise; + }; + +export type ReadyEmailSectionModel = Extract; diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx new file mode 100644 index 00000000000..e7f5478686e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx @@ -0,0 +1,99 @@ +import { useMemo, useRef } from 'react'; + +import { Confirmation } from '../../../blocks/confirmation'; +import { Button } from '../../../components/button'; +import { Dialog } from '../../../components/dialog'; +import { Icon } from '../../../components/icon'; +import { Section } from '../../../components/section'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { fill, useMessages } from '../../../localization'; +import type { UserProfileEmail } from '../user-profile-contact.types'; +import { UserProfileContactListRowView } from '../user-profile-contact-list-row.view'; +import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; +import type { UserProfileEmailSectionViewProps } from './user-profile-email-section.types'; + +export function UserProfileEmailSectionView({ + emails, + canAdd, + verification, + error, + onVerify, + onSetPrimary, + onRemove, +}: UserProfileEmailSectionViewProps) { + const m = useMessages('userProfileContact'); + const row = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: emails.map(email => email.id), + onRemove, + fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, + }); + const verificationDialog = useMemo(() => Dialog.createHandle(), []); + const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); + const verifyingId = useRef(undefined); + const addEmailAction = canAdd ? ( + + } + > + + {m.add} + + ) : undefined; + + return ( + + removeEmailConfirmation.open(email) : undefined} + onSetPrimary={onSetPrimary} + onVerify={id => { + verifyingId.current = id; + onVerify(id); + }} + > + {error} + + { + const id = verifyingId.current; + verifyingId.current = undefined; + return id ? removalFocus.trigger(id) : null; + }} + /> + {onRemove ? ( + + fill(email.isVerified ? m.email.removeDialog.verifiedDescription : m.email.removeDialog.description, { + emailAddress: email.value, + }) + } + actionLabel={m.email.removeDialog.confirm} + cancelLabel={m.email.removeDialog.cancel} + finalFocus={removalFocus.finalFocus} + onConfirm={email => removalFocus.remove(email.id)} + /> + ) : null} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx index 0050d4e0a42..73f76259b50 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/__tests__/user-profile-enterprise-accounts-host.feature.test.tsx @@ -12,10 +12,6 @@ it('places the connected enterprise section before Web3 wallets in the profile h serveFapi(enterpriseAccountSeed()); await renderWithClerk( } web3WalletsSlot={ Promise.resolve()); const onCreate = vi.fn(() => Promise.resolve({ sendCode, verifyCode: () => Promise.resolve() })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.dialog.test.tsx similarity index 96% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.dialog.test.tsx index ee19a8f3822..6b29b222601 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.dialog.test.tsx @@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { MosaicProvider } from '../../../mosaic-provider'; -import type { UserProfileAddPhoneDialogProps } from '../user-profile-account-section/user-profile-add-phone.dialog'; -import { UserProfileAddPhoneDialog } from '../user-profile-account-section/user-profile-add-phone.dialog'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import type { UserProfileAddPhoneDialogProps } from '../user-profile-add-phone.dialog'; +import { UserProfileAddPhoneDialog } from '../user-profile-add-phone.dialog'; function renderView(overrides: Partial = {}) { const props: UserProfileAddPhoneDialogProps = { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx similarity index 75% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx index e332ff68eb5..e13324c4612 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx @@ -3,29 +3,35 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { clerkApiError } from '../../../__tests__/clerk-errors'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { SaveError } from '../../../utils/errors'; -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'; +import { clerkApiError } from '../../../../__tests__/clerk-errors'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import { SaveError } from '../../../../utils/errors'; +import { useUserProfilePhoneSectionController } from '../user-profile-phone-section.controller'; +import type { ReadyPhoneSectionModel } from '../user-profile-phone-section.types'; +import { UserProfilePhoneSectionView } from '../user-profile-phone-section.view'; -function renderPhone(overrides: Partial = {}) { +const phoneVerifier = { sendCode: () => Promise.resolve(), verifyCode: () => Promise.resolve() }; + +type PhoneSectionProps = Partial>; + +function PhoneSection({ + phones = [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }], + getPhoneVerifier = () => phoneVerifier, + onSetPrimaryPhone = () => Promise.resolve(), + ...rest +}: PhoneSectionProps) { + const controller = useUserProfilePhoneSectionController({ phones, getPhoneVerifier, onSetPrimaryPhone, ...rest }); + return ; +} + +function renderPhone(overrides: PhoneSectionProps = {}) { return render( - + , ); } -const phoneVerifier = { sendCode: () => Promise.resolve(), verifyCode: () => Promise.resolve() }; - describe('phone actions', () => { it('ignores backdrop clicks and allows Escape to cancel removal', async () => { const user = userEvent.setup(); @@ -104,15 +110,12 @@ describe('phone actions', () => { ]); return ( - - setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))) - } + onSetPrimaryPhone={id => { + setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))); + return Promise.resolve(); + }} /> ); @@ -139,7 +142,10 @@ describe('phone actions', () => { it('keeps the phone when removal is canceled with Escape', async () => { const user = userEvent.setup(); const onRemovePhone = vi.fn(); - renderPhone({ onRemovePhone }); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', isDefault: true, isVerified: true }], + onRemovePhone, + }); const trigger = screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' }); trigger.focus(); @@ -161,15 +167,13 @@ describe('phone actions', () => { ]); return ( - phoneVerifier} onCreatePhone={() => Promise.resolve(phoneVerifier)} - onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} + onRemovePhone={id => { + setPhones(current => current.filter(phone => phone.id !== id)); + return Promise.resolve(); + }} /> ); @@ -235,18 +239,7 @@ describe('phone actions', () => { it('requires confirmation before removing a phone number', async () => { const user = userEvent.setup(); const onRemovePhone = vi.fn(); - render( - - - , - ); + renderPhone({ onRemovePhone }); 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(); @@ -274,4 +267,50 @@ describe('phone actions', () => { expect(dialog).not.toHaveTextContent('sign in'); } }); + + it('formats normalized phone numbers', () => { + renderPhone({ + phones: [{ id: 'phone_added', value: '+18015558181', isDefault: false, isVerified: true }], + onRemovePhone: vi.fn(), + }); + + expect(screen.getByRole('button', { name: 'Manage +1 (801) 555-8181' })).toBeInTheDocument(); + }); + + it('renders an actionable empty state when no phone number exists', () => { + renderPhone({ + phones: [], + onCreatePhone: () => Promise.resolve(phoneVerifier), + }); + + const group = screen.getByRole('group', { name: 'Phone' }); + const emptyState = within(group).getByText('No phone numbers added'); + expect(within(group).getByRole('heading', { name: 'Phone' })).toHaveClass('cl-section-title'); + expect(emptyState.closest('.cl-section-items')).not.toBeNull(); + expect(within(group).getByRole('button', { name: 'Add phone number' })).toHaveTextContent('Add'); + }); + + it('offers verify and set primary where each applies', async () => { + const user = userEvent.setup(); + const getPhoneVerifier = vi.fn(() => phoneVerifier); + const onSetPrimaryPhone = vi.fn().mockResolvedValue(undefined); + renderPhone({ + phones: [ + { id: 'phone_unverified', value: '+1 801-555-0100', isDefault: false, isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isDefault: false, isVerified: true }, + ], + getPhoneVerifier, + onSetPrimaryPhone, + onRemovePhone: vi.fn(), + }); + + 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'); + + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify phone number' })); + expect(getPhoneVerifier).toHaveBeenCalledExactlyOnceWith('phone_unverified'); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx new file mode 100644 index 00000000000..7e3bbc817d5 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx @@ -0,0 +1,254 @@ +import type { EnterpriseAccountConnectionJSON, PhoneNumberJSON } from '@clerk/shared/types'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { type FakeFapiSeed, holdRequests, serveFapi, VERIFICATION_CODE } from '../../../../__tests__/feature/fake-fapi'; +import type { FapiAttributeOverrides } from '../../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnterpriseAccount, + fapiEnvironment, + fapiPhoneNumber, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfilePhoneSection } from '../user-profile-phone-section'; + +const HOME = fapiPhoneNumber({ + id: 'idn_home', + phone_number: '+18015550100', + verification: fapiVerification('phone_code', { status: 'verified' }), +}); +const WORK = fapiPhoneNumber({ + id: 'idn_work', + phone_number: '+18015550199', + verification: fapiVerification('phone_code', { status: 'verified' }), +}); +const HOME_LABEL = '+1 (801) 555-0100'; +const WORK_LABEL = '+1 (801) 555-0199'; + +function signedIn(phones: PhoneNumberJSON[] = [], overrides: Partial = {}): FakeFapiSeed { + const user = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + username: 'alicesmith', + email_addresses: [fapiEmailAddress({ id: 'idn_email' })], + phone_numbers: phones, + primary_phone_number_id: phones[0]?.id ?? null, + }); + return { + environment: fapiEnvironment({ attributes: { phone_number: { enabled: true } } }), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + ...overrides, + }; +} + +function withPhoneAttribute(overrides: FapiAttributeOverrides['phone_number']): FakeFapiSeed { + return signedIn([HOME, WORK], { environment: fapiEnvironment({ attributes: { phone_number: overrides } }) }); +} + +async function renderSection(seed: FakeFapiSeed = signedIn()) { + const fapi = serveFapi(seed); + const view = await renderWithClerk(); + return { ...view, fapi, actor: userEvent.setup() }; +} + +type Actor = ReturnType; + +const row = () => screen.getByRole('group', { name: 'Phone' }); +const listed = () => + within(row()) + .queryAllByText(/^\+\d/) + .map(node => node.textContent); + +async function manage(actor: Actor, label: string, action: string) { + await actor.click(within(row()).getByRole('button', { name: `Manage ${label}` })); + await actor.click(await screen.findByRole('menuitem', { name: action })); +} + +async function addPhone(actor: Actor, digits: string) { + await actor.click(screen.getByRole('button', { name: 'Add phone number' })); + await actor.type(screen.getByRole('textbox', { name: 'Phone' }), digits); + await actor.click(screen.getByRole('button', { name: 'Send code' })); +} + +async function enterCode(actor: Actor, code: string) { + const input = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(input).not.toHaveAttribute('aria-disabled')); + await actor.click(input); + await actor.keyboard(code); +} + +describe('the user profile phone section', () => { + it('lists the primary number first and marks it', async () => { + await renderSection(signedIn([HOME, WORK])); + + expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]); + expect(within(row()).getByText(HOME_LABEL).parentElement).toHaveTextContent('Primary'); + }); + + it('says so when no number has been added', async () => { + await renderSection(); + + expect(row()).toHaveTextContent('No phone numbers added'); + }); + + it('adds a number, verifies the code it was sent, and lists it', async () => { + const { actor } = await renderSection(); + + await addPhone(actor, '8015550100'); + await enterCode(actor, VERIFICATION_CODE); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(listed()).toEqual([HOME_LABEL]); + expect(row()).not.toHaveTextContent('Unverified'); + }); + + it('starts the country on the one the instance located the user in', async () => { + const { actor } = await renderSection(signedIn([], { country: 'de' })); + + await actor.click(screen.getByRole('button', { name: 'Add phone number' })); + + expect(screen.getByRole('button', { name: 'Country, Germany' })).toBeInTheDocument(); + expect(screen.getByText('+49')).toBeInTheDocument(); + }); + + it('keeps the dialog open and says the code was wrong', async () => { + const { actor } = await renderSection(); + + await addPhone(actor, '8015550100'); + await enterCode(actor, '000000'); + + expect(await screen.findByText('Incorrect code')).toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBeInTheDocument(); + }); + + it('holds the resend behind a countdown while the code is still fresh', async () => { + const { actor } = await renderSection(); + + await addPhone(actor, '8015550100'); + + expect(await screen.findByRole('button', { name: /Didn’t receive a code\? Resend \(\d+\)/ })).toBeDisabled(); + }); + + it('verifies a number that was left unverified', async () => { + const pending = fapiPhoneNumber({ id: 'idn_new', phone_number: '+18015550199' }); + const { actor } = await renderSection(signedIn([HOME, pending])); + + expect(within(row()).getByText(WORK_LABEL).parentElement).toHaveTextContent('Unverified'); + await manage(actor, WORK_LABEL, 'Verify phone number'); + await enterCode(actor, VERIFICATION_CODE); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(within(row()).getByText(WORK_LABEL).parentElement).not.toHaveTextContent('Unverified')); + }); + + it('makes another verified number the primary one', async () => { + const { actor } = await renderSection(signedIn([HOME, WORK])); + + await manage(actor, WORK_LABEL, 'Set as primary'); + + await waitFor(() => expect(listed()).toEqual([WORK_LABEL, HOME_LABEL])); + expect(within(row()).getByText(WORK_LABEL).parentElement).toHaveTextContent('Primary'); + }); + + it('removes a number once the removal is confirmed', async () => { + const { actor } = await renderSection(signedIn([HOME, WORK])); + + await manage(actor, WORK_LABEL, 'Remove phone number'); + const dialog = await screen.findByRole('alertdialog', { name: 'Remove phone number?' }); + expect(dialog).toHaveTextContent(WORK_LABEL); + await actor.click(within(dialog).getByRole('button', { name: 'Remove' })); + + await waitFor(() => expect(listed()).toEqual([HOME_LABEL])); + }); + + it('explains a refused removal in the instance’s own copy', async () => { + const { actor } = await renderSection(signedIn([HOME, WORK])); + const destroy = holdRequests('post', '/v1/me/phone_numbers/:id'); + + await manage(actor, WORK_LABEL, 'Remove phone number'); + const dialog = await screen.findByRole('alertdialog', { name: 'Remove phone number?' }); + await actor.click(within(dialog).getByRole('button', { name: 'Remove' })); + await waitFor(() => expect(destroy.requests).toHaveLength(1)); + destroy.fail('action_blocked', 'Raw server sentence.'); + + expect(await within(dialog).findByText(/Please try again later or contact support/)).toBeInTheDocument(); + expect(dialog).not.toHaveTextContent('Raw server sentence.'); + expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]); + }); + + it('is left out when the instance does not collect phone numbers', async () => { + await renderSection(withPhoneAttribute({ enabled: false })); + + expect(screen.queryByRole('group', { name: 'Phone' })).not.toBeInTheDocument(); + }); + + it('lists immutable numbers without offering to add or remove one', async () => { + const { actor } = await renderSection(withPhoneAttribute({ enabled: true, immutable: true })); + + expect(listed()).toEqual([HOME_LABEL, WORK_LABEL]); + expect(screen.queryByRole('button', { name: 'Add phone number' })).not.toBeInTheDocument(); + await actor.click(within(row()).getByRole('button', { name: `Manage ${WORK_LABEL}` })); + + expect(await screen.findByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); + }); + + it('is left out when it is immutable and has nothing to list', async () => { + await renderSection( + signedIn([], { + environment: fapiEnvironment({ attributes: { phone_number: { enabled: true, immutable: true } } }), + }), + ); + + expect(screen.queryByRole('group', { name: 'Phone' })).not.toBeInTheDocument(); + }); +}); + +describe('the phone section for a user signed in through an enterprise connection', () => { + function signedInThroughSso(connection: Partial): FakeFapiSeed { + const user = fapiUser({ + id: 'user_1', + email_addresses: [fapiEmailAddress({ id: 'idn_primary', email_address: 'alice@acme.co' })], + phone_numbers: [HOME], + primary_phone_number_id: HOME.id, + enterprise_accounts: [ + fapiEnterpriseAccount({ id: 'eac_1', email_address: 'alice@acme.co' }, { name: 'Acme Corp', ...connection }), + ], + }); + return { + environment: fapiEnvironment({ + attributes: { phone_number: { enabled: true } }, + user_settings: { + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }; + } + + it('stops the user adding a number when the connection disables additional identifications', async () => { + await renderSection(signedInThroughSso({ disable_additional_identifications: true })); + + expect(listed()).toEqual([HOME_LABEL]); + expect(screen.queryByRole('button', { name: 'Add phone number' })).not.toBeInTheDocument(); + }); + + it('still lets the user add a number when the connection allows it', async () => { + await renderSection(signedInThroughSso({ disable_additional_identifications: false })); + + expect(within(row()).getByRole('button', { name: 'Add phone number' })).toBeInTheDocument(); + }); +}); + +describe('phone reverification', () => { + it.todo('confirms it is the user before a phone number is added, then adds it'); + it.todo('confirms it is the user before a phone number becomes the primary one'); + it.todo('leaves the add dialog open and untouched when the confirmation is dismissed'); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts similarity index 98% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts index fc2b3d600e7..7d8b7ab616b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts @@ -7,7 +7,7 @@ import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/use-machine'; import type { FormError } from '../../../utils/errors'; import { toFormError } from '../../../utils/errors'; -import type { UserProfilePhoneVerifier } from './user-profile-account-section.types'; +import type { UserProfilePhoneVerifier } from '../user-profile-contact.types'; import type { UserProfileAddPhoneDialogProps } from './user-profile-add-phone.dialog'; export type UserProfileAddPhoneField = 'phoneNumber' | 'code'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.dialog.tsx similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.dialog.tsx diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.messages.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.messages.ts rename to packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.messages.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts new file mode 100644 index 00000000000..431fbe9b86d --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller.ts @@ -0,0 +1,33 @@ +import { stringToFormattedPhoneString } from '@clerk/shared/phone'; + +import { usePendingAction } from '../../../hooks/use-pending-action'; +import { useUserProfileAddPhoneController } from './user-profile-add-phone.controller'; +import type { ReadyPhoneSectionModel, UserProfilePhoneSectionViewProps } from './user-profile-phone-section.types'; + +export function useUserProfilePhoneSectionController({ + phones, + defaultPhoneCountry, + onCreatePhone, + getPhoneVerifier, + onSetPrimaryPhone, + onRemovePhone, +}: Omit): UserProfilePhoneSectionViewProps { + const verification = useUserProfileAddPhoneController({ onCreate: onCreatePhone }); + const setPrimary = usePendingAction(); + + return { + phones: phones.map(phone => ({ ...phone, value: stringToFormattedPhoneString(phone.value) })), + defaultPhoneCountry, + canAdd: Boolean(onCreatePhone), + verification, + error: setPrimary.error, + onVerify: id => { + const phone = phones.find(phone => phone.id === id); + if (phone) { + verification.onVerifyPhone(phone.value, getPhoneVerifier(id)); + } + }, + onSetPrimary: id => void setPrimary.run(id, () => onSetPrimaryPhone(id)), + onRemove: onRemovePhone, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts new file mode 100644 index 00000000000..dc43d6c6f98 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.model.ts @@ -0,0 +1,58 @@ +import { useClerk } from '@clerk/shared/react'; +import type { PhoneNumberResource } from '@clerk/shared/types'; + +import { toCountryIso } from '../../../components/phone-input'; +import { save } from '../../../utils/errors'; +import type { UserProfilePhoneVerifier } from '../user-profile-contact.types'; +import { byId, canAddIdentifications, toContactAccess, toContacts } from '../user-profile-contact.utils'; +import { useUserProfileUserModel } from '../user-profile-user.model'; +import type { UserProfileAddPhoneField } from './user-profile-add-phone.controller'; +import type { UserProfilePhoneSectionModel } from './user-profile-phone-section.types'; + +const ADD_PHONE_FIELDS: readonly UserProfileAddPhoneField[] = ['phoneNumber', 'code']; + +function toPhoneVerifier(phone: PhoneNumberResource): UserProfilePhoneVerifier { + return { + sendCode: () => save(() => phone.prepareVerification()), + verifyCode: code => save(() => phone.attemptVerification({ code }), ADD_PHONE_FIELDS), + }; +} + +export function useUserProfilePhoneSectionModel(): UserProfilePhoneSectionModel { + const clerk = useClerk(); + const model = useUserProfileUserModel(); + if (model.status !== 'ready') { + return model; + } + + const { user, environment, currentUser, saveAsUser } = model; + const { attributes, enterpriseSSO } = environment.userSettings; + const access = toContactAccess( + attributes.phone_number, + user.phoneNumbers.length, + canAddIdentifications(user, enterpriseSSO.enabled), + ); + + if (!access.show) { + return { status: 'hidden' }; + } + + return { + status: 'ready', + userId: user.id, + phones: toContacts(user.phoneNumbers, user.primaryPhoneNumberId, phone => phone.phoneNumber), + defaultPhoneCountry: toCountryIso(clerk.__internal_country), + onCreatePhone: access.canCreate + ? async phoneNumber => { + const request = currentUser().createPhoneNumber({ phoneNumber }); + await save(() => request, ADD_PHONE_FIELDS); + return toPhoneVerifier(await request); + } + : undefined, + getPhoneVerifier: id => toPhoneVerifier(byId(user.phoneNumbers, id, 'phone number')), + onSetPrimaryPhone: id => saveAsUser(current => current.update({ primaryPhoneNumberId: id })), + onRemovePhone: access.canRemove + ? id => saveAsUser(current => byId(current.phoneNumbers, id, 'phone number').destroy()) + : undefined, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.tsx new file mode 100644 index 00000000000..c75873ab97e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.tsx @@ -0,0 +1,24 @@ +import { useUserProfilePhoneSectionController } from './user-profile-phone-section.controller'; +import { useUserProfilePhoneSectionModel } from './user-profile-phone-section.model'; +import type { ReadyPhoneSectionModel } from './user-profile-phone-section.types'; +import { UserProfilePhoneSectionView } from './user-profile-phone-section.view'; + +export function UserProfilePhoneSection() { + const model = useUserProfilePhoneSectionModel(); + + if (model.status !== 'ready') { + return null; + } + + return ( + + ); +} + +function Phones({ model }: { model: ReadyPhoneSectionModel }) { + const controller = useUserProfilePhoneSectionController(model); + return ; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts new file mode 100644 index 00000000000..e71d766ebd7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.types.ts @@ -0,0 +1,30 @@ +import type { CountryIso } from '../../../components/phone-input'; +import type { UserProfilePhone, UserProfilePhoneVerifier } from '../user-profile-contact.types'; +import type { UserProfileAddPhoneController } from './user-profile-add-phone.controller'; + +export interface UserProfilePhoneSectionViewProps { + phones: UserProfilePhone[]; + defaultPhoneCountry?: CountryIso; + canAdd: boolean; + verification: UserProfileAddPhoneController; + error: string | undefined; + onVerify: (id: string) => void; + onSetPrimary: (id: string) => void; + onRemove?: (id: string) => Promise; +} + +export type UserProfilePhoneSectionModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + phones: UserProfilePhone[]; + defaultPhoneCountry?: CountryIso; + onCreatePhone?: (phoneNumber: string) => Promise; + getPhoneVerifier: (id: string) => UserProfilePhoneVerifier; + onSetPrimaryPhone: (id: string) => Promise; + onRemovePhone?: (id: string) => Promise; + }; + +export type ReadyPhoneSectionModel = Extract; diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx new file mode 100644 index 00000000000..07c89c6ebc7 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx @@ -0,0 +1,101 @@ +import { useMemo, useRef } from 'react'; + +import { Confirmation } from '../../../blocks/confirmation'; +import { Button } from '../../../components/button'; +import { Dialog } from '../../../components/dialog'; +import { Icon } from '../../../components/icon'; +import { Section } from '../../../components/section'; +import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; +import { fill, useMessages } from '../../../localization'; +import type { UserProfilePhone } from '../user-profile-contact.types'; +import { UserProfileContactListRowView } from '../user-profile-contact-list-row.view'; +import { UserProfileAddPhoneDialog } from './user-profile-add-phone.dialog'; +import type { UserProfilePhoneSectionViewProps } from './user-profile-phone-section.types'; + +export function UserProfilePhoneSectionView({ + phones, + defaultPhoneCountry, + canAdd, + verification, + error, + onVerify, + onSetPrimary, + onRemove, +}: UserProfilePhoneSectionViewProps) { + const m = useMessages('userProfileContact'); + const row = useRef(null); + const removalFocus = useListRemovalFocus({ + ids: phones.map(phone => phone.id), + onRemove, + fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, + }); + const verificationDialog = useMemo(() => Dialog.createHandle(), []); + const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []); + const verifyingId = useRef(undefined); + const addPhoneAction = canAdd ? ( + + } + > + + {m.add} + + ) : undefined; + + return ( + + removePhoneConfirmation.open(phone) : undefined} + onSetPrimary={onSetPrimary} + onVerify={id => { + verifyingId.current = id; + onVerify(id); + }} + > + {error} + + { + const id = verifyingId.current; + verifyingId.current = undefined; + return id ? removalFocus.trigger(id) : null; + }} + /> + {onRemove ? ( + + fill(phone.isVerified ? m.phone.removeDialog.verifiedDescription : m.phone.removeDialog.description, { + phoneNumber: phone.value, + }) + } + actionLabel={m.phone.removeDialog.confirm} + cancelLabel={m.phone.removeDialog.cancel} + finalFocus={removalFocus.finalFocus} + onConfirm={phone => removalFocus.remove(phone.id)} + /> + ) : null} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx index abfe9a4bdda..0dee162ac7c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-panel.view.tsx @@ -2,21 +2,19 @@ import type { ReactElement, ReactNode, Ref } from 'react'; import { Panel } from '../../components/panel'; import { themeProps } from '../../props'; -import type { - UserProfileAccountSectionViewProps, - UserProfileEmail, - UserProfilePhone, -} from './user-profile-account-section/user-profile-account-section.view'; -import { UserProfileAccountSectionView } from './user-profile-account-section/user-profile-account-section.view'; import type { UserProfileConnectedAccount } from './user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; +import type { UserProfileEmail, UserProfilePhone } from './user-profile-contact.types'; import type { UserProfileWeb3Wallet } from './user-profile-web3-wallets-section/user-profile-web3-wallets-section.types'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; -export type { UserProfileNameAttribute } from './user-profile-account-section/user-profile-account-section.types'; -export type { UserProfileEditNameValue } from './user-profile-account-section/user-profile-edit-name.dialog'; +export type { UserProfileNameAttribute } from './user-profile-profile-section/user-profile-profile-section.types'; +export type { UserProfileEditNameValue } from './user-profile-profile-section/user-profile-edit-name.dialog'; -export interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { +export interface UserProfileProfilePanelViewProps { titleRef?: Ref; + profileSlot?: ReactNode; + emailSlot?: ReactNode; + phoneSlot?: ReactNode; connectedAccountsSlot?: ReactNode; web3WalletsSlot?: ReactNode; enterpriseAccountsSlot?: ReactNode; @@ -24,13 +22,14 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect } export function UserProfileProfilePanelView({ - name = '', titleRef, + profileSlot, + emailSlot, + phoneSlot, connectedAccountsSlot, web3WalletsSlot, enterpriseAccountsSlot, dangerSlot, - ...account }: UserProfileProfilePanelViewProps): ReactElement { return ( }> @@ -41,10 +40,9 @@ export function UserProfileProfilePanelView({ Account - + {profileSlot} + {emailSlot} + {phoneSlot} {connectedAccountsSlot} {enterpriseAccountsSlot} {web3WalletsSlot} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-name.controller.test.ts similarity index 94% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-name.controller.test.ts index 704c644a52a..e63915b0f05 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-name.controller.test.ts @@ -1,9 +1,9 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { SaveError } from '../../../utils/errors'; -import { useUserProfileEditNameController } from '../user-profile-account-section/user-profile-edit-name.controller'; -import type { UserProfileEditNameValue } from '../user-profile-account-section/user-profile-edit-name.dialog'; +import { SaveError } from '../../../../utils/errors'; +import { useUserProfileEditNameController } from '../user-profile-edit-name.controller'; +import type { UserProfileEditNameValue } from '../user-profile-edit-name.dialog'; function deferred() { let resolve: () => void = () => {}; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-name.dialog.test.tsx similarity index 92% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-name.dialog.test.tsx index b484bdc07ac..e50979503c7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-name.dialog.test.tsx @@ -2,15 +2,12 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { Button } from '../../../components/button'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { SaveError } from '../../../utils/errors'; -import { useUserProfileEditNameController } from '../user-profile-account-section/user-profile-edit-name.controller'; -import type { - UserProfileEditNameDialogProps, - UserProfileEditNameValue, -} from '../user-profile-account-section/user-profile-edit-name.dialog'; -import { UserProfileEditNameDialog } from '../user-profile-account-section/user-profile-edit-name.dialog'; +import { Button } from '../../../../components/button'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import { SaveError } from '../../../../utils/errors'; +import { useUserProfileEditNameController } from '../user-profile-edit-name.controller'; +import type { UserProfileEditNameDialogProps, UserProfileEditNameValue } from '../user-profile-edit-name.dialog'; +import { UserProfileEditNameDialog } from '../user-profile-edit-name.dialog'; type ViewProps = Omit & { onSubmit: (value: UserProfileEditNameValue) => Promise; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-username.controller.test.ts similarity index 97% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-username.controller.test.ts index 2c87dcd84ce..eb8a87f5d71 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-username.controller.test.ts @@ -1,8 +1,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { SaveError } from '../../../utils/errors'; -import { useUserProfileEditUsernameController } from '../user-profile-account-section/user-profile-edit-username.controller'; +import { SaveError } from '../../../../utils/errors'; +import { useUserProfileEditUsernameController } from '../user-profile-edit-username.controller'; function deferred() { let resolve: () => void = () => {}; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-username.dialog.test.tsx similarity index 92% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-username.dialog.test.tsx index 6c15db43c78..fdc57c2152a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-edit-username.dialog.test.tsx @@ -2,13 +2,13 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; -import { Button } from '../../../components/button'; -import type { MosaicLocalization } from '../../../localization'; -import { MosaicProvider } from '../../../mosaic-provider'; -import { SaveError } from '../../../utils/errors'; -import { useUserProfileEditUsernameController } from '../user-profile-account-section/user-profile-edit-username.controller'; -import type { UserProfileEditUsernameDialogProps } from '../user-profile-account-section/user-profile-edit-username.dialog'; -import { UserProfileEditUsernameDialog } from '../user-profile-account-section/user-profile-edit-username.dialog'; +import { Button } from '../../../../components/button'; +import type { MosaicLocalization } from '../../../../localization'; +import { MosaicProvider } from '../../../../mosaic-provider'; +import { SaveError } from '../../../../utils/errors'; +import { useUserProfileEditUsernameController } from '../user-profile-edit-username.controller'; +import type { UserProfileEditUsernameDialogProps } from '../user-profile-edit-username.dialog'; +import { UserProfileEditUsernameDialog } from '../user-profile-edit-username.dialog'; type ViewProps = Omit & { onSubmit: (username: string) => Promise; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-picture.controller.test.ts similarity index 97% rename from packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-picture.controller.test.ts index e3b0c90a972..b53a7f5852a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-picture.controller.test.ts @@ -2,9 +2,9 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { SaveError } from '../../../utils/errors'; -import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; -import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; +import { SaveError } from '../../../../utils/errors'; +import type { UserProfilePictureControllerOptions } from '../user-profile-picture.controller'; +import { useUserProfilePictureController } from '../user-profile-picture.controller'; const file = new File(['x'], 'me.png', { type: 'image/png' }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx new file mode 100644 index 00000000000..8c7e3b80792 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx @@ -0,0 +1,270 @@ +import { act, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { type FakeFapiSeed, fapiUrl, holdRequests, serveFapi, worker } from '../../../../__tests__/feature/fake-fapi'; +import type { FapiAttributeOverrides } from '../../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnterpriseAccount, + fapiEnvironment, + fapiSession, + fapiUser, +} from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileProfileSection } from '../user-profile-profile-section'; + +function signedIn(environment = fapiEnvironment()): FakeFapiSeed { + const user = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_email' })], + }); + return { environment, client: fapiClient([fapiSession({ id: 'sess_1', user })]) }; +} + +async function renderSection(seed: FakeFapiSeed = signedIn()) { + const fapi = serveFapi(seed); + const view = await renderWithClerk(); + return { ...view, fapi, actor: userEvent.setup() }; +} + +function fileInput(container: Element): HTMLInputElement { + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('expected a file input'); + } + return input; +} + +const oversized = () => new File([new Uint8Array(10 * 1000 * 1000 + 1)], 'big.png', { type: 'image/png' }); + +function withUsername(username: string | null, attribute: FapiAttributeOverrides['username'] = {}): FakeFapiSeed { + const user = fapiUser({ id: 'user_1', username, email_addresses: [fapiEmailAddress({ id: 'idn_email' })] }); + return { + environment: fapiEnvironment({ attributes: { username: attribute } }), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }; +} + +const profile = () => screen.getByRole('group', { name: 'Profile' }); + +function failsWith(path: string, error: Record, status: number) { + worker.use(http.post(fapiUrl(path), () => HttpResponse.json({ errors: [error] }, { status }))); +} + +describe('the user profile profile section', () => { + it('names the section and shows the full name', async () => { + await renderSection(); + + expect(within(screen.getByRole('group', { name: 'Profile' })).getByText('Alice Smith')).toBeInTheDocument(); + }); + + it('saves an edited name and closes the dialog', async () => { + const { actor } = await renderSection(); + + await actor.click(screen.getByRole('button', { name: 'Edit name' })); + const dialog = screen.getByRole('dialog', { name: 'Edit name' }); + await actor.clear(within(dialog).getByLabelText('First name')); + await actor.type(within(dialog).getByLabelText('First name'), 'Alicia'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByText('Alicia Smith')).toBeInTheDocument(); + }); + + it('leaves the name out when the instance collects neither half of it', async () => { + await renderSection( + signedIn(fapiEnvironment({ attributes: { first_name: { enabled: false }, last_name: { enabled: false } } })), + ); + + expect(screen.getByRole('group', { name: 'Profile' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit name' })).not.toBeInTheDocument(); + }); + + it('uploads a picked picture and then offers to change or remove it', async () => { + const { actor, container } = await renderSection(); + + expect(screen.queryByRole('button', { name: 'Manage profile picture' })).not.toBeInTheDocument(); + await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + + await actor.click(await screen.findByRole('button', { name: 'Manage profile picture' })); + expect(await screen.findByRole('menuitem', { name: 'Remove avatar' })).toBeInTheDocument(); + }); + + it('says why an upload was refused', async () => { + const { actor, container } = await renderSection(); + failsWith('/v1/me/profile_image', { code: 'avatar_file_size_exceeded', message: 'Too large' }, 413); + + await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + ), + ); + }); + + it('turns away a file past the size the row advertises without uploading it', async () => { + const { actor, container } = await renderSection(); + const upload = holdRequests('post', '/v1/me/profile_image'); + + await actor.upload(fileInput(container), oversized()); + + expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + expect(upload.requests).toHaveLength(0); + upload.release(); + }); + + it('clears the rejection once an acceptable file is picked', async () => { + const { actor, container } = await renderSection(); + + await actor.upload(fileInput(container), oversized()); + expect(screen.getByRole('alert')).toBeInTheDocument(); + + await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()); + }); + + it('replaces a rejected pick with why removing the picture failed', async () => { + const { actor, container } = await renderSection(); + await actor.upload(fileInput(container), new File(['x'], 'me.png', { type: 'image/png' })); + await screen.findByRole('button', { name: 'Manage profile picture' }); + + await actor.upload(fileInput(container), oversized()); + expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + + failsWith('/v1/me/profile_image', { code: 'action_blocked', message: 'Blocked' }, 403); + await actor.click(screen.getByRole('button', { name: 'Manage profile picture' })); + await actor.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); + + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent("This action couldn't be completed.")); + }); + + it('hands the name to an active enterprise connection instead of offering to edit it', async () => { + const user = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_primary', email_address: 'alice@acme.co' })], + enterprise_accounts: [ + fapiEnterpriseAccount({ id: 'eac_1', email_address: 'alice@acme.co' }, { name: 'Acme Corp' }), + ], + }); + await renderSection({ + environment: fapiEnvironment({ + user_settings: { + enterprise_sso: { enabled: true, self_serve_sso: false, self_serve_directory_sync: false }, + }, + }), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }); + + expect(await screen.findByText('Managed by Acme Corp')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit name' })).not.toBeInTheDocument(); + }); + + it('shows the newly active account and drops the draft the other one left open', async () => { + const alice = fapiUser({ + id: 'user_1', + first_name: 'Alice', + last_name: 'Smith', + email_addresses: [fapiEmailAddress({ id: 'idn_alice' })], + }); + const bob = fapiUser({ + id: 'user_2', + first_name: 'Bob', + last_name: 'Jones', + email_addresses: [fapiEmailAddress({ id: 'idn_bob' })], + }); + const { actor, clerk } = await renderSection({ + environment: fapiEnvironment(), + client: fapiClient([fapiSession({ id: 'sess_1', user: alice }), fapiSession({ id: 'sess_2', user: bob })]), + }); + + await actor.click(screen.getByRole('button', { name: 'Edit name' })); + const dialog = screen.getByRole('dialog', { name: 'Edit name' }); + await actor.clear(within(dialog).getByLabelText('First name')); + await actor.type(within(dialog).getByLabelText('First name'), 'Alicia'); + + await act(() => clerk.setActive({ session: 'sess_2' })); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(await screen.findByText('Bob Jones')).toBeInTheDocument(); + expect(screen.queryByText(/Alic/)).not.toBeInTheDocument(); + }); + + it('shows the username and saves an edited one', async () => { + const { actor } = await renderSection(withUsername('alicesmith')); + expect(profile()).toHaveTextContent('alicesmith'); + + await actor.click(within(profile()).getByRole('button', { name: 'Edit username' })); + const dialog = screen.getByRole('dialog', { name: 'Edit username' }); + await actor.clear(within(dialog).getByLabelText('Username')); + await actor.type(within(dialog).getByLabelText('Username'), 'alicia'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(profile()).toHaveTextContent('alicia'); + }); + + it('offers to set a username the user has not picked yet', async () => { + await renderSection(withUsername(null)); + + expect(within(profile()).getByRole('button', { name: 'Add username' })).toBeInTheDocument(); + }); + + it('keeps the username dialog open on the error the server names it for', async () => { + const { actor } = await renderSection(withUsername('alicesmith')); + worker.use( + http.post(fapiUrl('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/v1/me'), () => + HttpResponse.json( + { + errors: [ + { + code: 'form_identifier_exists', + message: 'Taken', + long_message: 'That username is taken. Please try another.', + meta: { param_name: 'username' }, + }, + ], + }, + { status: 422 }, + ), + ), + ); + + await actor.click(screen.getByRole('button', { name: 'Edit username' })); + const dialog = screen.getByRole('dialog', { name: 'Edit username' }); + await actor.type(within(dialog).getByLabelText('Username'), '2'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + expect(await within(dialog).findByText('That username is taken. Please try another.')).toBeInTheDocument(); + }); + + it('leaves the username out when the instance does not collect it', async () => { + await renderSection(withUsername('alicesmith', { enabled: false })); + + expect(within(profile()).queryByText('Username')).not.toBeInTheDocument(); + }); + + it('shows an immutable username without offering to change it', async () => { + await renderSection(withUsername('alicesmith', { immutable: true })); + + expect(profile()).toHaveTextContent('alicesmith'); + expect(screen.queryByRole('button', { name: 'Edit username' })).not.toBeInTheDocument(); + }); + + it('leaves the username out when it is immutable and was never set', async () => { + await renderSection(withUsername(null, { immutable: true })); + + expect(within(profile()).queryByText('Username')).not.toBeInTheDocument(); + }); +}); + +describe('username reverification', () => { + it.todo('confirms it is the user before the username changes'); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-name.controller.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-name.controller.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-name.dialog.tsx similarity index 97% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-name.dialog.tsx index 9c8fcc04d40..520740efc8d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-name.dialog.tsx @@ -10,7 +10,7 @@ import { Field } from '../../../components/field'; import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { UserProfileNameAttribute } from './user-profile-profile-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -45,7 +45,7 @@ export function UserProfileEditNameDialog({ title, form, }: UserProfileEditNameDialogProps) { - const m = useMessages('userProfileAccountSection'); + const m = useMessages('userProfileProfileSection'); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; const { enabled: showLastName = true, required: lastNameRequired = false } = lastNameAttribute; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-username.controller.ts similarity index 100% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-username.controller.ts diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-username.dialog.tsx similarity index 92% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-username.dialog.tsx index a3350af7924..bbc9d6e32ed 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-edit-username.dialog.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-edit-username.dialog.tsx @@ -31,7 +31,7 @@ export function UserProfileEditUsernameDialog({ title, form, }: UserProfileEditUsernameDialogProps) { - const m = useMessages('userProfileAccountSection'); + const m = useMessages('userProfileProfileSection').username; const usernameRef = useRef(null); const { feedback } = form.fields.username; const error = feedback?.type === 'error' ? feedback.message : undefined; @@ -53,7 +53,7 @@ export function UserProfileEditUsernameDialog({ renderBranding={false} > - {title ?? m.username.dialogTitle} + {title ?? m.dialogTitle} - {m.username.fieldLabel} + {m.fieldLabel} - {m.username.cancel} + {m.cancel} } /> @@ -103,7 +103,7 @@ export function UserProfileEditUsernameDialog({ disabled={!form.canSubmit} focusableWhenDisabled > - {m.username.save} + {m.save} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx new file mode 100644 index 00000000000..66ce55261dd --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-name-row.view.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; + +import { Section } from '../../../components/section'; +import { fill, useMessages } from '../../../localization'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { UserProfileManagedByLabel } from '../user-profile-managed-by'; + +export interface UserProfileNameRowViewProps { + name: string; + managedBy?: UserProfileManagedBy; + action?: ReactNode; +} + +export function UserProfileNameRowView({ name, managedBy, action }: UserProfileNameRowViewProps) { + const m = useMessages('userProfileProfileSection'); + + return ( + + + + {m.name.label} + {name || m.name.empty} + + {action ? ( + {action} + ) : managedBy ? ( + + ) : null} + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx similarity index 60% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx index 4ccc2ff2184..868d9c1a176 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture-row.view.tsx @@ -1,56 +1,34 @@ -import { useState } from 'react'; - import type { ActionMenuAction } from '../../../components/action-menu'; import { ActionMenu } from '../../../components/action-menu'; import { Avatar } from '../../../components/avatar'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import type { LocalizableError } from '../../../localization'; -import { useErrorText, useMessages } from '../../../localization'; -import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; +import { useMessages } from '../../../localization'; import { FileUpload } from '../../../primitives/file-upload'; -import { useUserProfilePictureController } from './user-profile-picture.controller'; +import type { UserProfilePictureController } from './user-profile-picture.controller'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; /** Matches the limit the row's own description advertises. */ const PROFILE_PICTURE_MAX_BYTES = 10 * 1000 * 1000; -/** Rejecting a pick locally reads the same as the server rejecting the upload. */ -const REJECTION_ERRORS: Record = { - accept: { code: 'avatar_file_type_invalid' }, - size: { code: 'avatar_file_size_exceeded' }, - overflow: { code: 'avatar_file_count_exceeded' }, -}; - -export interface UserProfilePictureRowViewProps { +export interface UserProfilePictureRowViewProps extends UserProfilePictureController { name: string; imageUrl?: string; hasImage?: boolean; - onChange?: (file: File) => Promise; - onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => Promise; } export function UserProfilePictureRowView({ name, imageUrl, hasImage = false, + previewUrl, + isPending, + error, onChange, onReject, onRemove, }: UserProfilePictureRowViewProps) { - const m = useMessages('userProfileAccountSection'); - const errorText = useErrorText(); - const controller = useUserProfilePictureController({ onChange, onRemove }); - const [rejection, setRejection] = useState(); - const error = rejection ? errorText(rejection) : controller.error; - const remove = controller.onRemove; - const handleRemove = remove - ? () => { - setRejection(undefined); - remove(); - } - : undefined; + const m = useMessages('userProfileProfileSection'); const initials = name .split(/\s+/) .map(part => part[0]) @@ -62,19 +40,14 @@ export function UserProfilePictureRowView({ } - onReject={rejections => { - const rejected = rejections[0]; - setRejection(rejected ? REJECTION_ERRORS[rejected.reason] : undefined); - onReject?.(rejections); - }} + onReject={onReject} onValueChange={files => { const file = files[0]; if (file) { - setRejection(undefined); - controller.onChange?.(file); + onChange?.(file); } }} > @@ -83,7 +56,7 @@ export function UserProfilePictureRowView({ {initials} @@ -93,9 +66,9 @@ export function UserProfilePictureRowView({ {m.picture.description} {error} @@ -112,7 +85,7 @@ function ProfilePictureActions({ canChange: boolean; onRemove?: () => void; }) { - const m = useMessages('userProfileAccountSection'); + const m = useMessages('userProfileProfileSection'); const { openFilePicker } = FileUpload.useFileUpload(); const actions: ActionMenuAction[] = []; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts similarity index 54% rename from packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts rename to packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts index 5e9998aacd9..4be2d86f88c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-picture.controller.ts @@ -1,8 +1,18 @@ import { useState } from 'react'; import { usePendingAction } from '../../../hooks/use-pending-action'; +import type { LocalizableError } from '../../../localization'; +import { useErrorText } from '../../../localization'; +import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; +/** Rejecting a pick locally reads the same as the server rejecting the upload. */ +const REJECTION_ERRORS: Record = { + accept: { code: 'avatar_file_type_invalid' }, + size: { code: 'avatar_file_size_exceeded' }, + overflow: { code: 'avatar_file_count_exceeded' }, +}; + export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; onRemove?: () => Promise; @@ -10,6 +20,7 @@ export interface UserProfilePictureControllerOptions { export interface UserProfilePictureController { onChange?: (file: File) => void; + onReject: (rejections: FileRejection[]) => void; onRemove?: () => void; isPending: boolean; previewUrl: string | undefined; @@ -20,21 +31,29 @@ export function useUserProfilePictureController({ onChange, onRemove, }: UserProfilePictureControllerOptions): UserProfilePictureController { + const errorText = useErrorText(); const [picked, setPicked] = useState(); + const [rejection, setRejection] = useState(); const upload = usePendingAction(); const previewUrl = FileUpload.useObjectUrl(upload.error ? undefined : picked); - const save = (next: File | undefined, action: () => Promise) => - upload.run('picture', async () => { + const save = (next: File | undefined, action: () => Promise) => { + setRejection(undefined); + return upload.run('picture', async () => { setPicked(next); await action(); }); + }; return { onChange: onChange ? file => void save(file, () => onChange(file)) : undefined, + onReject: rejections => { + const rejected = rejections[0]; + setRejection(rejected ? REJECTION_ERRORS[rejected.reason] : undefined); + }, onRemove: onRemove ? () => void save(undefined, onRemove) : undefined, isPending: upload.isPending, previewUrl, - error: upload.error, + error: rejection ? errorText(rejection) : upload.error, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts new file mode 100644 index 00000000000..daf0d07ebd6 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.messages.ts @@ -0,0 +1,37 @@ +export const userProfileProfileSectionMessages = { + title: 'Profile', + picture: { + label: 'Profile picture', + description: 'Recommend size 1:1, up to 10MB.', + upload: 'Upload', + manage: 'Manage profile picture', + change: 'Change avatar', + remove: 'Remove avatar', + }, + name: { + label: 'Name', + empty: 'No name added', + edit: 'Edit name', + add: 'Add name', + managedBy: 'Managed by {name}', + + dialogTitle: 'Edit name', + addDialogTitle: 'Add name', + firstNameLabel: 'First name', + lastNameLabel: 'Last name', + cancel: 'Cancel', + save: 'Save changes', + }, + username: { + label: 'Username', + empty: 'No username added', + edit: 'Edit username', + add: 'Add username', + + dialogTitle: 'Edit username', + addDialogTitle: 'Add username', + fieldLabel: 'Username', + cancel: 'Cancel', + save: 'Save changes', + }, +} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts new file mode 100644 index 00000000000..f3a64a5bfed --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.model.ts @@ -0,0 +1,69 @@ +import { getFullName } from '@clerk/shared/internal/clerk-js/user'; +import type { AttributeData, EnterpriseAccountResource } from '@clerk/shared/types'; + +import { isAttributeAvailable } from '../user-profile.utils'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import { useUserProfileUserModel } from '../user-profile-user.model'; +import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; +import type { UserProfileNameAttribute, UserProfileProfileSectionModel } from './user-profile-profile-section.types'; + +const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; +const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; + +function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { + if (!account) { + return undefined; + } + const connection = account.enterpriseConnection; + return { name: connection?.name || account.provider.replace(/^(oauth_|saml_)/, '') }; +} + +function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { + return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; +} + +export function useUserProfileProfileSectionModel(): UserProfileProfileSectionModel { + const model = useUserProfileUserModel(); + if (model.status !== 'ready') { + return model; + } + + const { user, environment, saveAsUser } = model; + const { attributes, usernameSettings } = environment.userSettings; + const usernameImmutable = Boolean(attributes.username?.immutable); + const showUsername = isAttributeAvailable(attributes.username) && !(usernameImmutable && !user.username); + const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); + + return { + status: 'ready', + userId: user.id, + name: getFullName(user), + firstName: user.firstName ?? '', + lastName: user.lastName ?? '', + firstNameAttribute: toNameAttribute(attributes.first_name), + lastNameAttribute: toNameAttribute(attributes.last_name), + nameManagedBy, + imageUrl: user.imageUrl, + hasImage: user.hasImage, + onProfilePictureChange: file => saveAsUser(current => current.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage + ? () => saveAsUser(current => current.setProfileImage({ file: null })) + : undefined, + onSubmitName: nameManagedBy + ? undefined + : value => + saveAsUser(current => current.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + showUsername, + username: user.username ?? '', + usernameRequired: Boolean(attributes.username?.required), + onSubmitUsername: + showUsername && !usernameImmutable + ? username => + saveAsUser(current => current.update({ username }), USERNAME_FIELDS, { + min_length: usernameSettings.min_length, + max_length: usernameSettings.max_length, + }) + : undefined, + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.tsx new file mode 100644 index 00000000000..2d0e125e66a --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.tsx @@ -0,0 +1,126 @@ +import { Button } from '../../../components/button'; +import { useMessages } from '../../../localization'; +import type { UserProfileEditNameControllerOptions } from './user-profile-edit-name.controller'; +import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; +import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; +import type { UserProfileEditUsernameControllerOptions } from './user-profile-edit-username.controller'; +import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; +import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; +import { useUserProfilePictureController } from './user-profile-picture.controller'; +import { useUserProfileProfileSectionModel } from './user-profile-profile-section.model'; +import type { ReadyProfileSectionModel, UserProfileNameAttribute } from './user-profile-profile-section.types'; +import { UserProfileProfileSectionView } from './user-profile-profile-section.view'; + +export function UserProfileProfileSection() { + const model = useUserProfileProfileSectionModel(); + + if (model.status !== 'ready') { + return null; + } + + return ( + + ); +} + +function Profile({ model }: { model: ReadyProfileSectionModel }) { + const picture = useUserProfilePictureController({ + onChange: model.onProfilePictureChange, + onRemove: model.onRemoveProfilePicture, + }); + + return ( + + ) : undefined + } + showUsername={model.showUsername} + username={model.username} + usernameAction={ + model.onSubmitUsername ? ( + + ) : undefined + } + picture={picture} + /> + ); +} + +function EditName({ + isSet, + firstNameAttribute, + lastNameAttribute, + ...options +}: UserProfileEditNameControllerOptions & { + isSet: boolean; + firstNameAttribute: UserProfileNameAttribute; + lastNameAttribute: UserProfileNameAttribute; +}) { + const m = useMessages('userProfileProfileSection'); + const controller = useUserProfileEditNameController(options); + + return ( + + {isSet ? m.name.edit : m.name.add} + + } + /> + ); +} + +function EditUsername(options: UserProfileEditUsernameControllerOptions) { + const m = useMessages('userProfileProfileSection'); + const controller = useUserProfileEditUsernameController(options); + const isSet = Boolean(options.username); + + return ( + + {isSet ? m.username.edit : m.username.add} + + } + /> + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.types.ts new file mode 100644 index 00000000000..35adb60c1cc --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.types.ts @@ -0,0 +1,57 @@ +import type { ReactNode } from 'react'; + +import type { UserProfileManagedBy } from '../user-profile-managed-by'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfilePictureController } from './user-profile-picture.controller'; + +/** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ +export interface UserProfileNameAttribute { + /** @default true */ + enabled?: boolean; + /** @default false */ + required?: boolean; +} + +export interface UserProfileProfileSectionViewProps { + name: string; + imageUrl?: string; + /** + * Whether `imageUrl` is a picture the user uploaded. Clerk's image service always returns a URL — + * a generated initials avatar when none was uploaded — so the row cannot tell the two apart from + * `imageUrl` alone. Supplied from `user.hasImage`. + */ + hasImage?: boolean; + showName: boolean; + nameManagedBy?: UserProfileManagedBy; + nameAction?: ReactNode; + showUsername: boolean; + username: string; + usernameAction?: ReactNode; + picture: UserProfilePictureController; +} + +export type UserProfileProfileSectionModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + userId: string; + name: string; + /** Passed alongside `name`, which cannot be split back into its two halves. */ + firstName: string; + lastName: string; + firstNameAttribute: UserProfileNameAttribute; + lastNameAttribute: UserProfileNameAttribute; + nameManagedBy?: UserProfileManagedBy; + imageUrl: string; + hasImage: boolean; + onProfilePictureChange: (file: File) => Promise; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise; + showUsername: boolean; + username: string; + usernameRequired: boolean; + onSubmitUsername?: (username: string) => Promise; + }; + +export type ReadyProfileSectionModel = Extract; diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.view.tsx new file mode 100644 index 00000000000..92afdccf8b6 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.view.tsx @@ -0,0 +1,54 @@ +import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; +import { UserProfileNameRowView } from './user-profile-name-row.view'; +import { UserProfilePictureRowView } from './user-profile-picture-row.view'; +import type { UserProfileProfileSectionViewProps } from './user-profile-profile-section.types'; +import { UserProfileUsernameRowView } from './user-profile-username-row.view'; + +export function UserProfileProfileSectionView({ + name, + imageUrl, + hasImage = false, + showName, + nameManagedBy, + nameAction, + showUsername, + username, + usernameAction, + picture, +}: UserProfileProfileSectionViewProps) { + const m = useMessages('userProfileProfileSection'); + + return ( + + + + + {m.title} + + + + + {showName ? ( + + ) : null} + {showUsername ? ( + + ) : null} + + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-username-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-username-row.view.tsx new file mode 100644 index 00000000000..8bc64f35a08 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-username-row.view.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +import { Section } from '../../../components/section'; +import { useMessages } from '../../../localization'; + +export interface UserProfileUsernameRowViewProps { + username: string; + action?: ReactNode; +} + +export function UserProfileUsernameRowView({ username, action }: UserProfileUsernameRowViewProps) { + const m = useMessages('userProfileProfileSection'); + + return ( + + + + {m.username.label} + {username || m.username.empty} + + {action ? {action} : null} + + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-user.model.ts b/packages/mosaic/src/features/user-profile/user-profile-user.model.ts new file mode 100644 index 00000000000..a1f0c68dd07 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-user.model.ts @@ -0,0 +1,53 @@ +import { useClerk, useUser } from '@clerk/shared/react'; +import type { EnvironmentResource, UserResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import type { MessageValues } from '../../localization'; +import { save, SaveError, UNEXPECTED_ERROR } from '../../utils/errors'; + +export type UserProfileUserModel = + | { status: 'loading' } + | { status: 'hidden' } + | { + status: 'ready'; + user: UserResource; + environment: EnvironmentResource; + currentUser: () => UserResource; + saveAsUser: ( + run: (current: UserResource) => Promise, + fields?: readonly TField[], + params?: MessageValues, + ) => Promise; + }; + +export function useUserProfileUserModel(): UserProfileUserModel { + const { isLoaded, user } = useUser(); + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user) { + return { status: 'hidden' }; + } + + const userId = user.id; + + const currentUser = (): UserResource => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new SaveError({ global: UNEXPECTED_ERROR }); + } + return current; + }; + + return { + status: 'ready', + user, + environment, + currentUser, + saveAsUser: (run, fields = [], params) => save(() => run(currentUser()), fields, params), + }; +} diff --git a/packages/mosaic/src/features/user-profile/user-profile.types.ts b/packages/mosaic/src/features/user-profile/user-profile.types.ts index b48ca703eaa..ef2065fa2d0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.types.ts @@ -1,9 +1,26 @@ import type { ReactNode } from 'react'; +import { FormSubmitError } from '../../components/form'; import type { UserProfileBillingPanelViewProps } from './user-profile-billing-panel.view'; import type { UserProfileProfilePanelViewProps } from './user-profile-profile-panel.view'; import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view'; +/** Plain data, so nothing downstream of the model imports a Clerk error. */ +export interface UserProfileFormError { + /** Rendered in the dialog's negative banner. */ + message?: string; + /** Rendered under the named control, which is also marked invalid. */ + fields?: Partial>; +} + +/** What a save rejects with when the failure names a control; a plain `Error` shows only the banner. */ +export class UserProfileSaveError extends FormSubmitError> { + constructor(message: string, fields?: Partial>) { + super({ message, fields }); + this.name = 'UserProfileSaveError'; + } +} + /** A page the UserProfile brings itself, named by the id its navigation knows it as. */ export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys'; diff --git a/packages/mosaic/src/features/user-profile/user-profile.utils.ts b/packages/mosaic/src/features/user-profile/user-profile.utils.ts new file mode 100644 index 00000000000..fa2aabc8e4f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile.utils.ts @@ -0,0 +1,5 @@ +import type { AttributeData } from '@clerk/shared/types'; + +export function isAttributeAvailable(attribute: AttributeData | undefined): boolean { + return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); +} diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index a55cc78ad8b..a7fca456c46 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -10,12 +10,11 @@ import { requestsTableTabMessages } from '../features/organization-profile/reque import { reverificationMessages } from '../features/reverification/reverification.messages'; import { userButtonMessages } from '../features/user-button/user-button.messages'; import { userProfileMessages } from '../features/user-profile/user-profile.messages'; -import { userProfileAccountSectionMessages } from '../features/user-profile/user-profile-account-section/user-profile-account-section.messages'; -import { userProfileAddEmailMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-email.messages'; -import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-account-section/user-profile-add-phone.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices-section/user-profile-active-devices.messages'; import { userProfileConnectedAccountsMessages } from '../features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages'; +import { userProfileContactMessages } from '../features/user-profile/user-profile-contact.messages'; import { userProfileDangerSectionMessages } from '../features/user-profile/user-profile-danger-section/user-profile-danger-section.messages'; +import { userProfileAddEmailMessages } from '../features/user-profile/user-profile-email-section/user-profile-add-email.messages'; import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages'; import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-add-authenticator.messages'; import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-add-sms.messages'; @@ -24,6 +23,8 @@ import { userProfileBackupCodesMessages } from '../features/user-profile/user-pr import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section/user-profile-mfa-section.messages'; import { userProfilePasskeysMessages } from '../features/user-profile/user-profile-passkeys-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; +import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-phone-section/user-profile-add-phone.messages'; +import { userProfileProfileSectionMessages } from '../features/user-profile/user-profile-profile-section/user-profile-profile-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages'; import { errorMessages } from './errors.messages'; import { roleMessages } from './roles.messages'; @@ -43,7 +44,6 @@ export const mosaicMessages = { reverification: reverificationMessages, userButton: userButtonMessages, userProfile: userProfileMessages, - userProfileAccountSection: userProfileAccountSectionMessages, userProfileActiveDevices: userProfileActiveDevicesMessages, userProfileAddEmail: userProfileAddEmailMessages, userProfileAddPhone: userProfileAddPhoneMessages, @@ -52,11 +52,13 @@ export const mosaicMessages = { userProfileAuthenticatorSetup: userProfileAuthenticatorSetupMessages, userProfileBackupCodes: userProfileBackupCodesMessages, userProfileConnectedAccounts: userProfileConnectedAccountsMessages, + userProfileContact: userProfileContactMessages, userProfileDangerSection: userProfileDangerSectionMessages, userProfileEnterpriseAccountsSection: userProfileEnterpriseAccountsMessages, userProfileMfa: userProfileMfaMessages, userProfilePasskeys: userProfilePasskeysMessages, userProfilePasswordSection: userProfilePasswordSectionMessages, + userProfileProfileSection: userProfileProfileSectionMessages, userProfileWeb3Wallets: userProfileWeb3WalletsMessages, }; diff --git a/packages/swingset/src/app/(clerk)/live/account-section/page.tsx b/packages/swingset/src/app/(clerk)/live/email/page.tsx similarity index 65% rename from packages/swingset/src/app/(clerk)/live/account-section/page.tsx rename to packages/swingset/src/app/(clerk)/live/email/page.tsx index ed07fdb14cd..50d09780d8e 100644 --- a/packages/swingset/src/app/(clerk)/live/account-section/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/email/page.tsx @@ -1,21 +1,20 @@ 'use client'; -import { UserProfileAccountSection } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section'; +import { UserProfileEmailSection } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useUser } from '@clerk/nextjs'; import Link from 'next/link'; -export default function AccountSectionLivePage() { +export default function EmailLivePage() { const { isLoaded, isSignedIn } = useUser(); return (
-

Account section

+

Email

- The user profile account section wired to the signed-in user. Changes to the picture, name, and username are - saved to the real account. + Add, verify, and remove the email addresses of the signed-in account.

{!isLoaded ?

Loading…

: null} @@ -30,7 +29,7 @@ export default function AccountSectionLivePage() { to use the live harness.

) : null} - {isLoaded && isSignedIn ? : null} + {isLoaded && isSignedIn ? : null}
); diff --git a/packages/swingset/src/app/(clerk)/live/phone/page.tsx b/packages/swingset/src/app/(clerk)/live/phone/page.tsx new file mode 100644 index 00000000000..f77f2b0e604 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/phone/page.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { UserProfilePhoneSection } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function PhoneLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Phone

+

+ Add, verify, and remove the phone numbers of the signed-in account. +

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/app/(clerk)/live/profile/page.tsx b/packages/swingset/src/app/(clerk)/live/profile/page.tsx new file mode 100644 index 00000000000..efefed56a92 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/profile/page.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { UserProfileProfileSection } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function ProfileLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Profile

+

Change the profile picture and name of the signed-in account.

+
+ {!isLoaded ?

Loading…

: null} + {isLoaded && !isSignedIn ? ( +

+ + Sign in + {' '} + to use the live harness. +

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index bb977dac557..d9b5899decc 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -18,7 +18,9 @@ const docModules: Record> = { 'user-profile-security-panel': dynamic(() => import('../stories/user-profile-security-panel.mdx')), 'user-profile-billing-panel': dynamic(() => import('../stories/user-profile-billing-panel.mdx')), 'user-profile-api-keys-panel': dynamic(() => import('../stories/user-profile-api-keys-panel.mdx')), - 'user-profile-account-section': dynamic(() => import('../stories/user-profile-account-section.mdx')), + 'user-profile-profile-section': dynamic(() => import('../stories/user-profile-profile-section.mdx')), + 'user-profile-email-section': dynamic(() => import('../stories/user-profile-email-section.mdx')), + 'user-profile-phone-section': dynamic(() => import('../stories/user-profile-phone-section.mdx')), 'user-profile-password-section': dynamic(() => import('../stories/user-profile-password-section.mdx')), 'user-profile-passkeys-section': dynamic(() => import('../stories/user-profile-passkeys-section.mdx')), 'user-profile-mfa-section': dynamic(() => import('../stories/user-profile-mfa-section.mdx')), diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index 92f81ba5105..77f849845e7 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -11,7 +11,9 @@ type LiveSidebarGroup = { const liveRoutes = new Map([ ['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/user-profile/user-profile-api-keys-panel', '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/live/api-keys'], - ['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/user-profile/user-profile-account-section', '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/live/account-section'], + ['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/user-profile/user-profile-profile-section', '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/live/profile'], + ['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/user-profile/user-profile-email-section', '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/live/email'], + ['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/user-profile/user-profile-phone-section', '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/live/phone'], ['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/user-profile/user-profile-password-section', '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/live/password'], ['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/user-profile/user-profile-active-devices-section', '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/live/active-devices'], ['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/user-profile/user-profile-mfa-section', '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/live/mfa'], diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 0d4e36aa2c0..a3fdf4645fb 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -313,22 +313,6 @@ import { meta as userProfileMeta, Overlay as UserProfileOverlay, } from '../stories/user-profile.stories'; -import { - AddEmailFails as UserProfileAccountSectionAddEmailFails, - AddPhoneFails as UserProfileAccountSectionAddPhoneFails, - Default as UserProfileAccountSectionDefault, - EmailLinkFails as UserProfileAccountSectionEmailLinkFails, - EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification, - EmailRemovalError as UserProfileAccountSectionEmailRemovalError, - EmailRemovalPending as UserProfileAccountSectionEmailRemovalPending, - EmailSsoFails as UserProfileAccountSectionEmailSsoFails, - EmailSsoVerification as UserProfileAccountSectionEmailSsoVerification, - meta as userProfileAccountSectionMeta, - MultipleAccounts as UserProfileAccountSectionMultipleAccounts, - NameManagedByConnection as UserProfileAccountSectionNameManagedByConnection, - PhoneRemovalError as UserProfileAccountSectionPhoneRemovalError, - PhoneRemovalPending as UserProfileAccountSectionPhoneRemovalPending, -} from '../stories/user-profile-account-section.stories'; import { Default as UserProfileActiveDevicesSectionDefault, Impersonation as UserProfileActiveDevicesSectionImpersonation, @@ -365,6 +349,17 @@ import { meta as userProfileDangerSectionMeta, WithError as UserProfileDangerSectionWithError, } from '../stories/user-profile-danger-section.stories'; +import { + AddEmailFails as UserProfileEmailSectionAddEmailFails, + Default as UserProfileEmailSectionDefault, + EmailLinkFails as UserProfileEmailSectionEmailLinkFails, + EmailLinkVerification as UserProfileEmailSectionEmailLinkVerification, + EmailRemovalError as UserProfileEmailSectionEmailRemovalError, + EmailRemovalPending as UserProfileEmailSectionEmailRemovalPending, + EmailSsoFails as UserProfileEmailSectionEmailSsoFails, + EmailSsoVerification as UserProfileEmailSectionEmailSsoVerification, + meta as userProfileEmailSectionMeta, +} from '../stories/user-profile-email-section.stories'; import { ConnectionError as UserProfileEnterpriseAccountsSectionConnectionError, ConnectOnly as UserProfileEnterpriseAccountsSectionConnectOnly, @@ -397,10 +392,24 @@ import { Empty as UserProfilePaymentMethodsSectionEmpty, meta as userProfilePaymentMethodsSectionMeta, } from '../stories/user-profile-payment-methods-section.stories'; +import { + AddPhoneFails as UserProfilePhoneSectionAddPhoneFails, + Default as UserProfilePhoneSectionDefault, + meta as userProfilePhoneSectionMeta, + PhoneRemovalError as UserProfilePhoneSectionPhoneRemovalError, + PhoneRemovalPending as UserProfilePhoneSectionPhoneRemovalPending, +} from '../stories/user-profile-phone-section.stories'; import { Default as UserProfileProfilePanelDefault, meta as userProfileProfilePanelMeta, } from '../stories/user-profile-profile-panel.stories'; +import { + Default as UserProfileProfileSectionDefault, + EditNameFails as UserProfileProfileSectionEditNameFails, + EditUsernameFails as UserProfileProfileSectionEditUsernameFails, + meta as userProfileProfileSectionMeta, + NameManagedByConnection as UserProfileProfileSectionNameManagedByConnection, +} from '../stories/user-profile-profile-section.stories'; import { Default as UserProfileSecurityPanelDefault, meta as userProfileSecurityPanelMeta, @@ -757,21 +766,32 @@ const userProfileModule: StoryModule = { Overlay: UserProfileOverlay, }; -const userProfileAccountSectionModule: StoryModule = { - meta: userProfileAccountSectionMeta, - Default: UserProfileAccountSectionDefault, - MultipleAccounts: UserProfileAccountSectionMultipleAccounts, - NameManagedByConnection: UserProfileAccountSectionNameManagedByConnection, - AddPhoneFails: UserProfileAccountSectionAddPhoneFails, - AddEmailFails: UserProfileAccountSectionAddEmailFails, - EmailLinkVerification: UserProfileAccountSectionEmailLinkVerification, - EmailLinkFails: UserProfileAccountSectionEmailLinkFails, - EmailSsoVerification: UserProfileAccountSectionEmailSsoVerification, - EmailSsoFails: UserProfileAccountSectionEmailSsoFails, - EmailRemovalPending: UserProfileAccountSectionEmailRemovalPending, - EmailRemovalError: UserProfileAccountSectionEmailRemovalError, - PhoneRemovalPending: UserProfileAccountSectionPhoneRemovalPending, - PhoneRemovalError: UserProfileAccountSectionPhoneRemovalError, +const userProfileProfileSectionModule: StoryModule = { + meta: userProfileProfileSectionMeta, + Default: UserProfileProfileSectionDefault, + NameManagedByConnection: UserProfileProfileSectionNameManagedByConnection, + EditNameFails: UserProfileProfileSectionEditNameFails, + EditUsernameFails: UserProfileProfileSectionEditUsernameFails, +}; + +const userProfileEmailSectionModule: StoryModule = { + meta: userProfileEmailSectionMeta, + Default: UserProfileEmailSectionDefault, + AddEmailFails: UserProfileEmailSectionAddEmailFails, + EmailLinkVerification: UserProfileEmailSectionEmailLinkVerification, + EmailLinkFails: UserProfileEmailSectionEmailLinkFails, + EmailSsoVerification: UserProfileEmailSectionEmailSsoVerification, + EmailSsoFails: UserProfileEmailSectionEmailSsoFails, + EmailRemovalPending: UserProfileEmailSectionEmailRemovalPending, + EmailRemovalError: UserProfileEmailSectionEmailRemovalError, +}; + +const userProfilePhoneSectionModule: StoryModule = { + meta: userProfilePhoneSectionMeta, + Default: UserProfilePhoneSectionDefault, + AddPhoneFails: UserProfilePhoneSectionAddPhoneFails, + PhoneRemovalPending: UserProfilePhoneSectionPhoneRemovalPending, + PhoneRemovalError: UserProfilePhoneSectionPhoneRemovalError, }; const userProfileProfilePanelModule: StoryModule = { meta: userProfileProfilePanelMeta, @@ -925,7 +945,9 @@ export const registry: StoryModule[] = [ userProfileBillingPanelModule, userProfileApiKeysPanelModule, // User Profile · Sections - userProfileAccountSectionModule, + userProfileProfileSectionModule, + userProfileEmailSectionModule, + userProfilePhoneSectionModule, userProfilePasswordSectionModule, userProfilePasskeysSectionModule, userProfileMfaSectionModule, diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index eab001aa375..b5ae406bad0 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -348,86 +348,6 @@ export function Stacked() { const accountTrigger = (props: RenderProps) => ; -/** The "add email address" dialog the account profile opens, driven by `open` rather than a trigger. */ -function AddEmailDialog({ - open, - onOpenChange, - onAdd, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; - onAdd: (value: string) => void; -}) { - const [value, setValue] = React.useState(''); - const inputRef = React.useRef(null); - - return ( - { - onOpenChange(next); - if (!next) { - setValue(''); - } - }} - > - - - - Add email address - A verification code will be sent to this address. - - { - event.preventDefault(); - onAdd(value.trim()); - setValue(''); - onOpenChange(false); - }} - /> - } - > - setValue(event.target.value)} - /> - - - - } - > - Cancel - - - - - - - ); -} - /** * The real user page inside a `profile` dialog. The dialog positions it and the page paints * itself — the same composition as a `Card` inside a `card` dialog — so the page names the @@ -436,10 +356,7 @@ function AddEmailDialog({ * page's own. */ export function Nested() { - const [addEmailOpen, setAddEmailOpen] = React.useState(false); - const { activePage, setActivePage, pages, addEmail } = useUserProfileFixture({ - onAddEmail: () => setAddEmailOpen(true), - }); + const { activePage, setActivePage, pages } = useUserProfileFixture(); return ( @@ -449,11 +366,6 @@ export function Nested() { pages={pages} onPageChange={setActivePage} /> - ); @@ -469,10 +381,7 @@ export function Nested() { * dragging the host below `48rem` collapses the sidebar without the browser window moving. */ export function Standalone() { - const [addEmailOpen, setAddEmailOpen] = React.useState(false); - const { activePage, setActivePage, pages, addEmail } = useUserProfileFixture({ - onAddEmail: () => setAddEmailOpen(true), - }); + const { activePage, setActivePage, pages } = useUserProfileFixture(); return (
-
); } diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts index be0e01fa58a..55846e757b7 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -1,8 +1,8 @@ import type { UserProfileEmailVerification, UserProfileEmailVerifier, -} from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileAccountSectionViewProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.view'; +} from '@clerk/mosaic/features/user-profile/user-profile-contact.types'; +import type { ReadyEmailSectionModel } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.types'; interface FixtureOptions { method?: UserProfileEmailVerification['method']; @@ -18,7 +18,7 @@ export function createUserProfileAddEmailFixture({ fail, onCreated, onVerified, -}: FixtureOptions = {}): Pick { +}: FixtureOptions = {}): Pick { const startLink = (id: string): UserProfileEmailVerification => { let cancelled = false; const verified = delay(fail === 'verify' ? 2000 : 4000).then(() => { diff --git a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts index ac1957fa72d..bb9a9e35a0d 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -1,5 +1,5 @@ -import type { UserProfilePhoneVerifier } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileAccountSectionViewProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.view'; +import type { UserProfilePhoneVerifier } from '@clerk/mosaic/features/user-profile/user-profile-contact.types'; +import type { ReadyPhoneSectionModel } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.types'; interface FixtureOptions { fail?: 'create' | 'verify'; @@ -10,7 +10,7 @@ interface FixtureOptions { const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export function createUserProfileAddPhoneFixture({ fail, onCreated, onVerified }: FixtureOptions = {}): Pick< - UserProfileAccountSectionViewProps, + ReadyPhoneSectionModel, 'onCreatePhone' | 'getPhoneVerifier' > { const verifier = (id: string): UserProfilePhoneVerifier => ({ diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts deleted file mode 100644 index 9c0068093a6..00000000000 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; -import type { FormError } from '@clerk/mosaic/utils/errors'; -import { SaveError } from '@clerk/mosaic/utils/errors'; -import { useState } from 'react'; - -import { useChaosFixture } from '@/components/ChaosProvider'; -import { chaosName } from '@/lib/chaos'; - -export interface UserProfileEditNameFixtureOptions { - firstName?: string; - lastName?: string; - latency?: number; - /** Fails every save instead of committing it. */ - failWith?: FormError; -} - -/** Stands in for the model. Everything else the dialog needs belongs to the controller. */ -export function useUserProfileEditNameFixture({ - firstName: initialFirstName = 'Preston', - lastName: initialLastName = 'Booth', - latency = 800, - failWith, -}: UserProfileEditNameFixtureOptions = {}) { - const seed = useChaosFixture({ firstName: initialFirstName, lastName: initialLastName }, () => ({ - firstName: chaosName(0), - lastName: chaosName(7), - })); - const [name, setName] = useState(seed); - - return { - ...name, - name: [name.firstName, name.lastName].filter(Boolean).join(' '), - onSubmitName: async (value: UserProfileEditNameValue) => { - await new Promise(resolve => setTimeout(resolve, latency)); - if (failWith) { - throw new SaveError(failWith); - } - setName(value); - }, - }; -} diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx b/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx index 56f2cebdbee..41a0d509c00 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.tsx @@ -1,6 +1,6 @@ import { Button } from '@clerk/mosaic/components/button'; -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile.types'; +import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile.types'; import { useUserProfileEditPasswordController } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-edit-password.dialog'; import type { diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts deleted file mode 100644 index 5e1dc6115b3..00000000000 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { FormError } from '@clerk/mosaic/utils/errors'; -import { SaveError } from '@clerk/mosaic/utils/errors'; -import { useState } from 'react'; - -import { useChaosFixture } from '@/components/ChaosProvider'; -import { chaosName } from '@/lib/chaos'; - -export interface UserProfileEditUsernameFixtureOptions { - username?: string; - latency?: number; - failWith?: FormError; -} - -export function useUserProfileEditUsernameFixture({ - username: initialUsername = 'prestonxyz', - latency = 800, - failWith, -}: UserProfileEditUsernameFixtureOptions = {}) { - const seed = useChaosFixture(initialUsername, () => chaosName(7).toLowerCase()); - const [username, setUsername] = useState(seed); - - return { - username, - onSubmitUsername: async (value: string) => { - await new Promise(resolve => setTimeout(resolve, latency)); - if (failWith) { - throw new SaveError(failWith); - } - setUsername(value); - }, - }; -} diff --git a/packages/swingset/src/stories/fixtures/user-profile-emails.ts b/packages/swingset/src/stories/fixtures/user-profile-emails.ts new file mode 100644 index 00000000000..f36ec7e55e1 --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-emails.ts @@ -0,0 +1,60 @@ +import type { + UserProfileEmail, + UserProfileEmailVerification, +} from '@clerk/mosaic/features/user-profile/user-profile-contact.types'; +import { useUserProfileEmailSectionController } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.controller'; +import type { UserProfileEmailSectionViewProps } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.types'; +import { useState } from 'react'; + +import { createUserProfileAddEmailFixture } from './user-profile-add-email'; + +export const exampleEmails: UserProfileEmail[] = [ + { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, +]; + +export interface UserProfileEmailsFixtureOptions { + initialEmails?: UserProfileEmail[]; + username?: string; + method?: UserProfileEmailVerification['method']; + failVerification?: boolean; + removalState?: 'pending' | 'error'; +} + +export function useUserProfileEmailsFixture({ + initialEmails = exampleEmails, + username, + method, + failVerification = false, + removalState, +}: UserProfileEmailsFixtureOptions = {}): UserProfileEmailSectionViewProps { + const [emails, setEmails] = useState(initialEmails); + const [removalFailed, setRemovalFailed] = useState(false); + const flow = createUserProfileAddEmailFixture({ + method, + fail: failVerification ? 'verify' : undefined, + onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), + onVerified: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), + }); + + return useUserProfileEmailSectionController({ + emails, + username, + ...flow, + onSetPrimaryEmail: id => { + setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))); + return Promise.resolve(); + }, + onRemoveEmail: async id => { + if (removalState === 'pending') { + await new Promise(resolve => setTimeout(resolve, 1500)); + } + if (removalState === 'error' && !removalFailed) { + setRemovalFailed(true); + throw new Error('Unable to remove this email address. Try again.'); + } + setEmails(current => current.filter(email => email.id !== id)); + }, + }); +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-phones.ts b/packages/swingset/src/stories/fixtures/user-profile-phones.ts new file mode 100644 index 00000000000..bd6f30641eb --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-phones.ts @@ -0,0 +1,51 @@ +import type { UserProfilePhone } from '@clerk/mosaic/features/user-profile/user-profile-contact.types'; +import { useUserProfilePhoneSectionController } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.controller'; +import type { UserProfilePhoneSectionViewProps } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.types'; +import { useState } from 'react'; + +import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; + +export const examplePhones: UserProfilePhone[] = [ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + { id: 'phone_2', value: '+18015550100', isDefault: false, isVerified: true }, +]; + +export interface UserProfilePhonesFixtureOptions { + initialPhones?: UserProfilePhone[]; + fail?: 'create' | 'verify'; + removalState?: 'pending' | 'error'; +} + +export function useUserProfilePhonesFixture({ + initialPhones = examplePhones, + fail, + removalState, +}: UserProfilePhonesFixtureOptions = {}): UserProfilePhoneSectionViewProps { + const [phones, setPhones] = useState(initialPhones); + const [removalFailed, setRemovalFailed] = useState(false); + const flow = createUserProfileAddPhoneFixture({ + fail, + onCreated: (id, value) => setPhones(current => [...current, { id, value, isDefault: false, isVerified: false }]), + onVerified: id => + setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), + }); + + return useUserProfilePhoneSectionController({ + phones, + ...flow, + onSetPrimaryPhone: id => { + setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))); + return Promise.resolve(); + }, + onRemovePhone: async id => { + if (removalState === 'pending') { + await new Promise(resolve => setTimeout(resolve, 1500)); + } + if (removalState === 'error' && !removalFailed) { + setRemovalFailed(true); + throw new Error('Unable to remove this phone number. Try again.'); + } + setPhones(current => current.filter(phone => phone.id !== id)); + }, + }); +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile-profile.tsx new file mode 100644 index 00000000000..adc2c33b82d --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-profile-profile.tsx @@ -0,0 +1,118 @@ +import { Button } from '@clerk/mosaic/components/button'; +import type { UserProfileManagedBy } from '@clerk/mosaic/features/user-profile/user-profile-managed-by'; +import { useUserProfileEditNameController } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-edit-name.controller'; +import type { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-edit-name.dialog'; +import { UserProfileEditNameDialog } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-edit-name.dialog'; +import { useUserProfileEditUsernameController } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-edit-username.controller'; +import { UserProfileEditUsernameDialog } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-edit-username.dialog'; +import { useUserProfilePictureController } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-picture.controller'; +import type { UserProfileProfileSectionViewProps } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section.types'; +import { useMessages } from '@clerk/mosaic/localization'; +import type { FormError } from '@clerk/mosaic/utils/errors'; +import { SaveError } from '@clerk/mosaic/utils/errors'; +import { useState } from 'react'; + +import { useChaosFixture } from '@/components/ChaosProvider'; +import { chaosName } from '@/lib/chaos'; + +import { usePreviewImage } from './use-preview-image'; + +export interface UserProfileProfileFixtureOptions { + firstName?: string; + lastName?: string; + username?: string; + imageUrl?: string; + nameManagedBy?: UserProfileManagedBy; + latency?: number; + /** Fails every name save instead of committing it. */ + nameFailWith?: FormError; + usernameFailWith?: FormError; +} + +export function useUserProfileProfileFixture({ + firstName: initialFirstName = 'Preston', + lastName: initialLastName = 'Booth', + username: initialUsername = 'prestonxyz', + imageUrl: initialImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4', + nameManagedBy, + latency = 800, + nameFailWith, + usernameFailWith, +}: UserProfileProfileFixtureOptions = {}): UserProfileProfileSectionViewProps { + const m = useMessages('userProfileProfileSection'); + const seed = useChaosFixture({ firstName: initialFirstName, lastName: initialLastName }, () => ({ + firstName: chaosName(0), + lastName: chaosName(7), + })); + const [value, setValue] = useState(seed); + const usernameSeed = useChaosFixture(initialUsername, () => chaosName(7).toLowerCase()); + const [username, setUsername] = useState(usernameSeed); + const name = [value.firstName, value.lastName].filter(Boolean).join(' '); + const { imageUrl, showFile, clearImage } = usePreviewImage(initialImageUrl); + const picture = useUserProfilePictureController({ onChange: showFile, onRemove: clearImage }); + const editName = useUserProfileEditNameController({ + ...value, + onSubmit: async next => { + await new Promise(resolve => setTimeout(resolve, latency)); + if (nameFailWith) { + throw new SaveError(nameFailWith); + } + setValue(next); + }, + }); + const editUsername = useUserProfileEditUsernameController({ + username, + onSubmit: async next => { + await new Promise(resolve => setTimeout(resolve, latency)); + if (usernameFailWith) { + throw new SaveError(usernameFailWith); + } + setUsername(next); + }, + }); + + return { + name, + imageUrl, + hasImage: Boolean(imageUrl), + showName: true, + nameManagedBy, + nameAction: nameManagedBy ? undefined : ( + + {name ? m.name.edit : m.name.add} + + } + /> + ), + showUsername: true, + username, + usernameAction: ( + + {username ? m.username.edit : m.username.add} + + } + /> + ), + picture, + }; +} diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index 069a450c382..9a199bb34ae 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -6,13 +6,16 @@ import type { UserProfileSubscription, } from '@clerk/mosaic/features/user-profile/user-profile-billing-panel.view'; import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; -import { UserProfileDangerSectionView } from '@clerk/mosaic/features/user-profile/user-profile-danger-section/user-profile-danger-section.view'; -import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; -import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; import type { UserProfileEmail, UserProfilePhone, -} from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +} from '@clerk/mosaic/features/user-profile/user-profile-contact.types'; +import { UserProfileDangerSectionView } from '@clerk/mosaic/features/user-profile/user-profile-danger-section/user-profile-danger-section.view'; +import { UserProfileEmailSectionView } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.view'; +import { UserProfilePasskeysSectionView } from '@clerk/mosaic/features/user-profile/user-profile-passkeys-section.view'; +import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; +import { UserProfilePhoneSectionView } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.view'; +import { UserProfileProfileSectionView } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section.view'; import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; import { useRef, useState } from 'react'; @@ -20,16 +23,14 @@ import { useChaosFixture } from '@/components/ChaosProvider'; import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos'; import { APIKeysPanelExample, useAPIKeysTableFixture } from './api-keys-table'; -import { usePreviewImage } from './use-preview-image'; import { useUserProfileActiveDevicesFixture } from './user-profile-active-devices'; -import { createUserProfileAddEmailFixture } from './user-profile-add-email'; -import { createUserProfileAddPhoneFixture } from './user-profile-add-phone'; import { useConnectedAccountsFixture } from './user-profile-connected-accounts'; -import { useUserProfileEditNameFixture } from './user-profile-edit-name'; import { useUserProfileEditPasswordFixture } from './user-profile-edit-password'; -import { useUserProfileEditUsernameFixture } from './user-profile-edit-username'; +import { useUserProfileEmailsFixture } from './user-profile-emails'; import { useUserProfileMfaExample } from './user-profile-mfa-example'; import { usePasskeysFixture } from './user-profile-passkeys'; +import { useUserProfilePhonesFixture } from './user-profile-phones'; +import { useUserProfileProfileFixture } from './user-profile-profile'; import { useWeb3WalletsFixture } from './user-profile-web3-wallets'; export function UserProfileDangerPreview() { @@ -37,11 +38,6 @@ export function UserProfileDangerPreview() { return ; } -export interface UserProfileFixtureOptions { - /** Replaces the default OTP flow, e.g. for a custom dialog example. */ - onAddEmail?: () => void; -} - const exampleEmails: UserProfileEmail[] = [ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, { id: 'email_2', value: 'preston.booth@gmail.com', isDefault: false, isVerified: true }, @@ -66,12 +62,11 @@ const examplePaymentMethods: UserProfilePaymentMethod[] = [ * Every page of the user profile, backed by local state so the actions on them do something. For * stories that need a realistic profile surface without being about it. */ -export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions = {}) { +export function useUserProfileFixture() { const titleRef = useRef(null); const connections = useConnectedAccountsFixture(); const wallets = useWeb3WalletsFixture(); - const editName = useUserProfileEditNameFixture(); - const editUsername = useUserProfileEditUsernameFixture(); + const profile = useUserProfileProfileFixture(); const editPassword = useUserProfileEditPasswordFixture(); const mfa = useUserProfileMfaExample(); const [activePage, setActivePage] = useState('account'); @@ -85,8 +80,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions isDefault: index === 0, })), ); - const [emails, setEmails] = useState(seedEmails); - const [phones, setPhones] = useState(seedPhones); + const emails = useUserProfileEmailsFixture({ + initialEmails: seedEmails, + username: profile.username, + }); + const phones = useUserProfilePhonesFixture({ initialPhones: seedPhones }); const passkeys = usePasskeysFixture(); const activeDevices = useUserProfileActiveDevicesFixture(); @@ -107,25 +105,12 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const [paymentMethods, setPaymentMethods] = useState(seedPaymentMethods); const [historyPageSize, setHistoryPageSize] = useState(10); const apiKeys = useAPIKeysTableFixture(); - const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); - const addEmail = (value: string) => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: false }]); - const phoneFlow = createUserProfileAddPhoneFixture({ - onCreated: (id, value) => setPhones(current => [...current, { id, value, isDefault: false, isVerified: false }]), - onVerified: id => - setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), - }); - const emailFlow = createUserProfileAddEmailFixture({ - onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), - onVerified: id => - setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), - }); - const pages: UserProfileViewProps['pages'] = { account: { - ...editName, - ...editUsername, titleRef, + profileSlot: , + emailSlot: , + phoneSlot: , connectedAccountsSlot: ( titleRef.current} /> ), - allowMultipleAccounts: true, - hasImage: Boolean(imageUrl), - imageUrl, - emails, - phones, - onAddEmail, - onCreateEmail: onAddEmail ? undefined : emailFlow.onCreateEmail, - getEmailVerifier: onAddEmail ? undefined : emailFlow.getEmailVerifier, - ...phoneFlow, dangerSlot: , - onManageEmail: () => undefined, - onManagePhone: () => undefined, - onProfilePictureChange: showFile, - onRemoveEmail: id => setEmails(current => current.filter(email => email.id !== id)), - onRemoveProfilePicture: clearImage, - onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), - onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), - onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), - onVerifyEmail: id => - setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), - onVerifyPhone: id => - setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), }, security: { passwordSlot: , @@ -211,5 +175,5 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions apiKeys: , }; - return { activePage, setActivePage, pages, addEmail, devices: activeDevices.devices }; + return { activePage, setActivePage, pages, devices: activeDevices.devices }; } diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx deleted file mode 100644 index 7ceb2340600..00000000000 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ /dev/null @@ -1,135 +0,0 @@ -import * as Stories from './user-profile-account-section.stories'; - -# UserProfileAccountSection - -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 - - - -## Multiple accounts - -**Add email** opens email entry followed by a six-digit verification code. Resend is available after -the countdown. In this preview, `000000` shows an incorrect-code error; another six-digit code adds -the verified address. - - - -## Name managed by a connection - -An enterprise connection owns the name, so the Name row names who manages it in place of an edit -action. The connection's logo leads the label, or a generic lock when it ships none. - - - -## Add phone failure - -Add a phone number to see a failed send request while keeping the entered number. - - - -## Email verification error - -This example rejects every verification attempt so the error remains visible and the user can retry. - - - -## Email-link verification - -When the instance verifies emails by link, Add email moves from email entry to a waiting step with a -resend countdown. In this preview the link opens itself after a few seconds and the address is added -as verified. - - - -### Link error - -The link expires, so the step shows the error and the user can send a new one after the countdown. - - - -## Enterprise SSO verification - -When an email matches an enterprise SSO connection, Add email moves to a step that names the domain -with a Connect action. In this preview, Connect simulates the provider and adds the address as -verified. - - - -### Connection error - -Connect to see the error. The user can retry or cancel. - - - -## Email removal pending - -Confirm removal to see a short pending state before the email disappears. - - - -## Email removal error - -The first attempt fails inside the confirmation. Retry succeeds. - - - -## Phone removal pending - -Remove a phone number to see the confirmation remain open until removal completes. - - - -## Phone removal error - -The first removal fails. Retry in the confirmation to remove the phone 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 deleted file mode 100644 index a927cacf736..00000000000 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import type { UserProfileEmailVerification } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { - UserProfileEmail, - UserProfilePhone, -} from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.view'; -import { UserProfileAccountSectionView } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.view'; -import type { FormError } from '@clerk/mosaic/utils/errors'; -import { useState } from 'react'; - -import type { StoryMeta } from '@/lib/types'; - -import { usePreviewImage } from './fixtures/use-preview-image'; -import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; -import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; -import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; -import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; - -export { default as __source } from './user-profile-account-section.stories?raw'; - -export const meta: StoryMeta = { - group: 'User Profile', - status: 'wip', - title: 'UserProfileAccountSection', - label: 'Account', - navigation: { category: 'Sections' }, - source: - 'packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.view.tsx', -}; - -function AccountSection({ - allowMultipleAccounts, - failPhone, - failWith, - usernameFailWith, - failEmailVerification = false, - emailVerificationMethod, - emailRemovalState, - phoneRemovalState, - nameManagedBy, -}: { - allowMultipleAccounts: boolean; - failPhone?: 'create' | 'verify'; - failWith?: FormError; - usernameFailWith?: FormError; - failEmailVerification?: boolean; - emailVerificationMethod?: UserProfileEmailVerification['method']; - emailRemovalState?: 'pending' | 'error'; - phoneRemovalState?: 'pending' | 'error'; - nameManagedBy?: { name: string }; -}) { - const [phoneRemovalFailed, setPhoneRemovalFailed] = useState(false); - const [emailRemovalFailed, setEmailRemovalFailed] = useState(false); - const editName = useUserProfileEditNameFixture({ failWith }); - const editUsername = useUserProfileEditUsernameFixture({ failWith: usernameFailWith }); - const [emails, setEmails] = useState( - allowMultipleAccounts - ? [ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, - ] - : [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], - ); - const [phones, setPhones] = useState([ - { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, - ...(allowMultipleAccounts ? [{ id: 'phone_2', value: '+18015550100', isDefault: false, isVerified: true }] : []), - ]); - const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); - const addPhone = createUserProfileAddPhoneFixture({ - fail: failPhone, - onCreated: (id, value) => setPhones(current => [...current, { id, value, isDefault: false, isVerified: false }]), - onVerified: id => - setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), - }); - const emailFlow = createUserProfileAddEmailFixture({ - method: emailVerificationMethod, - fail: failEmailVerification ? 'verify' : undefined, - onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), - onVerified: id => - setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), - }); - - return ( - undefined} - onManagePhone={() => undefined} - onRemoveEmail={async id => { - if (emailRemovalState === 'pending') { - await new Promise(resolve => setTimeout(resolve, 1500)); - } - if (emailRemovalState === 'error' && !emailRemovalFailed) { - setEmailRemovalFailed(true); - throw new Error('Unable to remove this email address. Try again.'); - } - setEmails(current => current.filter(email => email.id !== id)); - }} - onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} - onRemovePhone={async id => { - if (phoneRemovalState === 'pending') { - await new Promise(resolve => setTimeout(resolve, 1500)); - } - if (phoneRemovalState === 'error' && !phoneRemovalFailed) { - setPhoneRemovalFailed(true); - throw new Error('Unable to remove this phone number. Try again.'); - } - setPhones(current => current.filter(phone => phone.id !== id)); - }} - onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} - /> - ); -} - -export function Default() { - return ; -} - -/** - * An enterprise connection owns the name, so the row names who manages it in place of an edit - * action. - */ -export function NameManagedByConnection() { - return ( - - ); -} - -export function MultipleAccounts() { - return ; -} - -export function AddEmailFails() { - return ( - - ); -} - -export function EmailLinkVerification() { - return ( - - ); -} - -export function EmailLinkFails() { - return ( - - ); -} - -export function EmailSsoVerification() { - return ( - - ); -} - -export function EmailSsoFails() { - return ( - - ); -} - -/** Every save is rejected, so the dialog shows both halves of a failure at once. */ -export function EditNameFails() { - return ( - - ); -} - -export function EditUsernameFails() { - return ( - - ); -} - -export function AddPhoneFails() { - return ( - - ); -} - -export function EmailRemovalPending() { - return ( - - ); -} - -export function EmailRemovalError() { - return ( - - ); -} - -export function PhoneRemovalPending() { - return ( - - ); -} - -export function PhoneRemovalError() { - return ( - - ); -} diff --git a/packages/swingset/src/stories/user-profile-email-section.mdx b/packages/swingset/src/stories/user-profile-email-section.mdx new file mode 100644 index 00000000000..e51ec47e2ad --- /dev/null +++ b/packages/swingset/src/stories/user-profile-email-section.mdx @@ -0,0 +1,75 @@ +import * as Stories from './user-profile-email-section.stories'; + +# UserProfileEmailSection + +The user's email addresses, composed with `Section`. Each row's menu verifies, sets the primary +address, or removes it, and Add email opens the verification flow. + +**Add email** opens email entry followed by a six-digit verification code. Resend is available after +the countdown. In this preview, `000000` shows an incorrect-code error; another six-digit code adds +the verified address. + + + +## Email verification error + +This example rejects every verification attempt so the error remains visible and the user can retry. + + + +## Email-link verification + +When the instance verifies emails by link, Add email moves from email entry to a waiting step with a +resend countdown. In this preview the link opens itself after a few seconds and the address is added +as verified. + + + +### Link error + +The link expires, so the step shows the error and the user can send a new one after the countdown. + + + +## Enterprise SSO verification + +When an email matches an enterprise SSO connection, Add email moves to a step that names the domain +with a Connect action. In this preview, Connect simulates the provider and adds the address as +verified. + + + +### Connection error + +Connect to see the error. The user can retry or cancel. + + + +## Removal pending + +Confirm removal to see a short pending state before the email disappears. + + + +## Removal error + +The first attempt fails inside the confirmation. Retry succeeds. + + diff --git a/packages/swingset/src/stories/user-profile-email-section.stories.tsx b/packages/swingset/src/stories/user-profile-email-section.stories.tsx new file mode 100644 index 00000000000..c53326721a5 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-email-section.stories.tsx @@ -0,0 +1,65 @@ +import { UserProfileEmailSectionView } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +import type { UserProfileEmailsFixtureOptions } from './fixtures/user-profile-emails'; +import { useUserProfileEmailsFixture } from './fixtures/user-profile-emails'; + +export { default as __source } from './user-profile-email-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + status: 'wip', + title: 'UserProfileEmailSection', + label: 'Email', + navigation: { category: 'Sections' }, + source: 'packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx', +}; + +function EmailSection(options: UserProfileEmailsFixtureOptions) { + const emails = useUserProfileEmailsFixture({ username: 'prestonxyz', ...options }); + + return ; +} + +export function Default() { + return ; +} + +export function AddEmailFails() { + return ; +} + +export function EmailLinkVerification() { + return ; +} + +export function EmailLinkFails() { + return ( + + ); +} + +export function EmailSsoVerification() { + return ; +} + +export function EmailSsoFails() { + return ( + + ); +} + +export function EmailRemovalPending() { + return ; +} + +export function EmailRemovalError() { + return ; +} diff --git a/packages/swingset/src/stories/user-profile-password-section.stories.tsx b/packages/swingset/src/stories/user-profile-password-section.stories.tsx index 3279ca0545a..15308094922 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,4 +1,4 @@ -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; import type { StoryMeta } from '@/lib/types'; diff --git a/packages/swingset/src/stories/user-profile-phone-section.mdx b/packages/swingset/src/stories/user-profile-phone-section.mdx new file mode 100644 index 00000000000..26809b85d1f --- /dev/null +++ b/packages/swingset/src/stories/user-profile-phone-section.mdx @@ -0,0 +1,41 @@ +import * as Stories from './user-profile-phone-section.stories'; + +# UserProfilePhoneSection + +The user's phone numbers, composed with `Section`. Each row's menu verifies, sets the primary +number, or removes it. + +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. + + + +## Add phone failure + +Add a phone number to see a failed send request while keeping the entered number. + + + +## Removal pending + +Remove a phone number to see the confirmation remain open until removal completes. + + + +## Removal error + +The first removal fails. Retry in the confirmation to remove the phone number. + + diff --git a/packages/swingset/src/stories/user-profile-phone-section.stories.tsx b/packages/swingset/src/stories/user-profile-phone-section.stories.tsx new file mode 100644 index 00000000000..0adeba89b8b --- /dev/null +++ b/packages/swingset/src/stories/user-profile-phone-section.stories.tsx @@ -0,0 +1,39 @@ +import { UserProfilePhoneSectionView } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.view'; + +import type { StoryMeta } from '@/lib/types'; + +import type { UserProfilePhonesFixtureOptions } from './fixtures/user-profile-phones'; +import { useUserProfilePhonesFixture } from './fixtures/user-profile-phones'; + +export { default as __source } from './user-profile-phone-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + status: 'wip', + title: 'UserProfilePhoneSection', + label: 'Phone', + navigation: { category: 'Sections' }, + source: 'packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx', +}; + +function PhoneSection(options: UserProfilePhonesFixtureOptions) { + const phones = useUserProfilePhonesFixture(options); + + return ; +} + +export function Default() { + return ; +} + +export function AddPhoneFails() { + return ; +} + +export function PhoneRemovalPending() { + return ; +} + +export function PhoneRemovalError() { + return ; +} diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index ba9f6efd59b..78836052c38 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -15,41 +15,23 @@ import * as UserProfileProfilePanelStories from './user-profile-profile-panel.st ## Usage +Each section arrives as a slot, so the panel only arranges them under the Account title. + ```tsx import { useRef } from 'react'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +import { UserProfileProfileSection } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section'; +import { UserProfileEmailSection } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section'; +import { UserProfilePhoneSection } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section'; import { UserProfileConnectedAccountsSection } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section'; -import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; const titleRef = useRef(null); ({ - id: email.id, - value: email.emailAddress, - isDefault: email.id === user.primaryEmailAddressId, - isVerified: email.verification.status === 'verified', - }))} - phones={user.phoneNumbers.map(phone => ({ - id: phone.id, - value: phone.phoneNumber, - isDefault: phone.id === user.primaryPhoneNumberId, - isVerified: phone.verification.status === 'verified', - }))} titleRef={titleRef} + profileSlot={} + emailSlot={} + phoneSlot={} connectedAccountsSlot={ titleRef.current} />} - web3WalletsSlot={ titleRef.current} />} - onVerifyEmail={verifyEmail} - onCreateEmail={createEmail} - getEmailVerifier={getEmailVerifier} - onSetPrimaryEmail={setPrimaryEmail} - onRemoveEmail={removeEmail} - onVerifyPhone={verifyPhone} - onSetPrimaryPhone={setPrimaryPhone} - onRemovePhone={removePhone} /> ``` 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 4bc53fda085..e976f55794b 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -1,25 +1,20 @@ import { UserProfileConnectedAccountsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.view'; -import type { - UserProfileEmail, - UserProfilePhone, -} from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +import { UserProfileEmailSectionView } from '@clerk/mosaic/features/user-profile/user-profile-email-section/user-profile-email-section.view'; +import { UserProfilePhoneSectionView } from '@clerk/mosaic/features/user-profile/user-profile-phone-section/user-profile-phone-section.view'; import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +import { UserProfileProfileSectionView } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section.view'; import { UserProfileWeb3WalletsSectionView } from '@clerk/mosaic/features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets-section.view'; -import { useRef, useState } from 'react'; +import { useRef } from 'react'; import type { StoryMeta } from '@/lib/types'; -import { usePreviewImage } from './fixtures/use-preview-image'; import { UserProfileDangerPreview } from './fixtures/user-profile'; -import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-email'; -import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useConnectedAccountsFixture } from './fixtures/user-profile-connected-accounts'; -import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; -import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; +import { useUserProfileEmailsFixture } from './fixtures/user-profile-emails'; +import { useUserProfilePhonesFixture } from './fixtures/user-profile-phones'; +import { useUserProfileProfileFixture } from './fixtures/user-profile-profile'; import { useWeb3WalletsFixture } from './fixtures/user-profile-web3-wallets'; -const profileImageUrl = 'https://avatars.githubusercontent.com/u/51144033?v=4'; - export { default as __source } from './user-profile-profile-panel.stories?raw'; export const meta: StoryMeta = { @@ -33,32 +28,18 @@ export const meta: StoryMeta = { export function Default(_args: Record) { const titleRef = useRef(null); - const [emails, setEmails] = useState([ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', isDefault: false, isVerified: true }, - ]); - const [phones, setPhones] = useState([ - { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, - ]); - const { imageUrl, showFile, clearImage } = usePreviewImage(profileImageUrl); const connections = useConnectedAccountsFixture(); const wallets = useWeb3WalletsFixture(); - const editName = useUserProfileEditNameFixture(); - const editUsername = useUserProfileEditUsernameFixture(); - const emailFlow = createUserProfileAddEmailFixture({ - onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), - onVerified: id => - setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), - }); + const profile = useUserProfileProfileFixture(); + const emails = useUserProfileEmailsFixture({ username: profile.username }); + const phones = useUserProfilePhonesFixture(); return ( } + emailSlot={} + phoneSlot={} connectedAccountsSlot={ ) { fallbackFocus={() => titleRef.current} /> } - hasImage={Boolean(imageUrl)} - imageUrl={imageUrl} - phones={phones} - {...createUserProfileAddPhoneFixture({ - onCreated: (id, value) => - setPhones(current => [...current, { id, value, isDefault: false, isVerified: false }]), - onVerified: id => - setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), - })} dangerSlot={} - onManageEmail={() => undefined} - onManagePhone={() => undefined} - onProfilePictureChange={showFile} - onRemoveProfilePicture={clearImage} - onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} - onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} - onSetPrimaryEmail={id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id })))} - onSetPrimaryPhone={id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id })))} - onVerifyEmail={() => undefined} - onVerifyPhone={() => undefined} /> ); } diff --git a/packages/swingset/src/stories/user-profile-profile-section.mdx b/packages/swingset/src/stories/user-profile-profile-section.mdx new file mode 100644 index 00000000000..fcd79c52d65 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-section.mdx @@ -0,0 +1,37 @@ +import * as Stories from './user-profile-profile-section.stories'; + +# UserProfileProfileSection + +The profile image, name and username, composed with `Section`. Upload and remove act on the image in +place; Edit name opens a dialog for the first and last name, and Edit username opens one seeded with +the saved username. With no username yet, the row offers Add instead. + + + +## Name managed by a connection + +An enterprise connection owns the name, so the Name row names who manages it in place of an edit +action. The connection's logo leads the label, or a generic lock when it ships none. + + + +## Edit name failure + +Every save is rejected, so the dialog shows the form error and the field error together. + + + +## Edit username failure + +Every save is rejected, so the dialog shows the form error and the field error together. + + diff --git a/packages/swingset/src/stories/user-profile-profile-section.stories.tsx b/packages/swingset/src/stories/user-profile-profile-section.stories.tsx new file mode 100644 index 00000000000..a9ccc6e2fb3 --- /dev/null +++ b/packages/swingset/src/stories/user-profile-profile-section.stories.tsx @@ -0,0 +1,65 @@ +import { UserProfileProfileSectionView } from '@clerk/mosaic/features/user-profile/user-profile-profile-section/user-profile-profile-section.view'; +import type { FormError } from '@clerk/mosaic/utils/errors'; + +import type { StoryMeta } from '@/lib/types'; + +import { useUserProfileProfileFixture } from './fixtures/user-profile-profile'; + +export { default as __source } from './user-profile-profile-section.stories?raw'; + +export const meta: StoryMeta = { + group: 'User Profile', + status: 'wip', + title: 'UserProfileProfileSection', + label: 'Profile', + navigation: { category: 'Sections' }, + source: + 'packages/mosaic/src/features/user-profile/user-profile-profile-section/user-profile-profile-section.view.tsx', +}; + +function ProfileSection(options: { + nameFailWith?: FormError; + usernameFailWith?: FormError; + nameManagedBy?: { name: string }; +}) { + const profile = useUserProfileProfileFixture(options); + + return ; +} + +export function Default() { + return ; +} + +/** + * An enterprise connection owns the name, so the row names who manages it in place of an edit + * action. + */ +export function NameManagedByConnection() { + return ; +} + +/** Every save is rejected, so the dialog shows both halves of a failure at once. */ +export function EditNameFails() { + return ( + + ); +} + +export function EditUsernameFails() { + return ( + + ); +}