From 3df0be7cbaf9c07b1b875cb73c7cac9244c1f4fc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:01:05 -0400 Subject: [PATCH 01/63] feat(mosaic): wire user profile account section to Clerk --- ...ofile-account-section.integration.test.tsx | 153 +++++++++++ ...ser-profile-account-section.model.test.tsx | 250 ++++++++++++++++++ .../user-profile-edit-name.controller.test.ts | 49 ++-- ...r-profile-edit-username.controller.test.ts | 59 +++-- .../user-profile-picture.controller.test.ts | 92 +++++++ .../user-profile-profile-panel.view.test.tsx | 23 +- .../user-profile-account-section.model.ts | 112 ++++++++ .../user-profile-account-section.tsx | 20 ++ .../user-profile-account-section.types.ts | 18 ++ .../user-profile-account-section.utils.ts | 47 ++++ .../user-profile-account-section.view.tsx | 43 +-- .../user-profile-edit-name.controller.ts | 25 +- .../user-profile-edit-username.controller.ts | 23 +- .../user-profile-name-row.view.tsx | 8 +- .../user-profile-picture-row.view.tsx | 21 +- .../user-profile-picture.controller.ts | 50 ++++ .../user-profile-username-row.view.tsx | 12 +- .../swingset/src/app/(clerk)/live-sidebar.tsx | 5 +- .../app/(clerk)/live/account-section/page.tsx | 37 +++ .../src/stories/fixtures/use-preview-image.ts | 2 + .../fixtures/user-profile-edit-name.ts | 6 +- .../fixtures/user-profile-edit-username.ts | 4 +- 22 files changed, 958 insertions(+), 101 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts create mode 100644 packages/swingset/src/app/(clerk)/live/account-section/page.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx new file mode 100644 index 00000000000..f3537baf54c --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -0,0 +1,153 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import type * as ReverificationModule from '../../reverification'; +import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; + +let usernameEnabled: boolean; +let namesEnabled: boolean; +let user: { + firstName: string; + lastName: string; + username: string; + imageUrl: string; + hasImage: boolean; + enterpriseAccounts: never[]; + primaryEmailAddressId: null; + primaryPhoneNumberId: null; + emailAddresses: never[]; + phoneNumbers: never[]; + setProfileImage: ReturnType; + update: ReturnType; +}; + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: true, user }), + useSession: () => ({ session: { id: 'sess_1' } }), + useReverification: (fetcher: unknown) => fetcher, + useClerk: () => ({ + __internal_environment: { + userSettings: { + attributes: { + first_name: { enabled: namesEnabled, required: false }, + last_name: { enabled: namesEnabled, required: false }, + username: { enabled: usernameEnabled, required: false }, + }, + }, + }, + }), + }; +}); + +vi.mock('../../reverification', async importOriginal => ({ + ...(await importOriginal()), + Reverification: () => null, +})); + +function renderSection() { + return render( + + + , + ); +} + +beforeEach(() => { + usernameEnabled = true; + namesEnabled = true; + user = { + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://img.clerk.com/preston.png', + hasImage: false, + enterpriseAccounts: [], + primaryEmailAddressId: null, + primaryPhoneNumberId: null, + emailAddresses: [], + phoneNumbers: [], + setProfileImage: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve(user)), + }; +}); + +describe('UserProfileAccountSection', () => { + it('saves an edited name and closes the dialog', async () => { + const actor = userEvent.setup(); + 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'), 'Pres'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(user.update).toHaveBeenCalledExactlyOnceWith({ firstName: 'Pres', lastName: 'Booth' }); + }); + + it('keeps the username dialog open on the error the server names it for', async () => { + user.update.mockRejectedValue( + new ClerkAPIResponseError('failed', { + data: [ + { + code: 'form_identifier_exists', + message: 'Taken', + long_message: 'That username is taken. Please try another.', + meta: { param_name: 'username' }, + }, + ], + status: 422, + }), + ); + const actor = userEvent.setup(); + renderSection(); + + 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(); + expect(user.update).toHaveBeenCalledExactlyOnceWith({ username: 'prestonxyz2' }); + }); + + it('uploads a picked picture and shows why it failed', async () => { + user.setProfileImage.mockRejectedValue(new Error('Upload failed.')); + const actor = userEvent.setup(); + const { container } = renderSection(); + const file = new File(['x'], 'me.png', { type: 'image/png' }); + + const input = container.querySelector('input[type="file"]'); + if (!(input instanceof HTMLInputElement)) { + throw new Error('expected a file input'); + } + await actor.upload(input, file); + + expect(await screen.findByRole('alert')).toHaveTextContent('Upload failed.'); + expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); + }); + + it('leaves the username row out when the instance does not use usernames', () => { + usernameEnabled = false; + renderSection(); + + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + }); + + it('leaves the name row out when the instance collects neither name', () => { + namesEnabled = false; + renderSection(); + + expect(screen.queryByText('Name')).not.toBeInTheDocument(); + expect(screen.getByText('Username')).toBeInTheDocument(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx new file mode 100644 index 00000000000..6ddef651505 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -0,0 +1,250 @@ +import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import type * as SharedReact from '@clerk/shared/react'; +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useUserProfileAccountSectionModel } from '../user-profile-account-section/user-profile-account-section.model'; + +interface FakeAttribute { + enabled: boolean; + required: boolean; + immutable?: boolean; + used_for_first_factor: boolean; + used_for_second_factor: boolean; +} + +let isUserLoaded: boolean; +let user: { + firstName: string | null; + lastName: string | null; + username: string | null; + imageUrl: string; + hasImage: boolean; + enterpriseAccounts: { active: boolean }[]; + primaryEmailAddressId: string | null; + primaryPhoneNumberId: string | null; + emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[]; + phoneNumbers: { id: string; phoneNumber: string; verification: { status: string | null } }[]; + setProfileImage: ReturnType; + update: ReturnType; +} | null; +let attributes: Record<'first_name' | 'last_name' | 'username', FakeAttribute>; +let environmentHydrated: boolean; +let reverified: ReturnType; + +function attribute(overrides: Partial = {}): FakeAttribute { + return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; +} + +vi.mock('@clerk/shared/react', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useUser: () => ({ isLoaded: isUserLoaded, user }), + useSession: () => ({ session: { id: 'sess_1' } }), + useReverification: (fetcher: (...args: unknown[]) => Promise) => { + return (...args: unknown[]) => { + reverified(...args); + return fetcher(...args); + }; + }, + useClerk: () => ({ + __internal_environment: environmentHydrated ? { userSettings: { attributes } } : null, + }), + }; +}); + +function renderModel() { + return renderHook(() => useUserProfileAccountSectionModel()).result.current; +} + +function ready() { + const model = renderModel(); + if (model.status !== 'ready') { + throw new Error(`expected ready, got ${model.status}`); + } + return model; +} + +function apiError(paramName?: string) { + return new ClerkAPIResponseError('failed', { + data: [ + { + code: 'form_param_invalid', + message: 'Is invalid', + long_message: 'That value is invalid.', + meta: paramName ? { param_name: paramName } : undefined, + }, + ], + status: 422, + }); +} + +beforeEach(() => { + isUserLoaded = true; + environmentHydrated = true; + reverified = vi.fn(); + attributes = { first_name: attribute(), last_name: attribute(), username: attribute() }; + user = { + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://img.clerk.com/preston.png', + hasImage: true, + enterpriseAccounts: [], + primaryEmailAddressId: 'email_1', + primaryPhoneNumberId: null, + emailAddresses: [ + { id: 'email_2', emailAddress: 'other@clerk.dev', verification: { status: null } }, + { id: 'email_1', emailAddress: 'preston@clerk.dev', verification: { status: 'verified' } }, + ], + phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], + setProfileImage: vi.fn(() => Promise.resolve({})), + update: vi.fn(() => Promise.resolve(user)), + }; +}); + +describe('useUserProfileAccountSectionModel', () => { + it('waits for the user and the environment', () => { + isUserLoaded = false; + expect(renderModel()).toEqual({ status: 'loading' }); + + isUserLoaded = true; + environmentHydrated = false; + expect(renderModel()).toEqual({ status: 'loading' }); + }); + + it('is hidden when nobody is signed in', () => { + user = null; + expect(renderModel()).toEqual({ status: 'hidden' }); + }); + + it('maps the user to plain row data', () => { + const model = ready(); + + expect(model).toMatchObject({ + name: 'Preston Booth', + firstName: 'Preston', + lastName: 'Booth', + username: 'prestonxyz', + imageUrl: 'https://img.clerk.com/preston.png', + hasImage: true, + firstNameAttribute: { enabled: true, required: false }, + lastNameAttribute: { enabled: true, required: false }, + emails: [ + { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'other@clerk.dev', isDefault: false, isVerified: false }, + ], + phones: [{ id: 'phone_1', value: '+18018888181', isDefault: false, isVerified: true }], + }); + expect(model.reverification).toEqual({ isActive: false }); + }); + + describe('profile picture', () => { + it('uploads the picked file', async () => { + const file = new File(['x'], 'me.png', { type: 'image/png' }); + await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ error: null }); + expect(user?.setProfileImage).toHaveBeenCalledWith({ file }); + }); + + it('removes an uploaded picture', async () => { + await ready().onRemoveProfilePicture?.(); + expect(user?.setProfileImage).toHaveBeenCalledWith({ file: null }); + }); + + it('offers no remove while the avatar is the generated default', () => { + if (user) { + user.hasImage = false; + } + expect(ready().onRemoveProfilePicture).toBeUndefined(); + }); + + it('returns the API message when the upload fails', async () => { + user?.setProfileImage.mockRejectedValue(apiError()); + const file = new File(['x'], 'me.png', { type: 'image/png' }); + await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ + error: { kind: 'form', message: 'That value is invalid.' }, + }); + }); + }); + + describe('name', () => { + it('saves both halves of the name', async () => { + await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ error: null }); + expect(user?.update).toHaveBeenCalledWith({ firstName: 'Pres', lastName: 'B' }); + }); + + it('maps a field error onto the control that caused it', async () => { + user?.update.mockRejectedValue(apiError('first_name')); + await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ + error: { kind: 'form', fields: { firstName: 'That value is invalid.' } }, + }); + }); + + it('is read only while an enterprise account is active', () => { + user?.enterpriseAccounts.push({ active: true }); + expect(ready().onSubmitName).toBeUndefined(); + }); + + it('passes the instance name attributes through for the row to hide itself', () => { + attributes.first_name = attribute({ enabled: false }); + attributes.last_name = attribute({ required: true }); + expect(ready()).toMatchObject({ + firstNameAttribute: { enabled: false, required: false }, + lastNameAttribute: { enabled: true, required: true }, + }); + }); + }); + + it('returns the message of a non-API failure', async () => { + user?.update.mockRejectedValue(new Error('Network down.')); + await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ + error: { kind: 'form', message: 'Network down.' }, + }); + }); + + describe('username', () => { + it('saves the username behind reverification', async () => { + await ready().onSubmitUsername?.('preston'); + expect(reverified).toHaveBeenCalledWith('preston'); + expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); + }); + + it('maps a username error onto the field', async () => { + user?.update.mockRejectedValue(apiError('username')); + await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ + error: { kind: 'form', fields: { username: 'That value is invalid.' } }, + }); + }); + + it('reports a cancelled reverification as cancelled, not as a failure', async () => { + user?.update.mockRejectedValue(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })); + await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ error: { kind: 'cancelled' } }); + }); + + it('is hidden when the instance does not use usernames', () => { + attributes.username = attribute({ enabled: false }); + expect(ready().username).toBeUndefined(); + }); + + it('stays available when usernames only sign in', () => { + attributes.username = attribute({ enabled: false, used_for_first_factor: true }); + expect(ready().username).toBe('prestonxyz'); + }); + + it('shows an immutable username without offering to edit it', () => { + attributes.username = attribute({ immutable: true }); + const model = ready(); + expect(model.username).toBe('prestonxyz'); + expect(model.onSubmitUsername).toBeUndefined(); + }); + + it('is hidden when immutable and never set', () => { + attributes.username = attribute({ immutable: true }); + if (user) { + user.username = null; + } + expect(ready().username).toBeUndefined(); + }); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 8a9d757eb13..4a2d7e90a72 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -2,13 +2,24 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import type { + UserProfileFormError, + UserProfileSaveResult, +} from '../user-profile-account-section/user-profile-account-section.types'; import { userProfileEditNameMachine, useUserProfileEditNameController, } from '../user-profile-account-section/user-profile-edit-name.controller'; +import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { +type Result = UserProfileSaveResult; + +const saved = (): Promise => Promise.resolve({ error: null }); +const failed = (error: UserProfileFormError): Promise => + Promise.resolve({ error: { kind: 'form', ...error } }); +const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); + +function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); actor.send({ type: 'OPEN' }); return actor; @@ -16,7 +27,7 @@ function start(saveName: () => Promise, saved = { savedFirstName: 'Preston describe('userProfileEditNameMachine', () => { it('seeds both fields from the saved name on open', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); expect(actor.getSnapshot().value).toBe('editing'); expect(actor.getSnapshot().context.firstName).toBe('Preston'); @@ -24,7 +35,7 @@ describe('userProfileEditNameMachine', () => { }); it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); actor.send({ type: 'SAVE' }); expect(actor.getSnapshot().value).toBe('saving'); @@ -37,7 +48,7 @@ describe('userProfileEditNameMachine', () => { }); it('re-seeds from the saved name on the next open, dropping what was typed', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); actor.send({ type: 'CANCEL' }); @@ -47,7 +58,7 @@ describe('userProfileEditNameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => Promise.reject(new Error('Your name could not be updated.'))); + const actor = start(() => failed({ message: 'Your name could not be updated.' })); actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); actor.send({ type: 'SAVE' }); @@ -55,15 +66,13 @@ describe('userProfileEditNameMachine', () => { expect(actor.getSnapshot().context.lastName).toBe('Barton'); expect(actor.getSnapshot().context.error).toEqual({ message: 'Your name could not be updated.', - fields: undefined, }); }); - it('carries field copy through when the rejection names a control', async () => { - const failure = new UserProfileSaveError('Your name could not be updated.', { - firstName: 'First name is required.', - }); - const actor = start(() => Promise.reject(failure)); + it('carries field copy through when the error names a control', async () => { + const actor = start(() => + failed({ message: 'Your name could not be updated.', fields: { firstName: 'First name is required.' } }), + ); actor.send({ type: 'SAVE' }); await vi.waitFor(() => @@ -71,7 +80,7 @@ describe('userProfileEditNameMachine', () => { ); }); - it('falls back to generic copy when the rejection is not an Error', async () => { + it('falls back to generic copy when the save throws something that is not an Error', async () => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test const actor = start(() => Promise.reject('nope')); actor.send({ type: 'SAVE' }); @@ -81,8 +90,16 @@ describe('userProfileEditNameMachine', () => { ); }); + it('stays open without a banner when the save is cancelled', async () => { + const actor = start(cancelled); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); + expect(actor.getSnapshot().context.error).toBeUndefined(); + }); + it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); + const actor = start(() => failed({ message: 'nope' })); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); @@ -96,7 +113,7 @@ describe('userProfileEditNameMachine', () => { describe('useUserProfileEditNameController', () => { it('holds the dialog open across editing and saving, then closes on success', async () => { const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: () => Promise.resolve() }), + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), ); expect(result.current.isOpen).toBe(false); @@ -116,7 +133,7 @@ describe('useUserProfileEditNameController', () => { }); it('saves the values it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(saved); const { result } = renderHook(() => useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index c1c99cd6808..9994c7529f8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -2,13 +2,24 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import type { + UserProfileFormError, + UserProfileSaveResult, +} from '../user-profile-account-section/user-profile-account-section.types'; import { userProfileEditUsernameMachine, useUserProfileEditUsernameController, } from '../user-profile-account-section/user-profile-edit-username.controller'; +import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; -function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { +type Result = UserProfileSaveResult; + +const saved = (): Promise => Promise.resolve({ error: null }); +const failed = (error: UserProfileFormError): Promise => + Promise.resolve({ error: { kind: 'form', ...error } }); +const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); + +function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); actor.send({ type: 'OPEN' }); return actor; @@ -16,14 +27,14 @@ function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') describe('userProfileEditUsernameMachine', () => { it('seeds the field from the saved username on open', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); expect(actor.getSnapshot().value).toBe('editing'); expect(actor.getSnapshot().context.username).toBe('prestonxyz'); }); it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); expect(actor.getSnapshot().value).toBe('saving'); @@ -36,7 +47,7 @@ describe('userProfileEditUsernameMachine', () => { }); it('re-seeds from the saved username on the next open, dropping what was typed', () => { - const actor = start(() => Promise.resolve()); + const actor = start(saved); actor.send({ type: 'TYPE', value: 'ada' }); actor.send({ type: 'CANCEL' }); @@ -46,20 +57,19 @@ describe('userProfileEditUsernameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => Promise.reject(new Error('That username is taken.'))); + const actor = start(() => failed({ message: 'That username is taken.' })); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); expect(actor.getSnapshot().context.username).toBe('preston'); - expect(actor.getSnapshot().context.error).toEqual({ message: 'That username is taken.', fields: undefined }); + expect(actor.getSnapshot().context.error).toEqual({ message: 'That username is taken.' }); }); - it('carries field copy through when the rejection names the control', async () => { - const failure = new UserProfileSaveError('Your username could not be updated.', { - username: 'That username is taken.', - }); - const actor = start(() => Promise.reject(failure)); + it('carries field copy through when the error names the control', async () => { + const actor = start(() => + failed({ message: 'Your username could not be updated.', fields: { username: 'That username is taken.' } }), + ); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); @@ -68,7 +78,7 @@ describe('userProfileEditUsernameMachine', () => { ); }); - it('falls back to generic copy when the rejection is not an Error', async () => { + it('falls back to generic copy when the save throws something that is not an Error', async () => { // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test const actor = start(() => Promise.reject('nope')); actor.send({ type: 'TYPE', value: 'preston' }); @@ -80,7 +90,7 @@ describe('userProfileEditUsernameMachine', () => { }); it('refuses to save a value that has not moved', () => { - const saveUsername = vi.fn(() => Promise.resolve()); + const saveUsername = vi.fn(saved); const actor = start(saveUsername); actor.send({ type: 'SAVE' }); @@ -91,7 +101,7 @@ describe('userProfileEditUsernameMachine', () => { }); it('refuses to save an empty value, since clearing a username is not on offer', () => { - const saveUsername = vi.fn(() => Promise.resolve()); + const saveUsername = vi.fn(saved); const actor = start(saveUsername); actor.send({ type: 'TYPE', value: '' }); @@ -101,8 +111,17 @@ describe('userProfileEditUsernameMachine', () => { expect(saveUsername).not.toHaveBeenCalled(); }); + it('stays open without a banner when the save is cancelled', async () => { + const actor = start(cancelled); + actor.send({ type: 'TYPE', value: 'preston' }); + actor.send({ type: 'SAVE' }); + + await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); + expect(actor.getSnapshot().context.error).toBeUndefined(); + }); + it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => Promise.reject(new Error('nope'))); + const actor = start(() => failed({ message: 'nope' })); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); @@ -117,7 +136,7 @@ describe('userProfileEditUsernameMachine', () => { describe('useUserProfileEditUsernameController', () => { it('holds the dialog open across editing and saving, then closes on success', async () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), ); expect(result.current.isOpen).toBe(false); @@ -137,7 +156,7 @@ describe('useUserProfileEditUsernameController', () => { }); it('saves the value it is currently holding', async () => { - const onSubmit = vi.fn(() => Promise.resolve()); + const onSubmit = vi.fn(saved); const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); act(() => result.current.onOpenChange(true)); @@ -149,7 +168,7 @@ describe('useUserProfileEditUsernameController', () => { it('withholds the save until the value moves', () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), ); act(() => result.current.onOpenChange(true)); @@ -161,7 +180,7 @@ describe('useUserProfileEditUsernameController', () => { it('withholds the save on an empty value', () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: () => Promise.resolve() }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), ); act(() => result.current.onOpenChange(true)); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts new file mode 100644 index 00000000000..a1d749e7cca --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -0,0 +1,92 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { UserProfileSaveResult } from '../user-profile-account-section/user-profile-account-section.types'; +import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; +import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; + +const file = new File(['x'], 'me.png', { type: 'image/png' }); + +function renderController(options: UserProfilePictureControllerOptions) { + return renderHook(() => useUserProfilePictureController(options)); +} + +describe('useUserProfilePictureController', () => { + it('leaves out the actions the model did not offer', () => { + const { result } = renderController({}); + expect(result.current.onChange).toBeUndefined(); + expect(result.current.onRemove).toBeUndefined(); + }); + + it('is pending while the upload runs', async () => { + const upload = createDeferredPromise(); + const onChange = vi.fn(() => upload.promise.then(() => ({ error: null }))); + const { result } = renderController({ onChange }); + + act(() => { + void result.current.onChange?.(file); + }); + expect(result.current.isPending).toBe(true); + expect(onChange).toHaveBeenCalledWith(file); + + await act(async () => { + upload.resolve(); + await upload.promise; + }); + expect(result.current.isPending).toBe(false); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('ignores a second pick while one is in flight', () => { + const onChange = vi.fn(() => new Promise(() => {})); + const { result } = renderController({ onChange }); + + act(() => { + void result.current.onChange?.(file); + }); + act(() => { + void result.current.onChange?.(file); + }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('shows why the upload failed, and clears it on the next attempt', async () => { + const onChange = vi + .fn() + .mockResolvedValueOnce({ error: { kind: 'form', message: 'Too big.' } }) + .mockResolvedValueOnce({ error: null }); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBe('Too big.'); + expect(result.current.isPending).toBe(false); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('shows nothing when the action is cancelled', async () => { + const onChange = vi.fn().mockResolvedValue({ error: { kind: 'cancelled' } }); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBeUndefined(); + }); + + it('shows the message of an unexpected throw', async () => { + const onChange = vi.fn().mockRejectedValue(new Error('Network down.')); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.errorMessage).toBe('Network down.'); + }); + + it('reports a failed removal the same way', async () => { + const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', message: 'Nope.' } }); + const { result } = renderController({ onRemove }); + + await act(async () => result.current.onRemove?.()); + expect(result.current.errorMessage).toBe('Nope.'); + }); +}); 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 a12b39214b2..a2a46967cba 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 @@ -130,9 +130,9 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ - onProfilePictureChange: vi.fn(), - onSubmitName: () => Promise.resolve(), - onSubmitUsername: () => Promise.resolve(), + onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })), + onSubmitName: () => Promise.resolve({ error: null }), + onSubmitUsername: () => Promise.resolve({ error: null }), }); expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); @@ -163,9 +163,12 @@ describe('UserProfileProfilePanelView', () => { }); it('uploads the picked file when no profile picture is set', async () => { - const onProfilePictureChange = vi.fn(); + const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange, onRemoveProfilePicture: vi.fn() }); + const { container } = renderView({ + onProfilePictureChange, + onRemoveProfilePicture: vi.fn(() => Promise.resolve({ error: null })), + }); expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); @@ -180,7 +183,7 @@ describe('UserProfileProfilePanelView', () => { }); it('turns away a file past the size the row advertises', async () => { - const onProfilePictureChange = vi.fn(); + const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); const onProfilePictureReject = vi.fn(); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); @@ -200,7 +203,7 @@ describe('UserProfileProfilePanelView', () => { it('clears the rejection once an acceptable file is picked', async () => { const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange: vi.fn() }); + const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })) }); const input = container.querySelector('input[type="file"]'); if (!input) { throw new Error('File picker not found'); @@ -371,7 +374,7 @@ describe('UserProfileProfilePanelView', () => { it('forwards profile and contact actions', async () => { const onAddEmail = vi.fn(); const onRemoveEmail = vi.fn(); - renderView({ onSubmitName: () => Promise.resolve(), onAddEmail, onRemoveEmail }); + renderView({ onSubmitName: () => Promise.resolve({ error: null }), onAddEmail, onRemoveEmail }); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add email' })); @@ -390,7 +393,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-name dialog from the section, seeded with the saved name', async () => { - const onSubmitName = vi.fn(() => Promise.resolve()); + const onSubmitName = vi.fn(() => Promise.resolve({ error: null })); const user = userEvent.setup(); renderView({ firstName: 'Preston', lastName: 'Booth', onSubmitName }); @@ -408,7 +411,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-username dialog from the section, seeded with the saved username', async () => { - const onSubmitUsername = vi.fn(() => Promise.resolve()); + const onSubmitUsername = vi.fn(() => Promise.resolve({ error: null })); const user = userEvent.setup(); renderView({ username: 'prestonxyz', onSubmitUsername }); 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 new file mode 100644 index 00000000000..5b8266cef08 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.model.ts @@ -0,0 +1,112 @@ +import { getFullName } from '@clerk/shared/internal/clerk-js/user'; +import { useUser } from '@clerk/shared/react'; +import type { AttributeData, UserResource } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import type { ReverificationProps } from '../../reverification'; +import { useReverificationWithState } from '../../reverification'; +import type { + UserProfileEmail, + UserProfileNameAttribute, + UserProfilePhone, +} from './user-profile-account-section.types'; +import { isAttributeAvailable, toSaveResult } from './user-profile-account-section.utils'; +import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; +import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; + +type UserProfileAccountSectionData = Pick< + UserProfileAccountSectionViewProps, + | 'name' + | 'imageUrl' + | 'hasImage' + | 'firstName' + | 'lastName' + | 'firstNameAttribute' + | 'lastNameAttribute' + | 'username' + | 'emails' + | 'phones' + | 'onProfilePictureChange' + | 'onRemoveProfilePicture' + | 'onSubmitName' + | 'onSubmitUsername' +>; + +export type UserProfileAccountSectionModel = + | { status: 'loading' } + | { status: 'hidden' } + | (UserProfileAccountSectionData & { status: 'ready'; reverification: ReverificationProps }); + +const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; +const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; + +function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { + return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; +} + +function primaryFirst(items: T[], primaryId: string | null): T[] { + return [...items.filter(item => item.id === primaryId), ...items.filter(item => item.id !== primaryId)]; +} + +function toEmails(user: UserResource): UserProfileEmail[] { + return primaryFirst(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ + id: email.id, + value: email.emailAddress, + isDefault: email.id === user.primaryEmailAddressId, + isVerified: email.verification.status === 'verified', + })); +} + +function toPhones(user: UserResource): UserProfilePhone[] { + return primaryFirst(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ + id: phone.id, + value: phone.phoneNumber, + isDefault: phone.id === user.primaryPhoneNumberId, + isVerified: phone.verification.status === 'verified', + })); +} + +export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { + const { isLoaded, user } = useUser(); + const environment = useMosaicEnvironment(); + const [updateUsername, reverification] = useReverificationWithState((username: string) => user?.update({ username })); + + if (!isLoaded || !environment) { + return { status: 'loading' }; + } + + if (!user) { + return { status: 'hidden' }; + } + + const { attributes } = environment.userSettings; + const usernameAttribute = attributes.username; + const usernameImmutable = Boolean(usernameAttribute?.immutable); + const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); + const nameReadOnly = user.enterpriseAccounts.some(account => account.active); + + return { + status: 'ready', + reverification, + name: getFullName(user), + firstName: user.firstName ?? '', + lastName: user.lastName ?? '', + firstNameAttribute: toNameAttribute(attributes.first_name), + lastNameAttribute: toNameAttribute(attributes.last_name), + imageUrl: user.imageUrl, + hasImage: user.hasImage, + username: showUsername ? (user.username ?? '') : undefined, + emails: toEmails(user), + phones: toPhones(user), + onProfilePictureChange: file => toSaveResult(() => user.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage ? () => toSaveResult(() => user.setProfileImage({ file: null })) : undefined, + onSubmitName: nameReadOnly + ? undefined + : value => toSaveResult(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + onSubmitUsername: + showUsername && !usernameImmutable + ? username => toSaveResult(() => updateUsername(username), USERNAME_FIELDS) + : 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 new file mode 100644 index 00000000000..27e9d37fcd9 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.tsx @@ -0,0 +1,20 @@ +import { Reverification } from '../../reverification'; +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, reverification, ...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 index 6b820314fb2..6f077a315a1 100644 --- 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 @@ -8,6 +8,24 @@ export interface UserProfileFormError { fields?: Partial>; } +export type UserProfileSaveFailure = + | { kind: 'cancelled' } + | ({ kind: 'form' } & UserProfileFormError); + +export interface UserProfileSaveResult { + error: UserProfileSaveFailure | null; +} + +export function formErrorOf( + failure: UserProfileSaveFailure | null, +): UserProfileFormError | undefined { + if (failure?.kind !== 'form') { + return undefined; + } + const { kind: _kind, ...error } = failure; + return error; +} + /** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ export interface UserProfileNameAttribute { /** @default true */ 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-account-section/user-profile-account-section.utils.ts new file mode 100644 index 00000000000..86a80f17f49 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.utils.ts @@ -0,0 +1,47 @@ +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import type { AttributeData } from '@clerk/shared/types'; + +import type { UserProfileFormError, UserProfileSaveResult } 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); +} + +function snakeToCamel(value: string): string { + return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); +} + +export function toUserProfileFormError( + cause: unknown, + fields: readonly TField[], +): UserProfileFormError { + if (!isClerkAPIResponseError(cause)) { + return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; + } + const error: UserProfileFormError = {}; + for (const apiError of cause.errors) { + const text = apiError.longMessage || apiError.message; + const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); + if (field) { + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; + } else { + error.message ??= text; + } + } + return error; +} + +export async function toSaveResult( + run: () => Promise, + fields: readonly TField[] = [], +): Promise> { + try { + await run(); + return { error: null }; + } catch (cause) { + if (isReverificationCancelledError(cause)) { + return { error: { kind: 'cancelled' } }; + } + return { error: { kind: 'form', ...toUserProfileFormError(cause, fields) } }; + } +} 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 index 890216a2425..9f81d81b85b 100644 --- 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 @@ -8,8 +8,10 @@ import type { UserProfileEmail, UserProfileNameAttribute, UserProfilePhone, + UserProfileSaveResult, } from './user-profile-account-section.types'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.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'; @@ -28,7 +30,7 @@ export interface UserProfileAccountSectionViewProps { */ hasImage?: boolean; name: string; - username: string; + username?: string; /** Passed alongside `name`, which cannot be split back into its two halves. */ firstName?: string; lastName?: string; @@ -36,11 +38,11 @@ export interface UserProfileAccountSectionViewProps { lastNameAttribute?: UserProfileNameAttribute; emails: UserProfileEmail[]; phones: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => void; + onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => void; - onSubmitName?: (value: UserProfileEditNameValue) => Promise; - onSubmitUsername?: (username: string) => Promise; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise>; + onSubmitUsername?: (username: string) => Promise>; onAddEmail?: () => void; onSendEmailCode?: (emailAddress: string) => Promise; onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; @@ -88,6 +90,7 @@ export function UserProfileAccountSectionView({ onRemovePhone, }: UserProfileAccountSectionViewProps) { const m = useMessages('userProfileAccountSection'); + const showName = firstNameAttribute?.enabled !== false || lastNameAttribute?.enabled !== false; const phoneRow = ( - - + {showName ? ( + + ) : null} + {username !== undefined ? ( + + ) : null} {!allowMultipleAccounts ? emailRow : null} {!allowMultipleAccounts ? phoneRow : null} 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-account-section/user-profile-edit-name.controller.ts index 3c4c7f6adbd..7c9c1447f33 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,11 +1,11 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileFormError } from './user-profile-account-section.types'; -import { UserProfileSaveError } from './user-profile-account-section.types'; +import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; +import { formErrorOf } from './user-profile-account-section.types'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise; + saveName: (value: UserProfileEditNameValue) => Promise>; /** Injected every render. What `OPEN` seeds the fields from. */ savedFirstName: string; savedLastName: string; @@ -27,9 +27,6 @@ function notSeated(): Promise { } function toFormError(cause: unknown): UserProfileFormError { - if (cause instanceof UserProfileSaveError) { - return { message: cause.message, fields: cause.fields }; - } if (cause instanceof Error) { return { message: cause.message }; } @@ -69,7 +66,17 @@ export const userProfileEditNameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveName({ firstName: context.firstName, lastName: context.lastName }), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + onDone: [ + { + guard: (_, event) => event.output.error !== null, + + target: 'editing', + + actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + }, + + { target: 'idle', actions: assign(() => ({ error: undefined })) }, + ], onError: { target: 'editing', actions: assign((_, event) => ({ error: toFormError(event.error) })), @@ -82,8 +89,8 @@ export const userProfileEditNameMachine = createMachine({ export interface UserProfileEditNameControllerOptions { firstName?: string; lastName?: string; - /** Resolve to close the dialog; reject with an `Error` to keep it open showing why. */ - onSubmit: (value: UserProfileEditNameValue) => Promise; + /** Resolve with `{ error: null }` to close the dialog, or with an error to keep it open showing why. */ + onSubmit: (value: UserProfileEditNameValue) => Promise>; } export interface UserProfileEditNameController { 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-account-section/user-profile-edit-username.controller.ts index 367bc9db0d7..d87efc4f547 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,11 +1,11 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileFormError } from './user-profile-account-section.types'; -import { UserProfileSaveError } from './user-profile-account-section.types'; +import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; +import { formErrorOf } from './user-profile-account-section.types'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise; + saveUsername: (username: string) => Promise>; savedUsername: string; username: string; error: UserProfileFormError | undefined; @@ -28,9 +28,6 @@ function isSaveable(context: UserProfileEditUsernameContext): boolean { } function toFormError(cause: unknown): UserProfileFormError { - if (cause instanceof UserProfileSaveError) { - return { message: cause.message, fields: cause.fields }; - } if (cause instanceof Error) { return { message: cause.message }; } @@ -64,7 +61,17 @@ export const userProfileEditUsernameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveUsername(context.username), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, + onDone: [ + { + guard: (_, event) => event.output.error !== null, + + target: 'editing', + + actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + }, + + { target: 'idle', actions: assign(() => ({ error: undefined })) }, + ], onError: { target: 'editing', actions: assign((_, event) => ({ error: toFormError(event.error) })), @@ -76,7 +83,7 @@ export const userProfileEditUsernameMachine = createMachine({ export interface UserProfileEditUsernameControllerOptions { username?: string; - onSubmit: (username: string) => Promise; + onSubmit: (username: string) => Promise>; } export interface UserProfileEditUsernameController { 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 index 5f83e5dad9d..b2807f66877 100644 --- 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 @@ -1,9 +1,9 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { UserProfileNameAttribute, UserProfileSaveResult } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; export interface UserProfileNameRowViewProps { @@ -12,7 +12,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit?: (value: UserProfileEditNameValue) => Promise; + onSubmit?: (value: UserProfileEditNameValue) => Promise>; } export function UserProfileNameRowView({ @@ -58,7 +58,7 @@ function EditName({ lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise; + onSubmit: (value: UserProfileEditNameValue) => Promise>; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); 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-account-section/user-profile-picture-row.view.tsx index ff827ccb985..d6fc89976d1 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-account-section/user-profile-picture-row.view.tsx @@ -8,6 +8,8 @@ import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; +import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import { useUserProfilePictureController } 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. */ @@ -18,9 +20,9 @@ export interface UserProfilePictureRowViewProps { imageUrl?: string; hasImage?: boolean; errorMessage?: string; - onChange?: (file: File) => void; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => void; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ @@ -33,8 +35,9 @@ export function UserProfilePictureRowView({ onRemove, }: UserProfilePictureRowViewProps) { const m = useMessages('userProfileAccountSection'); + const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError; + const displayedError = errorMessage ?? rejectionError ?? controller.errorMessage; const initials = name .split(/\s+/) .map(part => part[0]) @@ -46,6 +49,8 @@ export function UserProfilePictureRowView({ } onReject={rejections => { const rejection = rejections[0]; @@ -56,7 +61,7 @@ export function UserProfilePictureRowView({ const file = files[0]; if (file) { setRejectionError(undefined); - onChange?.(file); + void controller.onChange?.(file); } }} > @@ -75,9 +80,9 @@ export function UserProfilePictureRowView({ {m.picture.description} {displayedError} @@ -92,7 +97,7 @@ function ProfilePictureActions({ }: { hasImage: boolean; canChange: boolean; - onRemove?: () => void; + onRemove?: () => Promise; }) { const m = useMessages('userProfileAccountSection'); const { openFilePicker } = FileUpload.useFileUpload(); @@ -103,7 +108,7 @@ function ProfilePictureActions({ } if (hasImage && onRemove) { - actions.push({ label: m.picture.remove, icon: 'x', onClick: onRemove }); + actions.push({ label: m.picture.remove, icon: 'x', onClick: () => void onRemove() }); } if (actions.length > 0) { 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-account-section/user-profile-picture.controller.ts new file mode 100644 index 00000000000..ae3cf28a6c9 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-picture.controller.ts @@ -0,0 +1,50 @@ +import { useRef, useState } from 'react'; + +import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import { formErrorOf } from './user-profile-account-section.types'; + +export interface UserProfilePictureControllerOptions { + onChange?: (file: File) => Promise; + onRemove?: () => Promise; +} + +export interface UserProfilePictureController { + onChange?: (file: File) => Promise; + onRemove?: () => Promise; + isPending: boolean; + errorMessage: string | undefined; +} + +export function useUserProfilePictureController({ + onChange, + onRemove, +}: UserProfilePictureControllerOptions): UserProfilePictureController { + const [isPending, setIsPending] = useState(false); + const [errorMessage, setErrorMessage] = useState(); + const inFlight = useRef(false); + + const run = async (action: () => Promise) => { + if (inFlight.current) { + return; + } + inFlight.current = true; + setIsPending(true); + setErrorMessage(undefined); + try { + const { error } = await action(); + setErrorMessage(formErrorOf(error)?.message); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Something went wrong. Please try again.'); + } finally { + inFlight.current = false; + setIsPending(false); + } + }; + + return { + onChange: onChange ? file => run(() => onChange(file)) : undefined, + onRemove: onRemove ? () => run(onRemove) : undefined, + isPending, + errorMessage, + }; +} 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 index 5e1aefc11f5..18aca4f8cc1 100644 --- 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 @@ -1,12 +1,14 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; +import type { UserProfileSaveResult } from './user-profile-account-section.types'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; +import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; export interface UserProfileUsernameRowViewProps { username: string; - onSubmit?: (username: string) => Promise; + onSubmit?: (username: string) => Promise>; } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { @@ -31,7 +33,13 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs ); } -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { +function EditUsername({ + username, + onSubmit, +}: { + username: string; + onSubmit: (username: string) => Promise>; +}) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); diff --git a/packages/swingset/src/app/(clerk)/live-sidebar.tsx b/packages/swingset/src/app/(clerk)/live-sidebar.tsx index 2e206b399de..458b373f2fd 100644 --- a/packages/swingset/src/app/(clerk)/live-sidebar.tsx +++ b/packages/swingset/src/app/(clerk)/live-sidebar.tsx @@ -18,7 +18,10 @@ import { SidebarRail, } from '@/components/ui/sidebar'; -const flows = [{ title: 'Reverification', href: '/live/reverification' }]; +const flows = [ + { title: 'Account section', href: '/live/account-section' }, + { title: 'Reverification', href: '/live/reverification' }, +]; export function LiveSidebar(props: ComponentProps) { const pathname = usePathname(); diff --git a/packages/swingset/src/app/(clerk)/live/account-section/page.tsx b/packages/swingset/src/app/(clerk)/live/account-section/page.tsx new file mode 100644 index 00000000000..39fb5565780 --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/account-section/page.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { UserProfileAccountSection } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section'; +import { MosaicProvider } from '@clerk/mosaic/MosaicProvider'; +import { useUser } from '@clerk/nextjs'; +import Link from 'next/link'; + +export default function AccountSectionLivePage() { + const { isLoaded, isSignedIn } = useUser(); + + return ( + +
+
+

Account section

+

+ The user profile account section wired to the signed-in user. Changes to the picture, name, and username are + saved to the real account. +

+
+ {!isLoaded ?

Loading…

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

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

+ ) : null} + {isLoaded && isSignedIn ? : null} +
+
+ ); +} diff --git a/packages/swingset/src/stories/fixtures/use-preview-image.ts b/packages/swingset/src/stories/fixtures/use-preview-image.ts index 10b34995775..fd8618fee8f 100644 --- a/packages/swingset/src/stories/fixtures/use-preview-image.ts +++ b/packages/swingset/src/stories/fixtures/use-preview-image.ts @@ -24,6 +24,7 @@ export function usePreviewImage(initialUrl?: string) { const next = URL.createObjectURL(file); objectUrlRef.current = next; setImageUrl(next); + return Promise.resolve({ error: null }); }, [release], ); @@ -31,6 +32,7 @@ export function usePreviewImage(initialUrl?: string) { const clearImage = useCallback(() => { release(); setImageUrl(undefined); + return Promise.resolve({ error: null }); }, [release]); return { imageUrl, showFile, clearImage }; diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts index 52084a183a6..e7be9a4e51f 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,5 +1,4 @@ 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 { UserProfileEditNameValue } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-edit-name.dialog'; import { useState } from 'react'; @@ -10,7 +9,7 @@ export interface UserProfileEditNameFixtureOptions { firstName?: string; lastName?: string; latency?: number; - /** Rejects every save instead of committing it. */ + /** Fails every save instead of committing it. */ failWith?: UserProfileFormError; } @@ -33,9 +32,10 @@ export function useUserProfileEditNameFixture({ onSubmitName: async (value: UserProfileEditNameValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + return { error: { kind: 'form' as const, ...failWith } }; } setName(value); + return { error: null }; }, }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts index 7732c594918..f54b5bacd35 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -1,5 +1,4 @@ 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 { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -24,9 +23,10 @@ export function useUserProfileEditUsernameFixture({ onSubmitUsername: async (value: string) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + return { error: { kind: 'form' as const, ...failWith } }; } setUsername(value); + return { error: null }; }, }; } From 5d54a39921e9ae9a7bebc57b1c991750835252c9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:07:00 -0400 Subject: [PATCH 02/63] refactor(mosaic): use shared snakeToCamel --- .../user-profile-account-section.utils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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-account-section/user-profile-account-section.utils.ts index 86a80f17f49..66a90b0e10f 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-account-section/user-profile-account-section.utils.ts @@ -1,5 +1,6 @@ import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; import type { AttributeData } from '@clerk/shared/types'; +import { snakeToCamel } from '@clerk/shared/underscore'; import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; @@ -7,10 +8,6 @@ export function isAttributeAvailable(attribute: AttributeData | undefined): bool return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); } -function snakeToCamel(value: string): string { - return value.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); -} - export function toUserProfileFormError( cause: unknown, fields: readonly TField[], From 3fd8b31d62422dfe6469f05a7d90d43b5aedb877 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:13:31 -0400 Subject: [PATCH 03/63] refactor(mosaic): move save result helpers to utils --- .../user-profile-edit-name.controller.test.ts | 9 +-- ...r-profile-edit-username.controller.test.ts | 9 +-- .../user-profile-picture.controller.test.ts | 4 +- .../user-profile-account-section.model.ts | 3 +- .../user-profile-account-section.types.ts | 28 +--------- .../user-profile-account-section.utils.ts | 39 ------------- .../user-profile-account-section.view.tsx | 10 ++-- .../user-profile-edit-name.controller.ts | 14 ++--- .../user-profile-edit-name.dialog.tsx | 5 +- .../user-profile-edit-username.controller.ts | 14 ++--- .../user-profile-edit-username.dialog.tsx | 4 +- .../user-profile-name-row.view.tsx | 7 ++- .../user-profile-picture-row.view.tsx | 6 +- .../user-profile-picture.controller.ts | 10 ++-- .../user-profile-username-row.view.tsx | 6 +- .../user-profile-profile-panel.view.tsx | 5 +- packages/mosaic/src/utils/save-result.ts | 56 +++++++++++++++++++ .../fixtures/user-profile-edit-name.ts | 4 +- .../fixtures/user-profile-edit-password.ts | 4 +- .../fixtures/user-profile-edit-username.ts | 4 +- .../user-profile-account-section.stories.tsx | 6 +- .../user-profile-password-section.stories.tsx | 4 +- 22 files changed, 118 insertions(+), 133 deletions(-) create mode 100644 packages/mosaic/src/utils/save-result.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 4a2d7e90a72..18b4163c7f0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -2,20 +2,17 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { - UserProfileFormError, - UserProfileSaveResult, -} from '../user-profile-account-section/user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; import { userProfileEditNameMachine, useUserProfileEditNameController, } from '../user-profile-account-section/user-profile-edit-name.controller'; import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; -type Result = UserProfileSaveResult; +type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: UserProfileFormError): Promise => +const failed = (error: FormError): Promise => Promise.resolve({ error: { kind: 'form', ...error } }); const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 9994c7529f8..56abdc5ed0a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -2,20 +2,17 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { - UserProfileFormError, - UserProfileSaveResult, -} from '../user-profile-account-section/user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; import { userProfileEditUsernameMachine, useUserProfileEditUsernameController, } from '../user-profile-account-section/user-profile-edit-username.controller'; import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; -type Result = UserProfileSaveResult; +type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: UserProfileFormError): Promise => +const failed = (error: FormError): Promise => Promise.resolve({ error: { kind: 'form', ...error } }); const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index a1d749e7cca..51749ffa094 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -2,7 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { UserProfileSaveResult } from '../user-profile-account-section/user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; @@ -39,7 +39,7 @@ describe('useUserProfilePictureController', () => { }); it('ignores a second pick while one is in flight', () => { - const onChange = vi.fn(() => new Promise(() => {})); + const onChange = vi.fn(() => new Promise(() => {})); const { result } = renderController({ onChange }); act(() => { 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 index 5b8266cef08..96bef0b8866 100644 --- 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 @@ -3,6 +3,7 @@ import { useUser } from '@clerk/shared/react'; import type { AttributeData, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import { toSaveResult } from '../../../utils/save-result'; import type { ReverificationProps } from '../../reverification'; import { useReverificationWithState } from '../../reverification'; import type { @@ -10,7 +11,7 @@ import type { UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; -import { isAttributeAvailable, toSaveResult } from './user-profile-account-section.utils'; +import { isAttributeAvailable } from './user-profile-account-section.utils'; import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; 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 index 6f077a315a1..ff0a40d25cd 100644 --- 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 @@ -1,31 +1,5 @@ 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>; -} - -export type UserProfileSaveFailure = - | { kind: 'cancelled' } - | ({ kind: 'form' } & UserProfileFormError); - -export interface UserProfileSaveResult { - error: UserProfileSaveFailure | null; -} - -export function formErrorOf( - failure: UserProfileSaveFailure | null, -): UserProfileFormError | undefined { - if (failure?.kind !== 'form') { - return undefined; - } - const { kind: _kind, ...error } = failure; - return error; -} - /** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ export interface UserProfileNameAttribute { /** @default true */ @@ -56,4 +30,4 @@ export interface UserProfileEmail { isDefault?: boolean; isVerified?: boolean; canRemove?: boolean; -} +} \ No newline at end of file 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-account-section/user-profile-account-section.utils.ts index 66a90b0e10f..fa2aabc8e4f 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-account-section/user-profile-account-section.utils.ts @@ -1,44 +1,5 @@ -import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; import type { AttributeData } from '@clerk/shared/types'; -import { snakeToCamel } from '@clerk/shared/underscore'; - -import type { UserProfileFormError, UserProfileSaveResult } 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); } - -export function toUserProfileFormError( - cause: unknown, - fields: readonly TField[], -): UserProfileFormError { - if (!isClerkAPIResponseError(cause)) { - return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; - } - const error: UserProfileFormError = {}; - for (const apiError of cause.errors) { - const text = apiError.longMessage || apiError.message; - const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); - if (field) { - error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; - } else { - error.message ??= text; - } - } - return error; -} - -export async function toSaveResult( - run: () => Promise, - fields: readonly TField[] = [], -): Promise> { - try { - await run(); - return { error: null }; - } catch (cause) { - if (isReverificationCancelledError(cause)) { - return { error: { kind: 'cancelled' } }; - } - return { error: { kind: 'form', ...toUserProfileFormError(cause, fields) } }; - } -} 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 index 9f81d81b85b..1fbe41cec7d 100644 --- 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 @@ -3,12 +3,12 @@ import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; +import type { SaveResult } from '../../../utils/save-result'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, UserProfileNameAttribute, UserProfilePhone, - UserProfileSaveResult, } from './user-profile-account-section.types'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -38,11 +38,11 @@ export interface UserProfileAccountSectionViewProps { lastNameAttribute?: UserProfileNameAttribute; emails: UserProfileEmail[]; phones: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => Promise; + onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => Promise; - onSubmitName?: (value: UserProfileEditNameValue) => Promise>; - onSubmitUsername?: (username: string) => Promise>; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise>; + onSubmitUsername?: (username: string) => Promise>; onAddEmail?: () => void; onSendEmailCode?: (emailAddress: string) => Promise; onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; 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-account-section/user-profile-edit-name.controller.ts index 7c9c1447f33..2ee8a2a24d7 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,17 +1,17 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; -import { formErrorOf } from './user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; +import { formErrorOf } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise>; + saveName: (value: UserProfileEditNameValue) => Promise>; /** Injected every render. What `OPEN` seeds the fields from. */ savedFirstName: string; savedLastName: string; firstName: string; lastName: string; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export type UserProfileEditNameEvent = @@ -26,7 +26,7 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-name deps are not seated')); } -function toFormError(cause: unknown): UserProfileFormError { +function toFormError(cause: unknown): FormError { if (cause instanceof Error) { return { message: cause.message }; } @@ -90,7 +90,7 @@ export interface UserProfileEditNameControllerOptions { firstName?: string; lastName?: string; /** Resolve with `{ error: null }` to close the dialog, or with an error to keep it open showing why. */ - onSubmit: (value: UserProfileEditNameValue) => Promise>; + onSubmit: (value: UserProfileEditNameValue) => Promise>; } export interface UserProfileEditNameController { @@ -102,7 +102,7 @@ export interface UserProfileEditNameController { onLastNameChange: (value: string) => void; onSubmit: () => void; isSaving: boolean; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export function useUserProfileEditNameController({ 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-account-section/user-profile-edit-name.dialog.tsx index 589a41516b5..cb3fff7314c 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-account-section/user-profile-edit-name.dialog.tsx @@ -9,7 +9,8 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileFormError, UserProfileNameAttribute } from './user-profile-account-section.types'; +import type { FormError } from '../../../utils/save-result'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -31,7 +32,7 @@ export interface UserProfileEditNameDialogProps { onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; isSaving?: boolean; - error?: UserProfileFormError; + error?: FormError; onSubmit: () => void; } 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-account-section/user-profile-edit-username.controller.ts index d87efc4f547..16ce655a27d 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,14 +1,14 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { UserProfileFormError, UserProfileSaveResult } from './user-profile-account-section.types'; -import { formErrorOf } from './user-profile-account-section.types'; +import type { FormError, SaveResult } from '../../../utils/save-result'; +import { formErrorOf } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise>; + saveUsername: (username: string) => Promise>; savedUsername: string; username: string; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export type UserProfileEditUsernameEvent = @@ -27,7 +27,7 @@ function isSaveable(context: UserProfileEditUsernameContext): boolean { return context.username !== context.savedUsername && context.username !== ''; } -function toFormError(cause: unknown): UserProfileFormError { +function toFormError(cause: unknown): FormError { if (cause instanceof Error) { return { message: cause.message }; } @@ -83,7 +83,7 @@ export const userProfileEditUsernameMachine = createMachine({ export interface UserProfileEditUsernameControllerOptions { username?: string; - onSubmit: (username: string) => Promise>; + onSubmit: (username: string) => Promise>; } export interface UserProfileEditUsernameController { @@ -94,7 +94,7 @@ export interface UserProfileEditUsernameController { onSubmit: () => void; canSave: boolean; isSaving: boolean; - error: UserProfileFormError | undefined; + error: FormError | undefined; } export function useUserProfileEditUsernameController({ 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-account-section/user-profile-edit-username.dialog.tsx index 7fa55d04cb8..ebc0e81ea32 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-account-section/user-profile-edit-username.dialog.tsx @@ -9,7 +9,7 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useMessages } from '../../../localization'; -import type { UserProfileFormError } from './user-profile-account-section.types'; +import type { FormError } from '../../../utils/save-result'; export type UserProfileEditUsernameField = 'username'; @@ -21,7 +21,7 @@ export interface UserProfileEditUsernameDialogProps { onUsernameChange: (value: string) => void; canSave?: boolean; isSaving?: boolean; - error?: UserProfileFormError; + error?: FormError; onSubmit: () => void; } 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 index b2807f66877..1b7b9cf44b1 100644 --- 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 @@ -1,7 +1,8 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { UserProfileNameAttribute, UserProfileSaveResult } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; +import type { UserProfileNameAttribute } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; @@ -12,7 +13,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit?: (value: UserProfileEditNameValue) => Promise>; + onSubmit?: (value: UserProfileEditNameValue) => Promise>; } export function UserProfileNameRowView({ @@ -58,7 +59,7 @@ function EditName({ lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise>; + onSubmit: (value: UserProfileEditNameValue) => Promise>; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); 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-account-section/user-profile-picture-row.view.tsx index d6fc89976d1..b99d53b2b16 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-account-section/user-profile-picture-row.view.tsx @@ -8,7 +8,7 @@ import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; -import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; import { useUserProfilePictureController } from './user-profile-picture.controller'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; @@ -20,9 +20,9 @@ export interface UserProfilePictureRowViewProps { imageUrl?: string; hasImage?: boolean; errorMessage?: string; - onChange?: (file: File) => Promise; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => Promise; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ 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-account-section/user-profile-picture.controller.ts index ae3cf28a6c9..fe221ec8e43 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-account-section/user-profile-picture.controller.ts @@ -1,11 +1,11 @@ import { useRef, useState } from 'react'; -import type { UserProfileSaveResult } from './user-profile-account-section.types'; -import { formErrorOf } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; +import { formErrorOf } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { - onChange?: (file: File) => Promise; - onRemove?: () => Promise; + onChange?: (file: File) => Promise; + onRemove?: () => Promise; } export interface UserProfilePictureController { @@ -23,7 +23,7 @@ export function useUserProfilePictureController({ const [errorMessage, setErrorMessage] = useState(); const inFlight = useRef(false); - const run = async (action: () => Promise) => { + const run = async (action: () => Promise) => { if (inFlight.current) { return; } 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 index 18aca4f8cc1..6592ee48453 100644 --- 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 @@ -1,14 +1,14 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { UserProfileSaveResult } from './user-profile-account-section.types'; +import type { SaveResult } from '../../../utils/save-result'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; export interface UserProfileUsernameRowViewProps { username: string; - onSubmit?: (username: string) => Promise>; + onSubmit?: (username: string) => Promise>; } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { @@ -38,7 +38,7 @@ function EditUsername({ onSubmit, }: { username: string; - onSubmit: (username: string) => Promise>; + onSubmit: (username: string) => Promise>; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); 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 05f8ccede3a..42d900b0764 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 @@ -19,10 +19,7 @@ import type { UserProfileWeb3Provider, UserProfileWeb3Wallet } from './user-prof import { UserProfileWeb3WalletsSectionView } from './user-profile-web3-wallets-section.view'; export type { UserProfileConnectedAccount, UserProfileEmail, UserProfilePhone, UserProfileWeb3Wallet }; -export type { - UserProfileFormError, - UserProfileNameAttribute, -} from './user-profile-account-section/user-profile-account-section.types'; +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 interface UserProfileProfilePanelViewProps extends UserProfileAccountSectionViewProps { diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts new file mode 100644 index 00000000000..97861e890ef --- /dev/null +++ b/packages/mosaic/src/utils/save-result.ts @@ -0,0 +1,56 @@ +import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import { snakeToCamel } from '@clerk/shared/underscore'; + +/** Plain data, so nothing downstream of the model imports a Clerk error. */ +export interface FormError { + /** Rendered in the dialog's negative banner. */ + message?: string; + /** Rendered under the named control, which is also marked invalid. */ + fields?: Partial>; +} + +export type SaveFailure = { kind: 'cancelled' } | ({ kind: 'form' } & FormError); + +export interface SaveResult { + error: SaveFailure | null; +} + +export function formErrorOf(failure: SaveFailure | null): FormError | undefined { + if (failure?.kind !== 'form') { + return undefined; + } + const { kind: _kind, ...error } = failure; + return error; +} + +export function toFormError(cause: unknown, fields: readonly TField[]): FormError { + if (!isClerkAPIResponseError(cause)) { + return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; + } + const error: FormError = {}; + for (const apiError of cause.errors) { + const text = apiError.longMessage || apiError.message; + const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); + if (field) { + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; + } else { + error.message ??= text; + } + } + return error; +} + +export async function toSaveResult( + run: () => Promise, + fields: readonly TField[] = [], +): Promise> { + try { + await run(); + return { error: null }; + } catch (cause) { + if (isReverificationCancelledError(cause)) { + return { error: { kind: 'cancelled' } }; + } + return { error: { kind: 'form', ...toFormError(cause, fields) } }; + } +} diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts index e7be9a4e51f..67ef43013ae 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,5 +1,5 @@ -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; 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/save-result'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -10,7 +10,7 @@ export interface UserProfileEditNameFixtureOptions { lastName?: string; latency?: number; /** Fails every save instead of committing it. */ - failWith?: UserProfileFormError; + failWith?: FormError; } /** Stands in for the model. Everything else the dialog needs belongs to the controller. */ diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index a00865cc05e..b9395091ae0 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,6 +1,6 @@ -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 { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -8,7 +8,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: UserProfileFormError; + failWith?: FormError; } export function useUserProfileEditPasswordFixture({ diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts index f54b5bacd35..73d049889d5 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -1,4 +1,4 @@ -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -7,7 +7,7 @@ import { chaosName } from '@/lib/chaos'; export interface UserProfileEditUsernameFixtureOptions { username?: string; latency?: number; - failWith?: UserProfileFormError; + failWith?: FormError; } export function useUserProfileEditUsernameFixture({ diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 3b17983b1c4..1b9ec90d1a9 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,5 +1,4 @@ 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 type { UserProfileEmail, UserProfilePhone, @@ -8,6 +7,7 @@ import { UserProfileAccountSectionView } from '@clerk/mosaic/features/user-profi import type { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; import { UserProfileVerifyEmailLinkDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; import { UserProfileVerifyEmailSsoDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -43,8 +43,8 @@ function AccountSection({ }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; - failWith?: UserProfileFormError; - usernameFailWith?: UserProfileFormError; + failWith?: FormError; + usernameFailWith?: FormError; failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; 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 a9162984b5c..e15881511e6 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,5 +1,5 @@ import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; -import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; +import type { FormError } from '@clerk/mosaic/utils/save-result'; import type { StoryMeta } from '@/lib/types'; @@ -24,7 +24,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: UserProfileFormError; + failWith?: FormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); From b116533d13780abc2ea02f2a4ea59da212adf0af Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:35:53 -0400 Subject: [PATCH 04/63] fix(mosaic): make save errors localizable and rethrow unexpected errors --- ...ofile-account-section.integration.test.tsx | 11 ++++- ...ser-profile-account-section.model.test.tsx | 27 ++++++++--- .../user-profile-edit-name.controller.test.ts | 28 ++++++----- .../user-profile-edit-name.dialog.test.tsx | 6 +-- ...r-profile-edit-username.controller.test.ts | 28 ++++++----- ...user-profile-edit-username.dialog.test.tsx | 18 +++++-- .../user-profile-picture.controller.test.ts | 26 ++++++---- .../user-profile-edit-name.controller.ts | 14 +----- .../user-profile-edit-name.dialog.tsx | 11 +++-- .../user-profile-edit-username.controller.ts | 14 +----- .../user-profile-edit-username.dialog.tsx | 9 ++-- .../user-profile-picture-row.view.tsx | 5 +- .../user-profile-picture.controller.ts | 19 ++++---- .../src/localization/errors.messages.ts | 14 ++++++ .../mosaic/src/localization/errors.test.tsx | 48 +++++++++++++++++++ packages/mosaic/src/localization/errors.ts | 14 ++++++ packages/mosaic/src/localization/index.ts | 2 + .../src/localization/localization.test-d.ts | 8 ++++ packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/utils/save-result.ts | 42 +++++++++++----- .../fixtures/user-profile-edit-password.ts | 7 ++- .../user-profile-account-section.stories.tsx | 8 ++-- .../user-profile-password-section.stories.tsx | 11 ++--- 23 files changed, 252 insertions(+), 120 deletions(-) create mode 100644 packages/mosaic/src/localization/errors.messages.ts create mode 100644 packages/mosaic/src/localization/errors.test.tsx create mode 100644 packages/mosaic/src/localization/errors.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index f3537baf54c..c4435f2e01d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -120,7 +120,12 @@ describe('UserProfileAccountSection', () => { }); it('uploads a picked picture and shows why it failed', async () => { - user.setProfileImage.mockRejectedValue(new Error('Upload failed.')); + user.setProfileImage.mockRejectedValue( + new ClerkAPIResponseError('failed', { + data: [{ code: 'avatar_file_size_exceeded', message: 'Too large', long_message: 'Too large.' }], + status: 413, + }), + ); const actor = userEvent.setup(); const { container } = renderSection(); const file = new File(['x'], 'me.png', { type: 'image/png' }); @@ -131,7 +136,9 @@ describe('UserProfileAccountSection', () => { } await actor.upload(input, file); - expect(await screen.findByRole('alert')).toHaveTextContent('Upload failed.'); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + ); expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 6ddef651505..56327346e6f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -163,7 +163,7 @@ describe('useUserProfileAccountSectionModel', () => { user?.setProfileImage.mockRejectedValue(apiError()); const file = new File(['x'], 'me.png', { type: 'image/png' }); await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ - error: { kind: 'form', message: 'That value is invalid.' }, + error: { kind: 'form', global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, }); }); }); @@ -177,7 +177,12 @@ describe('useUserProfileAccountSectionModel', () => { it('maps a field error onto the control that caused it', async () => { user?.update.mockRejectedValue(apiError('first_name')); await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ - error: { kind: 'form', fields: { firstName: 'That value is invalid.' } }, + error: { + kind: 'form', + fields: { + firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, + }, + }, }); }); @@ -196,13 +201,18 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('returns the message of a non-API failure', async () => { - user?.update.mockRejectedValue(new Error('Network down.')); + it('returns a Clerk runtime failure by its code, without the developer message', async () => { + user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ - error: { kind: 'form', message: 'Network down.' }, + error: { kind: 'form', global: { code: 'network_error' } }, }); }); + it('rethrows a failure that is not from Clerk', async () => { + user?.update.mockRejectedValue(new TypeError('boom')); + await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toThrow('boom'); + }); + describe('username', () => { it('saves the username behind reverification', async () => { await ready().onSubmitUsername?.('preston'); @@ -213,7 +223,12 @@ describe('useUserProfileAccountSectionModel', () => { it('maps a username error onto the field', async () => { user?.update.mockRejectedValue(apiError('username')); await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ - error: { kind: 'form', fields: { username: 'That value is invalid.' } }, + error: { + kind: 'form', + fields: { + username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, + }, + }, }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 18b4163c7f0..65c987f2bb4 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -55,36 +55,40 @@ describe('userProfileEditNameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ message: 'Your name could not be updated.' })); + const actor = start(() => failed({ global: { message: 'Your name could not be updated.' } })); actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); expect(actor.getSnapshot().context.lastName).toBe('Barton'); expect(actor.getSnapshot().context.error).toEqual({ - message: 'Your name could not be updated.', + global: { message: 'Your name could not be updated.' }, }); }); it('carries field copy through when the error names a control', async () => { const actor = start(() => - failed({ message: 'Your name could not be updated.', fields: { firstName: 'First name is required.' } }), + failed({ + global: { message: 'Your name could not be updated.' }, + fields: { firstName: { message: 'First name is required.' } }, + }), ); actor.send({ type: 'SAVE' }); await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: 'First name is required.' }), + expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: { message: 'First name is required.' } }), ); }); - it('falls back to generic copy when the save throws something that is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); + it('shows the generic banner and logs when the save throws unexpectedly', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new TypeError('boom'); + const actor = start(() => Promise.reject(failure)); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); }); it('stays open without a banner when the save is cancelled', async () => { @@ -96,9 +100,9 @@ describe('userProfileEditNameMachine', () => { }); it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ message: 'nope' })); + const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); + await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); actor.send({ type: 'CANCEL' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index 07a83dcbba9..7f2e8e17859 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -133,8 +133,8 @@ describe('UserProfileEditNameDialog', () => { it('announces the failure in a negative banner and marks the blamed field invalid', () => { renderView({ error: { - message: 'Your name could not be updated.', - fields: { lastName: 'Last name must be 64 characters or fewer.' }, + global: { message: 'Your name could not be updated.' }, + fields: { lastName: { message: 'Last name must be 64 characters or fewer.' } }, }, }); @@ -147,7 +147,7 @@ describe('UserProfileEditNameDialog', () => { }); it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { firstName: 'First name is required.' } } }); + renderView({ error: { fields: { firstName: { message: 'First name is required.' } } } }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(firstNameField()).toHaveAttribute('aria-invalid', 'true'); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 56abdc5ed0a..9831410fd64 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -54,36 +54,40 @@ describe('userProfileEditUsernameMachine', () => { }); it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ message: 'That username is taken.' })); + const actor = start(() => failed({ global: { message: 'That username is taken.' } })); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); expect(actor.getSnapshot().context.username).toBe('preston'); - expect(actor.getSnapshot().context.error).toEqual({ message: 'That username is taken.' }); + expect(actor.getSnapshot().context.error).toEqual({ global: { message: 'That username is taken.' } }); }); it('carries field copy through when the error names the control', async () => { const actor = start(() => - failed({ message: 'Your username could not be updated.', fields: { username: 'That username is taken.' } }), + failed({ + global: { message: 'Your username could not be updated.' }, + fields: { username: { message: 'That username is taken.' } }, + }), ); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ username: 'That username is taken.' }), + expect(actor.getSnapshot().context.error?.fields).toEqual({ username: { message: 'That username is taken.' } }), ); }); - it('falls back to generic copy when the save throws something that is not an Error', async () => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test - const actor = start(() => Promise.reject('nope')); + it('shows the generic banner and logs when the save throws unexpectedly', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new TypeError('boom'); + const actor = start(() => Promise.reject(failure)); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.message).toBe('Something went wrong. Please try again.'), - ); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); }); it('refuses to save a value that has not moved', () => { @@ -118,10 +122,10 @@ describe('userProfileEditUsernameMachine', () => { }); it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ message: 'nope' })); + const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.message).toBe('nope')); + await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); actor.send({ type: 'CANCEL' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx index ad079f44a49..8acd861593a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx @@ -3,11 +3,12 @@ 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 '../../../MosaicProvider'; 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'; -function renderView(overrides: Partial = {}) { +function renderView(overrides: Partial = {}, localization?: MosaicLocalization) { const props: UserProfileEditUsernameDialogProps = { open: true, onOpenChange: vi.fn(), @@ -19,7 +20,7 @@ function renderView(overrides: Partial = {}) return { props, ...render( - + , ), @@ -95,7 +96,7 @@ describe('UserProfileEditUsernameDialog', () => { }); it('announces the failure in a negative banner', () => { - renderView({ error: { message: 'Your username could not be updated.' } }); + renderView({ error: { global: { message: 'Your username could not be updated.' } } }); const banner = screen.getByRole('alert'); expect(banner).toHaveAttribute('data-color', 'negative'); @@ -104,13 +105,22 @@ describe('UserProfileEditUsernameDialog', () => { }); it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { username: 'That username is taken.' } } }); + renderView({ error: { fields: { username: { message: 'That username is taken.' } } } }); expect(screen.queryByRole('alert')).not.toBeInTheDocument(); expect(screen.getByText('That username is taken.')).toBeInTheDocument(); expect(usernameField()).toHaveAttribute('aria-invalid', 'true'); }); + it('shows the translation for the error code over the message Clerk sent', () => { + renderView( + { error: { fields: { username: { code: 'form_identifier_exists', paramName: 'username', message: 'Taken.' } } } }, + { overrides: { 'errors.form_identifier_exists__username': 'Ese nombre de usuario ya existe.' } }, + ); + + expect(usernameField()).toHaveAccessibleDescription('Ese nombre de usuario ya existe.'); + }); + it('withholds the save while the caller says the value is unacceptable', async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index 51749ffa094..e5cd146e671 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -35,7 +35,7 @@ describe('useUserProfilePictureController', () => { await upload.promise; }); expect(result.current.isPending).toBe(false); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); it('ignores a second pick while one is in flight', () => { @@ -54,16 +54,18 @@ describe('useUserProfilePictureController', () => { it('shows why the upload failed, and clears it on the next attempt', async () => { const onChange = vi .fn() - .mockResolvedValueOnce({ error: { kind: 'form', message: 'Too big.' } }) + .mockResolvedValueOnce({ + error: { kind: 'form', global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, + }) .mockResolvedValueOnce({ error: null }); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBe('Too big.'); + expect(result.current.error).toEqual({ code: 'avatar_file_size_exceeded', message: 'Too big.' }); expect(result.current.isPending).toBe(false); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); it('shows nothing when the action is cancelled', async () => { @@ -71,22 +73,26 @@ describe('useUserProfilePictureController', () => { const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBeUndefined(); + expect(result.current.error).toBeUndefined(); }); - it('shows the message of an unexpected throw', async () => { - const onChange = vi.fn().mockRejectedValue(new Error('Network down.')); + it('shows the generic error and logs an unexpected throw', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new TypeError('boom'); + const onChange = vi.fn().mockRejectedValue(failure); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.errorMessage).toBe('Network down.'); + expect(result.current.error).toEqual({}); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); }); it('reports a failed removal the same way', async () => { - const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', message: 'Nope.' } }); + const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', global: { message: 'Nope.' } } }); const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); - expect(result.current.errorMessage).toBe('Nope.'); + expect(result.current.error).toEqual({ message: 'Nope.' }); }); }); 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-account-section/user-profile-edit-name.controller.ts index 2ee8a2a24d7..e1f212d6642 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf } from '../../../utils/save-result'; +import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { @@ -26,13 +26,6 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-name deps are not seated')); } -function toFormError(cause: unknown): FormError { - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: 'Something went wrong. Please try again.' }; -} - export const userProfileEditNameMachine = createMachine({ id: 'editName', initial: 'idle', @@ -69,17 +62,14 @@ export const userProfileEditNameMachine = createMachine({ onDone: [ { guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), }, }), }, 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-account-section/user-profile-edit-name.dialog.tsx index cb3fff7314c..c9571d88231 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-account-section/user-profile-edit-name.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { useMessages } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; import type { FormError } from '../../../utils/save-result'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; @@ -56,6 +56,7 @@ export function UserProfileEditNameDialog({ onSubmit, }: UserProfileEditNameDialogProps) { const m = useMessages('userProfileAccountSection'); + const errorText = useErrorText(); const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; @@ -94,12 +95,12 @@ export function UserProfileEditNameDialog({ /> } > - {error?.message ? ( + {error?.global ? ( - {error.message} + {errorText(error.global)} ) : null} {showFirstName ? ( @@ -116,7 +117,7 @@ export function UserProfileEditNameDialog({ onChange={event => onFirstNameChange(event.target.value)} /> - {error?.fields?.firstName} + {error?.fields?.firstName ? errorText(error.fields.firstName) : null} ) : null} @@ -134,7 +135,7 @@ export function UserProfileEditNameDialog({ onChange={event => onLastNameChange(event.target.value)} /> - {error?.fields?.lastName} + {error?.fields?.lastName ? errorText(error.fields.lastName) : null} ) : null} 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-account-section/user-profile-edit-username.controller.ts index 16ce655a27d..d30b3c81867 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf } from '../../../utils/save-result'; +import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { @@ -27,13 +27,6 @@ function isSaveable(context: UserProfileEditUsernameContext): boolean { return context.username !== context.savedUsername && context.username !== ''; } -function toFormError(cause: unknown): FormError { - if (cause instanceof Error) { - return { message: cause.message }; - } - return { message: 'Something went wrong. Please try again.' }; -} - export const userProfileEditUsernameMachine = createMachine({ id: 'editUsername', initial: 'idle', @@ -64,17 +57,14 @@ export const userProfileEditUsernameMachine = createMachine({ onDone: [ { guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), }, }), }, 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-account-section/user-profile-edit-username.dialog.tsx index ebc0e81ea32..335559accbf 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-account-section/user-profile-edit-username.dialog.tsx @@ -8,7 +8,7 @@ import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; -import { useMessages } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; import type { FormError } from '../../../utils/save-result'; export type UserProfileEditUsernameField = 'username'; @@ -37,6 +37,7 @@ export function UserProfileEditUsernameDialog({ onSubmit, }: UserProfileEditUsernameDialogProps) { const m = useMessages('userProfileAccountSection'); + const errorText = useErrorText(); const formId = useId(); const usernameRef = useRef(null); @@ -73,12 +74,12 @@ export function UserProfileEditUsernameDialog({ /> } > - {error?.message ? ( + {error?.global ? ( - {error.message} + {errorText(error.global)} ) : null} @@ -91,7 +92,7 @@ export function UserProfileEditUsernameDialog({ onChange={event => onUsernameChange(event.target.value)} /> - {error?.fields?.username} + {error?.fields?.username ? errorText(error.fields.username) : 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-account-section/user-profile-picture-row.view.tsx index b99d53b2b16..96f9f9610f1 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-account-section/user-profile-picture-row.view.tsx @@ -5,7 +5,7 @@ import { ActionMenu } from '../../../components/action-menu'; import { Avatar } from '../../../components/avatar'; import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; +import { useErrorText, useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; import type { SaveResult } from '../../../utils/save-result'; @@ -35,9 +35,10 @@ export function UserProfilePictureRowView({ onRemove, }: UserProfilePictureRowViewProps) { const m = useMessages('userProfileAccountSection'); + const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError ?? controller.errorMessage; + const displayedError = errorMessage ?? rejectionError ?? (controller.error ? errorText(controller.error) : undefined); const initials = name .split(/\s+/) .map(part => part[0]) 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-account-section/user-profile-picture.controller.ts index fe221ec8e43..8221415ee03 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-account-section/user-profile-picture.controller.ts @@ -1,7 +1,8 @@ import { useRef, useState } from 'react'; +import type { LocalizableError } from '../../../localization'; import type { SaveResult } from '../../../utils/save-result'; -import { formErrorOf } from '../../../utils/save-result'; +import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -12,7 +13,7 @@ export interface UserProfilePictureController { onChange?: (file: File) => Promise; onRemove?: () => Promise; isPending: boolean; - errorMessage: string | undefined; + error: LocalizableError | undefined; } export function useUserProfilePictureController({ @@ -20,7 +21,7 @@ export function useUserProfilePictureController({ onRemove, }: UserProfilePictureControllerOptions): UserProfilePictureController { const [isPending, setIsPending] = useState(false); - const [errorMessage, setErrorMessage] = useState(); + const [error, setError] = useState(); const inFlight = useRef(false); const run = async (action: () => Promise) => { @@ -29,12 +30,12 @@ export function useUserProfilePictureController({ } inFlight.current = true; setIsPending(true); - setErrorMessage(undefined); + setError(undefined); try { - const { error } = await action(); - setErrorMessage(formErrorOf(error)?.message); - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : 'Something went wrong. Please try again.'); + const result = await action(); + setError(formErrorOf(result.error)?.global); + } catch (cause) { + setError(unexpectedFormError(cause).global); } finally { inFlight.current = false; setIsPending(false); @@ -45,6 +46,6 @@ export function useUserProfilePictureController({ onChange: onChange ? file => run(() => onChange(file)) : undefined, onRemove: onRemove ? () => run(onRemove) : undefined, isPending, - errorMessage, + error, }; } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts new file mode 100644 index 00000000000..faac31e6c9b --- /dev/null +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -0,0 +1,14 @@ +export const errorMessages: { readonly generic: string } & Readonly> = { + generic: 'Something went wrong. Please try again.', + action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", + avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', + avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + form_new_password_matches_current: 'New password cannot be the same as the current password.', + form_password_length_too_short: 'Your password is too short. It must be at least 8 characters long.', + form_password_matches_identifier: + 'Password cannot match your email address, phone number or username. For account safety, please use a different password.', + form_password_not_strong_enough: 'Your password is not strong enough.', + form_password_pwned: + 'This password has been found as part of a breach and can not be used, please try another password instead.', + form_username_needs_non_number_char: 'Your username must contain at least one non-numeric character.', +}; diff --git a/packages/mosaic/src/localization/errors.test.tsx b/packages/mosaic/src/localization/errors.test.tsx new file mode 100644 index 00000000000..e7194b31835 --- /dev/null +++ b/packages/mosaic/src/localization/errors.test.tsx @@ -0,0 +1,48 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; + +import type { MosaicCatalog } from './catalog'; +import { MosaicLocalizationProvider, resolveLocalization } from './context'; +import { useErrorText } from './errors'; + +function errorText(overrides?: MosaicCatalog) { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook(() => useErrorText(), { wrapper }).result.current; +} + +describe('useErrorText', () => { + it('prefers the message for the code on that field', () => { + const text = errorText({ + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + 'errors.form_identifier_exists': 'Ya existe.', + }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe( + 'Nombre de usuario en uso.', + ); + }); + + it('falls back to the message for the code', () => { + const text = errorText({ 'errors.form_identifier_exists': 'Ya existe.' }); + expect(text({ code: 'form_identifier_exists', paramName: 'username', message: 'Taken' })).toBe('Ya existe.'); + }); + + it('ships English for known codes', () => { + expect(errorText()({ code: 'avatar_file_type_invalid', message: 'api text' })).toBe( + 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', + ); + }); + + it('falls back to the message Clerk sent for a code it has no text for', () => { + expect(errorText()({ code: 'form_identifier_exists', message: 'That username is taken.' })).toBe( + 'That username is taken.', + ); + }); + + it('falls back to the generic message when there is nothing else', () => { + expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); + expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); + }); +}); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts new file mode 100644 index 00000000000..9dd17f65dc5 --- /dev/null +++ b/packages/mosaic/src/localization/errors.ts @@ -0,0 +1,14 @@ +import { useMessages } from './context'; + +export interface LocalizableError { + code?: string; + paramName?: string; + message?: string; +} + +export function useErrorText(): (error: LocalizableError) => string { + const messages = useMessages('errors'); + const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); + return ({ code, paramName, message }) => + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; +} diff --git a/packages/mosaic/src/localization/index.ts b/packages/mosaic/src/localization/index.ts index fbfc32e732c..cfff089fc6d 100644 --- a/packages/mosaic/src/localization/index.ts +++ b/packages/mosaic/src/localization/index.ts @@ -1,4 +1,6 @@ export type { MosaicCatalog, MosaicLocalization } from './catalog'; +export type { LocalizableError } from './errors'; +export { useErrorText } from './errors'; export { MosaicLocalizationProvider, resolveLocalization, useLocale, useMessages } from './context'; export type { MosaicMessages } from './registry'; export { mosaicMessages } from './registry'; diff --git a/packages/mosaic/src/localization/localization.test-d.ts b/packages/mosaic/src/localization/localization.test-d.ts index fe2150c3c46..c3844e7ee94 100644 --- a/packages/mosaic/src/localization/localization.test-d.ts +++ b/packages/mosaic/src/localization/localization.test-d.ts @@ -24,6 +24,14 @@ describe('MosaicCatalog', () => { void value; }); + test('accepts text for any error code Clerk may return', () => { + const value: MosaicCatalog = { + 'errors.form_identifier_exists__username': 'Nombre de usuario en uso.', + errors: { generic: 'Algo salió mal.', form_param_nil: 'Obligatorio.' }, + }; + void value; + }); + test('rejects unknown keys and paths', () => { const value: MosaicCatalog = { // @ts-expect-error typo in the path diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 34a933c8880..6e4a6cae586 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -27,8 +27,10 @@ import { userProfileEnterpriseAccountsMessages } from '../features/user-profile/ import { userProfileMfaMessages } from '../features/user-profile/user-profile-mfa-section.messages'; import { userProfilePasswordSectionMessages } from '../features/user-profile/user-profile-password-section/user-profile-password-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets.messages'; +import { errorMessages } from './errors.messages'; export const mosaicMessages = { + errors: errorMessages, form: formMessages, membersTableTab: membersTableTabMessages, invitationsTableTab: invitationsTableTabMessages, diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts index 97861e890ef..a80771b01f4 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/save-result.ts @@ -1,12 +1,11 @@ -import { isClerkAPIResponseError, isReverificationCancelledError } from '@clerk/shared/error'; +import { isClerkAPIResponseError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; -/** Plain data, so nothing downstream of the model imports a Clerk error. */ +import type { LocalizableError } from '../localization'; + export interface FormError { - /** Rendered in the dialog's negative banner. */ - message?: string; - /** Rendered under the named control, which is also marked invalid. */ - fields?: Partial>; + global?: LocalizableError; + fields?: Partial>; } export type SaveFailure = { kind: 'cancelled' } | ({ kind: 'form' } & FormError); @@ -23,18 +22,31 @@ export function formErrorOf(failure: SaveFailure return error; } -export function toFormError(cause: unknown, fields: readonly TField[]): FormError { +export function unexpectedFormError(cause: unknown): FormError { + console.error(cause); + return { global: {} }; +} + +function toFormError(cause: unknown, fields: readonly TField[]): FormError | undefined { + if (isClerkRuntimeError(cause)) { + return { global: { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}) } }; + } if (!isClerkAPIResponseError(cause)) { - return { message: cause instanceof Error ? cause.message : 'Something went wrong. Please try again.' }; + return undefined; } const error: FormError = {}; for (const apiError of cause.errors) { - const text = apiError.longMessage || apiError.message; - const field = fields.find(f => apiError.meta?.paramName && snakeToCamel(apiError.meta.paramName) === f); + const paramName = apiError.meta?.paramName; + const localizable: LocalizableError = { + code: apiError.code, + ...(paramName ? { paramName } : {}), + message: apiError.longMessage || apiError.message, + }; + const field = fields.find(f => paramName && snakeToCamel(paramName) === f); if (field) { - error.fields = { ...error.fields, [field]: error.fields?.[field] ?? text }; + error.fields = { ...error.fields, [field]: error.fields?.[field] ?? localizable }; } else { - error.message ??= text; + error.global ??= localizable; } } return error; @@ -51,6 +63,10 @@ export async function toSaveResult( if (isReverificationCancelledError(cause)) { return { error: { kind: 'cancelled' } }; } - return { error: { kind: 'form', ...toFormError(cause, fields) } }; + const error = toFormError(cause, fields); + if (!error) { + throw cause; + } + return { error: { kind: 'form', ...error } }; } } diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index b9395091ae0..a7711b2469a 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,6 +1,5 @@ -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import type { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -8,7 +7,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: FormError; + failWith?: UserProfileSaveError; } export function useUserProfileEditPasswordFixture({ @@ -27,7 +26,7 @@ export function useUserProfileEditPasswordFixture({ await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); - throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); + throw failWith; } setHasPassword(true); }, diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 1b9ec90d1a9..c1190e3a4c1 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -207,8 +207,8 @@ export function EditNameFails() { ); @@ -219,8 +219,8 @@ export function EditUsernameFails() { ); 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 e15881511e6..58d71efb577 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,5 +1,5 @@ +import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; import type { StoryMeta } from '@/lib/types'; @@ -24,7 +24,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: FormError; + failWith?: UserProfileSaveError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -73,10 +73,9 @@ export function ManagedByEnterprise() { export function EditPasswordFails() { return ( ); } From 5c9d8006b79999cb299455923253b44f3f67647e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 15:41:15 -0400 Subject: [PATCH 05/63] chore(repo): add empty changeset --- .changeset/mosaic-user-profile-account-wireup.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .changeset/mosaic-user-profile-account-wireup.md diff --git a/.changeset/mosaic-user-profile-account-wireup.md b/.changeset/mosaic-user-profile-account-wireup.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-account-wireup.md @@ -0,0 +1,2 @@ +--- +--- From 30a2e4beec61bfed8a78e1fbb6aa2e67af112323 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 21 Sep 2026 17:02:49 -0400 Subject: [PATCH 06/63] refactor(mosaic): make unexpected save errors explicit and drop dead picture error prop --- .../user-profile-edit-name.controller.test.ts | 2 +- ...r-profile-edit-username.controller.test.ts | 2 +- .../user-profile-picture-row.view.test.tsx | 19 +++++++++---------- .../user-profile-picture.controller.test.ts | 2 +- .../user-profile-edit-name.controller.ts | 4 ++-- .../user-profile-edit-username.controller.ts | 4 ++-- .../user-profile-picture-row.view.tsx | 4 +--- .../user-profile-picture.controller.ts | 5 +++-- packages/mosaic/src/utils/save-result.ts | 5 +---- 9 files changed, 21 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 65c987f2bb4..43c484355ad 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -86,7 +86,7 @@ describe('userProfileEditNameMachine', () => { const actor = start(() => Promise.reject(failure)); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 9831410fd64..c602e44dd84 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -85,7 +85,7 @@ describe('userProfileEditUsernameMachine', () => { actor.send({ type: 'TYPE', value: 'preston' }); actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: {} })); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); 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 index 9b6e43d131c..9fb25d9d760 100644 --- 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 @@ -18,18 +18,17 @@ function renderView(overrides: Partial = {}) { } describe('UserProfilePictureRowView', () => { - it('renders and clears the error supplied by the section', () => { - const { rerender } = renderView({ errorMessage: 'File size exceeds the maximum limit of 10MB.' }); - - expect(screen.getByRole('alert')).toHaveTextContent('File size exceeds the maximum limit of 10MB.'); + it('shows why removing the picture failed', async () => { + const user = userEvent.setup(); + renderView({ + hasImage: true, + onRemove: vi.fn().mockResolvedValue({ error: { kind: 'form', global: { code: 'action_blocked' } } }), + }); - rerender( - - - , - ); + await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove avatar' })); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + expect(await screen.findByRole('alert')).toHaveTextContent("This action couldn't be completed."); }); it('offers Upload while the avatar is only a generated default', () => { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index e5cd146e671..bb068591fd0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -83,7 +83,7 @@ describe('useUserProfilePictureController', () => { const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.error).toEqual({}); + expect(result.current.error).toEqual({ code: 'generic' }); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); 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-account-section/user-profile-edit-name.controller.ts index e1f212d6642..4f95b1f36cf 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; +import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { @@ -69,7 +69,7 @@ export const userProfileEditNameMachine = createMachine({ ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), + actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], }, }), }, 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-account-section/user-profile-edit-username.controller.ts index d30b3c81867..a68664daf8f 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; +import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { @@ -64,7 +64,7 @@ export const userProfileEditUsernameMachine = createMachine({ ], onError: { target: 'editing', - actions: assign((_, event) => ({ error: unexpectedFormError(event.error) })), + actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], }, }), }, 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-account-section/user-profile-picture-row.view.tsx index 96f9f9610f1..967d5499406 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-account-section/user-profile-picture-row.view.tsx @@ -19,7 +19,6 @@ export interface UserProfilePictureRowViewProps { name: string; imageUrl?: string; hasImage?: boolean; - errorMessage?: string; onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; onRemove?: () => Promise; @@ -29,7 +28,6 @@ export function UserProfilePictureRowView({ name, imageUrl, hasImage = false, - errorMessage, onChange, onReject, onRemove, @@ -38,7 +36,7 @@ export function UserProfilePictureRowView({ const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); - const displayedError = errorMessage ?? rejectionError ?? (controller.error ? errorText(controller.error) : undefined); + const displayedError = rejectionError ?? (controller.error ? errorText(controller.error) : undefined); const initials = name .split(/\s+/) .map(part => part[0]) 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-account-section/user-profile-picture.controller.ts index 8221415ee03..bfcc0775d49 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-account-section/user-profile-picture.controller.ts @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; import type { SaveResult } from '../../../utils/save-result'; -import { formErrorOf, unexpectedFormError } from '../../../utils/save-result'; +import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -35,7 +35,8 @@ export function useUserProfilePictureController({ const result = await action(); setError(formErrorOf(result.error)?.global); } catch (cause) { - setError(unexpectedFormError(cause).global); + console.error(cause); + setError(UNEXPECTED_ERROR); } finally { inFlight.current = false; setIsPending(false); diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts index a80771b01f4..e4bedf8baed 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/save-result.ts @@ -22,10 +22,7 @@ export function formErrorOf(failure: SaveFailure return error; } -export function unexpectedFormError(cause: unknown): FormError { - console.error(cause); - return { global: {} }; -} +export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; function toFormError(cause: unknown, fields: readonly TField[]): FormError | undefined { if (isClerkRuntimeError(cause)) { From 3f7110b7e146642974c2ca9a575d3509c786fe4b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 07:40:55 -0400 Subject: [PATCH 07/63] refactor(mosaic): drop reverification from the account section --- ...ofile-account-section.integration.test.tsx | 8 ------ ...ser-profile-account-section.model.test.tsx | 26 +++---------------- .../user-profile-edit-name.controller.test.ts | 12 +-------- ...r-profile-edit-username.controller.test.ts | 13 +--------- .../user-profile-picture-row.view.test.tsx | 2 +- .../user-profile-picture.controller.test.ts | 12 ++------- .../user-profile-account-section.model.ts | 8 ++---- .../user-profile-account-section.tsx | 10 ++----- .../user-profile-edit-name.controller.ts | 4 +-- .../user-profile-edit-username.controller.ts | 4 +-- .../user-profile-picture.controller.ts | 4 +-- packages/mosaic/src/utils/save-result.ts | 19 +++----------- .../fixtures/user-profile-edit-name.ts | 2 +- .../fixtures/user-profile-edit-username.ts | 2 +- 14 files changed, 24 insertions(+), 102 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index c4435f2e01d..9799f401658 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import type * as ReverificationModule from '../../reverification'; import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; let usernameEnabled: boolean; @@ -30,8 +29,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { return { ...actual, useUser: () => ({ isLoaded: true, user }), - useSession: () => ({ session: { id: 'sess_1' } }), - useReverification: (fetcher: unknown) => fetcher, useClerk: () => ({ __internal_environment: { userSettings: { @@ -46,11 +43,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { }; }); -vi.mock('../../reverification', async importOriginal => ({ - ...(await importOriginal()), - Reverification: () => null, -})); - function renderSection() { return render( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 56327346e6f..019c8bf8a4e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -30,7 +30,6 @@ let user: { } | null; let attributes: Record<'first_name' | 'last_name' | 'username', FakeAttribute>; let environmentHydrated: boolean; -let reverified: ReturnType; function attribute(overrides: Partial = {}): FakeAttribute { return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; @@ -41,13 +40,6 @@ vi.mock('@clerk/shared/react', async importOriginal => { return { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), - useSession: () => ({ session: { id: 'sess_1' } }), - useReverification: (fetcher: (...args: unknown[]) => Promise) => { - return (...args: unknown[]) => { - reverified(...args); - return fetcher(...args); - }; - }, useClerk: () => ({ __internal_environment: environmentHydrated ? { userSettings: { attributes } } : null, }), @@ -83,7 +75,6 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; - reverified = vi.fn(); attributes = { first_name: attribute(), last_name: attribute(), username: attribute() }; user = { firstName: 'Preston', @@ -137,7 +128,6 @@ describe('useUserProfileAccountSectionModel', () => { ], phones: [{ id: 'phone_1', value: '+18018888181', isDefault: false, isVerified: true }], }); - expect(model.reverification).toEqual({ isActive: false }); }); describe('profile picture', () => { @@ -163,7 +153,7 @@ describe('useUserProfileAccountSectionModel', () => { user?.setProfileImage.mockRejectedValue(apiError()); const file = new File(['x'], 'me.png', { type: 'image/png' }); await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ - error: { kind: 'form', global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, + error: { global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, }); }); }); @@ -178,7 +168,6 @@ describe('useUserProfileAccountSectionModel', () => { user?.update.mockRejectedValue(apiError('first_name')); await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ error: { - kind: 'form', fields: { firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, }, @@ -204,7 +193,7 @@ describe('useUserProfileAccountSectionModel', () => { it('returns a Clerk runtime failure by its code, without the developer message', async () => { user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ - error: { kind: 'form', global: { code: 'network_error' } }, + error: { global: { code: 'network_error' } }, }); }); @@ -214,9 +203,8 @@ describe('useUserProfileAccountSectionModel', () => { }); describe('username', () => { - it('saves the username behind reverification', async () => { - await ready().onSubmitUsername?.('preston'); - expect(reverified).toHaveBeenCalledWith('preston'); + it('saves the username', async () => { + await expect(ready().onSubmitUsername?.('preston')).resolves.toEqual({ error: null }); expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); }); @@ -224,7 +212,6 @@ describe('useUserProfileAccountSectionModel', () => { user?.update.mockRejectedValue(apiError('username')); await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ error: { - kind: 'form', fields: { username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, }, @@ -232,11 +219,6 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('reports a cancelled reverification as cancelled, not as a failure', async () => { - user?.update.mockRejectedValue(new ClerkRuntimeError('cancelled', { code: 'reverification_cancelled' })); - await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ error: { kind: 'cancelled' } }); - }); - it('is hidden when the instance does not use usernames', () => { attributes.username = attribute({ enabled: false }); expect(ready().username).toBeUndefined(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 43c484355ad..96ef9d9523e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -12,9 +12,7 @@ import type { UserProfileEditNameField } from '../user-profile-account-section/u type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => - Promise.resolve({ error: { kind: 'form', ...error } }); -const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); +const failed = (error: FormError): Promise => Promise.resolve({ error }); function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); @@ -91,14 +89,6 @@ describe('userProfileEditNameMachine', () => { log.mockRestore(); }); - it('stays open without a banner when the save is cancelled', async () => { - const actor = start(cancelled); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.error).toBeUndefined(); - }); - it('drops the error when the dialog is cancelled', async () => { const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'SAVE' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index c602e44dd84..2106956cf68 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -12,9 +12,7 @@ import type { UserProfileEditUsernameField } from '../user-profile-account-secti type Result = SaveResult; const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => - Promise.resolve({ error: { kind: 'form', ...error } }); -const cancelled = (): Promise => Promise.resolve({ error: { kind: 'cancelled' } }); +const failed = (error: FormError): Promise => Promise.resolve({ error }); function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); @@ -112,15 +110,6 @@ describe('userProfileEditUsernameMachine', () => { expect(saveUsername).not.toHaveBeenCalled(); }); - it('stays open without a banner when the save is cancelled', async () => { - const actor = start(cancelled); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.error).toBeUndefined(); - }); - it('drops the error when the dialog is cancelled', async () => { const actor = start(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'TYPE', value: 'preston' }); 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 index 9fb25d9d760..fbef0a3f8d7 100644 --- 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 @@ -22,7 +22,7 @@ describe('UserProfilePictureRowView', () => { const user = userEvent.setup(); renderView({ hasImage: true, - onRemove: vi.fn().mockResolvedValue({ error: { kind: 'form', global: { code: 'action_blocked' } } }), + onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), }); await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index bb068591fd0..366a10a56dd 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -55,7 +55,7 @@ describe('useUserProfilePictureController', () => { const onChange = vi .fn() .mockResolvedValueOnce({ - error: { kind: 'form', global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, + error: { global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, }) .mockResolvedValueOnce({ error: null }); const { result } = renderController({ onChange }); @@ -68,14 +68,6 @@ describe('useUserProfilePictureController', () => { expect(result.current.error).toBeUndefined(); }); - it('shows nothing when the action is cancelled', async () => { - const onChange = vi.fn().mockResolvedValue({ error: { kind: 'cancelled' } }); - const { result } = renderController({ onChange }); - - await act(async () => result.current.onChange?.(file)); - expect(result.current.error).toBeUndefined(); - }); - it('shows the generic error and logs an unexpected throw', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}); const failure = new TypeError('boom'); @@ -89,7 +81,7 @@ describe('useUserProfilePictureController', () => { }); it('reports a failed removal the same way', async () => { - const onRemove = vi.fn().mockResolvedValue({ error: { kind: 'form', global: { message: 'Nope.' } } }); + const onRemove = vi.fn().mockResolvedValue({ error: { global: { message: 'Nope.' } } }); const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); 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 index 96bef0b8866..9f15ec482db 100644 --- 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 @@ -4,8 +4,6 @@ import type { AttributeData, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import { toSaveResult } from '../../../utils/save-result'; -import type { ReverificationProps } from '../../reverification'; -import { useReverificationWithState } from '../../reverification'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -37,7 +35,7 @@ type UserProfileAccountSectionData = Pick< export type UserProfileAccountSectionModel = | { status: 'loading' } | { status: 'hidden' } - | (UserProfileAccountSectionData & { status: 'ready'; reverification: ReverificationProps }); + | (UserProfileAccountSectionData & { status: 'ready' }); const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; @@ -71,7 +69,6 @@ function toPhones(user: UserResource): UserProfilePhone[] { export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); - const [updateUsername, reverification] = useReverificationWithState((username: string) => user?.update({ username })); if (!isLoaded || !environment) { return { status: 'loading' }; @@ -89,7 +86,6 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'ready', - reverification, name: getFullName(user), firstName: user.firstName ?? '', lastName: user.lastName ?? '', @@ -107,7 +103,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo : value => toSaveResult(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable - ? username => toSaveResult(() => updateUsername(username), USERNAME_FIELDS) + ? username => toSaveResult(() => user.update({ username }), USERNAME_FIELDS) : 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 index 27e9d37fcd9..4b902832b22 100644 --- 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 @@ -1,4 +1,3 @@ -import { Reverification } from '../../reverification'; import { useUserProfileAccountSectionModel } from './user-profile-account-section.model'; import { UserProfileAccountSectionView } from './user-profile-account-section.view'; @@ -9,12 +8,7 @@ export function UserProfileAccountSection() { return null; } - const { status: _status, reverification, ...viewProps } = model; + const { status: _status, ...viewProps } = model; - return ( - <> - - - - ); + return ; } 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-account-section/user-profile-edit-name.controller.ts index 4f95b1f36cf..4c15962b310 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { @@ -63,7 +63,7 @@ export const userProfileEditNameMachine = createMachine({ { guard: (_, event) => event.output.error !== null, target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + actions: assign((_, event) => ({ error: event.output.error ?? undefined })), }, { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], 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-account-section/user-profile-edit-username.controller.ts index a68664daf8f..893fc935b96 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,7 +1,7 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; import type { FormError, SaveResult } from '../../../utils/save-result'; -import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { UNEXPECTED_ERROR } from '../../../utils/save-result'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { @@ -58,7 +58,7 @@ export const userProfileEditUsernameMachine = createMachine({ { guard: (_, event) => event.output.error !== null, target: 'editing', - actions: assign((_, event) => ({ error: formErrorOf(event.output.error) })), + actions: assign((_, event) => ({ error: event.output.error ?? undefined })), }, { target: 'idle', actions: assign(() => ({ error: undefined })) }, ], 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-account-section/user-profile-picture.controller.ts index bfcc0775d49..074adf1713e 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-account-section/user-profile-picture.controller.ts @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; import type { SaveResult } from '../../../utils/save-result'; -import { formErrorOf, UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { UNEXPECTED_ERROR } from '../../../utils/save-result'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -33,7 +33,7 @@ export function useUserProfilePictureController({ setError(undefined); try { const result = await action(); - setError(formErrorOf(result.error)?.global); + setError(result.error?.global); } catch (cause) { console.error(cause); setError(UNEXPECTED_ERROR); diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/save-result.ts index e4bedf8baed..afd19950908 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/save-result.ts @@ -1,4 +1,4 @@ -import { isClerkAPIResponseError, isClerkRuntimeError, isReverificationCancelledError } from '@clerk/shared/error'; +import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; import type { LocalizableError } from '../localization'; @@ -8,18 +8,8 @@ export interface FormError { fields?: Partial>; } -export type SaveFailure = { kind: 'cancelled' } | ({ kind: 'form' } & FormError); - export interface SaveResult { - error: SaveFailure | null; -} - -export function formErrorOf(failure: SaveFailure | null): FormError | undefined { - if (failure?.kind !== 'form') { - return undefined; - } - const { kind: _kind, ...error } = failure; - return error; + error: FormError | null; } export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; @@ -57,13 +47,10 @@ export async function toSaveResult( await run(); return { error: null }; } catch (cause) { - if (isReverificationCancelledError(cause)) { - return { error: { kind: 'cancelled' } }; - } const error = toFormError(cause, fields); if (!error) { throw cause; } - return { error: { kind: 'form', ...error } }; + return { error }; } } diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts index 67ef43013ae..8cff25098ce 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -32,7 +32,7 @@ export function useUserProfileEditNameFixture({ onSubmitName: async (value: UserProfileEditNameValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: { kind: 'form' as const, ...failWith } }; + return { error: failWith }; } setName(value); return { error: null }; diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts index 73d049889d5..e639a441204 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -23,7 +23,7 @@ export function useUserProfileEditUsernameFixture({ onSubmitUsername: async (value: string) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: { kind: 'form' as const, ...failWith } }; + return { error: failWith }; } setUsername(value); return { error: null }; From 6600231831f578e1f6828d2622a951597608e75f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 07:53:15 -0400 Subject: [PATCH 08/63] fix(mosaic): clear a rejected picture pick when removing --- .../user-profile-picture-row.view.test.tsx | 26 ++++++++++++++++++- .../user-profile-picture-row.view.tsx | 9 ++++++- 2 files changed, 33 insertions(+), 2 deletions(-) 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 index fbef0a3f8d7..88e7d48c8c4 100644 --- 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 @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -31,6 +31,30 @@ describe('UserProfilePictureRowView', () => { expect(await screen.findByRole('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().mockResolvedValue({ error: { 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, 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-account-section/user-profile-picture-row.view.tsx index 967d5499406..88c888e7916 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-account-section/user-profile-picture-row.view.tsx @@ -37,6 +37,13 @@ export function UserProfilePictureRowView({ const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejectionError, setRejectionError] = useState(); const displayedError = rejectionError ?? (controller.error ? errorText(controller.error) : undefined); + const remove = controller.onRemove; + const handleRemove = remove + ? () => { + setRejectionError(undefined); + return remove(); + } + : undefined; const initials = name .split(/\s+/) .map(part => part[0]) @@ -81,7 +88,7 @@ export function UserProfilePictureRowView({ {displayedError} From c3fd5a1fb8e9cde175a6e35663e6bf4410248a1e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:03:12 -0400 Subject: [PATCH 09/63] fix(mosaic): offer to set a username the user does not have --- .../user-profile-username-row.view.test.tsx | 24 +++++++++++++++++++ .../user-profile-account-section.messages.ts | 2 ++ .../user-profile-edit-username.dialog.tsx | 4 +++- .../user-profile-username-row.view.tsx | 6 +++-- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx 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 new file mode 100644 index 00000000000..30c1fb16301 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-username-row.view.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileUsernameRowView } from '../user-profile-account-section/user-profile-username-row.view'; + +describe('UserProfileUsernameRowView', () => { + it('offers to set a username the user does not have yet', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + expect(document.querySelector('.cl-section-description')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Set username' })); + expect(screen.getByRole('dialog', { name: 'Set username' })).toBeInTheDocument(); + }); +}); 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-account-section/user-profile-account-section.messages.ts index 985d6683d9a..5d0d444ac79 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-account-section/user-profile-account-section.messages.ts @@ -32,8 +32,10 @@ export const userProfileAccountSectionMessages = { username: { label: 'Username', edit: 'Edit username', + set: 'Set username', dialogTitle: 'Edit username', + setDialogTitle: 'Set username', fieldLabel: 'Username', cancel: 'Cancel', save: 'Save changes', 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-account-section/user-profile-edit-username.dialog.tsx index 335559accbf..b00c06aa406 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-account-section/user-profile-edit-username.dialog.tsx @@ -19,6 +19,7 @@ export interface UserProfileEditUsernameDialogProps { trigger?: DialogTriggerProps['render']; username: string; onUsernameChange: (value: string) => void; + title?: string; canSave?: boolean; isSaving?: boolean; error?: FormError; @@ -31,6 +32,7 @@ export function UserProfileEditUsernameDialog({ trigger, username, onUsernameChange, + title, canSave = true, isSaving = false, error, @@ -63,7 +65,7 @@ export function UserProfileEditUsernameDialog({ renderBranding={false} > - {m.username.dialogTitle} + {title ?? m.username.dialogTitle} {m.username.label} - {username} + {username ? {username} : null} {onSubmit ? ( @@ -42,18 +42,20 @@ function EditUsername({ }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); + const isSet = Boolean(username); return ( - {m.username.edit} + {isSet ? m.username.edit : m.username.set} } /> From 467fe635919135d5dc566c06037f77fd77749e63 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:07:34 -0400 Subject: [PATCH 10/63] fix(mosaic): hide contacts the instance does not collect --- ...ser-profile-account-section.model.test.tsx | 19 +++++++++++++++++-- .../user-profile-account-section.model.ts | 6 ++++-- .../user-profile-account-section.view.tsx | 17 +++++++++-------- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 019c8bf8a4e..601c434f885 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -28,7 +28,7 @@ let user: { setProfileImage: ReturnType; update: ReturnType; } | null; -let attributes: Record<'first_name' | 'last_name' | 'username', FakeAttribute>; +let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; let environmentHydrated: boolean; function attribute(overrides: Partial = {}): FakeAttribute { @@ -75,7 +75,13 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; - attributes = { first_name: attribute(), last_name: attribute(), username: attribute() }; + attributes = { + first_name: attribute(), + last_name: attribute(), + username: attribute(), + email_address: attribute(), + phone_number: attribute(), + }; user = { firstName: 'Preston', lastName: 'Booth', @@ -130,6 +136,15 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('leaves out the contacts the instance does not collect', () => { + attributes.email_address = attribute({ enabled: false }); + attributes.phone_number = attribute({ enabled: false }); + const model = ready(); + + expect(model.emails).toBeUndefined(); + expect(model.phones).toBeUndefined(); + }); + describe('profile picture', () => { it('uploads the picked file', async () => { const file = new File(['x'], 'me.png', { type: 'image/png' }); 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 index 9f15ec482db..0934fc2987e 100644 --- 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 @@ -83,6 +83,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const usernameImmutable = Boolean(usernameAttribute?.immutable); const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); const nameReadOnly = user.enterpriseAccounts.some(account => account.active); + const showEmails = isAttributeAvailable(attributes.email_address); + const showPhones = isAttributeAvailable(attributes.phone_number); return { status: 'ready', @@ -94,8 +96,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, - emails: toEmails(user), - phones: toPhones(user), + emails: showEmails ? toEmails(user) : undefined, + phones: showPhones ? toPhones(user) : undefined, onProfilePictureChange: file => toSaveResult(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => toSaveResult(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameReadOnly 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 index 1fbe41cec7d..75086ae876c 100644 --- 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 @@ -36,8 +36,9 @@ export interface UserProfileAccountSectionViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - emails: UserProfileEmail[]; - phones: UserProfilePhone[]; + /** Left out when the instance does not collect the attribute, which drops the row. */ + emails?: UserProfileEmail[]; + phones?: UserProfilePhone[]; onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; onRemoveProfilePicture?: () => Promise; @@ -91,7 +92,7 @@ export function UserProfileAccountSectionView({ }: UserProfileAccountSectionViewProps) { const m = useMessages('userProfileAccountSection'); const showName = firstNameAttribute?.enabled !== false || lastNameAttribute?.enabled !== false; - const phoneRow = ( + const phoneRow = phones ? ( - ); - const emailRow = ( + ) : null; + const emailRow = emails ? ( - ); + ) : null; return (
@@ -150,12 +151,12 @@ export function UserProfileAccountSectionView({ {!allowMultipleAccounts ? phoneRow : null} - {allowMultipleAccounts ? ( + {allowMultipleAccounts && emailRow ? ( {emailRow} ) : null} - {allowMultipleAccounts ? ( + {allowMultipleAccounts && phoneRow ? ( {phoneRow} From 3b0d8c7645191001ad3c4cb2c3e5c3ed4ce07fb9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:19:18 -0400 Subject: [PATCH 11/63] fix(mosaic): always separate the account section's contact cards --- .../user-profile-account-section.model.ts | 2 ++ 1 file changed, 2 insertions(+) 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 index 0934fc2987e..acb9f6e6cdd 100644 --- 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 @@ -16,6 +16,7 @@ import type { UserProfileEditUsernameField } from './user-profile-edit-username. type UserProfileAccountSectionData = Pick< UserProfileAccountSectionViewProps, + | 'allowMultipleAccounts' | 'name' | 'imageUrl' | 'hasImage' @@ -88,6 +89,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'ready', + allowMultipleAccounts: true, name: getFullName(user), firstName: user.firstName ?? '', lastName: user.lastName ?? '', From 43c425c47b53d342d427d70835c383ae523ad70e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:30:51 -0400 Subject: [PATCH 12/63] fix(mosaic): drop the name row's description when there is no name --- .../user-profile-name-row.view.test.tsx | 20 +++++++++++++++++++ .../user-profile-name-row.view.tsx | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx 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 new file mode 100644 index 00000000000..70aa39e3730 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-name-row.view.test.tsx @@ -0,0 +1,20 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MosaicProvider } from '../../../MosaicProvider'; +import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; + +describe('UserProfileNameRowView', () => { + it('leaves the description out when the user has no name', () => { + render( + + + , + ); + + expect(document.querySelector('.cl-section-description')).toBeNull(); + }); +}); 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 index 1b7b9cf44b1..60c215534d6 100644 --- 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 @@ -30,7 +30,7 @@ export function UserProfileNameRowView({ {m.name.label} - {name} + {name ? {name} : null} {onSubmit ? ( From bb77fa5bfefae2de25791eeb8256e5884008381a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 22 Sep 2026 19:32:37 -0400 Subject: [PATCH 13/63] fix(mosaic): offer to set a name the user does not have --- .../__tests__/user-profile-name-row.view.test.tsx | 8 ++++++-- .../user-profile-account-section.messages.ts | 2 ++ .../user-profile-edit-name.dialog.tsx | 4 +++- .../user-profile-name-row.view.tsx | 6 +++++- 4 files changed, 16 insertions(+), 4 deletions(-) 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 index 70aa39e3730..946a67215ba 100644 --- 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 @@ -1,11 +1,13 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; describe('UserProfileNameRowView', () => { - it('leaves the description out when the user has no name', () => { + it('offers to set a name the user does not have yet', async () => { + const user = userEvent.setup(); render( { ); expect(document.querySelector('.cl-section-description')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Set name' })); + expect(screen.getByRole('dialog', { name: 'Set name' })).toBeInTheDocument(); }); }); 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-account-section/user-profile-account-section.messages.ts index 5d0d444ac79..575b6bf9e40 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-account-section/user-profile-account-section.messages.ts @@ -22,8 +22,10 @@ export const userProfileAccountSectionMessages = { name: { label: 'Name', edit: 'Edit name', + set: 'Set name', dialogTitle: 'Edit name', + setDialogTitle: 'Set name', firstNameLabel: 'First name', lastNameLabel: 'Last name', cancel: 'Cancel', 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-account-section/user-profile-edit-name.dialog.tsx index c9571d88231..05ba7b04bd5 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-account-section/user-profile-edit-name.dialog.tsx @@ -31,6 +31,7 @@ export interface UserProfileEditNameDialogProps { lastNameAttribute?: UserProfileNameAttribute; onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; + title?: string; isSaving?: boolean; error?: FormError; onSubmit: () => void; @@ -51,6 +52,7 @@ export function UserProfileEditNameDialog({ lastNameAttribute = {}, onFirstNameChange, onLastNameChange, + title, isSaving = false, error, onSubmit, @@ -85,7 +87,7 @@ export function UserProfileEditNameDialog({ renderBranding={false} > - {m.name.dialogTitle} + {title ?? m.name.dialogTitle} - {m.name.edit} + {isSet ? m.name.edit : m.name.set} } /> From e51c8fb7b860e66547ee2c15b7299b6a147dcf3b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 14:01:27 -0400 Subject: [PATCH 14/63] refactor(mosaic): carry save failures on one rejection A save either resolves or rejects with a `SaveError` holding the structured, localizable payload. Drops the parallel `SaveResult` return channel and the password section's string bridge, which flattened error codes to messages and so could never reach the message catalog. --- packages/mosaic/.vitest/json/output.json | 39 ++++++++++++++++ ...ser-profile-account-section.model.test.tsx | 41 ++++++++++------- .../user-profile-edit-name.controller.test.ts | 11 +++-- ...r-profile-edit-username.controller.test.ts | 11 +++-- ...ser-profile-password-section.view.test.tsx | 7 +-- .../user-profile-picture-row.view.test.tsx | 5 ++- .../user-profile-picture.controller.test.ts | 14 +++--- .../user-profile-account-section.model.ts | 10 ++--- .../user-profile-account-section.view.tsx | 12 +++-- .../user-profile-edit-name.controller.ts | 21 +++------ .../user-profile-edit-name.dialog.tsx | 2 +- .../user-profile-edit-username.controller.ts | 19 +++----- .../user-profile-edit-username.dialog.tsx | 2 +- .../user-profile-name-row.view.tsx | 7 ++- .../user-profile-picture-row.view.tsx | 5 +-- .../user-profile-picture.controller.ts | 15 +++---- .../user-profile-username-row.view.tsx | 12 +---- .../utils/{save-result.ts => form-error.ts} | 44 ++++++++++++++----- 18 files changed, 157 insertions(+), 120 deletions(-) create mode 100644 packages/mosaic/.vitest/json/output.json rename packages/mosaic/src/utils/{save-result.ts => form-error.ts} (52%) diff --git a/packages/mosaic/.vitest/json/output.json b/packages/mosaic/.vitest/json/output.json new file mode 100644 index 00000000000..2bbcde4d5c3 --- /dev/null +++ b/packages/mosaic/.vitest/json/output.json @@ -0,0 +1,39 @@ +{ + "numTotalTestSuites": 1, + "numPassedTestSuites": 0, + "numFailedTestSuites": 1, + "numPendingTestSuites": 0, + "numTotalTests": 0, + "numPassedTests": 0, + "numFailedTests": 0, + "numPendingTests": 0, + "numTodoTests": 0, + "snapshot": { + "added": 0, + "failure": false, + "filesAdded": 0, + "filesRemoved": 0, + "filesRemovedList": [], + "filesUnmatched": 0, + "filesUpdated": 0, + "matched": 0, + "total": 0, + "unchecked": 0, + "uncheckedKeysByFile": [], + "unmatched": 0, + "updated": 0, + "didUpdate": false + }, + "startTime": 1790015468853, + "success": false, + "testResults": [ + { + "assertionResults": [], + "startTime": 1790015468853, + "endTime": 1790015468853, + "status": "failed", + "message": "Failed to resolve import \"../user-profile-account-section/user-profile-account-section.model\" from \"src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx\". Does the file exist?", + "name": "/Users/alex/Repos/javascript.carp-mosaic-user-profile-avatar-wireup/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx" + } + ] +} diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 601c434f885..d9623189b3d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -3,6 +3,8 @@ import type * as SharedReact from '@clerk/shared/react'; import { renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FormError } from '../../../utils/form-error'; +import { SaveError } from '../../../utils/form-error'; import { useUserProfileAccountSectionModel } from '../user-profile-account-section/user-profile-account-section.model'; interface FakeAttribute { @@ -50,6 +52,15 @@ function renderModel() { return renderHook(() => useUserProfileAccountSectionModel()).result.current; } +async function rejection(save: Promise | undefined): Promise { + try { + await save; + return undefined; + } catch (cause) { + return cause instanceof SaveError ? cause.formError : undefined; + } +} + function ready() { const model = renderModel(); if (model.status !== 'ready') { @@ -148,7 +159,7 @@ describe('useUserProfileAccountSectionModel', () => { describe('profile picture', () => { it('uploads the picked file', async () => { const file = new File(['x'], 'me.png', { type: 'image/png' }); - await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ error: null }); + await ready().onProfilePictureChange?.(file); expect(user?.setProfileImage).toHaveBeenCalledWith({ file }); }); @@ -167,25 +178,23 @@ describe('useUserProfileAccountSectionModel', () => { it('returns the API message when the upload fails', async () => { user?.setProfileImage.mockRejectedValue(apiError()); const file = new File(['x'], 'me.png', { type: 'image/png' }); - await expect(ready().onProfilePictureChange?.(file)).resolves.toEqual({ - error: { global: { code: 'form_param_invalid', message: 'That value is invalid.' } }, + await expect(rejection(ready().onProfilePictureChange?.(file))).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, }); }); }); describe('name', () => { it('saves both halves of the name', async () => { - await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ error: null }); + await ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }); expect(user?.update).toHaveBeenCalledWith({ firstName: 'Pres', lastName: 'B' }); }); it('maps a field error onto the control that caused it', async () => { user?.update.mockRejectedValue(apiError('first_name')); - await expect(ready().onSubmitName?.({ firstName: '', lastName: 'B' })).resolves.toEqual({ - error: { - fields: { - firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, - }, + await expect(rejection(ready().onSubmitName?.({ firstName: '', lastName: 'B' }))).resolves.toEqual({ + fields: { + firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, }, }); }); @@ -207,8 +216,8 @@ describe('useUserProfileAccountSectionModel', () => { it('returns a Clerk runtime failure by its code, without the developer message', async () => { user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); - await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).resolves.toEqual({ - error: { global: { code: 'network_error' } }, + await expect(rejection(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }))).resolves.toEqual({ + global: { code: 'network_error' }, }); }); @@ -219,17 +228,15 @@ describe('useUserProfileAccountSectionModel', () => { describe('username', () => { it('saves the username', async () => { - await expect(ready().onSubmitUsername?.('preston')).resolves.toEqual({ error: null }); + await ready().onSubmitUsername?.('preston'); expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); }); it('maps a username error onto the field', async () => { user?.update.mockRejectedValue(apiError('username')); - await expect(ready().onSubmitUsername?.('x')).resolves.toEqual({ - error: { - fields: { - username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, - }, + await expect(rejection(ready().onSubmitUsername?.('x'))).resolves.toEqual({ + fields: { + username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, }, }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index 96ef9d9523e..c2ad1a81714 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -2,19 +2,18 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { FormError, SaveResult } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { SaveError } from '../../../utils/form-error'; import { userProfileEditNameMachine, useUserProfileEditNameController, } from '../user-profile-account-section/user-profile-edit-name.controller'; import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; -type Result = SaveResult; +const saved = (): Promise => Promise.resolve(); +const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); -const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => Promise.resolve({ error }); - -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { +function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); actor.send({ type: 'OPEN' }); return actor; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 2106956cf68..bd9d03dd7ad 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -2,19 +2,18 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../../machine/createActor'; -import type { FormError, SaveResult } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { SaveError } from '../../../utils/form-error'; import { userProfileEditUsernameMachine, useUserProfileEditUsernameController, } from '../user-profile-account-section/user-profile-edit-username.controller'; import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; -type Result = SaveResult; +const saved = (): Promise => Promise.resolve(); +const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); -const saved = (): Promise => Promise.resolve({ error: null }); -const failed = (error: FormError): Promise => Promise.resolve({ error }); - -function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { +function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); actor.send({ type: 'OPEN' }); return actor; diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 3abe4b71818..7754247812a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; +import { SaveError } from '../../../utils/form-error'; import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; @@ -66,8 +66,9 @@ describe('UserProfilePasswordSectionView', () => { const onSubmitPassword = vi .fn() .mockRejectedValueOnce( - new UserProfileSaveError('Your password could not be updated.', { - currentPassword: 'Incorrect password.', + new SaveError({ + global: { message: 'Your password could not be updated.' }, + fields: { currentPassword: { message: 'Incorrect password.' } }, }), ) .mockResolvedValue(undefined); 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 index 88e7d48c8c4..f71b5153422 100644 --- 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 @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; +import { SaveError } from '../../../utils/form-error'; 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'; @@ -22,7 +23,7 @@ describe('UserProfilePictureRowView', () => { const user = userEvent.setup(); renderView({ hasImage: true, - onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), + onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), }); await user.click(screen.getByRole('button', { name: 'Manage profile picture' })); @@ -36,7 +37,7 @@ describe('UserProfilePictureRowView', () => { const { container } = renderView({ hasImage: true, onChange: vi.fn(), - onRemove: vi.fn().mockResolvedValue({ error: { global: { code: 'action_blocked' } } }), + onRemove: vi.fn().mockRejectedValue(new SaveError({ global: { code: 'action_blocked' } })), }); const input = container.querySelector('input[type="file"]'); if (!(input instanceof HTMLInputElement)) { diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index 366a10a56dd..a92f99ed67b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -2,7 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import type { SaveResult } from '../../../utils/save-result'; +import { SaveError } from '../../../utils/form-error'; import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; import { useUserProfilePictureController } from '../user-profile-account-section/user-profile-picture.controller'; @@ -21,7 +21,7 @@ describe('useUserProfilePictureController', () => { it('is pending while the upload runs', async () => { const upload = createDeferredPromise(); - const onChange = vi.fn(() => upload.promise.then(() => ({ error: null }))); + const onChange = vi.fn(() => upload.promise.then(() => undefined)); const { result } = renderController({ onChange }); act(() => { @@ -39,7 +39,7 @@ describe('useUserProfilePictureController', () => { }); it('ignores a second pick while one is in flight', () => { - const onChange = vi.fn(() => new Promise(() => {})); + const onChange = vi.fn(() => new Promise(() => {})); const { result } = renderController({ onChange }); act(() => { @@ -54,10 +54,8 @@ describe('useUserProfilePictureController', () => { it('shows why the upload failed, and clears it on the next attempt', async () => { const onChange = vi .fn() - .mockResolvedValueOnce({ - error: { global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } }, - }) - .mockResolvedValueOnce({ error: null }); + .mockRejectedValueOnce(new SaveError({ global: { code: 'avatar_file_size_exceeded', message: 'Too big.' } })) + .mockResolvedValueOnce(undefined); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); @@ -81,7 +79,7 @@ describe('useUserProfilePictureController', () => { }); it('reports a failed removal the same way', async () => { - const onRemove = vi.fn().mockResolvedValue({ error: { global: { message: 'Nope.' } } }); + const onRemove = vi.fn().mockRejectedValue(new SaveError({ global: { message: 'Nope.' } })); const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); 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 index acb9f6e6cdd..d06f4fb9ddf 100644 --- 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 @@ -3,7 +3,7 @@ import { useUser } from '@clerk/shared/react'; import type { AttributeData, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { toSaveResult } from '../../../utils/save-result'; +import { save } from '../../../utils/form-error'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -100,14 +100,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo username: showUsername ? (user.username ?? '') : undefined, emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, - onProfilePictureChange: file => toSaveResult(() => user.setProfileImage({ file })), - onRemoveProfilePicture: user.hasImage ? () => toSaveResult(() => user.setProfileImage({ file: null })) : undefined, + onProfilePictureChange: file => save(() => user.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameReadOnly ? undefined - : value => toSaveResult(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + : value => save(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable - ? username => toSaveResult(() => user.update({ username }), USERNAME_FIELDS) + ? username => save(() => user.update({ username }), USERNAME_FIELDS) : undefined, }; } 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 index 75086ae876c..1de473c3f6f 100644 --- 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 @@ -3,15 +3,13 @@ import * as stylex from '@stylexjs/stylex'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; -import type { SaveResult } from '../../../utils/save-result'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; +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'; @@ -39,11 +37,11 @@ export interface UserProfileAccountSectionViewProps { /** Left out when the instance does not collect the attribute, which drops the row. */ emails?: UserProfileEmail[]; phones?: UserProfilePhone[]; - onProfilePictureChange?: (file: File) => Promise; + onProfilePictureChange?: (file: File) => Promise; onProfilePictureReject?: (rejections: FileRejection[]) => void; - onRemoveProfilePicture?: () => Promise; - onSubmitName?: (value: UserProfileEditNameValue) => Promise>; - onSubmitUsername?: (username: string) => Promise>; + onRemoveProfilePicture?: () => Promise; + onSubmitName?: (value: UserProfileEditNameValue) => Promise; + onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; onSendEmailCode?: (emailAddress: string) => Promise; onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; 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-account-section/user-profile-edit-name.controller.ts index 4c15962b310..5ee4aa2610a 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,11 +1,11 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { FormError, SaveResult } from '../../../utils/save-result'; -import { UNEXPECTED_ERROR } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { toFormError } from '../../../utils/form-error'; import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise>; + saveName: (value: UserProfileEditNameValue) => Promise; /** Injected every render. What `OPEN` seeds the fields from. */ savedFirstName: string; savedLastName: string; @@ -59,17 +59,10 @@ export const userProfileEditNameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveName({ firstName: context.firstName, lastName: context.lastName }), { - onDone: [ - { - guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: event.output.error ?? undefined })), - }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, - ], + onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, onError: { target: 'editing', - actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], + actions: assign((_, event) => ({ error: toFormError(event.error) })), }, }), }, @@ -79,8 +72,8 @@ export const userProfileEditNameMachine = createMachine({ export interface UserProfileEditNameControllerOptions { firstName?: string; lastName?: string; - /** Resolve with `{ error: null }` to close the dialog, or with an error to keep it open showing why. */ - onSubmit: (value: UserProfileEditNameValue) => Promise>; + /** Resolve to close the dialog, or reject with a `SaveError` to keep it open showing why. */ + onSubmit: (value: UserProfileEditNameValue) => Promise; } export interface UserProfileEditNameController { 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-account-section/user-profile-edit-name.dialog.tsx index 05ba7b04bd5..7d2b6281c31 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-account-section/user-profile-edit-name.dialog.tsx @@ -9,7 +9,7 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; 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-account-section/user-profile-edit-username.controller.ts index 893fc935b96..e4dbe813c22 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,11 +1,11 @@ import { setup } from '../../../machine/setup'; import { useMachine } from '../../../machine/useMachine'; -import type { FormError, SaveResult } from '../../../utils/save-result'; -import { UNEXPECTED_ERROR } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; +import { toFormError } from '../../../utils/form-error'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise>; + saveUsername: (username: string) => Promise; savedUsername: string; username: string; error: FormError | undefined; @@ -54,17 +54,10 @@ export const userProfileEditUsernameMachine = createMachine({ }, saving: { invoke: fromPromise(context => context.saveUsername(context.username), { - onDone: [ - { - guard: (_, event) => event.output.error !== null, - target: 'editing', - actions: assign((_, event) => ({ error: event.output.error ?? undefined })), - }, - { target: 'idle', actions: assign(() => ({ error: undefined })) }, - ], + onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, onError: { target: 'editing', - actions: [(_, event) => console.error(event.error), assign(() => ({ error: { global: UNEXPECTED_ERROR } }))], + actions: assign((_, event) => ({ error: toFormError(event.error) })), }, }), }, @@ -73,7 +66,7 @@ export const userProfileEditUsernameMachine = createMachine({ export interface UserProfileEditUsernameControllerOptions { username?: string; - onSubmit: (username: string) => Promise>; + onSubmit: (username: string) => Promise; } export interface UserProfileEditUsernameController { 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-account-section/user-profile-edit-username.dialog.tsx index b00c06aa406..b57855a91a5 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-account-section/user-profile-edit-username.dialog.tsx @@ -9,7 +9,7 @@ import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Input } from '../../../components/input'; import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/save-result'; +import type { FormError } from '../../../utils/form-error'; export type UserProfileEditUsernameField = 'username'; 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 index de36323a3d8..42a90278442 100644 --- 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 @@ -1,10 +1,9 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { SaveResult } from '../../../utils/save-result'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; import { useUserProfileEditNameController } from './user-profile-edit-name.controller'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; import { UserProfileEditNameDialog } from './user-profile-edit-name.dialog'; export interface UserProfileNameRowViewProps { @@ -13,7 +12,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit?: (value: UserProfileEditNameValue) => Promise>; + onSubmit?: (value: UserProfileEditNameValue) => Promise; } export function UserProfileNameRowView({ @@ -62,7 +61,7 @@ function EditName({ lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onSubmit: (value: UserProfileEditNameValue) => Promise>; + onSubmit: (value: UserProfileEditNameValue) => Promise; }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditNameController({ firstName, lastName, onSubmit }); 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-account-section/user-profile-picture-row.view.tsx index 88c888e7916..eb48a29b2ac 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-account-section/user-profile-picture-row.view.tsx @@ -8,7 +8,6 @@ import { Section } from '../../../components/section'; import { useErrorText, useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; -import type { SaveResult } from '../../../utils/save-result'; import { useUserProfilePictureController } from './user-profile-picture.controller'; const PROFILE_PICTURE_MIME_TYPES = 'image/png,image/jpeg,image/gif,image/webp'; @@ -19,9 +18,9 @@ export interface UserProfilePictureRowViewProps { name: string; imageUrl?: string; hasImage?: boolean; - onChange?: (file: File) => Promise; + onChange?: (file: File) => Promise; onReject?: (rejections: FileRejection[]) => void; - onRemove?: () => Promise; + onRemove?: () => Promise; } export function UserProfilePictureRowView({ 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-account-section/user-profile-picture.controller.ts index 074adf1713e..0b59b6fb643 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-account-section/user-profile-picture.controller.ts @@ -1,12 +1,11 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; -import type { SaveResult } from '../../../utils/save-result'; -import { UNEXPECTED_ERROR } from '../../../utils/save-result'; +import { toFormError } from '../../../utils/form-error'; export interface UserProfilePictureControllerOptions { - onChange?: (file: File) => Promise; - onRemove?: () => Promise; + onChange?: (file: File) => Promise; + onRemove?: () => Promise; } export interface UserProfilePictureController { @@ -24,7 +23,7 @@ export function useUserProfilePictureController({ const [error, setError] = useState(); const inFlight = useRef(false); - const run = async (action: () => Promise) => { + const run = async (action: () => Promise) => { if (inFlight.current) { return; } @@ -32,11 +31,9 @@ export function useUserProfilePictureController({ setIsPending(true); setError(undefined); try { - const result = await action(); - setError(result.error?.global); + await action(); } catch (cause) { - console.error(cause); - setError(UNEXPECTED_ERROR); + setError(toFormError(cause).global); } finally { inFlight.current = false; setIsPending(false); 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 index e0f35042151..369abaa9b6b 100644 --- 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 @@ -1,14 +1,12 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; -import type { SaveResult } from '../../../utils/save-result'; import { useUserProfileEditUsernameController } from './user-profile-edit-username.controller'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dialog'; export interface UserProfileUsernameRowViewProps { username: string; - onSubmit?: (username: string) => Promise>; + onSubmit?: (username: string) => Promise; } export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { @@ -33,13 +31,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs ); } -function EditUsername({ - username, - onSubmit, -}: { - username: string; - onSubmit: (username: string) => Promise>; -}) { +function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { const m = useMessages('userProfileAccountSection'); const controller = useUserProfileEditUsernameController({ username, onSubmit }); const isSet = Boolean(username); diff --git a/packages/mosaic/src/utils/save-result.ts b/packages/mosaic/src/utils/form-error.ts similarity index 52% rename from packages/mosaic/src/utils/save-result.ts rename to packages/mosaic/src/utils/form-error.ts index afd19950908..f5d9b13a258 100644 --- a/packages/mosaic/src/utils/save-result.ts +++ b/packages/mosaic/src/utils/form-error.ts @@ -8,13 +8,23 @@ export interface FormError { fields?: Partial>; } -export interface SaveResult { - error: FormError | null; -} - export const UNEXPECTED_ERROR: LocalizableError = { code: 'generic' }; -function toFormError(cause: unknown, fields: readonly TField[]): FormError | undefined { +/** What a save rejects with when the failure is the user's to fix; anything else propagates. */ +export class SaveError extends Error { + readonly formError: FormError; + + constructor(formError: FormError) { + super(formError.global?.message ?? 'Save failed'); + this.name = 'SaveError'; + this.formError = formError; + } +} + +function toClerkFormError( + cause: unknown, + fields: readonly TField[], +): FormError | undefined { if (isClerkRuntimeError(cause)) { return { global: { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}) } }; } @@ -39,18 +49,30 @@ function toFormError(cause: unknown, fields: readonly TFi return error; } -export async function toSaveResult( +/** + * Runs a save and rejects with a `SaveError` the view can render. `fields` names the controls the + * failure may be routed to; an error that names none of them lands in `global`. + */ +export async function save( run: () => Promise, fields: readonly TField[] = [], -): Promise> { +): Promise { try { await run(); - return { error: null }; } catch (cause) { - const error = toFormError(cause, fields); - if (!error) { + const formError = toClerkFormError(cause, fields); + if (!formError) { throw cause; } - return { error }; + throw new SaveError(formError); + } +} + +/** Reads what a rejected save left for the view. An unrecognized rejection is the generic error. */ +export function toFormError(cause: unknown): FormError { + if (cause instanceof SaveError) { + return cause.formError; } + console.error(cause); + return { global: UNEXPECTED_ERROR }; } From b247231b638366a6bd72ba5f9ba8d612e4947027 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:47:20 -0400 Subject: [PATCH 15/63] fix(mosaic): offer to add the name and username the user has not set An empty row now reads "No name added" / "No username added" next to an Add action, rather than dropping the description and labelling the action Set. --- .../user-profile-name-row.view.test.tsx | 24 +++++++++++++++---- .../user-profile-username-row.view.test.tsx | 24 +++++++++++++++---- .../user-profile-account-section.messages.ts | 10 ++++---- .../user-profile-name-row.view.tsx | 6 ++--- .../user-profile-username-row.view.tsx | 6 ++--- 5 files changed, 52 insertions(+), 18 deletions(-) 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 index 946a67215ba..23caf7b8757 100644 --- 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 @@ -6,7 +6,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileNameRowView } from '../user-profile-account-section/user-profile-name-row.view'; describe('UserProfileNameRowView', () => { - it('offers to set a name the user does not have yet', async () => { + it('offers to add a name the user does not have yet', async () => { const user = userEvent.setup(); render( @@ -17,8 +17,24 @@ describe('UserProfileNameRowView', () => { , ); - expect(document.querySelector('.cl-section-description')).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Set name' })); - expect(screen.getByRole('dialog', { name: 'Set name' })).toBeInTheDocument(); + 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(); }); }); 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 index 30c1fb16301..9779b3210b0 100644 --- 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 @@ -6,7 +6,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileUsernameRowView } from '../user-profile-account-section/user-profile-username-row.view'; describe('UserProfileUsernameRowView', () => { - it('offers to set a username the user does not have yet', async () => { + it('offers to add a username the user does not have yet', async () => { const user = userEvent.setup(); render( @@ -17,8 +17,24 @@ describe('UserProfileUsernameRowView', () => { , ); - expect(document.querySelector('.cl-section-description')).toBeNull(); - await user.click(screen.getByRole('button', { name: 'Set username' })); - expect(screen.getByRole('dialog', { name: 'Set username' })).toBeInTheDocument(); + 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/user-profile-account-section/user-profile-account-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-account-section.messages.ts index 575b6bf9e40..3f17c4e172b 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-account-section/user-profile-account-section.messages.ts @@ -21,11 +21,12 @@ export const userProfileAccountSectionMessages = { }, name: { label: 'Name', + empty: 'No name added', edit: 'Edit name', - set: 'Set name', + add: 'Add name', dialogTitle: 'Edit name', - setDialogTitle: 'Set name', + addDialogTitle: 'Add name', firstNameLabel: 'First name', lastNameLabel: 'Last name', cancel: 'Cancel', @@ -33,11 +34,12 @@ export const userProfileAccountSectionMessages = { }, username: { label: 'Username', + empty: 'No username added', edit: 'Edit username', - set: 'Set username', + add: 'Add username', dialogTitle: 'Edit username', - setDialogTitle: 'Set username', + addDialogTitle: 'Add username', fieldLabel: 'Username', cancel: 'Cancel', save: 'Save changes', 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 index 42a90278442..f9db05298f9 100644 --- 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 @@ -29,7 +29,7 @@ export function UserProfileNameRowView({ {m.name.label} - {name ? {name} : null} + {name || m.name.empty} {onSubmit ? ( @@ -72,14 +72,14 @@ function EditName({ firstNameAttribute={firstNameAttribute} lastNameAttribute={lastNameAttribute} open={controller.isOpen} - title={isSet ? m.name.dialogTitle : m.name.setDialogTitle} + title={isSet ? m.name.dialogTitle : m.name.addDialogTitle} trigger={ } /> 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 index 369abaa9b6b..4031945d4f7 100644 --- 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 @@ -16,7 +16,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs {m.username.label} - {username ? {username} : null} + {username || m.username.empty} {onSubmit ? ( @@ -40,14 +40,14 @@ function EditUsername({ username, onSubmit }: { username: string; onSubmit: (use - {isSet ? m.username.edit : m.username.set} + {isSet ? m.username.edit : m.username.add} } /> From ab6e1139875a450f8ccf4ced1007b31fff59e023 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:50:42 -0400 Subject: [PATCH 16/63] fix(repo): move the swingset fixtures onto the save rejection The account-section fixtures still returned the removed `SaveResult` and imported `utils/save-result`, which no longer exists, so swingset did not build. --- .../src/stories/fixtures/use-preview-image.ts | 4 ++-- .../src/stories/fixtures/user-profile-edit-name.ts | 6 +++--- .../stories/fixtures/user-profile-edit-password.ts | 12 ++++++++---- .../stories/fixtures/user-profile-edit-username.ts | 6 +++--- .../stories/user-profile-account-section.stories.tsx | 2 +- .../user-profile-password-section.stories.tsx | 12 +++++++----- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/use-preview-image.ts b/packages/swingset/src/stories/fixtures/use-preview-image.ts index fd8618fee8f..7a3f7c5495f 100644 --- a/packages/swingset/src/stories/fixtures/use-preview-image.ts +++ b/packages/swingset/src/stories/fixtures/use-preview-image.ts @@ -24,7 +24,7 @@ export function usePreviewImage(initialUrl?: string) { const next = URL.createObjectURL(file); objectUrlRef.current = next; setImageUrl(next); - return Promise.resolve({ error: null }); + return Promise.resolve(); }, [release], ); @@ -32,7 +32,7 @@ export function usePreviewImage(initialUrl?: string) { const clearImage = useCallback(() => { release(); setImageUrl(undefined); - return Promise.resolve({ error: null }); + return Promise.resolve(); }, [release]); return { imageUrl, showFile, clearImage }; diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts index 8cff25098ce..083b7436224 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-name.ts @@ -1,5 +1,6 @@ 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/save-result'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -32,10 +33,9 @@ export function useUserProfileEditNameFixture({ onSubmitName: async (value: UserProfileEditNameValue) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: failWith }; + throw new SaveError(failWith); } setName(value); - return { error: null }; }, }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index a7711b2469a..621a0c283c7 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,5 +1,9 @@ -import type { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; -import type { UserProfileEditPasswordValue } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { + UserProfileEditPasswordField, + UserProfileEditPasswordValue, +} from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -7,7 +11,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: UserProfileSaveError; + failWith?: FormError; } export function useUserProfileEditPasswordFixture({ @@ -26,7 +30,7 @@ export function useUserProfileEditPasswordFixture({ await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); - throw failWith; + throw new SaveError(failWith); } setHasPassword(true); }, diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts index e639a441204..67a08f99aea 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-username.ts @@ -1,4 +1,5 @@ -import type { FormError } from '@clerk/mosaic/utils/save-result'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; +import { SaveError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import { useChaosFixture } from '@/components/ChaosProvider'; @@ -23,10 +24,9 @@ export function useUserProfileEditUsernameFixture({ onSubmitUsername: async (value: string) => { await new Promise(resolve => setTimeout(resolve, latency)); if (failWith) { - return { error: failWith }; + throw new SaveError(failWith); } setUsername(value); - return { error: null }; }, }; } diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index c1190e3a4c1..c4f212f6d34 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -7,7 +7,7 @@ import { UserProfileAccountSectionView } from '@clerk/mosaic/features/user-profi import type { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; import { UserProfileVerifyEmailLinkDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; import { UserProfileVerifyEmailSsoDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; -import type { FormError } from '@clerk/mosaic/utils/save-result'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; import type { StoryMeta } from '@/lib/types'; 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 58d71efb577..8b84910116f 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,5 +1,6 @@ -import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +import type { UserProfileEditPasswordField } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; +import type { FormError } from '@clerk/mosaic/utils/form-error'; import type { StoryMeta } from '@/lib/types'; @@ -24,7 +25,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: UserProfileSaveError; + failWith?: FormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -73,9 +74,10 @@ export function ManagedByEnterprise() { export function EditPasswordFails() { return ( ); } From 79310f31181c5f04495866ba2f9908708ede7996 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:55:35 -0400 Subject: [PATCH 17/63] fix(mosaic): drop the removed save result from the panel view tests --- .../user-profile-profile-panel.view.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 a2a46967cba..a5f789f567a 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 @@ -130,9 +130,9 @@ describe('UserProfileProfilePanelView', () => { it('composes the profile content without profile navigation', () => { renderView({ - onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })), - onSubmitName: () => Promise.resolve({ error: null }), - onSubmitUsername: () => Promise.resolve({ error: null }), + onProfilePictureChange: vi.fn(() => Promise.resolve()), + onSubmitName: () => Promise.resolve(), + onSubmitUsername: () => Promise.resolve(), }); expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument(); @@ -163,11 +163,11 @@ describe('UserProfileProfilePanelView', () => { }); it('uploads the picked file when no profile picture is set', async () => { - const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); + const onProfilePictureChange = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, - onRemoveProfilePicture: vi.fn(() => Promise.resolve({ error: null })), + onRemoveProfilePicture: vi.fn(() => Promise.resolve()), }); expect(screen.queryByRole('button', { name: 'Manage profile picture' })).toBeNull(); @@ -183,7 +183,7 @@ describe('UserProfileProfilePanelView', () => { }); it('turns away a file past the size the row advertises', async () => { - const onProfilePictureChange = vi.fn(() => Promise.resolve({ error: null })); + const onProfilePictureChange = vi.fn(() => Promise.resolve()); const onProfilePictureReject = vi.fn(); const user = userEvent.setup(); const { container } = renderView({ onProfilePictureChange, onProfilePictureReject }); @@ -203,7 +203,7 @@ describe('UserProfileProfilePanelView', () => { it('clears the rejection once an acceptable file is picked', async () => { const user = userEvent.setup(); - const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve({ error: null })) }); + const { container } = renderView({ onProfilePictureChange: vi.fn(() => Promise.resolve()) }); const input = container.querySelector('input[type="file"]'); if (!input) { throw new Error('File picker not found'); @@ -374,7 +374,7 @@ describe('UserProfileProfilePanelView', () => { it('forwards profile and contact actions', async () => { const onAddEmail = vi.fn(); const onRemoveEmail = vi.fn(); - renderView({ onSubmitName: () => Promise.resolve({ error: null }), onAddEmail, onRemoveEmail }); + renderView({ onSubmitName: () => Promise.resolve(), onAddEmail, onRemoveEmail }); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add email' })); @@ -393,7 +393,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-name dialog from the section, seeded with the saved name', async () => { - const onSubmitName = vi.fn(() => Promise.resolve({ error: null })); + const onSubmitName = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ firstName: 'Preston', lastName: 'Booth', onSubmitName }); @@ -411,7 +411,7 @@ describe('UserProfileProfilePanelView', () => { }); it('drives the edit-username dialog from the section, seeded with the saved username', async () => { - const onSubmitUsername = vi.fn(() => Promise.resolve({ error: null })); + const onSubmitUsername = vi.fn(() => Promise.resolve()); const user = userEvent.setup(); renderView({ username: 'prestonxyz', onSubmitUsername }); From ad289e8faa3db4176e8cd76d616982c243905a3b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 15:58:50 -0400 Subject: [PATCH 18/63] test(mosaic): leave the row gating to the model test --- ...ofile-account-section.integration.test.tsx | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index 9799f401658..f12cbbee267 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -7,8 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; -let usernameEnabled: boolean; -let namesEnabled: boolean; let user: { firstName: string; lastName: string; @@ -33,9 +31,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { __internal_environment: { userSettings: { attributes: { - first_name: { enabled: namesEnabled, required: false }, - last_name: { enabled: namesEnabled, required: false }, - username: { enabled: usernameEnabled, required: false }, + first_name: { enabled: true, required: false }, + last_name: { enabled: true, required: false }, + username: { enabled: true, required: false }, }, }, }, @@ -52,8 +50,6 @@ function renderSection() { } beforeEach(() => { - usernameEnabled = true; - namesEnabled = true; user = { firstName: 'Preston', lastName: 'Booth', @@ -133,20 +129,4 @@ describe('UserProfileAccountSection', () => { ); expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); }); - - it('leaves the username row out when the instance does not use usernames', () => { - usernameEnabled = false; - renderSection(); - - expect(screen.queryByText('Username')).not.toBeInTheDocument(); - expect(screen.getByText('Name')).toBeInTheDocument(); - }); - - it('leaves the name row out when the instance collects neither name', () => { - namesEnabled = false; - renderSection(); - - expect(screen.queryByText('Name')).not.toBeInTheDocument(); - expect(screen.getByText('Username')).toBeInTheDocument(); - }); }); From a66d6664d895ee75ac9794232852959a57ed8acc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 16:19:13 -0400 Subject: [PATCH 19/63] fix(mosaic): hold the name save until the name changes The picture row now names its local rejections by the same error codes the server uses, so one message serves both. --- .../user-profile-edit-name.controller.test.ts | 25 ++++++++++++++++--- .../user-profile-account-section.messages.ts | 6 ----- .../user-profile-edit-name.controller.ts | 8 +++++- .../user-profile-edit-name.dialog.tsx | 8 ++++-- .../user-profile-picture-row.view.tsx | 24 ++++++++++++------ .../src/localization/errors.messages.ts | 1 + 6 files changed, 52 insertions(+), 20 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index c2ad1a81714..c12e6f022a2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -19,6 +19,13 @@ function start(saveName: () => Promise, saved = { savedFirstName: 'Preston return actor; } +/** The save is guarded on a change, so a test about the save has to make one first. */ +function startEdited(saveName: () => Promise) { + const actor = start(saveName); + actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); + return actor; +} + describe('userProfileEditNameMachine', () => { it('seeds both fields from the saved name on open', () => { const actor = start(saved); @@ -64,7 +71,7 @@ describe('userProfileEditNameMachine', () => { }); it('carries field copy through when the error names a control', async () => { - const actor = start(() => + const actor = startEdited(() => failed({ global: { message: 'Your name could not be updated.' }, fields: { firstName: { message: 'First name is required.' } }, @@ -80,7 +87,7 @@ describe('userProfileEditNameMachine', () => { it('shows the generic banner and logs when the save throws unexpectedly', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}); const failure = new TypeError('boom'); - const actor = start(() => Promise.reject(failure)); + const actor = startEdited(() => Promise.reject(failure)); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); @@ -89,7 +96,7 @@ describe('userProfileEditNameMachine', () => { }); it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ global: { message: 'nope' } })); + const actor = startEdited(() => failed({ global: { message: 'nope' } })); actor.send({ type: 'SAVE' }); await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); @@ -134,4 +141,16 @@ describe('useUserProfileEditNameController', () => { await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' })); }); + + it('withholds the save while the name is unchanged', () => { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), + ); + + act(() => result.current.onOpenChange(true)); + expect(result.current.canSave).toBe(false); + + act(() => result.current.onLastNameChange('Barton')); + expect(result.current.canSave).toBe(true); + }); }); 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-account-section/user-profile-account-section.messages.ts index 3f17c4e172b..cd9ddc712d0 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-account-section/user-profile-account-section.messages.ts @@ -12,12 +12,6 @@ export const userProfileAccountSectionMessages = { manage: 'Manage profile picture', change: 'Change avatar', remove: 'Remove avatar', - - errors: { - accept: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', - size: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', - overflow: 'Only one file can be uploaded at a time.', - }, }, name: { label: 'Name', 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-account-section/user-profile-edit-name.controller.ts index 5ee4aa2610a..6d798e464db 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -26,6 +26,10 @@ function notSeated(): Promise { return Promise.reject(new Error('edit-name deps are not seated')); } +function isSaveable(context: UserProfileEditNameContext): boolean { + return context.firstName !== context.savedFirstName || context.lastName !== context.savedLastName; +} + export const userProfileEditNameMachine = createMachine({ id: 'editName', initial: 'idle', @@ -53,7 +57,7 @@ export const userProfileEditNameMachine = createMachine({ editing: { on: { TYPE: { actions: assign((_, event) => ({ [event.field]: event.value })) }, - SAVE: 'saving', + SAVE: { target: 'saving', guard: isSaveable }, CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, }, }, @@ -84,6 +88,7 @@ export interface UserProfileEditNameController { onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; onSubmit: () => void; + canSave: boolean; isSaving: boolean; error: FormError | undefined; } @@ -105,6 +110,7 @@ export function useUserProfileEditNameController({ onFirstNameChange: value => send({ type: 'TYPE', field: 'firstName', value }), onLastNameChange: value => send({ type: 'TYPE', field: 'lastName', value }), onSubmit: () => send({ type: 'SAVE' }), + canSave: isSaveable(snapshot.context), isSaving: snapshot.value === 'saving', error: snapshot.context.error, }; 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-account-section/user-profile-edit-name.dialog.tsx index 7d2b6281c31..6b65255090b 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-account-section/user-profile-edit-name.dialog.tsx @@ -32,6 +32,7 @@ export interface UserProfileEditNameDialogProps { onFirstNameChange: (value: string) => void; onLastNameChange: (value: string) => void; title?: string; + canSave?: boolean; isSaving?: boolean; error?: FormError; onSubmit: () => void; @@ -53,6 +54,7 @@ export function UserProfileEditNameDialog({ onFirstNameChange, onLastNameChange, title, + canSave = true, isSaving = false, error, onSubmit, @@ -64,10 +66,10 @@ export function UserProfileEditNameDialog({ const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; const { enabled: showLastName = true, required: lastNameRequired = false } = lastNameAttribute; - // `isSaving` only cancels the press on the action; it does not stop a native submit. + // `canSave`/`isSaving` only cancel the press on the action; they do not stop a native submit. const handleSubmit = (event: FormEvent) => { event.preventDefault(); - if (!isSaving) { + if (canSave && !isSaving) { onSubmit(); } }; @@ -158,6 +160,8 @@ export function UserProfileEditNameDialog({ form={formId} fullWidth isPending={isSaving} + disabled={!canSave} + focusableWhenDisabled > {m.name.save} 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-account-section/user-profile-picture-row.view.tsx index eb48a29b2ac..ee7eab49fc3 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-account-section/user-profile-picture-row.view.tsx @@ -5,8 +5,9 @@ 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 } from '../../../primitives/file-upload'; +import type { FileRejection, FileRejectionReason } from '../../../primitives/file-upload'; import { FileUpload } from '../../../primitives/file-upload'; import { useUserProfilePictureController } from './user-profile-picture.controller'; @@ -14,6 +15,13 @@ 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 { name: string; imageUrl?: string; @@ -34,12 +42,12 @@ export function UserProfilePictureRowView({ const m = useMessages('userProfileAccountSection'); const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); - const [rejectionError, setRejectionError] = useState(); - const displayedError = rejectionError ?? (controller.error ? errorText(controller.error) : undefined); + const [rejection, setRejection] = useState(); + const error = rejection ?? controller.error; const remove = controller.onRemove; const handleRemove = remove ? () => { - setRejectionError(undefined); + setRejection(undefined); return remove(); } : undefined; @@ -58,14 +66,14 @@ export function UserProfilePictureRowView({ aria-busy={controller.isPending || undefined} render={} onReject={rejections => { - const rejection = rejections[0]; - setRejectionError(rejection ? m.picture.errors[rejection.reason] : undefined); + const rejected = rejections[0]; + setRejection(rejected ? REJECTION_ERRORS[rejected.reason] : undefined); onReject?.(rejections); }} onValueChange={files => { const file = files[0]; if (file) { - setRejectionError(undefined); + setRejection(undefined); void controller.onChange?.(file); } }} @@ -90,7 +98,7 @@ export function UserProfilePictureRowView({ onRemove={handleRemove} /> - {displayedError} + {error ? errorText(error) : undefined} ); } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index faac31e6c9b..722e6c32581 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -1,6 +1,7 @@ export const errorMessages: { readonly generic: string } & Readonly> = { generic: 'Something went wrong. Please try again.', action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", + avatar_file_count_exceeded: 'Only one file can be uploaded at a time.', avatar_file_size_exceeded: 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', avatar_file_type_invalid: 'File type not supported. Please upload a JPG, PNG, GIF, or WEBP image.', form_new_password_matches_current: 'New password cannot be the same as the current password.', From b7248e315aac222b2f2828c51a5e6215e1c13e45 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 16:19:29 -0400 Subject: [PATCH 20/63] chore(repo): ignore the vitest cache directory --- packages/mosaic/.vitest/json/output.json | 39 ------------------------ 1 file changed, 39 deletions(-) delete mode 100644 packages/mosaic/.vitest/json/output.json diff --git a/packages/mosaic/.vitest/json/output.json b/packages/mosaic/.vitest/json/output.json deleted file mode 100644 index 2bbcde4d5c3..00000000000 --- a/packages/mosaic/.vitest/json/output.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "numTotalTestSuites": 1, - "numPassedTestSuites": 0, - "numFailedTestSuites": 1, - "numPendingTestSuites": 0, - "numTotalTests": 0, - "numPassedTests": 0, - "numFailedTests": 0, - "numPendingTests": 0, - "numTodoTests": 0, - "snapshot": { - "added": 0, - "failure": false, - "filesAdded": 0, - "filesRemoved": 0, - "filesRemovedList": [], - "filesUnmatched": 0, - "filesUpdated": 0, - "matched": 0, - "total": 0, - "unchecked": 0, - "uncheckedKeysByFile": [], - "unmatched": 0, - "updated": 0, - "didUpdate": false - }, - "startTime": 1790015468853, - "success": false, - "testResults": [ - { - "assertionResults": [], - "startTime": 1790015468853, - "endTime": 1790015468853, - "status": "failed", - "message": "Failed to resolve import \"../user-profile-account-section/user-profile-account-section.model\" from \"src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx\". Does the file exist?", - "name": "/Users/alex/Repos/javascript.carp-mosaic-user-profile-avatar-wireup/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx" - } - ] -} From 584760c04b2b3bd25846434544e22cc8f0048a01 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:30:44 -0400 Subject: [PATCH 21/63] feat(mosaic): state why a section row offers no action Section.Note takes an action's place in a row, carrying the .cl-section-note and .cl-section-note-icon slots that the feature-level managed-by label had no way to expose. It holds the trailing slot itself, so it needs no Section.Actions around it. --- .../mosaic/src/components/section/index.ts | 1 + .../src/components/section/section.styles.ts | 18 ++++++++ .../src/components/section/section.test.tsx | 40 ++++++++++++++++++ .../mosaic/src/components/section/section.tsx | 29 +++++++++++++ .../user-profile-name-row.view.test.tsx | 13 ++++++ .../user-profile-name-row.view.tsx | 11 ++++- .../user-profile/user-profile-managed-by.tsx | 41 +++++++++++++++++++ .../user-profile-password-row.view.tsx | 41 ++----------------- packages/swingset/src/stories/section.mdx | 2 + 9 files changed, 158 insertions(+), 38 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx diff --git a/packages/mosaic/src/components/section/index.ts b/packages/mosaic/src/components/section/index.ts index 8b920fdc6fe..65aa7c80800 100644 --- a/packages/mosaic/src/components/section/index.ts +++ b/packages/mosaic/src/components/section/index.ts @@ -9,6 +9,7 @@ export type { SectionLabelProps, SectionMediaProps, SectionMediaSize, + SectionNoteProps, SectionRootProps, SectionRowProps, SectionTitleProps, diff --git a/packages/mosaic/src/components/section/section.styles.ts b/packages/mosaic/src/components/section/section.styles.ts index a8978b6bf7f..143b8d304c9 100644 --- a/packages/mosaic/src/components/section/section.styles.ts +++ b/packages/mosaic/src/components/section/section.styles.ts @@ -128,6 +128,24 @@ export const styles = stylex.create({ flexShrink: 0, justifyContent: 'flex-end', }, + note: { + alignItems: 'center', + color: colorVars['--cl-color-foreground-secondary'], + columnGap: space['1'], + display: 'flex', + flexShrink: 0, + fontSize: typeScaleVars['--cl-text-xs-size'], + fontWeight: fontWeightVars['--cl-font-normal'], + lineHeight: typeScaleVars['--cl-text-xs-leading'], + }, + noteIcon: { + alignItems: 'center', + display: 'flex', + flexShrink: 0, + justifyContent: 'center', + height: space['4'], + width: space['4'], + }, // Sits under the row's item rather than inside its content, so a message never shifts the // media and actions off the center line they share. error: { diff --git a/packages/mosaic/src/components/section/section.test.tsx b/packages/mosaic/src/components/section/section.test.tsx index 06d629f4ae9..d609d2ddc74 100644 --- a/packages/mosaic/src/components/section/section.test.tsx +++ b/packages/mosaic/src/components/section/section.test.tsx @@ -270,4 +270,44 @@ describe('Section', () => { rerender(); await waitFor(() => expect(screen.queryByTestId('error')).not.toBeInTheDocument()); }); + + it('states why a row has no action, with the leading glyph in its own slot', () => { + render( + + + + + + Name + + + } + > + Managed by Okta + + + + + , + ); + + const note = screen.getByTestId('note'); + expect(note).toHaveClass('cl-section-note'); + expect(note).toHaveTextContent('Managed by Okta'); + expect(note.querySelector('.cl-section-note-icon')).toContainElement(screen.getByRole('presentation')); + // Holds the trailing slot itself, so it needs no Section.Actions around it. + expect(note.parentElement).toBe(screen.getByTestId('item')); + }); + + it('leaves out the glyph slot when the note carries no icon', () => { + render(Managed by Acme SSO); + + expect(screen.getByTestId('note').querySelector('.cl-section-note-icon')).toBeNull(); + }); }); diff --git a/packages/mosaic/src/components/section/section.tsx b/packages/mosaic/src/components/section/section.tsx index 201c410c958..cf88fc7a298 100644 --- a/packages/mosaic/src/components/section/section.tsx +++ b/packages/mosaic/src/components/section/section.tsx @@ -27,6 +27,7 @@ export type SectionContentProps = MosaicComponentProps<'div'>; export type SectionLabelProps = MosaicComponentProps<'div'>; export type SectionDescriptionProps = MosaicComponentProps<'div'>; export type SectionActionsProps = MosaicComponentProps<'div'>; +export type SectionNoteProps = MosaicComponentProps<'div'> & { icon?: React.ReactNode }; export type SectionErrorProps = MosaicComponentProps<'p'>; const mediaSizes = { @@ -227,6 +228,33 @@ const Actions = React.forwardRef(function S }); }); +/** + * Static text that takes an action's place in a row, stating why it offers none. Sits directly in + * `Section.Item` where a `Section.Actions` would; it holds its own trailing position, so it does not + * need one. `icon` renders into a fixed leading slot, sized to the text, for a logo or a lock. + */ +const Note = React.forwardRef(function SectionNote( + { icon, children, render, xstyle, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('section-note'), stylex.props(reset.base, styles.note, xstyle), rest), + children: ( + <> + {icon ? ( + {icon} + ) : null} + {children} + + ), + }, + }); +}); + /** * A row-level message, mirroring `Field.Error` for a row that holds no form control. Place it as a * sibling of `Section.Item` inside `Section.Row`, not inside `Section.Content`: the item stays a @@ -299,5 +327,6 @@ export const Section = { Label, Description, Actions, + Note, Error: SectionError, }; 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 index 23caf7b8757..224dd3b36d9 100644 --- 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 @@ -37,4 +37,17 @@ describe('UserProfileNameRowView', () => { 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/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 index f9db05298f9..f734d91c7e3 100644 --- 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 @@ -1,6 +1,8 @@ import { Button } from '../../../components/button'; import { Section } from '../../../components/section'; -import { useMessages } from '../../../localization'; +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'; @@ -12,6 +14,7 @@ export interface UserProfileNameRowViewProps { lastName?: string; firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; + managedBy?: UserProfileManagedBy; onSubmit?: (value: UserProfileEditNameValue) => Promise; } @@ -21,9 +24,11 @@ export function UserProfileNameRowView({ lastName, firstNameAttribute, lastNameAttribute, + managedBy, onSubmit, }: UserProfileNameRowViewProps) { const m = useMessages('userProfileAccountSection'); + return ( @@ -42,6 +47,10 @@ export function UserProfileNameRowView({ onSubmit={onSubmit} /> + ) : managedBy ? ( + + {fill(m.name.managedBy, { name: managedBy.name })} + ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx new file mode 100644 index 00000000000..d92fb71b680 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-managed-by.tsx @@ -0,0 +1,41 @@ +import * as stylex from '@stylexjs/stylex'; + +import { Icon } from '../../components/icon'; +import { Section } from '../../components/section'; + +/** The enterprise connection a row's value comes from, which is why the row has nothing to edit. */ +export interface UserProfileManagedBy { + name: string; + iconUrl?: string; +} + +const styles = stylex.create({ + logo: { + height: '100%', + width: '100%', + }, +}); + +export function UserProfileManagedByLabel({ iconUrl, children }: { iconUrl?: string; children: string }) { + return ( + + ) : ( + + ) + } + > + {children} + + ); +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx index 46012fc9643..96efcb2b33b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-row.view.tsx @@ -1,16 +1,11 @@ -import * as stylex from '@stylexjs/stylex'; - import { Button } from '../../../components/button'; -import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; -import { Text } from '../../../components/text'; import { fill, useMessages } from '../../../localization'; +import { UserProfileManagedByLabel } from '../user-profile-managed-by'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import { UserProfileEditPasswordDialog } from './user-profile-edit-password.dialog'; -import { styles } from './user-profile-password-section.styles'; import type { UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, UserProfilePasswordSectionViewProps, } from './user-profile-password-section.types'; @@ -29,9 +24,9 @@ export function UserProfilePasswordRowView({ {hasPassword ? m.masked : m.noPasswordSet} {managedBy ? ( - - - + + {fill(m.managedBy, { name: managedBy.name })} + ) : onSubmitPassword ? ( - {iconUrl ? ( - - ) : ( - - )} - } - size='sm' - xstyle={styles.managedByText} - > - {fill(m.managedBy, { name })} - -
- ); -} - function EditPassword({ hasPassword, requiresCurrentPassword, diff --git a/packages/swingset/src/stories/section.mdx b/packages/swingset/src/stories/section.mdx index bfe05b66695..ed09cd3e93d 100644 --- a/packages/swingset/src/stories/section.mdx +++ b/packages/swingset/src/stories/section.mdx @@ -26,6 +26,8 @@ import { Section } from '@clerk/mosaic/components/section'; `Section.Group` owns the outlined surface. Its direct `Section.Row` children own separation and may stack one or more `Section.Item` entries. Each item lays out optional media, flexible content, and trailing actions. `Section.Media` accepts `sm`, `md`, `lg`, and `xl` sizes. +When a row offers no action, `Section.Note` takes the place of its `Section.Actions` to say why — it sits directly in `Section.Item` and holds the trailing slot itself. Its `icon` prop renders into a fixed leading slot sized to the text, for a provider logo or a lock. + Use `Section.Items` for a nested value list beneath a row's header item. The row keeps a 12px header-to-list gap, and nested items stack without gaps with 4px vertical padding. `Section.Title` renders at the level from the nearest `HeadingLevelProvider`, or `h2` outside one. From 5d8de0097244ce522b989cff8f3e31c16acc2a1a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:30:51 -0400 Subject: [PATCH 22/63] feat(mosaic): show the picked avatar while it uploads FileUpload.useObjectUrl is the one implementation of file to URL, shared by ItemPreview and the profile picture row. The row shows the pick right away and drops it if the upload fails, so the avatar never shows a picture the account does not have. --- .../user-profile-picture.controller.test.ts | 98 ++++++++++++++++++- .../user-profile-picture-row.view.tsx | 2 +- .../user-profile-picture.controller.ts | 27 ++++- .../src/primitives/file-upload/README.md | 5 + .../file-upload/file-upload-item-preview.tsx | 20 +--- .../src/primitives/file-upload/parts.ts | 1 + .../primitives/file-upload/use-object-url.ts | 26 +++++ 7 files changed, 158 insertions(+), 21 deletions(-) create mode 100644 packages/mosaic/src/primitives/file-upload/use-object-url.ts diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index a92f99ed67b..c5abf01570d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -1,6 +1,6 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SaveError } from '../../../utils/form-error'; import type { UserProfilePictureControllerOptions } from '../user-profile-account-section/user-profile-picture.controller'; @@ -13,6 +13,20 @@ function renderController(options: UserProfilePictureControllerOptions) { } describe('useUserProfilePictureController', () => { + // jsdom ships neither half of the object URL API. + let nextUrl = 0; + const revoked: string[] = []; + + beforeEach(() => { + nextUrl = 0; + revoked.length = 0; + vi.stubGlobal('URL', { + ...URL, + createObjectURL: () => `blob:preview-${++nextUrl}`, + revokeObjectURL: (url: string) => revoked.push(url), + }); + }); + it('leaves out the actions the model did not offer', () => { const { result } = renderController({}); expect(result.current.onChange).toBeUndefined(); @@ -85,4 +99,86 @@ describe('useUserProfilePictureController', () => { await act(async () => result.current.onRemove?.()); expect(result.current.error).toEqual({ message: 'Nope.' }); }); + + it('shows the picked file while the upload is still running', async () => { + const upload = createDeferredPromise(); + const onChange = vi.fn(() => upload.promise.then(() => undefined)); + const { result } = renderController({ onChange }); + + expect(result.current.previewUrl).toBeUndefined(); + + act(() => { + void result.current.onChange?.(file); + }); + expect(result.current.previewUrl).toBe('blob:preview-1'); + + await act(async () => { + upload.resolve(); + await upload.promise; + }); + expect(result.current.previewUrl).toBe('blob:preview-1'); + }); + + it('drops the preview when the upload fails, so the avatar is never a lie', async () => { + const upload = createDeferredPromise(); + const onChange = vi.fn(() => upload.promise.then(() => undefined)); + const { result } = renderController({ onChange }); + + act(() => { + void result.current.onChange?.(file); + }); + expect(result.current.previewUrl).toBe('blob:preview-1'); + + await act(async () => { + upload.reject(new SaveError({ global: { message: 'Nope.' } })); + await upload.promise.catch(() => undefined); + }); + expect(result.current.previewUrl).toBeUndefined(); + expect(revoked).toEqual(['blob:preview-1']); + }); + + it('drops the preview once the picture is removed', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const onRemove = vi.fn().mockResolvedValue(undefined); + const { result } = renderController({ onChange, onRemove }); + + await act(async () => result.current.onChange?.(file)); + expect(result.current.previewUrl).toBe('blob:preview-1'); + + await act(async () => result.current.onRemove?.()); + expect(result.current.previewUrl).toBeUndefined(); + expect(revoked).toEqual(['blob:preview-1']); + }); + + it('revokes the previous preview when a second pick replaces it', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + await act(async () => result.current.onChange?.(new File(['y'], 'other.png', { type: 'image/png' }))); + + expect(result.current.previewUrl).toBe('blob:preview-2'); + expect(revoked).toEqual(['blob:preview-1']); + }); + + it('keeps one preview when the same file is picked twice', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const { result } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + await act(async () => result.current.onChange?.(file)); + + expect(result.current.previewUrl).toBe('blob:preview-1'); + expect(revoked).toEqual([]); + }); + + it('revokes the preview on unmount', async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + const { result, unmount } = renderController({ onChange }); + + await act(async () => result.current.onChange?.(file)); + unmount(); + + expect(revoked).toEqual(['blob:preview-1']); + }); }); 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-account-section/user-profile-picture-row.view.tsx index ee7eab49fc3..b18c69fbe3d 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-account-section/user-profile-picture-row.view.tsx @@ -83,7 +83,7 @@ export function UserProfilePictureRowView({ {initials} 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-account-section/user-profile-picture.controller.ts index 0b59b6fb643..765c08e981d 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-account-section/user-profile-picture.controller.ts @@ -1,6 +1,7 @@ import { useRef, useState } from 'react'; import type { LocalizableError } from '../../../localization'; +import { FileUpload } from '../../../primitives/file-upload'; import { toFormError } from '../../../utils/form-error'; export interface UserProfilePictureControllerOptions { @@ -12,6 +13,7 @@ export interface UserProfilePictureController { onChange?: (file: File) => Promise; onRemove?: () => Promise; isPending: boolean; + previewUrl: string | undefined; error: LocalizableError | undefined; } @@ -21,9 +23,11 @@ export function useUserProfilePictureController({ }: UserProfilePictureControllerOptions): UserProfilePictureController { const [isPending, setIsPending] = useState(false); const [error, setError] = useState(); + const [preview, setPreview] = useState(); + const previewUrl = FileUpload.useObjectUrl(preview); const inFlight = useRef(false); - const run = async (action: () => Promise) => { + const run = async (action: () => Promise, revert?: () => void) => { if (inFlight.current) { return; } @@ -33,6 +37,7 @@ export function useUserProfilePictureController({ try { await action(); } catch (cause) { + revert?.(); setError(toFormError(cause).global); } finally { inFlight.current = false; @@ -41,9 +46,25 @@ export function useUserProfilePictureController({ }; return { - onChange: onChange ? file => run(() => onChange(file)) : undefined, - onRemove: onRemove ? () => run(onRemove) : undefined, + onChange: onChange + ? file => + run( + () => { + setPreview(file); + return onChange(file); + }, + () => setPreview(undefined), + ) + : undefined, + onRemove: onRemove + ? () => + run(async () => { + await onRemove(); + setPreview(undefined); + }) + : undefined, isPending, + previewUrl, error, }; } diff --git a/packages/mosaic/src/primitives/file-upload/README.md b/packages/mosaic/src/primitives/file-upload/README.md index b7c785e4179..3a8bdae5783 100644 --- a/packages/mosaic/src/primitives/file-upload/README.md +++ b/packages/mosaic/src/primitives/file-upload/README.md @@ -116,6 +116,11 @@ added. `{ files, addFiles, removeFile, clearFiles, openFilePicker, disabled }` for reading the selection and driving custom UI. It must be called inside `FileUpload.Root`. +`FileUpload.useObjectUrl(file)` returns a URL for a locally picked file, revoked when the file is +replaced or the caller unmounts, and `undefined` when there is no file. `ItemPreview` uses it, and so +does any surface that shows the pick in its own element rather than a thumbnail — an avatar showing +the new image while the upload is still running. It does not need `FileUpload.Root`. + ## Props ### `FileUpload.Root` diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx index 6c06be6f198..0a50f345ffc 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-item-preview.tsx @@ -1,16 +1,16 @@ 'use client'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { useFileUploadItemContext } from './file-upload-context'; +import { useObjectUrl } from './use-object-url'; export type FileUploadItemPreviewProps = ComponentProps<'img'>; /** * Renders an `` thumbnail for the item's file when it is an image. For * non-image files it renders nothing, so it is safe to drop in unconditionally. - * The object URL is created on mount and revoked on unmount to avoid leaks. */ export const FileUploadItemPreview = React.forwardRef( function FileUploadItemPreview(props, ref) { @@ -18,19 +18,7 @@ export const FileUploadItemPreview = React.forwardRef(undefined); - - useEffect(() => { - if (!isImage) { - return; - } - const url = URL.createObjectURL(file); - setObjectUrl(url); - return () => { - URL.revokeObjectURL(url); - setObjectUrl(undefined); - }; - }, [file, isImage]); + const objectUrl = useObjectUrl(isImage ? file : undefined); const defaultProps: Record = { src: objectUrl, @@ -43,7 +31,7 @@ export const FileUploadItemPreview = React.forwardRef with an empty src. - enabled: isImage && objectUrl != null, + enabled: objectUrl != null, props: mergeProps<'img'>(defaultProps, otherProps), }); }, diff --git a/packages/mosaic/src/primitives/file-upload/parts.ts b/packages/mosaic/src/primitives/file-upload/parts.ts index cc9cf9e54a0..21ced5ec2e6 100644 --- a/packages/mosaic/src/primitives/file-upload/parts.ts +++ b/packages/mosaic/src/primitives/file-upload/parts.ts @@ -10,3 +10,4 @@ export { type FileUploadItemProps, FileUploadItem as Item } from './file-upload- export { type FileUploadItemPreviewProps, FileUploadItemPreview as ItemPreview } from './file-upload-item-preview'; export { type FileUploadItemDeleteProps, FileUploadItemDelete as ItemDelete } from './file-upload-item-delete'; export { useFileUpload } from './file-upload-context'; +export { useObjectUrl } from './use-object-url'; diff --git a/packages/mosaic/src/primitives/file-upload/use-object-url.ts b/packages/mosaic/src/primitives/file-upload/use-object-url.ts new file mode 100644 index 00000000000..0acd52cc38f --- /dev/null +++ b/packages/mosaic/src/primitives/file-upload/use-object-url.ts @@ -0,0 +1,26 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +/** + * The object URL for a locally picked file, revoked when the file is replaced or the caller + * unmounts. Answers `undefined` for no file, so a caller can fall back to an already stored image. + */ +export function useObjectUrl(file: File | undefined): string | undefined { + const [url, setUrl] = useState(); + + useEffect(() => { + if (!file) { + setUrl(undefined); + return; + } + const created = URL.createObjectURL(file); + setUrl(created); + return () => { + URL.revokeObjectURL(created); + setUrl(undefined); + }; + }, [file]); + + return url; +} From 46493e688005829c9176ef2284af5e70c0a3e392 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:30:57 -0400 Subject: [PATCH 23/63] fix(mosaic): name the username length the instance actually requires A LocalizableError can carry the values its message interpolates, so a save passes the instance settings a failure needs to read. The username save passes the configured bounds, which form_username_invalid_length fills in. --- ...ofile-account-section.integration.test.tsx | 1 + ...ser-profile-account-section.model.test.tsx | 37 +++++++++++++++---- .../user-profile-account-section.model.ts | 28 +++++++++++--- .../src/localization/errors.messages.ts | 1 + .../mosaic/src/localization/errors.test.tsx | 13 +++++++ packages/mosaic/src/localization/errors.ts | 11 +++++- packages/mosaic/src/utils/form-error.ts | 19 ++++++++-- 7 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index f12cbbee267..e26e926d863 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -35,6 +35,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { last_name: { enabled: true, required: false }, username: { enabled: true, required: false }, }, + usernameSettings: { min_length: 4, max_length: 64 }, }, }, }), diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index d9623189b3d..4d99091666f 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -22,7 +22,11 @@ let user: { username: string | null; imageUrl: string; hasImage: boolean; - enterpriseAccounts: { active: boolean }[]; + enterpriseAccounts: { + active: boolean; + provider: string; + enterpriseConnection: { name: string; logoPublicUrl: string | null } | null; + }[]; primaryEmailAddressId: string | null; primaryPhoneNumberId: string | null; emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[]; @@ -31,6 +35,7 @@ let user: { update: ReturnType; } | null; let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; +let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; function attribute(overrides: Partial = {}): FakeAttribute { @@ -43,7 +48,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ - __internal_environment: environmentHydrated ? { userSettings: { attributes } } : null, + __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, }), }; }); @@ -86,6 +91,7 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; + usernameSettings = { min_length: 4, max_length: 64 }; attributes = { first_name: attribute(), last_name: attribute(), @@ -199,9 +205,21 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('is read only while an enterprise account is active', () => { - user?.enterpriseAccounts.push({ active: true }); - expect(ready().onSubmitName).toBeUndefined(); + it('names the connection managing the name instead of offering to edit it', () => { + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Okta', logoPublicUrl: 'https://img.clerk.com/okta.svg' }, + }); + expect(ready()).toMatchObject({ + nameManagedBy: { name: 'Okta', iconUrl: 'https://img.clerk.com/okta.svg' }, + onSubmitName: undefined, + }); + }); + + it('falls back to the provider when the active account carries no connection', () => { + user?.enterpriseAccounts.push({ active: true, provider: 'saml_okta', enterpriseConnection: null }); + expect(ready().nameManagedBy).toEqual({ name: 'okta', iconUrl: undefined }); }); it('passes the instance name attributes through for the row to hide itself', () => { @@ -232,11 +250,16 @@ describe('useUserProfileAccountSectionModel', () => { expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); }); - it('maps a username error onto the field', async () => { + it('maps a username error onto the field, with the configured length bounds to fill its message', async () => { user?.update.mockRejectedValue(apiError('username')); await expect(rejection(ready().onSubmitUsername?.('x'))).resolves.toEqual({ fields: { - username: { code: 'form_param_invalid', paramName: 'username', message: 'That value is invalid.' }, + username: { + code: 'form_param_invalid', + paramName: 'username', + message: 'That value is invalid.', + params: { min_length: 4, max_length: 64 }, + }, }, }); }); 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 index d06f4fb9ddf..b2525e2e238 100644 --- 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 @@ -1,9 +1,10 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; -import type { AttributeData, UserResource } from '@clerk/shared/types'; +import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import { save } from '../../../utils/form-error'; +import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, UserProfileNameAttribute, @@ -24,6 +25,7 @@ type UserProfileAccountSectionData = Pick< | 'lastName' | 'firstNameAttribute' | 'lastNameAttribute' + | 'nameManagedBy' | 'username' | 'emails' | 'phones' @@ -41,6 +43,17 @@ export type UserProfileAccountSectionModel = 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_)/, ''), + iconUrl: connection?.logoPublicUrl ?? undefined, + }; +} + function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameAttribute { return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; } @@ -79,11 +92,11 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'hidden' }; } - const { attributes } = environment.userSettings; + const { attributes, usernameSettings } = environment.userSettings; const usernameAttribute = attributes.username; const usernameImmutable = Boolean(usernameAttribute?.immutable); const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); - const nameReadOnly = user.enterpriseAccounts.some(account => account.active); + const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); const showEmails = isAttributeAvailable(attributes.email_address); const showPhones = isAttributeAvailable(attributes.phone_number); @@ -95,6 +108,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo 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, @@ -102,12 +116,16 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo phones: showPhones ? toPhones(user) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, - onSubmitName: nameReadOnly + onSubmitName: nameManagedBy ? undefined : value => save(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable - ? username => save(() => user.update({ username }), USERNAME_FIELDS) + ? username => + save(() => user.update({ username }), USERNAME_FIELDS, { + min_length: usernameSettings.min_length, + max_length: usernameSettings.max_length, + }) : undefined, }; } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 722e6c32581..ce13a6a65ec 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -11,5 +11,6 @@ export const errorMessages: { readonly generic: string } & Readonly { ); }); + it('fills the values the error carries into its message', () => { + expect(errorText()({ code: 'form_username_invalid_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Your username must be between 4 and 64 characters long.', + ); + }); + + it('fills an override the same way', () => { + const text = errorText({ 'errors.form_username_invalid_length': 'Entre {min_length} y {max_length} caracteres.' }); + expect(text({ code: 'form_username_invalid_length', params: { min_length: 4, max_length: 64 } })).toBe( + 'Entre 4 y 64 caracteres.', + ); + }); + it('falls back to the generic message when there is nothing else', () => { expect(errorText({ 'errors.generic': 'Algo salió mal.' })({})).toBe('Algo salió mal.'); expect(errorText()({ code: 'toString' })).toBe('Something went wrong. Please try again.'); diff --git a/packages/mosaic/src/localization/errors.ts b/packages/mosaic/src/localization/errors.ts index 9dd17f65dc5..d11da404a36 100644 --- a/packages/mosaic/src/localization/errors.ts +++ b/packages/mosaic/src/localization/errors.ts @@ -1,14 +1,21 @@ import { useMessages } from './context'; +import type { MessageValues } from './messages'; +import { fill } from './messages'; export interface LocalizableError { code?: string; paramName?: string; message?: string; + /** Values for the `{placeholder}`s in whichever message the code resolves to. */ + params?: MessageValues; } export function useErrorText(): (error: LocalizableError) => string { const messages = useMessages('errors'); const lookup = (key: string | undefined) => (key && Object.hasOwn(messages, key) ? messages[key] : undefined); - return ({ code, paramName, message }) => - lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return ({ code, paramName, message, params }) => { + const template = + lookup(code && paramName ? `${code}__${paramName}` : undefined) ?? lookup(code) ?? message ?? messages.generic; + return params ? fill(template, params) : template; + }; } diff --git a/packages/mosaic/src/utils/form-error.ts b/packages/mosaic/src/utils/form-error.ts index f5d9b13a258..45dd494ae4d 100644 --- a/packages/mosaic/src/utils/form-error.ts +++ b/packages/mosaic/src/utils/form-error.ts @@ -1,7 +1,7 @@ import { isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; import { snakeToCamel } from '@clerk/shared/underscore'; -import type { LocalizableError } from '../localization'; +import type { LocalizableError, MessageValues } from '../localization'; export interface FormError { global?: LocalizableError; @@ -24,9 +24,16 @@ export class SaveError extends Error { function toClerkFormError( cause: unknown, fields: readonly TField[], + params: MessageValues | undefined, ): FormError | undefined { if (isClerkRuntimeError(cause)) { - return { global: { code: cause.code, ...(cause.longMessage ? { message: cause.longMessage } : {}) } }; + return { + global: { + code: cause.code, + ...(cause.longMessage ? { message: cause.longMessage } : {}), + ...(params ? { params } : {}), + }, + }; } if (!isClerkAPIResponseError(cause)) { return undefined; @@ -38,6 +45,7 @@ function toClerkFormError( code: apiError.code, ...(paramName ? { paramName } : {}), message: apiError.longMessage || apiError.message, + ...(params ? { params } : {}), }; const field = fields.find(f => paramName && snakeToCamel(paramName) === f); if (field) { @@ -51,16 +59,19 @@ function toClerkFormError( /** * Runs a save and rejects with a `SaveError` the view can render. `fields` names the controls the - * failure may be routed to; an error that names none of them lands in `global`. + * failure may be routed to; an error that names none of them lands in `global`. `params` carries + * the instance settings a failure's message may need to read — the length bounds a username was + * measured against, say — since only the caller's layer can resolve them. */ export async function save( run: () => Promise, fields: readonly TField[] = [], + params?: MessageValues, ): Promise { try { await run(); } catch (cause) { - const formError = toClerkFormError(cause, fields); + const formError = toClerkFormError(cause, fields, params); if (!formError) { throw cause; } From 3910ab4bd4e22db54c542e5c94b0ebe068d1c0e7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 23 Sep 2026 17:31:05 -0400 Subject: [PATCH 24/63] feat(mosaic): name the connection managing a row's value The managed-by shape is one type shared by the name and password rows, and the enterprise-managed case has a swingset story of its own. --- .../user-profile-account-section.messages.ts | 1 + .../user-profile-account-section.view.tsx | 4 ++++ .../user-profile-password-section.styles.ts | 13 ------------- .../user-profile-password-section.types.ts | 9 +++------ .../user-profile-password-section.view.tsx | 2 +- .../user-profile-security-panel.view.tsx | 4 ++-- packages/swingset/src/lib/registry.ts | 2 ++ .../stories/user-profile-account-section.mdx | 10 ++++++++++ .../user-profile-account-section.stories.tsx | 17 +++++++++++++++++ 9 files changed, 40 insertions(+), 22 deletions(-) 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-account-section/user-profile-account-section.messages.ts index cd9ddc712d0..97ef3318fbf 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-account-section/user-profile-account-section.messages.ts @@ -18,6 +18,7 @@ export const userProfileAccountSectionMessages = { empty: 'No name added', edit: 'Edit name', add: 'Add name', + managedBy: 'Managed by {name}', dialogTitle: 'Edit name', addDialogTitle: 'Add name', 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 index 1de473c3f6f..bf3923075d0 100644 --- 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 @@ -3,6 +3,7 @@ import * as stylex from '@stylexjs/stylex'; 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 { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, @@ -34,6 +35,7 @@ export interface UserProfileAccountSectionViewProps { 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[]; @@ -67,6 +69,7 @@ export function UserProfileAccountSectionView({ lastName, firstNameAttribute, lastNameAttribute, + nameManagedBy, emails, phones, onProfilePictureChange, @@ -136,6 +139,7 @@ export function UserProfileAccountSectionView({ lastName={lastName} firstNameAttribute={firstNameAttribute} lastNameAttribute={lastNameAttribute} + managedBy={nameManagedBy} onSubmit={onSubmitName} /> ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts index 74b0946ba9d..047e4a2c042 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.styles.ts @@ -28,17 +28,4 @@ export const styles = stylex.create({ checkboxDescription: { color: colorVars['--cl-color-foreground-secondary'], }, - managedBy: { - gap: space['1.5'], - alignItems: 'center', - display: 'flex', - }, - managedByIcon: { - flexShrink: 0, - height: space['4'], - width: space['4'], - }, - managedByText: { - color: colorVars['--cl-color-foreground-secondary'], - }, }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index 894bd6ec9f7..df72789e738 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,3 +1,5 @@ +import type { UserProfileManagedBy } from '../user-profile-managed-by'; + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { @@ -13,16 +15,11 @@ export interface UserProfileEditPasswordValue { signOutOfOtherSessions: boolean; } -export interface UserProfilePasswordManagedBy { - name: string; - iconUrl?: string; -} - export interface UserProfilePasswordSectionViewProps { sectionTitle?: string; hasPassword?: boolean; requiresCurrentPassword?: boolean; /** Replaces the edit action with the enterprise provider’s name. */ - managedBy?: UserProfilePasswordManagedBy; + managedBy?: UserProfileManagedBy; onSubmitPassword?: (value: UserProfileEditPasswordValue) => Promise; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx index 3e1261091f2..6438506b5a0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.view.tsx @@ -3,11 +3,11 @@ import { useMessages } from '../../../localization'; import { UserProfilePasswordRowView } from './user-profile-password-row.view'; import type { UserProfilePasswordSectionViewProps } from './user-profile-password-section.types'; +export type { UserProfileManagedBy } from '../user-profile-managed-by'; export type { UserProfileEditPasswordField, UserProfileEditPasswordValue, UserProfileEditPasswordValues, - UserProfilePasswordManagedBy, UserProfilePasswordSectionViewProps, } from './user-profile-password-section.types'; diff --git a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx index 0062d803e29..00f3130c54b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-security-panel.view.tsx @@ -15,7 +15,7 @@ import type { UserProfilePasskey } from './user-profile-passkeys-section.view'; import { UserProfilePasskeysSectionView } from './user-profile-passkeys-section.view'; import type { UserProfileEditPasswordValue, - UserProfilePasswordManagedBy, + UserProfileManagedBy, UserProfilePasswordSectionViewProps, } from './user-profile-password-section/user-profile-password-section.view'; import { UserProfilePasswordSectionView } from './user-profile-password-section/user-profile-password-section.view'; @@ -26,8 +26,8 @@ export type { UserProfileEditPasswordValue, UserProfileMfaAddableMethod, UserProfileMfaMethod, + UserProfileManagedBy, UserProfilePasskey, - UserProfilePasswordManagedBy, }; export interface UserProfileSecurityPanelViewProps diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 663265d9514..b71e0d0488f 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -315,6 +315,7 @@ import { 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'; @@ -747,6 +748,7 @@ const userProfileAccountSectionModule: StoryModule = { meta: userProfileAccountSectionMeta, Default: UserProfileAccountSectionDefault, MultipleAccounts: UserProfileAccountSectionMultipleAccounts, + NameManagedByConnection: UserProfileAccountSectionNameManagedByConnection, AddPhoneFails: UserProfileAccountSectionAddPhoneFails, AddEmailFails: UserProfileAccountSectionAddEmailFails, EmailLinkVerification: UserProfileAccountSectionEmailLinkVerification, diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index 3110e103387..ee41391ae5d 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -41,6 +41,16 @@ 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. diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index c4f212f6d34..3b53cdbebd0 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -40,6 +40,7 @@ function AccountSection({ failEmailVerification = false, emailRemovalState, phoneRemovalState, + nameManagedBy, }: { allowMultipleAccounts: boolean; failAt?: UserProfileAddPhoneDialogProps['step']; @@ -48,6 +49,7 @@ function AccountSection({ failEmailVerification?: boolean; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; + nameManagedBy?: { name: string; iconUrl?: string }; }) { const [phoneRemovalFailed, setPhoneRemovalFailed] = useState(false); const [emailRemovalFailed, setEmailRemovalFailed] = useState(false); @@ -86,6 +88,8 @@ function AccountSection({ imageUrl={imageUrl} phones={phones} {...addPhone} + nameManagedBy={nameManagedBy} + onSubmitName={nameManagedBy ? undefined : editName.onSubmitName} onProfilePictureChange={showFile} onRemoveProfilePicture={clearImage} onManageEmail={() => undefined} @@ -120,6 +124,19 @@ export function Default() { return ; } +/** + * An enterprise connection owns the name, so the row names who manages it in place of an edit + * action. The connection's logo leads the label, or a generic lock when the IDP ships none. + */ +export function NameManagedByConnection() { + return ( + + ); +} + export function MultipleAccounts() { return ; } From 07b30e1ed2ca4d3c68bf0105cbec522f35a96b46 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Thu, 24 Sep 2026 13:07:12 -0400 Subject: [PATCH 25/63] refactor(mosaic): move the legacy save error to the password section The account section carries its failures on SaveError now, so UserProfileSaveError and UserProfileFormError live with their only remaining consumer. --- .../user-profile-password-section.view.test.tsx | 7 +++---- .../user-profile-account-section.types.ts | 12 +----------- ...ser-profile-edit-password.controller.test.ts | 2 +- .../user-profile-password-section.types.ts | 17 +++++++++++++++++ .../fixtures/user-profile-edit-password.ts | 9 ++++----- .../user-profile-password-section.stories.tsx | 9 ++++----- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx index 7754247812a..777e234ce10 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-password-section.view.test.tsx @@ -3,8 +3,8 @@ import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; -import { SaveError } from '../../../utils/form-error'; import type { UserProfilePasswordSectionViewProps } from '../user-profile-password-section/user-profile-password-section.types'; +import { UserProfileSaveError } from '../user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '../user-profile-password-section/user-profile-password-section.view'; function renderView(props: UserProfilePasswordSectionViewProps = {}) { @@ -66,9 +66,8 @@ describe('UserProfilePasswordSectionView', () => { const onSubmitPassword = vi .fn() .mockRejectedValueOnce( - new SaveError({ - global: { message: 'Your password could not be updated.' }, - fields: { currentPassword: { message: 'Incorrect password.' } }, + new UserProfileSaveError('Your password could not be updated.', { + currentPassword: 'Incorrect password.', }), ) .mockResolvedValue(undefined); 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 index ff0a40d25cd..7defa07f149 100644 --- 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 @@ -1,5 +1,3 @@ -import { FormSubmitError } from '../../../components/form'; - /** One name attribute as the instance configures it. Supplied from `userSettings.attributes.first_name` and `last_name`. */ export interface UserProfileNameAttribute { /** @default true */ @@ -8,14 +6,6 @@ export interface UserProfileNameAttribute { 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 UserProfilePhone { id: string; value: string; @@ -30,4 +20,4 @@ export interface UserProfileEmail { isDefault?: boolean; isVerified?: boolean; canRemove?: boolean; -} \ No newline at end of file +} diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index b82f79e1d5a..7e7c21dd153 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -1,9 +1,9 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { UserProfileSaveError } from '../user-profile-account-section/user-profile-account-section.types'; import { useUserProfileEditPasswordController } from './user-profile-edit-password.controller'; import type { UserProfileEditPasswordValue } from './user-profile-password-section.types'; +import { UserProfileSaveError } from './user-profile-password-section.types'; function deferred() { let resolve: () => void = () => {}; diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index df72789e738..f7d795cc867 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -1,5 +1,22 @@ +import { FormSubmitError } from '../../../components/form'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; +/** Plain data, so nothing downstream of the view 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'; + } +} + export type UserProfileEditPasswordField = 'currentPassword' | 'newPassword' | 'confirmPassword'; export interface UserProfileEditPasswordValues { diff --git a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts index 621a0c283c7..597f2edbbb4 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-edit-password.ts @@ -1,9 +1,8 @@ import type { - UserProfileEditPasswordField, UserProfileEditPasswordValue, + UserProfileFormError, } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; -import type { FormError } from '@clerk/mosaic/utils/form-error'; -import { SaveError } from '@clerk/mosaic/utils/form-error'; +import { UserProfileSaveError } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { useState } from 'react'; export interface UserProfileEditPasswordFixtureOptions { @@ -11,7 +10,7 @@ export interface UserProfileEditPasswordFixtureOptions { requiresCurrentPassword?: boolean; latency?: number; /** Rejects the first save so the next attempt can succeed. */ - failWith?: FormError; + failWith?: UserProfileFormError; } export function useUserProfileEditPasswordFixture({ @@ -30,7 +29,7 @@ export function useUserProfileEditPasswordFixture({ await new Promise(resolve => setTimeout(resolve, latency)); if (failWith && !hasFailed) { setHasFailed(true); - throw new SaveError(failWith); + throw new UserProfileSaveError(failWith.message ?? 'Something went wrong.', failWith.fields); } setHasPassword(true); }, 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 8b84910116f..8fd8a8c827c 100644 --- a/packages/swingset/src/stories/user-profile-password-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-password-section.stories.tsx @@ -1,6 +1,5 @@ -import type { UserProfileEditPasswordField } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; +import type { UserProfileFormError } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.types'; import { UserProfilePasswordSectionView } from '@clerk/mosaic/features/user-profile/user-profile-password-section/user-profile-password-section.view'; -import type { FormError } from '@clerk/mosaic/utils/form-error'; import type { StoryMeta } from '@/lib/types'; @@ -25,7 +24,7 @@ function PasswordSection({ }: { hasPassword?: boolean; requiresCurrentPassword?: boolean; - failWith?: FormError; + failWith?: UserProfileFormError; }) { const editPassword = useUserProfileEditPasswordFixture({ hasPassword, requiresCurrentPassword, failWith }); @@ -75,8 +74,8 @@ export function EditPasswordFails() { return ( ); From 6dad14a31ed5694692a117bee846a0b085d70c24 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:16:56 -0400 Subject: [PATCH 26/63] feat(mosaic): localize save errors in useForm --- .../src/components/form/form.machine.ts | 28 +++++++++++++++++-- .../src/components/form/use-form.test.ts | 20 +++++++++++++ .../mosaic/src/components/form/use-form.ts | 4 ++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 6cca449f303..3228e696f73 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,5 +1,7 @@ +import type { LocalizableError } from '../../localization'; import { setup } from '../../machine/setup'; import type { TransitionResult } from '../../machine/types'; +import { SaveError } from '../../utils/form-error'; import { keysOf, mapKeys } from '../../utils/object'; import type { FieldFeedback, FormError, FormFieldErrors } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; @@ -33,6 +35,7 @@ export interface FormDeps { onSubmit: (values: TValues) => Promise; canSubmit: (values: TValues) => boolean; fallbackMessage: string; + errorText: (error: LocalizableError) => string; } export interface FormContext extends FormDeps { @@ -130,13 +133,32 @@ function displayableFields( return result; } +function savedFieldErrors( + context: FormContext, + fields: Partial> | undefined, +): FormFieldErrors { + const result: FormFieldErrors = {}; + for (const name of keysOf(context.values)) { + const error = typeof name === 'string' ? fields?.[name] : undefined; + if (error !== undefined) { + result[name] = context.errorText(error); + } + } + return result; +} + function toFormError(cause: unknown, context: FormContext): FormError { const error: FormError = cause instanceof FormSubmitError ? { message: cause.banner, fields: displayableFields(context, cause.fields) } - : cause instanceof Error - ? { message: cause.message } - : {}; + : cause instanceof SaveError + ? { + message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, + fields: savedFieldErrors(context, cause.formError.fields), + } + : cause instanceof Error + ? { message: cause.message } + : {}; const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; return visible ? error : { ...error, message: context.fallbackMessage }; } diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index b985ee5539c..653fd612c69 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -1,6 +1,7 @@ import { act, renderHook } from '@testing-library/react'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import { SaveError } from '../../utils/form-error'; import type { FieldFeedback } from './form-submit-error'; import { FormSubmitError } from './form-submit-error'; import { useForm } from './use-form'; @@ -101,6 +102,25 @@ describe('useForm', () => { expect(result.current.fields.username.feedback).toBeUndefined(); }); + it('localizes a SaveError onto the form message and field feedback', async () => { + const failure = new SaveError<'username'>({ + global: { code: 'unknown_code', message: 'Could not save' }, + fields: { username: { code: 'generic' } }, + }); + const { result } = renderHook(() => + useForm({ initialValues: { username: 'alex' }, onSubmit: () => Promise.reject(failure) }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Could not save'); + expect(result.current.fields.username.feedback).toEqual({ + type: 'error', + message: 'Something went wrong. Please try again.', + }); + }); + it('maps a fields-only FormSubmitError onto field feedback with no form message', async () => { const failure = new FormSubmitError({ fields: { username: 'Taken', bio: 'Too long' } }); expect(failure.message).toBe('Taken Too long'); diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index cdb82fd408f..5e8e81118fe 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -1,6 +1,6 @@ import { useCallback, useId, useRef } from 'react'; -import { useMessages } from '../../localization'; +import { useErrorText, useMessages } from '../../localization'; import type { StateMachine } from '../../machine/types'; import { useMachine } from '../../machine/useMachine'; import { keysOf, mapKeys } from '../../utils/object'; @@ -84,6 +84,7 @@ const always = () => true; export function useForm(options: UseFormOptions): UseFormResult { const id = useId(); const m = useMessages('form'); + const errorText = useErrorText(); const elements = useRef(new Map()); const refs = useRef(new Map()); @@ -93,6 +94,7 @@ export function useForm(options: UseFormOptions onSubmit: options.onSubmit, canSubmit: options.canSubmit ?? always, fallbackMessage: m.error, + errorText, }; const machineRef = useRef, FormEvent> | null>(null); if (machineRef.current === null) { From 957a663dea708433c3247a4b379aa5bb09c56918 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:23:30 -0400 Subject: [PATCH 27/63] refactor(mosaic): drive the name form with useForm --- .../user-profile-edit-name.controller.test.ts | 183 ++++++------------ .../user-profile-edit-name.dialog.test.tsx | 137 ++++++------- .../user-profile-edit-name.controller.ts | 115 ++--------- .../user-profile-edit-name.dialog.tsx | 138 +++++++------ .../user-profile-name-row.view.tsx | 3 +- 5 files changed, 213 insertions(+), 363 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts index c12e6f022a2..d746f5d7530 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.controller.test.ts @@ -1,156 +1,93 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/createActor'; -import type { FormError } from '../../../utils/form-error'; import { SaveError } from '../../../utils/form-error'; -import { - userProfileEditNameMachine, - useUserProfileEditNameController, -} from '../user-profile-account-section/user-profile-edit-name.controller'; -import type { UserProfileEditNameField } from '../user-profile-account-section/user-profile-edit-name.dialog'; - -const saved = (): Promise => Promise.resolve(); -const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); - -function start(saveName: () => Promise, saved = { savedFirstName: 'Preston', savedLastName: 'Booth' }) { - const actor = createActor(userProfileEditNameMachine, { context: { saveName, ...saved } }).start(); - actor.send({ type: 'OPEN' }); - return actor; -} - -/** The save is guarded on a change, so a test about the save has to make one first. */ -function startEdited(saveName: () => Promise) { - const actor = start(saveName); - actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); - return actor; -} - -describe('userProfileEditNameMachine', () => { - it('seeds both fields from the saved name on open', () => { - const actor = start(saved); - - expect(actor.getSnapshot().value).toBe('editing'); - expect(actor.getSnapshot().context.firstName).toBe('Preston'); - expect(actor.getSnapshot().context.lastName).toBe('Booth'); - }); - - it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(saved); - actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); - actor.send({ type: 'SAVE' }); - expect(actor.getSnapshot().value).toBe('saving'); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); - expect(actor.getSnapshot().status).toBe('active'); - - actor.send({ type: 'OPEN' }); - expect(actor.getSnapshot().value).toBe('editing'); - }); +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'; - it('re-seeds from the saved name on the next open, dropping what was typed', () => { - const actor = start(saved); - actor.send({ type: 'TYPE', field: 'firstName', value: 'Ada' }); - actor.send({ type: 'CANCEL' }); - - actor.send({ type: 'OPEN' }); - - expect(actor.getSnapshot().context.firstName).toBe('Preston'); - }); - - it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ global: { message: 'Your name could not be updated.' } })); - actor.send({ type: 'TYPE', field: 'lastName', value: 'Barton' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.lastName).toBe('Barton'); - expect(actor.getSnapshot().context.error).toEqual({ - global: { message: 'Your name could not be updated.' }, - }); +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; }); + return { promise, resolve }; +} - it('carries field copy through when the error names a control', async () => { - const actor = startEdited(() => - failed({ - global: { message: 'Your name could not be updated.' }, - fields: { firstName: { message: 'First name is required.' } }, - }), - ); - actor.send({ type: 'SAVE' }); +function renderController(onSubmit: (value: UserProfileEditNameValue) => Promise = () => Promise.resolve()) { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), + ); + act(() => result.current.onOpenChange(true)); + return result; +} - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ firstName: { message: 'First name is required.' } }), +describe('useUserProfileEditNameController', () => { + it('opens on the saved name', () => { + const { result } = renderHook(() => + useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: vi.fn() }), ); - }); + expect(result.current.isOpen).toBe(false); - it('shows the generic banner and logs when the save throws unexpectedly', async () => { - const log = vi.spyOn(console, 'error').mockImplementation(() => {}); - const failure = new TypeError('boom'); - const actor = startEdited(() => Promise.reject(failure)); - actor.send({ type: 'SAVE' }); + act(() => result.current.onOpenChange(true)); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); - expect(log).toHaveBeenCalledWith(failure); - log.mockRestore(); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values).toEqual({ firstName: 'Preston', lastName: 'Booth' }); }); - it('drops the error when the dialog is cancelled', async () => { - const actor = startEdited(() => failed({ global: { message: 'nope' } })); - actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); + it('withholds the save while the name is unchanged', () => { + const result = renderController(); + expect(result.current.form.canSubmit).toBe(false); - actor.send({ type: 'CANCEL' }); + act(() => result.current.form.setValue('lastName', 'Barton')); - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.error).toBeUndefined(); + expect(result.current.form.canSubmit).toBe(true); }); -}); - -describe('useUserProfileEditNameController', () => { - it('holds the dialog open across editing and saving, then closes on success', async () => { - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), - ); - expect(result.current.isOpen).toBe(false); - act(() => result.current.onOpenChange(true)); - expect(result.current.isOpen).toBe(true); - expect(result.current.firstName).toBe('Preston'); - expect(result.current.isSaving).toBe(false); + it('saves the values it is holding, stays open while saving, then closes', async () => { + const request = deferred(); + const onSubmit = vi.fn(() => request.promise); + const result = renderController(onSubmit); + act(() => result.current.form.setValue('firstName', 'Ada')); - act(() => result.current.onLastNameChange('Barton')); - expect(result.current.lastName).toBe('Barton'); + act(() => result.current.form.submit()); + act(() => result.current.onOpenChange(false)); - act(() => result.current.onSubmit()); + expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' }); expect(result.current.isOpen).toBe(true); - expect(result.current.isSaving).toBe(true); - + expect(result.current.form.isSubmitting).toBe(true); + await act(async () => request.resolve()); await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('saves the values it is currently holding', async () => { - const onSubmit = vi.fn(saved); - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }), + it('keeps what was typed and shows why when the save fails', async () => { + const result = renderController(() => + Promise.reject( + new SaveError({ + global: { message: 'Your name could not be updated.' }, + fields: { lastName: { message: 'Last name is too long.' } }, + }), + ), ); + act(() => result.current.form.setValue('lastName', 'Barton')); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onFirstNameChange('Ada')); - act(() => result.current.onSubmit()); + act(() => result.current.form.submit()); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ firstName: 'Ada', lastName: 'Booth' })); + await waitFor(() => expect(result.current.form.error).toBe('Your name could not be updated.')); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.lastName).toBe('Barton'); + expect(result.current.form.fields.lastName.feedback).toEqual({ type: 'error', message: 'Last name is too long.' }); }); - it('withholds the save while the name is unchanged', () => { - const { result } = renderHook(() => - useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit: saved }), - ); + it('re-seeds from the saved name and drops the error on the next open', async () => { + const result = renderController(() => Promise.reject(new SaveError({ global: { message: 'nope' } }))); + act(() => result.current.form.setValue('firstName', 'Ada')); + act(() => result.current.form.submit()); + await waitFor(() => expect(result.current.form.error).toBe('nope')); + act(() => result.current.onOpenChange(false)); act(() => result.current.onOpenChange(true)); - expect(result.current.canSave).toBe(false); - act(() => result.current.onLastNameChange('Barton')); - expect(result.current.canSave).toBe(true); + expect(result.current.form.values.firstName).toBe('Preston'); + expect(result.current.form.error).toBeUndefined(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index 7f2e8e17859..c507932a8ce 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -4,28 +4,41 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; import { MosaicProvider } from '../../../MosaicProvider'; -import type { UserProfileEditNameDialogProps } from '../user-profile-account-section/user-profile-edit-name.dialog'; +import { SaveError } from '../../../utils/form-error'; +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'; -function renderView(overrides: Partial = {}) { - const props: UserProfileEditNameDialogProps = { +type ViewProps = Omit & { + onSubmit: (value: UserProfileEditNameValue) => Promise; +}; + +function View({ onSubmit, ...props }: ViewProps) { + const { form } = useUserProfileEditNameController({ firstName: 'Preston', lastName: 'Booth', onSubmit }); + return ( + + ); +} + +function renderView(overrides: Partial = {}) { + const props: ViewProps = { open: true, onOpenChange: vi.fn(), - firstName: 'Preston', - lastName: 'Booth', - onFirstNameChange: vi.fn(), - onLastNameChange: vi.fn(), - onSubmit: vi.fn(), + onSubmit: vi.fn(() => Promise.resolve()), ...overrides, }; - return { - props, - ...render( - - - , - ), - }; + render( + + + , + ); + return props; } const firstNameField = () => screen.getByLabelText('First name'); @@ -39,7 +52,7 @@ describe('UserProfileEditNameDialog', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('names the dialog and shows the values it was given', () => { + it('names the dialog and shows the saved name', () => { renderView(); expect(screen.getByRole('dialog', { name: 'Edit name' })).toBeInTheDocument(); @@ -50,67 +63,45 @@ describe('UserProfileEditNameDialog', () => { it('opens on the first name rather than the corner dismiss', async () => { renderView(); - // `FloatingFocusManager` moves focus in an effect, hence the wait. await waitFor(() => expect(firstNameField()).toHaveFocus()); }); it('asks to open from the trigger', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ open: false, onOpenChange, trigger: }); + const props = renderView({ open: false, trigger: }); await user.click(screen.getByRole('button', { name: 'Edit name' })); - expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); - }); - - it('reports each keystroke to its own field, holding nothing itself', async () => { - const onFirstNameChange = vi.fn(); - const onLastNameChange = vi.fn(); - const user = userEvent.setup(); - renderView({ onFirstNameChange, onLastNameChange }); - - await user.type(firstNameField(), 'x'); - await user.type(lastNameField(), 'y'); - - expect(onFirstNameChange).toHaveBeenCalledWith('Prestonx'); - expect(onLastNameChange).toHaveBeenCalledWith('Boothy'); - // Controlled: the rendered value only moves when the caller says so. - expect(firstNameField()).toHaveValue('Preston'); + expect(props.onOpenChange).toHaveBeenCalledWith(true, expect.anything()); }); - it('submits from the action, without validating', async () => { - const onSubmit = vi.fn(); + it('withholds the save until the name changes, then submits what was typed', async () => { const user = userEvent.setup(); - renderView({ firstName: '', onSubmit }); + const props = renderView(); + expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.type(lastNameField(), 'x'); await user.click(saveButton()); - expect(onSubmit).toHaveBeenCalledTimes(1); + expect(props.onSubmit).toHaveBeenCalledExactlyOnceWith({ firstName: 'Preston', lastName: 'Boothx' }); }); - it('renders both fields optional and enabled unless told otherwise', () => { + it('renders both fields optional unless told otherwise', () => { renderView(); expect(firstNameField()).not.toBeRequired(); expect(lastNameField()).not.toBeRequired(); }); - it('marks a field required from its attribute', () => { - renderView({ firstNameAttribute: { required: true } }); - - expect(firstNameField()).toBeRequired(); - expect(lastNameField()).not.toBeRequired(); - }); - it('holds the submit while a required field is empty', async () => { - const onSubmit = vi.fn(); const user = userEvent.setup(); - renderView({ firstName: '', firstNameAttribute: { required: true }, onSubmit }); + const props = renderView({ firstNameAttribute: { required: true } }); + expect(firstNameField()).toBeRequired(); + await user.clear(firstNameField()); await user.click(saveButton()); - expect(onSubmit).not.toHaveBeenCalled(); + expect(props.onSubmit).not.toHaveBeenCalled(); }); it('drops a field the instance has disabled, and opens on the one that remains', async () => { @@ -121,51 +112,47 @@ describe('UserProfileEditNameDialog', () => { }); it('asks to close from cancel', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ onOpenChange }); + const props = renderView(); await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); }); - it('announces the failure in a negative banner and marks the blamed field invalid', () => { + it('announces the failure in a negative banner and marks the blamed field invalid', async () => { + const user = userEvent.setup(); renderView({ - error: { - global: { message: 'Your name could not be updated.' }, - fields: { lastName: { message: 'Last name must be 64 characters or fewer.' } }, - }, + onSubmit: () => + Promise.reject( + new SaveError({ + global: { message: 'Your name could not be updated.' }, + fields: { lastName: { message: 'Last name must be 64 characters or fewer.' } }, + }), + ), }); - const banner = screen.getByRole('alert'); + await user.type(lastNameField(), 'x'); + await user.click(saveButton()); + + const banner = await screen.findByRole('alert'); expect(banner).toHaveAttribute('data-color', 'negative'); expect(banner).toHaveTextContent('Your name could not be updated.'); - expect(screen.getByText('Last name must be 64 characters or fewer.')).toBeInTheDocument(); + expect(lastNameField()).toHaveAccessibleDescription('Last name must be 64 characters or fewer.'); expect(lastNameField()).toHaveAttribute('aria-invalid', 'true'); expect(firstNameField()).not.toHaveAttribute('aria-invalid', 'true'); }); - it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { firstName: { message: 'First name is required.' } } } }); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(firstNameField()).toHaveAttribute('aria-invalid', 'true'); - }); - it('stays inert while the save runs', async () => { - const onSubmit = vi.fn(); - const onFirstNameChange = vi.fn(); const user = userEvent.setup(); - renderView({ isSaving: true, onSubmit, onFirstNameChange }); + const props = renderView({ onSubmit: vi.fn(() => new Promise(() => {})) }); - await user.type(firstNameField(), 'Ada'); + await user.type(firstNameField(), 'x'); + await user.click(saveButton()); expect(firstNameField()).toBeDisabled(); - expect(onFirstNameChange).not.toHaveBeenCalled(); - // Busy, not unavailable: the pending affordance is `isPending`, not a second disabled state. expect(saveButton()).toHaveAttribute('aria-busy', 'true'); await user.click(saveButton()); - expect(onSubmit).not.toHaveBeenCalled(); + expect(props.onSubmit).toHaveBeenCalledOnce(); }); }); 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-account-section/user-profile-edit-name.controller.ts index 6d798e464db..6f52edda5f3 100644 --- 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-account-section/user-profile-edit-name.controller.ts @@ -1,77 +1,8 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; -import type { FormError } from '../../../utils/form-error'; -import { toFormError } from '../../../utils/form-error'; -import type { UserProfileEditNameField, UserProfileEditNameValue } from './user-profile-edit-name.dialog'; +import { useState } from 'react'; -export interface UserProfileEditNameContext { - saveName: (value: UserProfileEditNameValue) => Promise; - /** Injected every render. What `OPEN` seeds the fields from. */ - savedFirstName: string; - savedLastName: string; - firstName: string; - lastName: string; - error: FormError | undefined; -} - -export type UserProfileEditNameEvent = - | { type: 'OPEN' } - | { type: 'TYPE'; field: UserProfileEditNameField; value: string } - | { type: 'SAVE' } - | { type: 'CANCEL' }; - -const { createMachine, assign, fromPromise } = setup(); - -function notSeated(): Promise { - return Promise.reject(new Error('edit-name deps are not seated')); -} - -function isSaveable(context: UserProfileEditNameContext): boolean { - return context.firstName !== context.savedFirstName || context.lastName !== context.savedLastName; -} - -export const userProfileEditNameMachine = createMachine({ - id: 'editName', - initial: 'idle', - context: { - saveName: notSeated, - savedFirstName: '', - savedLastName: '', - firstName: '', - lastName: '', - error: undefined, - }, - states: { - idle: { - on: { - OPEN: { - target: 'editing', - actions: assign(context => ({ - firstName: context.savedFirstName, - lastName: context.savedLastName, - error: undefined, - })), - }, - }, - }, - editing: { - on: { - TYPE: { actions: assign((_, event) => ({ [event.field]: event.value })) }, - SAVE: { target: 'saving', guard: isSaveable }, - CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - }, - }, - saving: { - invoke: fromPromise(context => context.saveName({ firstName: context.firstName, lastName: context.lastName }), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - onError: { - target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), - }, - }), - }, - }, -}); +import type { UseFormResult } from '../../../components/form'; +import { useForm } from '../../../components/form'; +import type { UserProfileEditNameValue } from './user-profile-edit-name.dialog'; export interface UserProfileEditNameControllerOptions { firstName?: string; @@ -83,14 +14,7 @@ export interface UserProfileEditNameControllerOptions { export interface UserProfileEditNameController { isOpen: boolean; onOpenChange: (open: boolean) => void; - firstName: string; - lastName: string; - onFirstNameChange: (value: string) => void; - onLastNameChange: (value: string) => void; - onSubmit: () => void; - canSave: boolean; - isSaving: boolean; - error: FormError | undefined; + form: UseFormResult; } export function useUserProfileEditNameController({ @@ -98,20 +22,23 @@ export function useUserProfileEditNameController({ lastName = '', onSubmit, }: UserProfileEditNameControllerOptions): UserProfileEditNameController { - const [snapshot, send] = useMachine(userProfileEditNameMachine, { - context: { saveName: onSubmit, savedFirstName: firstName, savedLastName: lastName }, + const [isOpen, setIsOpen] = useState(false); + const form = useForm({ + initialValues: { firstName, lastName }, + canSubmit: values => values.firstName !== firstName || values.lastName !== lastName, + onSubmit: async values => { + await onSubmit(values); + setIsOpen(false); + }, }); - return { - isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - firstName: snapshot.context.firstName, - lastName: snapshot.context.lastName, - onFirstNameChange: value => send({ type: 'TYPE', field: 'firstName', value }), - onLastNameChange: value => send({ type: 'TYPE', field: 'lastName', value }), - onSubmit: () => send({ type: 'SAVE' }), - canSave: isSaveable(snapshot.context), - isSaving: snapshot.value === 'saving', - error: snapshot.context.error, + const onOpenChange = (open: boolean) => { + if (!open && form.isSubmitting) { + return; + } + form.reset(); + setIsOpen(open); }; + + return { isOpen, onOpenChange, form }; } 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-account-section/user-profile-edit-name.dialog.tsx index 6b65255090b..391d5eb345f 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-account-section/user-profile-edit-name.dialog.tsx @@ -1,5 +1,6 @@ -import type { FormEvent } from 'react'; -import { useId, useRef } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import type { RefObject } from 'react'; +import { useRef } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,9 +8,9 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; -import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/form-error'; +import { useMessages } from '../../../localization'; import type { UserProfileNameAttribute } from './user-profile-account-section.types'; export type UserProfileEditNameField = 'firstName' | 'lastName'; @@ -24,56 +25,32 @@ export interface UserProfileEditNameDialogProps { onOpenChange: (open: boolean) => void; /** Rendering the opener here is what returns focus to it on close. */ trigger?: DialogTriggerProps['render']; - firstName: string; - lastName: string; /** A disabled attribute drops its field; a required one blocks the submit while empty. */ firstNameAttribute?: UserProfileNameAttribute; lastNameAttribute?: UserProfileNameAttribute; - onFirstNameChange: (value: string) => void; - onLastNameChange: (value: string) => void; title?: string; - canSave?: boolean; - isSaving?: boolean; - error?: FormError; - onSubmit: () => void; + form: UseFormResult; } /** - * Edits the user's first and last name. Holds nothing, and validates nothing beyond the native - * `required` the instance asks for: the name the API will take is the API's to decide, so the action - * stays live and a rejection comes back as `error`. + * Edits the user's first and last name. Validates nothing beyond the native `required` the + * instance asks for: the name the API will take is the API's to decide, so a rejection comes back + * through the form. */ export function UserProfileEditNameDialog({ open, onOpenChange, trigger, - firstName, - lastName, firstNameAttribute = {}, lastNameAttribute = {}, - onFirstNameChange, - onLastNameChange, title, - canSave = true, - isSaving = false, - error, - onSubmit, + form, }: UserProfileEditNameDialogProps) { const m = useMessages('userProfileAccountSection'); - const errorText = useErrorText(); - const formId = useId(); const initialFocusRef = useRef(null); const { enabled: showFirstName = true, required: firstNameRequired = false } = firstNameAttribute; const { enabled: showLastName = true, required: lastNameRequired = false } = lastNameAttribute; - // `canSave`/`isSaving` only cancel the press on the action; they do not stop a native submit. - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (canSave && !isSaving) { - onSubmit(); - } - }; - return ( } > - {error?.global ? ( + {form.error ? ( - {errorText(error.global)} + {form.error} ) : null} {showFirstName ? ( - - {m.name.firstNameLabel} - onFirstNameChange(event.target.value)} - /> - - {error?.fields?.firstName ? errorText(error.fields.firstName) : null} - - + /> ) : null} {showLastName ? ( - - {m.name.lastNameLabel} - onLastNameChange(event.target.value)} - /> - - {error?.fields?.lastName ? errorText(error.fields.lastName) : null} - - + /> ) : null}
@@ -157,10 +118,10 @@ export function UserProfileEditNameDialog({ } /> {m.name.save} @@ -171,3 +132,40 @@ export function UserProfileEditNameDialog({ ); } + +function NameField({ + label, + autoComplete, + form, + inputRef, + name, + required, +}: { + label: string; + autoComplete: 'given-name' | 'family-name'; + form: UseFormResult; + inputRef?: RefObject; + name: UserProfileEditNameField; + required: boolean; +}) { + const { feedback } = form.fields[name]; + const error = feedback?.type === 'error' ? feedback.message : undefined; + const { ref, ...control } = form.register(name); + const mergedRef = useMergeRefs([ref, inputRef]); + + return ( + + {label} + + {error ? {error} : 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 index f734d91c7e3..66e90712c9a 100644 --- 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 @@ -77,10 +77,11 @@ function EditName({ return ( Date: Fri, 25 Sep 2026 11:23:31 -0400 Subject: [PATCH 28/63] refactor(mosaic): drive the username form with useForm --- ...r-profile-edit-username.controller.test.ts | 202 +++++------------- ...user-profile-edit-username.dialog.test.tsx | 132 ++++++------ .../user-profile-edit-username.controller.ts | 102 ++------- .../user-profile-edit-username.dialog.tsx | 68 +++--- .../user-profile-username-row.view.tsx | 3 +- 5 files changed, 175 insertions(+), 332 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index bd9d03dd7ad..018df00b0ae 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -1,180 +1,90 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { createActor } from '../../../machine/createActor'; -import type { FormError } from '../../../utils/form-error'; import { SaveError } from '../../../utils/form-error'; -import { - userProfileEditUsernameMachine, - useUserProfileEditUsernameController, -} from '../user-profile-account-section/user-profile-edit-username.controller'; -import type { UserProfileEditUsernameField } from '../user-profile-account-section/user-profile-edit-username.dialog'; - -const saved = (): Promise => Promise.resolve(); -const failed = (error: FormError): Promise => Promise.reject(new SaveError(error)); - -function start(saveUsername: () => Promise, savedUsername = 'prestonxyz') { - const actor = createActor(userProfileEditUsernameMachine, { context: { saveUsername, savedUsername } }).start(); - actor.send({ type: 'OPEN' }); - return actor; -} - -describe('userProfileEditUsernameMachine', () => { - it('seeds the field from the saved username on open', () => { - const actor = start(saved); - - expect(actor.getSnapshot().value).toBe('editing'); - expect(actor.getSnapshot().context.username).toBe('prestonxyz'); - }); - - it('returns to idle when the save lands, and can be opened again', async () => { - const actor = start(saved); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - expect(actor.getSnapshot().value).toBe('saving'); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('idle')); - expect(actor.getSnapshot().status).toBe('active'); - - actor.send({ type: 'OPEN' }); - expect(actor.getSnapshot().value).toBe('editing'); - }); - - it('re-seeds from the saved username on the next open, dropping what was typed', () => { - const actor = start(saved); - actor.send({ type: 'TYPE', value: 'ada' }); - actor.send({ type: 'CANCEL' }); - - actor.send({ type: 'OPEN' }); - - expect(actor.getSnapshot().context.username).toBe('prestonxyz'); - }); - - it('keeps what was typed when the save fails, so it can be corrected', async () => { - const actor = start(() => failed({ global: { message: 'That username is taken.' } })); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('editing')); - expect(actor.getSnapshot().context.username).toBe('preston'); - expect(actor.getSnapshot().context.error).toEqual({ global: { message: 'That username is taken.' } }); - }); - - it('carries field copy through when the error names the control', async () => { - const actor = start(() => - failed({ - global: { message: 'Your username could not be updated.' }, - fields: { username: { message: 'That username is taken.' } }, - }), - ); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => - expect(actor.getSnapshot().context.error?.fields).toEqual({ username: { message: 'That username is taken.' } }), - ); - }); - - it('shows the generic banner and logs when the save throws unexpectedly', async () => { - const log = vi.spyOn(console, 'error').mockImplementation(() => {}); - const failure = new TypeError('boom'); - const actor = start(() => Promise.reject(failure)); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ global: { code: 'generic' } })); - expect(log).toHaveBeenCalledWith(failure); - log.mockRestore(); - }); - - it('refuses to save a value that has not moved', () => { - const saveUsername = vi.fn(saved); - const actor = start(saveUsername); +import { useUserProfileEditUsernameController } from '../user-profile-account-section/user-profile-edit-username.controller'; - actor.send({ type: 'SAVE' }); - - // The guard holds the transition, so the rule survives a SAVE from anywhere, not just the button. - expect(actor.getSnapshot().value).toBe('editing'); - expect(saveUsername).not.toHaveBeenCalled(); +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise(r => { + resolve = r; }); + return { promise, resolve }; +} - it('refuses to save an empty value, since clearing a username is not on offer', () => { - const saveUsername = vi.fn(saved); - const actor = start(saveUsername); - actor.send({ type: 'TYPE', value: '' }); - - actor.send({ type: 'SAVE' }); - - expect(actor.getSnapshot().value).toBe('editing'); - expect(saveUsername).not.toHaveBeenCalled(); - }); - - it('drops the error when the dialog is cancelled', async () => { - const actor = start(() => failed({ global: { message: 'nope' } })); - actor.send({ type: 'TYPE', value: 'preston' }); - actor.send({ type: 'SAVE' }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error?.global?.message).toBe('nope')); - - actor.send({ type: 'CANCEL' }); - - expect(actor.getSnapshot().value).toBe('idle'); - expect(actor.getSnapshot().context.error).toBeUndefined(); - }); -}); +function renderController(onSubmit: (username: string) => Promise = () => Promise.resolve()) { + const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); + act(() => result.current.onOpenChange(true)); + return result; +} describe('useUserProfileEditUsernameController', () => { - it('holds the dialog open across editing and saving, then closes on success', async () => { + it('opens on the saved username', () => { const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), + useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: vi.fn() }), ); expect(result.current.isOpen).toBe(false); act(() => result.current.onOpenChange(true)); + expect(result.current.isOpen).toBe(true); - expect(result.current.username).toBe('prestonxyz'); - expect(result.current.isSaving).toBe(false); + expect(result.current.form.values.username).toBe('prestonxyz'); + }); - act(() => result.current.onUsernameChange('preston')); - expect(result.current.username).toBe('preston'); + it('withholds the save until the value moves, and on an empty value', () => { + const result = renderController(); + expect(result.current.form.canSubmit).toBe(false); - act(() => result.current.onSubmit()); - expect(result.current.isOpen).toBe(true); - expect(result.current.isSaving).toBe(true); + act(() => result.current.form.setValue('username', '')); + expect(result.current.form.canSubmit).toBe(false); - await waitFor(() => expect(result.current.isOpen).toBe(false)); + act(() => result.current.form.setValue('username', 'ada')); + expect(result.current.form.canSubmit).toBe(true); }); - it('saves the value it is currently holding', async () => { - const onSubmit = vi.fn(saved); - const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); + it('saves the value it is holding, stays open while saving, then closes', async () => { + const request = deferred(); + const onSubmit = vi.fn(() => request.promise); + const result = renderController(onSubmit); + act(() => result.current.form.setValue('username', 'ada')); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onUsernameChange('ada')); - act(() => result.current.onSubmit()); + act(() => result.current.form.submit()); + act(() => result.current.onOpenChange(false)); - await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ada')); + expect(onSubmit).toHaveBeenCalledWith('ada'); + expect(result.current.isOpen).toBe(true); + await act(async () => request.resolve()); + await waitFor(() => expect(result.current.isOpen).toBe(false)); }); - it('withholds the save until the value moves', () => { - const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), + it('keeps what was typed and shows why when the save fails', async () => { + const result = renderController(() => + Promise.reject(new SaveError({ fields: { username: { message: 'That username is taken.' } } })), ); + act(() => result.current.form.setValue('username', 'ada')); - act(() => result.current.onOpenChange(true)); - expect(result.current.canSave).toBe(false); + act(() => result.current.form.submit()); - act(() => result.current.onUsernameChange('ada')); - expect(result.current.canSave).toBe(true); + await waitFor(() => + expect(result.current.form.fields.username.feedback).toEqual({ + type: 'error', + message: 'That username is taken.', + }), + ); + expect(result.current.isOpen).toBe(true); + expect(result.current.form.values.username).toBe('ada'); }); - it('withholds the save on an empty value', () => { - const { result } = renderHook(() => - useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit: saved }), - ); + it('re-seeds from the saved username and drops the error on the next open', async () => { + const result = renderController(() => Promise.reject(new SaveError({ global: { message: 'nope' } }))); + act(() => result.current.form.setValue('username', 'ada')); + act(() => result.current.form.submit()); + await waitFor(() => expect(result.current.form.error).toBe('nope')); + act(() => result.current.onOpenChange(false)); act(() => result.current.onOpenChange(true)); - act(() => result.current.onUsernameChange('')); - expect(result.current.canSave).toBe(false); + expect(result.current.form.values.username).toBe('prestonxyz'); + expect(result.current.form.error).toBeUndefined(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx index 8acd861593a..7b0d78bc041 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.dialog.test.tsx @@ -5,26 +5,38 @@ import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../../components/button'; import type { MosaicLocalization } from '../../../localization'; import { MosaicProvider } from '../../../MosaicProvider'; +import { SaveError } from '../../../utils/form-error'; +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'; -function renderView(overrides: Partial = {}, localization?: MosaicLocalization) { - const props: UserProfileEditUsernameDialogProps = { +type ViewProps = Omit & { + onSubmit: (username: string) => Promise; +}; + +function View({ onSubmit, ...props }: ViewProps) { + const { form } = useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit }); + return ( + + ); +} + +function renderView(overrides: Partial = {}, localization?: MosaicLocalization) { + const props: ViewProps = { open: true, onOpenChange: vi.fn(), - username: 'prestonxyz', - onUsernameChange: vi.fn(), - onSubmit: vi.fn(), + onSubmit: vi.fn(() => Promise.resolve()), ...overrides, }; - return { - props, - ...render( - - - , - ), - }; + render( + + + , + ); + return props; } const usernameField = () => screen.getByLabelText('Username'); @@ -37,7 +49,7 @@ describe('UserProfileEditUsernameDialog', () => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); - it('names the dialog and shows the value it was given', () => { + it('names the dialog and shows the saved username', () => { renderView(); expect(screen.getByRole('dialog', { name: 'Edit username' })).toBeInTheDocument(); @@ -47,106 +59,98 @@ describe('UserProfileEditUsernameDialog', () => { it('opens on the field rather than the corner dismiss', async () => { renderView(); - // `FloatingFocusManager` moves focus in an effect, hence the wait. await waitFor(() => expect(usernameField()).toHaveFocus()); }); it('asks to open from the trigger', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ open: false, onOpenChange, trigger: }); + const props = renderView({ open: false, trigger: }); await user.click(screen.getByRole('button', { name: 'Edit username' })); - expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(true, expect.anything()); }); - it('reports each keystroke, holding nothing itself', async () => { - const onUsernameChange = vi.fn(); + it('withholds the save until the value moves, keeping the action reachable', async () => { const user = userEvent.setup(); - renderView({ onUsernameChange }); + const props = renderView(); - await user.type(usernameField(), 'x'); + expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.click(saveButton()); + await user.type(usernameField(), '{Enter}'); - expect(onUsernameChange).toHaveBeenCalledWith('prestonxyzx'); - // Controlled: the rendered value only moves when the caller says so. - expect(usernameField()).toHaveValue('prestonxyz'); + expect(props.onSubmit).not.toHaveBeenCalled(); }); it('submits from the action and from enter in the field', async () => { - const onSubmit = vi.fn(); const user = userEvent.setup(); - renderView({ onSubmit }); + const props = renderView({ onSubmit: vi.fn(() => Promise.reject(new SaveError({ global: { message: 'no' } }))) }); + await user.type(usernameField(), 'x'); await user.click(saveButton()); - // One field, so native implicit submission carries Enter with no submit button in the form. + await screen.findByRole('alert'); await user.type(usernameField(), '{Enter}'); - expect(onSubmit).toHaveBeenCalledTimes(2); + await waitFor(() => expect(props.onSubmit).toHaveBeenCalledTimes(2)); + expect(props.onSubmit).toHaveBeenCalledWith('prestonxyzx'); }); it('asks to close from cancel', async () => { - const onOpenChange = vi.fn(); const user = userEvent.setup(); - renderView({ onOpenChange }); + const props = renderView(); await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); }); - it('announces the failure in a negative banner', () => { - renderView({ error: { global: { message: 'Your username could not be updated.' } } }); + it('announces a general failure in a negative banner', async () => { + const user = userEvent.setup(); + renderView({ + onSubmit: () => Promise.reject(new SaveError({ global: { message: 'Your username could not be updated.' } })), + }); - const banner = screen.getByRole('alert'); + await user.type(usernameField(), 'x'); + await user.click(saveButton()); + + const banner = await screen.findByRole('alert'); expect(banner).toHaveAttribute('data-color', 'negative'); expect(banner).toHaveTextContent('Your username could not be updated.'); expect(usernameField()).not.toHaveAttribute('aria-invalid', 'true'); }); - it('renders a field-scoped failure with no banner', () => { - renderView({ error: { fields: { username: { message: 'That username is taken.' } } } }); - - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(screen.getByText('That username is taken.')).toBeInTheDocument(); - expect(usernameField()).toHaveAttribute('aria-invalid', 'true'); - }); - - it('shows the translation for the error code over the message Clerk sent', () => { + it('shows the translation for the error code on the field, with no banner', async () => { + const user = userEvent.setup(); renderView( - { error: { fields: { username: { code: 'form_identifier_exists', paramName: 'username', message: 'Taken.' } } } }, + { + onSubmit: () => + Promise.reject( + new SaveError({ + fields: { username: { code: 'form_identifier_exists', paramName: 'username', message: 'Taken.' } }, + }), + ), + }, { overrides: { 'errors.form_identifier_exists__username': 'Ese nombre de usuario ya existe.' } }, ); - expect(usernameField()).toHaveAccessibleDescription('Ese nombre de usuario ya existe.'); - }); - - it('withholds the save while the caller says the value is unacceptable', async () => { - const onSubmit = vi.fn(); - const user = userEvent.setup(); - renderView({ canSave: false, onSubmit }); - - // Inert but still reachable, so the reason stays discoverable by keyboard. - expect(saveButton()).toHaveAttribute('aria-disabled', 'true'); + await user.type(usernameField(), 'x'); await user.click(saveButton()); - await user.type(usernameField(), '{Enter}'); - expect(onSubmit).not.toHaveBeenCalled(); + await waitFor(() => expect(usernameField()).toHaveAccessibleDescription('Ese nombre de usuario ya existe.')); + expect(usernameField()).toHaveAttribute('aria-invalid', 'true'); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); it('stays inert while the save runs', async () => { - const onSubmit = vi.fn(); - const onUsernameChange = vi.fn(); const user = userEvent.setup(); - renderView({ isSaving: true, onSubmit, onUsernameChange }); + const props = renderView({ onSubmit: vi.fn(() => new Promise(() => {})) }); - await user.type(usernameField(), 'ada'); + await user.type(usernameField(), 'x'); + await user.click(saveButton()); expect(usernameField()).toBeDisabled(); - expect(onUsernameChange).not.toHaveBeenCalled(); - // Busy, not unavailable: the pending affordance is `isPending`, not a second disabled state. expect(saveButton()).toHaveAttribute('aria-busy', 'true'); await user.click(saveButton()); - expect(onSubmit).not.toHaveBeenCalled(); + expect(props.onSubmit).toHaveBeenCalledOnce(); }); }); 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-account-section/user-profile-edit-username.controller.ts index e4dbe813c22..c272a103d7e 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -1,68 +1,8 @@ -import { setup } from '../../../machine/setup'; -import { useMachine } from '../../../machine/useMachine'; -import type { FormError } from '../../../utils/form-error'; -import { toFormError } from '../../../utils/form-error'; -import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; +import { useState } from 'react'; -export interface UserProfileEditUsernameContext { - saveUsername: (username: string) => Promise; - savedUsername: string; - username: string; - error: FormError | undefined; -} - -export type UserProfileEditUsernameEvent = - | { type: 'OPEN' } - | { type: 'TYPE'; value: string } - | { type: 'SAVE' } - | { type: 'CANCEL' }; - -const { createMachine, assign, fromPromise } = setup(); - -function notSeated(): Promise { - return Promise.reject(new Error('edit-username deps are not seated')); -} - -function isSaveable(context: UserProfileEditUsernameContext): boolean { - return context.username !== context.savedUsername && context.username !== ''; -} - -export const userProfileEditUsernameMachine = createMachine({ - id: 'editUsername', - initial: 'idle', - context: { - saveUsername: notSeated, - savedUsername: '', - username: '', - error: undefined, - }, - states: { - idle: { - on: { - OPEN: { - target: 'editing', - actions: assign(context => ({ username: context.savedUsername, error: undefined })), - }, - }, - }, - editing: { - on: { - TYPE: { actions: assign((_, event) => ({ username: event.value })) }, - SAVE: { target: 'saving', guard: isSaveable }, - CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - }, - }, - saving: { - invoke: fromPromise(context => context.saveUsername(context.username), { - onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, - onError: { - target: 'editing', - actions: assign((_, event) => ({ error: toFormError(event.error) })), - }, - }), - }, - }, -}); +import type { UseFormResult } from '../../../components/form'; +import { useForm } from '../../../components/form'; +import type { UserProfileEditUsernameValue } from './user-profile-edit-username.dialog'; export interface UserProfileEditUsernameControllerOptions { username?: string; @@ -72,30 +12,30 @@ export interface UserProfileEditUsernameControllerOptions { export interface UserProfileEditUsernameController { isOpen: boolean; onOpenChange: (open: boolean) => void; - username: string; - onUsernameChange: (value: string) => void; - onSubmit: () => void; - canSave: boolean; - isSaving: boolean; - error: FormError | undefined; + form: UseFormResult; } export function useUserProfileEditUsernameController({ username = '', onSubmit, }: UserProfileEditUsernameControllerOptions): UserProfileEditUsernameController { - const [snapshot, send] = useMachine(userProfileEditUsernameMachine, { - context: { saveUsername: onSubmit, savedUsername: username }, + const [isOpen, setIsOpen] = useState(false); + const form = useForm({ + initialValues: { username }, + canSubmit: values => values.username !== username && values.username !== '', + onSubmit: async values => { + await onSubmit(values.username); + setIsOpen(false); + }, }); - return { - isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - username: snapshot.context.username, - onUsernameChange: value => send({ type: 'TYPE', value }), - onSubmit: () => send({ type: 'SAVE' }), - canSave: isSaveable(snapshot.context), - isSaving: snapshot.value === 'saving', - error: snapshot.context.error, + const onOpenChange = (open: boolean) => { + if (!open && form.isSubmitting) { + return; + } + form.reset(); + setIsOpen(open); }; + + return { isOpen, onOpenChange, form }; } 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-account-section/user-profile-edit-username.dialog.tsx index b57855a91a5..a7cb68a8f9f 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-account-section/user-profile-edit-username.dialog.tsx @@ -1,5 +1,5 @@ -import type { FormEvent } from 'react'; -import { useId, useRef } from 'react'; +import { useMergeRefs } from '@floating-ui/react'; +import { useRef } from 'react'; import { Banner } from '../../../components/banner'; import { Button, SubmitButton } from '../../../components/button'; @@ -7,48 +7,37 @@ import { Card } from '../../../components/card'; import type { DialogTriggerProps } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; +import type { UseFormResult } from '../../../components/form'; import { Input } from '../../../components/input'; -import { useErrorText, useMessages } from '../../../localization'; -import type { FormError } from '../../../utils/form-error'; +import { useMessages } from '../../../localization'; export type UserProfileEditUsernameField = 'username'; +export interface UserProfileEditUsernameValue { + username: string; +} + export interface UserProfileEditUsernameDialogProps { open: boolean; onOpenChange: (open: boolean) => void; trigger?: DialogTriggerProps['render']; - username: string; - onUsernameChange: (value: string) => void; title?: string; - canSave?: boolean; - isSaving?: boolean; - error?: FormError; - onSubmit: () => void; + form: UseFormResult; } export function UserProfileEditUsernameDialog({ open, onOpenChange, trigger, - username, - onUsernameChange, title, - canSave = true, - isSaving = false, - error, - onSubmit, + form, }: UserProfileEditUsernameDialogProps) { const m = useMessages('userProfileAccountSection'); - const errorText = useErrorText(); - const formId = useId(); const usernameRef = useRef(null); - - const handleSubmit = (event: FormEvent) => { - event.preventDefault(); - if (canSave && !isSaving) { - onSubmit(); - } - }; + const { feedback } = form.fields.username; + const error = feedback?.type === 'error' ? feedback.message : undefined; + const { ref, ...control } = form.register('username'); + const mergedRef = useMergeRefs([ref, usernameRef]); return ( } > - {error?.global ? ( + {form.error ? ( - {errorText(error.global)} + {form.error} ) : null} - + {m.username.fieldLabel} onUsernameChange(event.target.value)} + {...control} /> - - {error?.fields?.username ? errorText(error.fields.username) : null} - + {error ? {error} : null} @@ -111,10 +99,10 @@ export function UserProfileEditUsernameDialog({ } /> {m.username.save} 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 index 4031945d4f7..ef3decc5cab 100644 --- 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 @@ -38,8 +38,9 @@ function EditUsername({ username, onSubmit }: { username: string; onSubmit: (use return ( Date: Fri, 25 Sep 2026 11:28:08 -0400 Subject: [PATCH 29/63] fix(mosaic): show the generic message and log unrecognized save errors in useForm --- .../src/components/form/form.machine.ts | 16 +++--- .../src/components/form/use-form.test.ts | 52 +++++++++---------- ...r-profile-edit-password.controller.test.ts | 2 +- .../user-profile-password-section.types.ts | 2 +- packages/swingset/src/stories/use-form.mdx | 2 +- 5 files changed, 35 insertions(+), 39 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index 3228e696f73..c90577f3d88 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -148,17 +148,17 @@ function savedFieldErrors( } function toFormError(cause: unknown, context: FormContext): FormError { + if (!(cause instanceof FormSubmitError) && !(cause instanceof SaveError)) { + console.error(cause); + return { message: context.fallbackMessage }; + } const error: FormError = cause instanceof FormSubmitError ? { message: cause.banner, fields: displayableFields(context, cause.fields) } - : cause instanceof SaveError - ? { - message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, - fields: savedFieldErrors(context, cause.formError.fields), - } - : cause instanceof Error - ? { message: cause.message } - : {}; + : { + message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, + fields: savedFieldErrors(context, cause.formError.fields), + }; const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; return visible ? error : { ...error, message: context.fallbackMessage }; } diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 653fd612c69..448fafd7248 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -136,36 +136,32 @@ describe('useForm', () => { expect(result.current.fields.bio.feedback).toEqual({ type: 'error', message: 'Too long' }); }); - it('shows only the message for a plain Error and a generic message otherwise', async () => { - const plain = renderHook(() => - useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error('Nope')) }), - ); - await act(async () => { - plain.result.current.submit(); - await flush(); - }); - expect(plain.result.current.error).toBe('Nope'); - - const cause: unknown = 'boom'; - const unknown = renderHook(() => - useForm({ - initialValues: { username: '' }, - onSubmit: async () => { - await Promise.resolve(); - throw cause; - }, - }), - ); - await act(async () => { - unknown.result.current.submit(); - await flush(); - }); - expect(unknown.result.current.error).toBe('Something went wrong. Please try again.'); + it('shows the generic message and logs the cause for an unrecognized rejection', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const causes: unknown[] = [new Error('Nope'), 'boom']; + for (const cause of causes) { + const { result } = renderHook(() => + useForm({ + initialValues: { username: '' }, + onSubmit: async () => { + await Promise.resolve(); + throw cause; + }, + }), + ); + await act(async () => { + result.current.submit(); + await flush(); + }); + expect(result.current.error).toBe('Something went wrong. Please try again.'); + expect(log).toHaveBeenCalledWith(cause); + } + log.mockRestore(); }); it('falls back to the generic message when a submit error has nothing to show', async () => { const empty = renderHook(() => - useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new Error()) }), + useForm({ initialValues: { username: '' }, onSubmit: () => Promise.reject(new SaveError({})) }), ); await act(async () => { empty.result.current.submit(); @@ -201,7 +197,7 @@ describe('useForm', () => { useForm({ initialValues: { username: '' }, onSubmit: () => { - throw new Error('Nope'); + throw new FormSubmitError({ message: 'Nope' }); }, }), ); @@ -229,7 +225,7 @@ describe('useForm', () => { const { result } = renderHook(() => useForm({ initialValues: { username: '' }, - onSubmit: () => (fail ? Promise.reject(new Error('Nope')) : Promise.resolve()), + onSubmit: () => (fail ? Promise.reject(new FormSubmitError({ message: 'Nope' })) : Promise.resolve()), }), ); await act(async () => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts index 7e7c21dd153..d31856c2502 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-edit-password.controller.test.ts @@ -198,7 +198,7 @@ describe('useUserProfileEditPasswordController', () => { }); it('keeps a failed save visible next to a fresh mismatch', async () => { - const { result } = renderController(() => Promise.reject(new Error('Incorrect password.'))); + const { result } = renderController(() => Promise.reject(new UserProfileSaveError('Incorrect password.'))); open(result); fill(result); act(() => result.current.form.submit()); diff --git a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts index f7d795cc867..ca85eaf3ba7 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-password-section/user-profile-password-section.types.ts @@ -9,7 +9,7 @@ export interface UserProfileFormError { fields?: Partial>; } -/** What a save rejects with when the failure names a control; a plain `Error` shows only the banner. */ +/** What a save rejects with to show its message and name the failing controls; anything else shows the generic error. */ export class UserProfileSaveError extends FormSubmitError> { constructor(message: string, fields?: Partial>) { super({ message, fields }); diff --git a/packages/swingset/src/stories/use-form.mdx b/packages/swingset/src/stories/use-form.mdx index 8ff77b8bd2e..de6623a515b 100644 --- a/packages/swingset/src/stories/use-form.mdx +++ b/packages/swingset/src/stories/use-form.mdx @@ -79,7 +79,7 @@ const mergedRef = useMergeRefs([ref, initialFocusRef]); ; ``` -A model reports a failed save by rejecting `onSubmit` with `FormSubmitError`. `message` lands on `form.error` and each `fields` entry on that field's feedback. Either part may be omitted: fields alone show only under the fields with no banner. A plain `Error` shows only its message, and anything else shows the localized generic message. +A model reports a failed save by rejecting `onSubmit` with `FormSubmitError`. `message` lands on `form.error` and each `fields` entry on that field's feedback. Either part may be omitted: fields alone show only under the fields with no banner. Anything else shows the localized generic message and is logged with `console.error`. ```ts throw new FormSubmitError({ From 2e78ab19732bdc27156a638b1a7b68d7ecfec3ba Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:41:10 -0400 Subject: [PATCH 30/63] feat(mosaic): order, mark and manage emails in the user profile contact list --- .../mosaic-user-profile-email-wireup.md | 2 + ...ser-profile-account-section.model.test.tsx | 106 +++++++++++++++++- ...ser-profile-contact-list-row.view.test.tsx | 22 +++- ...ser-profile-contact-removal-focus.test.tsx | 4 +- .../user-profile-email-actions.test.tsx | 24 +++- .../user-profile-phone-actions.test.tsx | 55 ++++++--- .../user-profile-profile-panel.view.test.tsx | 22 ++-- .../user-profile-account-section.messages.ts | 7 +- .../user-profile-account-section.model.ts | 25 +++-- .../user-profile-account-section.types.ts | 17 +-- .../user-profile-account-section.utils.ts | 20 +++- .../user-profile-contact-list-row.view.tsx | 10 +- .../user-profile-email-row.view.tsx | 43 +++---- .../user-profile-phone-row.view.tsx | 41 +++---- .../user-profile-set-primary.controller.ts | 47 ++++++++ .../user-profile-account-section.stories.tsx | 10 +- .../user-profile-profile-panel.stories.tsx | 8 +- 17 files changed, 335 insertions(+), 128 deletions(-) create mode 100644 .changeset/mosaic-user-profile-email-wireup.md create mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts diff --git a/.changeset/mosaic-user-profile-email-wireup.md b/.changeset/mosaic-user-profile-email-wireup.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-email-wireup.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 4d99091666f..b23b793d4c0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -15,6 +15,11 @@ interface FakeAttribute { used_for_second_factor: boolean; } +interface FakeVerification { + status: string | null; + expireAt?: Date | null; +} + let isUserLoaded: boolean; let user: { firstName: string | null; @@ -29,8 +34,13 @@ let user: { }[]; primaryEmailAddressId: string | null; primaryPhoneNumberId: string | null; - emailAddresses: { id: string; emailAddress: string; verification: { status: string | null } }[]; - phoneNumbers: { id: string; phoneNumber: string; verification: { status: string | null } }[]; + emailAddresses: { + id: string; + emailAddress: string; + verification: FakeVerification; + destroy?: ReturnType; + }[]; + phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; setProfileImage: ReturnType; update: ReturnType; } | null; @@ -109,8 +119,18 @@ beforeEach(() => { primaryEmailAddressId: 'email_1', primaryPhoneNumberId: null, emailAddresses: [ - { id: 'email_2', emailAddress: 'other@clerk.dev', verification: { status: null } }, - { id: 'email_1', emailAddress: 'preston@clerk.dev', verification: { status: 'verified' } }, + { + id: 'email_2', + emailAddress: 'other@clerk.dev', + verification: { status: null }, + destroy: vi.fn(() => Promise.resolve()), + }, + { + id: 'email_1', + emailAddress: 'preston@clerk.dev', + verification: { status: 'verified' }, + destroy: vi.fn(() => Promise.resolve()), + }, ], phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], setProfileImage: vi.fn(() => Promise.resolve({})), @@ -153,6 +173,50 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('orders contacts primary first, then verified, then pending by expiry, then never started', () => { + if (!user) { + throw new Error('expected a user'); + } + user.primaryEmailAddressId = 'email_primary'; + user.primaryPhoneNumberId = 'phone_primary'; + user.emailAddresses = [ + { id: 'email_unstarted', emailAddress: 'd@clerk.dev', verification: { status: null } }, + { + id: 'email_late', + emailAddress: 'e@clerk.dev', + verification: { status: 'unverified', expireAt: new Date(2000) }, + }, + { id: 'email_verified_b', emailAddress: 'b@clerk.dev', verification: { status: 'verified' } }, + { + id: 'email_primary', + emailAddress: 'a@clerk.dev', + verification: { status: 'unverified', expireAt: new Date(3000) }, + }, + { + id: 'email_early', + emailAddress: 'f@clerk.dev', + verification: { status: 'unverified', expireAt: new Date(1000) }, + }, + { id: 'email_verified_a', emailAddress: 'c@clerk.dev', verification: { status: 'verified' } }, + ]; + user.phoneNumbers = [ + { id: 'phone_unstarted', phoneNumber: '+18018888183', verification: { status: null } }, + { id: 'phone_verified', phoneNumber: '+18018888182', verification: { status: 'verified' } }, + { id: 'phone_primary', phoneNumber: '+18018888181', verification: { status: 'verified' } }, + ]; + const model = ready(); + + expect(model.emails?.map(email => email.id)).toEqual([ + 'email_primary', + 'email_verified_a', + 'email_verified_b', + 'email_early', + 'email_late', + 'email_unstarted', + ]); + expect(model.phones?.map(phone => phone.id)).toEqual(['phone_primary', 'phone_verified', 'phone_unstarted']); + }); + it('leaves out the contacts the instance does not collect', () => { attributes.email_address = attribute({ enabled: false }); attributes.phone_number = attribute({ enabled: false }); @@ -162,6 +226,40 @@ describe('useUserProfileAccountSectionModel', () => { expect(model.phones).toBeUndefined(); }); + describe('emails', () => { + it('sets the chosen email as primary', async () => { + await ready().onSetPrimaryEmail?.('email_2'); + expect(user?.update).toHaveBeenCalledWith({ primaryEmailAddressId: 'email_2' }); + }); + + it('returns the API message when setting the primary fails', async () => { + user?.update.mockRejectedValue(apiError()); + await expect(rejection(ready().onSetPrimaryEmail?.('email_2'))).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, + }); + }); + + it('removes the chosen email', async () => { + await ready().onRemoveEmail?.('email_2'); + expect(user?.emailAddresses[0]?.destroy).toHaveBeenCalled(); + expect(user?.emailAddresses[1]?.destroy).not.toHaveBeenCalled(); + }); + + it('returns the API message when removing fails', async () => { + user?.emailAddresses[0]?.destroy?.mockRejectedValue(apiError()); + await expect(rejection(ready().onRemoveEmail?.('email_2'))).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, + }); + }); + + it('still offers a new primary but no removal when the email address is immutable', () => { + attributes.email_address = attribute({ immutable: true }); + const model = ready(); + expect(model.onSetPrimaryEmail).toBeDefined(); + expect(model.onRemoveEmail).toBeUndefined(); + }); + }); + describe('profile picture', () => { it('uploads the picked file', async () => { const file = new File(['x'], 'me.png', { type: 'image/png' }); 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 ba9098b25f9..87680d6dc7d 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 @@ -8,8 +8,7 @@ describe('UserProfileContactListRowView', () => { it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => { const onVerify = vi.fn(); const onSetPrimary = vi.fn(); - const onRemove = vi.fn(); - const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true, canRemove: false }; + const item = { id: 'contact_1', value: 'Contact', isDefault: true, isVerified: true }; render( { items={[item]} onVerify={onVerify} onSetPrimary={onSetPrimary} - onRemove={onRemove} />, ); expect(screen.queryByRole('button', { name: 'Manage Contact' })).not.toBeInTheDocument(); }); + it.each(['email', 'phone'] as const)('marks an unverified %s', kind => { + render( + , + ); + + expect(screen.getAllByText('Unverified')).toHaveLength(1); + expect(screen.getByText('Pending contact').parentElement).toHaveTextContent('Unverified'); + }); + it('offers removal when it applies', async () => { const user = userEvent.setup(); const onRemove = vi.fn(); @@ -31,7 +45,7 @@ describe('UserProfileContactListRowView', () => { , ); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx index 40335a5ee1e..8004597b13c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-contact-removal-focus.test.tsx @@ -14,7 +14,9 @@ describe('contact removal focus', () => { const labels = kind === 'email' ? values : ['+1 (801) 555-0100', '+1 (801) 555-0200']; function Example() { - const [items, setItems] = useState(values.map(value => ({ id: value, value }))); + const [items, setItems] = useState( + values.map(value => ({ id: value, value, isDefault: false, isVerified: true })), + ); const remove = (id: string) => setItems(current => current.filter(item => item.id !== id)); return ( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 0666505cbce..8301014f25c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -16,7 +16,7 @@ function renderEmail(overrides: Partial = {} name='Test' username='test' phones={[]} - emails={[{ id: 'email_1', value: 'test@example.com', isVerified: true }]} + emails={[{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true }]} {...overrides} /> , @@ -45,7 +45,9 @@ describe('email actions', () => { it('focuses Add email after removing the last email', async () => { const user = userEvent.setup(); function Example() { - const [emails, setEmails] = useState([{ id: 'email_1', value: 'test@example.com', isVerified: true }]); + const [emails, setEmails] = useState([ + { id: 'email_1', value: 'test@example.com', isDefault: false, isVerified: true }, + ]); return ( { expect(onRemoveEmail).toHaveBeenNthCalledWith(1, 'email_1'); expect(onRemoveEmail).toHaveBeenNthCalledWith(2, 'email_1'); }); + it.each([ + [true, 'You won’t be able to use it to sign in.'], + [false, undefined], + ])('warns about signing in only when removing a verified email (verified: %s)', async (isVerified, warning) => { + const user = userEvent.setup(); + renderEmail({ + emails: [{ id: 'email_1', value: 'test@example.com', isDefault: false, isVerified }], + onRemoveEmail: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove email' })); + const dialog = screen.getByRole('alertdialog'); + if (warning) { + expect(dialog).toHaveTextContent(warning); + } else { + expect(dialog).not.toHaveTextContent('sign in'); + } + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx index 84745a2cd9c..3c9c8dfb4d3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -15,7 +15,7 @@ function renderPhone(overrides: Partial = {} name='Test' username='test' emails={[]} - phones={[{ id: 'phone_1', value: '+18015550100', isVerified: true }]} + phones={[{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }]} {...overrides} /> , @@ -78,19 +78,19 @@ describe('phone actions', () => { finish(); await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Set as primary' })).toBeInTheDocument()); }); - it.each([{ isDefault: true, isVerified: true }, { isDefault: false, isVerified: false }, { isDefault: false }])( - 'hides set primary for an ineligible phone: %j', - async flags => { - const user = userEvent.setup(); - renderPhone({ - phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], - onSetPrimaryPhone: vi.fn(), - onRemovePhone: vi.fn(), - }); - await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); - expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); - }, - ); + it.each([ + { isDefault: true, isVerified: true }, + { isDefault: false, isVerified: false }, + ])('hides set primary for an ineligible phone: %j', async flags => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', ...flags }], + onSetPrimaryPhone: vi.fn(), + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + expect(screen.queryByRole('menuitem', { name: 'Set as primary' })).not.toBeInTheDocument(); + }); it('updates the primary badge immediately without confirmation', async () => { const user = userEvent.setup(); @@ -154,7 +154,9 @@ describe('phone actions', () => { it('focuses Add phone number after removing the last phone', async () => { const user = userEvent.setup(); function Example() { - const [phones, setPhones] = useState([{ id: 'phone_1', value: '+18015550100', isVerified: true }]); + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }, + ]); return ( { it('does not offer removal when it is forbidden', async () => { const user = userEvent.setup(); renderPhone({ - phones: [{ id: 'phone_1', value: '+18015550100', isVerified: true, canRemove: false }], + phones: [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }], onSetPrimaryPhone: vi.fn(), - onRemovePhone: vi.fn(), }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); @@ -227,7 +228,7 @@ describe('phone actions', () => { name='Test' username='test' emails={[]} - phones={[{ id: 'phone_1', value: '+18015550100', isVerified: true }]} + phones={[{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified: true }]} onRemovePhone={onRemovePhone} /> , @@ -241,4 +242,22 @@ describe('phone actions', () => { expect(onRemovePhone).toHaveBeenCalledExactlyOnceWith('phone_1'); await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()); }); + it.each([ + [true, 'You won’t be able to use it to sign in.'], + [false, undefined], + ])('warns about signing in only when removing a verified phone (verified: %s)', async (isVerified, warning) => { + const user = userEvent.setup(); + renderPhone({ + phones: [{ id: 'phone_1', value: '+18015550100', isDefault: false, isVerified }], + onRemovePhone: vi.fn(), + }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Remove phone number' })); + const dialog = screen.getByRole('alertdialog'); + if (warning) { + expect(dialog).toHaveTextContent(warning); + } else { + expect(dialog).not.toHaveTextContent('sign in'); + } + }); }); 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 a5f789f567a..b5cf906b150 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 @@ -12,10 +12,10 @@ const props: UserProfileProfilePanelViewProps = { name: 'Preston Booth', username: 'prestonxyz', emails: [ - { id: 'email_1', value: 'item1@clerk.dev', isDefault: true }, - { id: 'email_2', value: 'item2@clerk.dev' }, + { 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' }], + phones: [{ id: 'phone_1', value: '+1 801-888-8181', isDefault: false, isVerified: true }], }; function renderView(overrides: Partial = {}) { @@ -118,7 +118,7 @@ describe('UserProfileProfilePanelView', () => { it.each([false, true])('formats normalized phone numbers with multiple accounts set to %s', allowMultipleAccounts => { renderView({ allowMultipleAccounts, - phones: [{ id: 'phone_added', value: '+18015558181' }], + phones: [{ id: 'phone_added', value: '+18015558181', isDefault: false, isVerified: true }], onRemovePhone: vi.fn(), }); @@ -218,7 +218,7 @@ describe('UserProfileProfilePanelView', () => { it('breaks out both contact types when multiple accounts are allowed', () => { renderView({ - emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true }], + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], onAddEmail: vi.fn(), onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve(), @@ -239,7 +239,7 @@ describe('UserProfileProfilePanelView', () => { 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 }], + emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], onManageEmail: vi.fn(), onManagePhone: vi.fn(), }); @@ -440,12 +440,12 @@ describe('UserProfileProfilePanelView', () => { renderView({ emails: [ { id: 'email_primary', value: 'primary@clerk.dev', isDefault: true, isVerified: false }, - { id: 'email_secondary', value: 'secondary@clerk.dev', isVerified: true }, - { id: 'email_unverified', value: 'unverified@clerk.dev', 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', isVerified: false }, - { id: 'phone_secondary', value: '+1 801-555-0101', isVerified: true }, + { id: 'phone_unverified', value: '+1 801-555-0100', isDefault: false, isVerified: false }, + { id: 'phone_secondary', value: '+1 801-555-0101', isDefault: false, isVerified: true }, ], connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz' }], onVerifyEmail, @@ -520,14 +520,12 @@ describe('UserProfileProfilePanelView', () => { value: 'immutable@clerk.dev', isDefault: true, isVerified: true, - canRemove: false, }, ], phones: [], connectedAccounts: [{ id: 'github', provider: 'GitHub', identifier: 'prestonxyz', canRemove: false }], onVerifyEmail: vi.fn(), onSetPrimaryEmail: vi.fn(), - onRemoveEmail: vi.fn(), onRemoveConnectedAccount: vi.fn(), }); 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-account-section/user-profile-account-section.messages.ts index 97ef3318fbf..e030e57cf39 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-account-section/user-profile-account-section.messages.ts @@ -40,6 +40,7 @@ export const userProfileAccountSectionMessages = { save: 'Save changes', }, primary: 'Primary', + unverified: 'Unverified', add: 'Add', manage: 'Manage', setPrimary: 'Set as primary', @@ -56,7 +57,8 @@ export const userProfileAccountSectionMessages = { primaryError: 'Unable to set the primary email address. Try again.', removeDialog: { title: 'Remove email address?', - description: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', + description: '{emailAddress} will be removed from your account.', + verifiedDescription: '{emailAddress} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, @@ -71,7 +73,8 @@ export const userProfileAccountSectionMessages = { primaryError: 'Unable to set the primary phone number. Try again.', removeDialog: { title: 'Remove phone number?', - description: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', + description: '{phoneNumber} will be removed from your account.', + verifiedDescription: '{phoneNumber} will be removed from your account. You won’t be able to use it to sign in.', confirm: 'Remove', cancel: 'Cancel', }, diff --git a/packages/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 index b2525e2e238..35ab98d2f47 100644 --- 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 @@ -1,6 +1,6 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; -import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; +import type { AttributeData, EmailAddressResource, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import { save } from '../../../utils/form-error'; @@ -10,7 +10,7 @@ import type { UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; -import { isAttributeAvailable } from './user-profile-account-section.utils'; +import { isAttributeAvailable, sortByVerification } from './user-profile-account-section.utils'; import type { UserProfileAccountSectionViewProps } from './user-profile-account-section.view'; import type { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -29,6 +29,8 @@ type UserProfileAccountSectionData = Pick< | 'username' | 'emails' | 'phones' + | 'onSetPrimaryEmail' + | 'onRemoveEmail' | 'onProfilePictureChange' | 'onRemoveProfilePicture' | 'onSubmitName' @@ -43,6 +45,14 @@ export type UserProfileAccountSectionModel = const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; +function emailById(user: UserResource, id: string): EmailAddressResource { + const email = user.emailAddresses.find(email => email.id === id); + if (!email) { + throw new Error(`No email address with id ${id}`); + } + return email; +} + function toManagedBy(account: EnterpriseAccountResource | undefined): UserProfileManagedBy | undefined { if (!account) { return undefined; @@ -58,12 +68,8 @@ function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameA return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; } -function primaryFirst(items: T[], primaryId: string | null): T[] { - return [...items.filter(item => item.id === primaryId), ...items.filter(item => item.id !== primaryId)]; -} - function toEmails(user: UserResource): UserProfileEmail[] { - return primaryFirst(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ + return sortByVerification(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ id: email.id, value: email.emailAddress, isDefault: email.id === user.primaryEmailAddressId, @@ -72,7 +78,7 @@ function toEmails(user: UserResource): UserProfileEmail[] { } function toPhones(user: UserResource): UserProfilePhone[] { - return primaryFirst(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ + return sortByVerification(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ id: phone.id, value: phone.phoneNumber, isDefault: phone.id === user.primaryPhoneNumberId, @@ -98,6 +104,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); const showEmails = isAttributeAvailable(attributes.email_address); + const emailsImmutable = Boolean(attributes.email_address?.immutable); const showPhones = isAttributeAvailable(attributes.phone_number); return { @@ -114,6 +121,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo username: showUsername ? (user.username ?? '') : undefined, emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, + onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, + onRemoveEmail: showEmails && !emailsImmutable ? id => save(() => emailById(user, id).destroy()) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameManagedBy 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 index 7defa07f149..d7afb176a53 100644 --- 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 @@ -6,18 +6,13 @@ export interface UserProfileNameAttribute { required?: boolean; } -export interface UserProfilePhone { +export interface UserProfileContact { id: string; value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; + isDefault: boolean; + isVerified: boolean; } -export interface UserProfileEmail { - id: string; - value: string; - isDefault?: boolean; - isVerified?: boolean; - canRemove?: boolean; -} +export type UserProfileEmail = UserProfileContact; + +export type UserProfilePhone = UserProfileContact; 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-account-section/user-profile-account-section.utils.ts index fa2aabc8e4f..9a085302c44 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-account-section/user-profile-account-section.utils.ts @@ -1,5 +1,23 @@ -import type { AttributeData } from '@clerk/shared/types'; +import type { AttributeData, VerificationResource } 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); } + +type Identification = { id: string; verification: Pick }; + +export function sortByVerification(items: T[], primaryId: string | null): T[] { + const others = items.filter(item => item.id !== primaryId); + const verified = others.filter(item => item.verification.status === 'verified'); + const pending = others.filter(item => item.verification.status && item.verification.status !== 'verified'); + const unstarted = others.filter(item => !item.verification.status); + + verified.sort((a, b) => a.id.localeCompare(b.id)); + pending.sort((a, b) => { + const aExpireAt = a.verification.expireAt; + const bExpireAt = b.verification.expireAt; + return aExpireAt && bExpireAt ? aExpireAt.getTime() - bExpireAt.getTime() : 0; + }); + + return [...items.filter(item => item.id === primaryId), ...verified, ...pending, ...unstarted]; +} 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-account-section/user-profile-contact-list-row.view.tsx index d21c5abd97b..907c765bf40 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-account-section/user-profile-contact-list-row.view.tsx @@ -8,6 +8,7 @@ import { Icon } from '../../../components/icon'; import { Section } from '../../../components/section'; import { fill, useMessages } from '../../../localization'; import { styles } from '../user-profile-profile-panel.styles'; +import type { UserProfileContact } from './user-profile-account-section.types'; export interface UserProfileContactListRowViewProps { rowRef?: Ref; @@ -15,7 +16,7 @@ export interface UserProfileContactListRowViewProps { addAction?: ReactNode; kind: 'email' | 'phone'; label: string; - items: Array<{ id: string; value: string; isDefault?: boolean; isVerified?: boolean; canRemove?: boolean }>; + items: UserProfileContact[]; onAdd?: () => void; onVerify?: (id: string) => void; onSetPrimary?: (id: string) => void; @@ -80,16 +81,16 @@ export function UserProfileContactListRowView({ items.map(item => { const actions: ActionMenuAction[] = []; - if (item.isVerified === false && onVerify) { + if (!item.isVerified && onVerify) { actions.push({ label: item.isDefault ? m.completeVerification : m[kind].verify, onClick: () => onVerify(item.id), }); - } else if (!item.isDefault && item.isVerified === true && onSetPrimary) { + } else if (item.isVerified && !item.isDefault && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); } - if (onRemove && item.canRemove !== false) { + if (onRemove) { actions.push({ label: m[kind].remove, color: 'negative', @@ -103,6 +104,7 @@ export function UserProfileContactListRowView({ {item.value} {item.isDefault ? {m.primary} : null} + {!item.isVerified ? {m.unverified} : null} {actions.length > 0 ? ( 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 index 471492e5a03..9db44fe83cf 100644 --- 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 @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; @@ -12,6 +12,7 @@ import { useUserProfileAddEmailController } from './user-profile-add-email.contr 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 { useUserProfileSetPrimaryController } from './user-profile-set-primary.controller'; export interface UserProfileEmailRowViewProps { emails: UserProfileEmail[]; @@ -68,31 +69,15 @@ export function UserProfileEmailRowView({ ) : undefined; const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); - const [isSettingPrimary, setIsSettingPrimary] = useState(false); - const [primaryError, setPrimaryError] = useState(); - const settingPrimary = useRef(false); - - const setPrimaryEmail = async (id: string) => { - const email = emails.find(email => email.id === id); - if (!onSetPrimaryEmail || !email?.isVerified || email.isDefault || settingPrimary.current) { - return; - } - settingPrimary.current = true; - setIsSettingPrimary(true); - setPrimaryError(undefined); - try { - await onSetPrimaryEmail(id); - } catch (error) { - setPrimaryError(error instanceof Error ? error.message : m.email.primaryError); - } finally { - settingPrimary.current = false; - setIsSettingPrimary(false); - } - }; + const primary = useUserProfileSetPrimaryController({ + items: emails, + onSetPrimary: onSetPrimaryEmail, + fallbackError: m.email.primaryError, + }); const removeEmail = (id: string) => { const email = emails.find(email => email.id === id); - if (email && email.canRemove !== false && onRemoveEmail) { + if (email && onRemoveEmail) { removeEmailConfirmation.open(email); } }; @@ -119,22 +104,26 @@ export function UserProfileEmailRowView({ label={m.email.label} addAction={addEmailAction} onRemove={onRemoveEmail ? removeEmail : undefined} - onSetPrimary={onSetPrimaryEmail && !isSettingPrimary ? id => void setPrimaryEmail(id) : undefined} + onSetPrimary={primary.onSetPrimary} onVerify={onVerifyEmail} /> - {primaryError ? ( + {primary.error ? ( - {primaryError} + {primary.error} ) : null} {onRemoveEmail ? ( fill(m.email.removeDialog.description, { emailAddress: email.value })} + description={email => + 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} 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 index 110e754bd45..62f26346248 100644 --- 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 @@ -1,5 +1,5 @@ import { stringToFormattedPhoneString } from '@clerk/shared/phone'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef } from 'react'; import { Confirmation } from '../../../blocks/confirmation'; import { Button } from '../../../components/button'; @@ -13,6 +13,7 @@ import { useUserProfileAddPhoneController } from './user-profile-add-phone.contr 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 { useUserProfileSetPrimaryController } from './user-profile-set-primary.controller'; export interface UserProfilePhoneRowViewProps { phones: UserProfilePhone[]; @@ -50,31 +51,15 @@ export function UserProfilePhoneRowView({ /> ) : undefined; const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []); - const [isSettingPrimary, setIsSettingPrimary] = useState(false); - const [primaryError, setPrimaryError] = useState(); - const settingPrimary = useRef(false); - - const setPrimaryPhone = async (id: string) => { - const phone = phones.find(phone => phone.id === id); - if (!onSetPrimaryPhone || !phone?.isVerified || phone.isDefault || settingPrimary.current) { - return; - } - settingPrimary.current = true; - setIsSettingPrimary(true); - setPrimaryError(undefined); - try { - await onSetPrimaryPhone(id); - } catch (error) { - setPrimaryError(error instanceof Error ? error.message : m.phone.primaryError); - } finally { - settingPrimary.current = false; - setIsSettingPrimary(false); - } - }; + const primary = useUserProfileSetPrimaryController({ + items: phones, + onSetPrimary: onSetPrimaryPhone, + fallbackError: m.phone.primaryError, + }); const removePhone = (id: string) => { const phone = phones.find(phone => phone.id === id); - if (phone && phone.canRemove !== false && onRemovePhone) { + if (phone && onRemovePhone) { removePhoneConfirmation.open(phone); } }; @@ -105,15 +90,15 @@ export function UserProfilePhoneRowView({ label={m.phone.label} addAction={addPhoneAction} onRemove={onRemovePhone ? removePhone : undefined} - onSetPrimary={onSetPrimaryPhone && !isSettingPrimary ? id => void setPrimaryPhone(id) : undefined} + onSetPrimary={primary.onSetPrimary} onVerify={onVerifyPhone} /> - {primaryError ? ( + {primary.error ? ( - {primaryError} + {primary.error} ) : null} {onRemovePhone ? ( @@ -121,7 +106,9 @@ export function UserProfilePhoneRowView({ handle={removePhoneConfirmation} title={m.phone.removeDialog.title} description={phone => - fill(m.phone.removeDialog.description, { phoneNumber: stringToFormattedPhoneString(phone.value) }) + fill(phone.isVerified ? m.phone.removeDialog.verifiedDescription : m.phone.removeDialog.description, { + phoneNumber: stringToFormattedPhoneString(phone.value), + }) } actionLabel={m.phone.removeDialog.confirm} cancelLabel={m.phone.removeDialog.cancel} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts new file mode 100644 index 00000000000..a0d71966e02 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts @@ -0,0 +1,47 @@ +import { useRef, useState } from 'react'; + +import type { UserProfileContact } from './user-profile-account-section.types'; + +export interface UserProfileSetPrimaryControllerOptions { + items: UserProfileContact[]; + onSetPrimary?: (id: string) => void | Promise; + fallbackError: string; +} + +export interface UserProfileSetPrimaryController { + onSetPrimary: ((id: string) => void) | undefined; + error: string | undefined; +} + +export function useUserProfileSetPrimaryController({ + items, + onSetPrimary, + fallbackError, +}: UserProfileSetPrimaryControllerOptions): UserProfileSetPrimaryController { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(); + const pending = useRef(false); + + const setPrimary = async (id: string) => { + const item = items.find(item => item.id === id); + if (!onSetPrimary || !item?.isVerified || item.isDefault || pending.current) { + return; + } + pending.current = true; + setIsPending(true); + setError(undefined); + try { + await onSetPrimary(id); + } catch (error) { + setError(error instanceof Error ? error.message : fallbackError); + } finally { + pending.current = false; + setIsPending(false); + } + }; + + return { + onSetPrimary: onSetPrimary && !isPending ? id => void setPrimary(id) : undefined, + error, + }; +} diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 3b53cdbebd0..56d77d311b6 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -59,22 +59,24 @@ function AccountSection({ allowMultipleAccounts ? [ { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', 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', 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({ failAt, - onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); const emailFlow = createUserProfileAddEmailFixture({ failAt: failEmailVerification ? 'verify' : undefined, - onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); return ( diff --git a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx index c0fb9ce4bf4..96400c4ec62 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -31,7 +31,7 @@ export const meta: StoryMeta = { export function Default(_args: Record) { const [emails, setEmails] = useState([ { id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'item2@clerk.dev', 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 }, @@ -42,7 +42,8 @@ export function Default(_args: Record) { const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); return ( @@ -61,7 +62,8 @@ export function Default(_args: Record) { imageUrl={imageUrl} phones={phones} {...createUserProfileAddPhoneFixture({ - onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), })} onConnectAccount={connections.onConnect} onDeleteAccount={() => Promise.resolve()} From 621fc8c7b1c63d224c7a77fbc20431fc17d2f6b3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:46:05 -0400 Subject: [PATCH 31/63] feat(mosaic): add and verify user profile emails with a code --- .../mosaic-user-profile-add-email-code.md | 2 + ...ser-profile-account-section.model.test.tsx | 87 +++++++- .../user-profile-add-email.dialog.test.tsx | 16 +- ...ser-profile-add-email.integration.test.tsx | 47 +++- .../user-profile-email-actions.test.tsx | 9 +- .../user-profile-account-section.model.ts | 32 ++- .../user-profile-account-section.types.ts | 10 + .../user-profile-account-section.view.tsx | 13 +- .../user-profile-add-email.controller.test.ts | 141 +++++++++--- .../user-profile-add-email.controller.ts | 202 ++++++++++-------- .../user-profile-add-email.dialog.tsx | 21 +- .../user-profile-add-email.messages.ts | 9 +- .../user-profile-email-row.view.tsx | 124 ++++++----- .../user-profile-profile-panel.view.tsx | 8 +- .../mosaic/src/hooks/useListRemovalFocus.ts | 4 +- .../fixtures/user-profile-add-email.ts | 41 ++-- .../src/stories/fixtures/user-profile.ts | 79 +++---- .../user-profile-account-section.stories.tsx | 7 +- .../stories/user-profile-profile-panel.mdx | 4 +- .../user-profile-profile-panel.stories.tsx | 5 +- 20 files changed, 574 insertions(+), 287 deletions(-) create mode 100644 .changeset/mosaic-user-profile-add-email-code.md diff --git a/.changeset/mosaic-user-profile-add-email-code.md b/.changeset/mosaic-user-profile-add-email-code.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-add-email-code.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index b23b793d4c0..31c0971d14e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -30,7 +30,11 @@ let user: { enterpriseAccounts: { active: boolean; provider: string; - enterpriseConnection: { name: string; logoPublicUrl: string | null } | null; + enterpriseConnection: { + name: string; + logoPublicUrl: string | null; + disableAdditionalIdentifications?: boolean; + } | null; }[]; primaryEmailAddressId: string | null; primaryPhoneNumberId: string | null; @@ -39,14 +43,18 @@ let user: { emailAddress: string; verification: FakeVerification; destroy?: ReturnType; + prepareVerification?: ReturnType; + attemptVerification?: ReturnType; }[]; phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; setProfileImage: ReturnType; update: ReturnType; + createEmailAddress: ReturnType; } | null; let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; +let enterpriseSSOEnabled: boolean; function attribute(overrides: Partial = {}): FakeAttribute { return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; @@ -58,7 +66,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ - __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, + __internal_environment: environmentHydrated + ? { userSettings: { attributes, usernameSettings, enterpriseSSO: { enabled: enterpriseSSOEnabled } } } + : null, }), }; }); @@ -67,7 +77,7 @@ function renderModel() { return renderHook(() => useUserProfileAccountSectionModel()).result.current; } -async function rejection(save: Promise | undefined): Promise { +async function rejection(save: void | Promise | undefined): Promise { try { await save; return undefined; @@ -101,6 +111,7 @@ function apiError(paramName?: string) { beforeEach(() => { isUserLoaded = true; environmentHydrated = true; + enterpriseSSOEnabled = true; usernameSettings = { min_length: 4, max_length: 64 }; attributes = { first_name: attribute(), @@ -124,6 +135,8 @@ beforeEach(() => { emailAddress: 'other@clerk.dev', verification: { status: null }, destroy: vi.fn(() => Promise.resolve()), + prepareVerification: vi.fn(() => Promise.resolve()), + attemptVerification: vi.fn(() => Promise.resolve()), }, { id: 'email_1', @@ -135,6 +148,7 @@ beforeEach(() => { phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], setProfileImage: vi.fn(() => Promise.resolve({})), update: vi.fn(() => Promise.resolve(user)), + createEmailAddress: vi.fn(() => Promise.resolve({ id: 'email_new' })), }; }); @@ -252,6 +266,73 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('maps a create error onto the email field', async () => { + user?.createEmailAddress.mockRejectedValue(apiError('email_address')); + await expect(rejection(ready().onCreateEmail?.('taken@clerk.dev'))).resolves.toEqual({ + fields: { + emailAddress: { + code: 'form_param_invalid', + paramName: 'email_address', + message: 'That value is invalid.', + }, + }, + }); + }); + + it('sends a code to the chosen email', async () => { + const verification = ready().getEmailVerifier?.('email_2').start(); + if (verification?.method !== 'code') { + throw new Error('expected a code verification'); + } + await expect(verification.sent).resolves.toBeUndefined(); + expect(user?.emailAddresses[0]?.prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); + }); + + it('creates an email and verifies the created address before the user reloads', async () => { + const prepareVerification = vi.fn(() => Promise.resolve()); + user?.createEmailAddress.mockResolvedValue({ id: 'email_new', prepareVerification }); + const email = await ready().onCreateEmail?.('new@clerk.dev'); + expect(user?.createEmailAddress).toHaveBeenCalledWith({ email: 'new@clerk.dev' }); + email?.start(); + expect(prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); + }); + + it('verifies the code for the chosen email', async () => { + await ready().getEmailVerifier?.('email_2').verifyCode('123456'); + expect(user?.emailAddresses[0]?.attemptVerification).toHaveBeenCalledWith({ code: '123456' }); + }); + + it('maps a wrong code onto the code field', async () => { + user?.emailAddresses[0]?.attemptVerification?.mockRejectedValue(apiError('code')); + await expect(rejection(ready().getEmailVerifier?.('email_2').verifyCode('000000'))).resolves.toEqual({ + fields: { code: { code: 'form_param_invalid', paramName: 'code', message: 'That value is invalid.' } }, + }); + }); + + it('offers no new email when the email address is immutable', () => { + attributes.email_address = attribute({ immutable: true }); + expect(ready().onCreateEmail).toBeUndefined(); + }); + + it('offers no new email when the active enterprise connection forbids more identifications', () => { + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true }, + }); + expect(ready().onCreateEmail).toBeUndefined(); + }); + + it('ignores the enterprise restriction when the instance has enterprise SSO off', () => { + enterpriseSSOEnabled = false; + user?.enterpriseAccounts.push({ + active: true, + provider: 'saml_okta', + enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true }, + }); + expect(ready().onCreateEmail).toBeDefined(); + }); + it('still offers a new primary but no removal when the email address is immutable', () => { attributes.email_address = attribute({ immutable: true }); const model = ready(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx index 86eaa964d24..fba6c43ccba 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx @@ -54,7 +54,7 @@ describe('UserProfileAddEmailDialog', () => { it.each(['', 'invalid-address'])('uses native email validation for %j', async emailAddress => { const user = userEvent.setup(); const { props } = renderView({ emailAddress }); - await user.click(screen.getByRole('button', { name: 'Send code' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); expect(props.onSubmit).not.toHaveBeenCalled(); expect(screen.getByRole('textbox', { name: 'Email' })).toBeInvalid(); }); @@ -63,8 +63,7 @@ describe('UserProfileAddEmailDialog', () => { const user = userEvent.setup(); const onSubmit = vi.fn(); render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Close', exact: true })).toHaveFocus()); - await user.click(screen.getByRole('textbox', { name: 'Verification code' })); + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Verification code' })).toHaveFocus()); if (method === 'typing') { await user.keyboard('12345'); @@ -77,7 +76,7 @@ describe('UserProfileAddEmailDialog', () => { expect(onSubmit).toHaveBeenCalledExactlyOnceWith('123456'); }); - it('focuses the email field and submits through the form or Send code', async () => { + it('focuses the email field and submits through the form or Continue', async () => { const user = userEvent.setup(); const { props } = renderView(); @@ -91,7 +90,7 @@ describe('UserProfileAddEmailDialog', () => { emailForm.requestSubmit(); expect(props.onSubmit).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Send code' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); expect(props.onSubmit).toHaveBeenCalledTimes(2); }); @@ -181,6 +180,11 @@ describe('UserProfileAddEmailDialog', () => { />
, ); - expect(screen.getByRole('button', { name: 'Sending a new code…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Sending code…' })).toBeDisabled(); + }); + + it('disables Continue when the email cannot be submitted', () => { + renderView({ canSubmitEmail: false }); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx index 6b82e9d908f..a054dc8f95d 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -10,8 +10,9 @@ describe('profile add email', () => { 'owns the dialog and returns focus with multiple accounts = %s', async allowMultipleAccounts => { const user = userEvent.setup(); - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); + const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const); + const verifyCode = vi.fn(() => Promise.resolve()); + const onCreate = vi.fn(() => Promise.resolve({ start, verifyCode })); render( { username='test' emails={[]} phones={[]} - onSendEmailCode={onSend} - onVerifyEmailCode={onVerify} + onCreateEmail={onCreate} + getEmailVerifier={() => ({ start, verifyCode })} /> , ); @@ -29,14 +30,46 @@ describe('profile add email', () => { await user.click(trigger); expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); - await user.click(screen.getByRole('button', { name: 'Send code' })); + await user.click(screen.getByRole('button', { name: 'Continue' })); const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); await waitFor(() => expect(codeInput).toHaveFocus()); await user.keyboard('123456'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(start).toHaveBeenCalledOnce(); + expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); await waitFor(() => expect(trigger).toHaveFocus()); }, ); + + it('verifies an unverified email from its menu and returns focus to the menu', async () => { + const user = userEvent.setup(); + const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const); + const verifyCode = vi.fn(() => Promise.resolve()); + const getEmailVerifier = vi.fn(() => ({ start, verifyCode })); + render( + + + , + ); + expect(screen.queryByRole('button', { name: 'Add email' })).not.toBeInTheDocument(); + const trigger = screen.getByRole('button', { name: 'Manage other@example.com' }); + await user.click(trigger); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + expect(dialog).toHaveTextContent('other@example.com'); + expect(getEmailVerifier).toHaveBeenCalledExactlyOnceWith('email_2'); + await waitFor(() => expect(start).toHaveBeenCalledOnce()); + await user.keyboard('123456'); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); + await waitFor(() => expect(trigger).toHaveFocus()); + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 8301014f25c..6e4afe99a66 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -8,6 +8,11 @@ import { MosaicProvider } from '../../../MosaicProvider'; import type { UserProfileAccountSectionViewProps } from '../user-profile-account-section/user-profile-account-section.view'; import { UserProfileAccountSectionView } from '../user-profile-account-section/user-profile-account-section.view'; +const codeVerifier = { + start: () => ({ method: 'code', sent: Promise.resolve() }) as const, + verifyCode: () => Promise.resolve(), +}; + function renderEmail(overrides: Partial = {}) { return render( @@ -56,8 +61,8 @@ describe('email actions', () => { username='test' phones={[]} emails={emails} - onSendEmailCode={() => Promise.resolve()} - onVerifyEmailCode={() => Promise.resolve()} + onCreateEmail={() => Promise.resolve(codeVerifier)} + getEmailVerifier={() => codeVerifier} onRemoveEmail={id => setEmails(current => current.filter(email => email.id !== id))} /> 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 index 35ab98d2f47..f75090a7269 100644 --- 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 @@ -7,11 +7,13 @@ import { save } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, + UserProfileEmailVerifier, UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; import { isAttributeAvailable, sortByVerification } 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 { UserProfileEditNameField } from './user-profile-edit-name.dialog'; import type { UserProfileEditUsernameField } from './user-profile-edit-username.dialog'; @@ -29,6 +31,8 @@ type UserProfileAccountSectionData = Pick< | 'username' | 'emails' | 'phones' + | 'onCreateEmail' + | 'getEmailVerifier' | 'onSetPrimaryEmail' | 'onRemoveEmail' | 'onProfilePictureChange' @@ -44,6 +48,7 @@ export type UserProfileAccountSectionModel = const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; +const ADD_EMAIL_FIELDS: readonly UserProfileAddEmailField[] = ['emailAddress', 'code']; function emailById(user: UserResource, id: string): EmailAddressResource { const email = user.emailAddresses.find(email => email.id === id); @@ -53,6 +58,22 @@ function emailById(user: UserResource, id: string): EmailAddressResource { return email; } +function toEmailVerifier(email: EmailAddressResource): UserProfileEmailVerifier { + return { + start: () => ({ method: 'code', sent: save(() => email.prepareVerification({ strategy: 'email_code' })) }), + 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; @@ -98,13 +119,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'hidden' }; } - const { attributes, usernameSettings } = environment.userSettings; + const { attributes, usernameSettings, enterpriseSSO } = environment.userSettings; 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 showEmails = isAttributeAvailable(attributes.email_address); const emailsImmutable = Boolean(attributes.email_address?.immutable); + const canCreateEmail = showEmails && !emailsImmutable && canAddIdentifications(user, enterpriseSSO.enabled); const showPhones = isAttributeAvailable(attributes.phone_number); return { @@ -121,6 +143,14 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo username: showUsername ? (user.username ?? '') : undefined, emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, + onCreateEmail: canCreateEmail + ? async emailAddress => { + const request = user.createEmailAddress({ email: emailAddress }); + await save(() => request, ADD_EMAIL_FIELDS); + return toEmailVerifier(await request); + } + : undefined, + getEmailVerifier: showEmails ? id => toEmailVerifier(emailById(user, id)) : undefined, onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, onRemoveEmail: showEmails && !emailsImmutable ? id => save(() => emailById(user, id).destroy()) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), 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 index d7afb176a53..62b73c573c3 100644 --- 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 @@ -16,3 +16,13 @@ export interface UserProfileContact { export type UserProfileEmail = UserProfileContact; export type UserProfilePhone = UserProfileContact; + +export interface UserProfileEmailVerification { + method: 'code'; + sent: Promise; +} + +export interface UserProfileEmailVerifier { + start: () => UserProfileEmailVerification; + 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 index bf3923075d0..d8b9bd9baff 100644 --- 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 @@ -7,6 +7,7 @@ import type { UserProfileManagedBy } from '../user-profile-managed-by'; import { styles } from './user-profile-account-section.styles'; import type { UserProfileEmail, + UserProfileEmailVerifier, UserProfileNameAttribute, UserProfilePhone, } from './user-profile-account-section.types'; @@ -45,8 +46,8 @@ export interface UserProfileAccountSectionViewProps { onSubmitName?: (value: UserProfileEditNameValue) => Promise; onSubmitUsername?: (username: string) => Promise; onAddEmail?: () => void; - onSendEmailCode?: (emailAddress: string) => Promise; - onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; + onCreateEmail?: (emailAddress: string) => Promise; + getEmailVerifier?: (id: string) => UserProfileEmailVerifier; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void | Promise; @@ -78,8 +79,8 @@ export function UserProfileAccountSectionView({ onSubmitName, onSubmitUsername, onAddEmail, - onSendEmailCode, - onVerifyEmailCode, + onCreateEmail, + getEmailVerifier, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -110,8 +111,8 @@ export function UserProfileAccountSectionView({ emails={emails} allowMultipleAccounts={allowMultipleAccounts} onAddEmail={onAddEmail} - onSendEmailCode={onSendEmailCode} - onVerifyEmailCode={onVerifyEmailCode} + onCreateEmail={onCreateEmail} + getEmailVerifier={getEmailVerifier} onManageEmail={onManageEmail} onVerifyEmail={onVerifyEmail} onSetPrimaryEmail={onSetPrimaryEmail} 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-account-section/user-profile-add-email.controller.test.ts index ed7f6cf6ef3..bd2f056cef8 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-account-section/user-profile-add-email.controller.test.ts @@ -2,8 +2,25 @@ 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/form-error'; +import type { UserProfileEmailVerification, UserProfileEmailVerifier } from './user-profile-account-section.types'; import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; +const sentCode = (): UserProfileEmailVerification => ({ method: 'code', sent: Promise.resolve() }); + +function verifier( + start: () => UserProfileEmailVerification = sentCode, + verifyCode: (code: string) => Promise = () => Promise.resolve(), +) { + return { start: vi.fn(start), verifyCode: vi.fn(verifyCode) } satisfies UserProfileEmailVerifier; +} + +const created = () => Promise.resolve(verifier()); + +function saveError(message: string, field?: 'emailAddress' | 'code') { + return new SaveError(field ? { fields: { [field]: { message } } } : { global: { message } }); +} + describe('useUserProfileAddEmailController', () => { afterEach(() => vi.useRealTimers()); @@ -12,10 +29,12 @@ describe('useUserProfileAddEmailController', () => { const verification = createDeferredPromise(); const { result } = renderHook(() => useUserProfileAddEmailController({ - onSend: () => Promise.resolve(), - onVerify: async () => { - await verification.promise; - }, + onCreate: () => + Promise.resolve( + verifier(sentCode, async () => { + await verification.promise; + }), + ), }), ); act(() => result.current.onOpenChange(true)); @@ -29,7 +48,7 @@ describe('useUserProfileAddEmailController', () => { } expect(result.current.resendSeconds).toBe(0); await act(async () => { - verification.reject(new Error('Incorrect code')); + verification.reject(saveError('Incorrect code', 'code')); await Promise.resolve(); }); expect(result.current.errorMessage).toBe('Incorrect code'); @@ -40,36 +59,35 @@ describe('useUserProfileAddEmailController', () => { const { result } = renderHook(() => useUserProfileAddEmailController({ initialEmailAddress: 'saved@example.com', - onSend: () => Promise.resolve(), - onVerify: () => Promise.resolve(), + onCreate: created, }), ); act(() => result.current.onOpenChange(true)); expect(result.current.emailAddress).toBe('saved@example.com'); }); - it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { + it('ignores cancellation and duplicate submissions while creating, then resets on reopen', async () => { const request = createDeferredPromise(); - const onSend = vi.fn(async () => { + const onCreate = vi.fn(async () => { await request.promise; + return verifier(); }); - const { result } = renderHook(() => - useUserProfileAddEmailController({ onSend, onVerify: () => Promise.resolve() }), - ); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate })); act(() => result.current.onOpenChange(true)); act(() => result.current.onEmailAddressChange('new@example.com')); act(() => { result.current.onSubmit(); result.current.onSubmit(); result.current.onEmailAddressChange('other@example.com'); - result.current.onOpenChange(false); }); + act(() => result.current.onOpenChange(false)); expect(result.current.open).toBe(true); - expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); await act(async () => { request.resolve(); await request.promise; }); + await waitFor(() => expect(result.current.isResending).toBe(false)); act(() => result.current.onCodeChange('123')); act(() => result.current.onOpenChange(false)); expect(result.current.open).toBe(false); @@ -82,17 +100,16 @@ describe('useUserProfileAddEmailController', () => { it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => { vi.useFakeTimers(); - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify })); + const email = verifier(); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate: () => Promise.resolve(email) })); act(() => result.current.onOpenChange(true)); await act(async () => { result.current.onSubmit(); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); }); expect(result.current.resendSeconds).toBe(12); act(() => result.current.onResend()); - expect(onSend).toHaveBeenCalledTimes(1); + expect(email.start).toHaveBeenCalledTimes(1); for (let second = 0; second < 12; second++) { await act(async () => vi.advanceTimersByTimeAsync(1000)); } @@ -105,18 +122,17 @@ describe('useUserProfileAddEmailController', () => { result.current.onOpenChange(false); await Promise.resolve(); }); - expect(onSend).toHaveBeenCalledTimes(2); - expect(onVerify).not.toHaveBeenCalled(); + expect(email.start).toHaveBeenCalledTimes(2); + expect(email.verifyCode).not.toHaveBeenCalled(); expect(result.current.open).toBe(true); expect(result.current.code).toBe(''); expect(result.current.resendSeconds).toBe(12); }); it.each(['email', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { - const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined); + const operation = vi.fn().mockRejectedValueOnce(saveError('Try again')).mockResolvedValue(verifier()); const { result } = renderHook(() => useUserProfileAddEmailController({ - onSend: step === 'email' ? operation : () => Promise.resolve(), - onVerify: step === 'verify' ? operation : () => Promise.resolve(), + onCreate: step === 'email' ? operation : () => Promise.resolve(verifier(sentCode, operation)), }), ); act(() => result.current.onOpenChange(true)); @@ -137,10 +153,10 @@ describe('useUserProfileAddEmailController', () => { await waitFor(() => expect(operation).toHaveBeenCalledTimes(2)); await waitFor(() => expect(result.current.errorMessage).toBeUndefined()); }); - it('sends a code, verifies the submitted code, and closes on success', async () => { - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileAddEmailController({ onSend, onVerify })); + it('creates the email, sends it a code, verifies the submitted code, and closes on success', async () => { + const email = verifier(); + const onCreate = vi.fn(() => Promise.resolve(email)); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate })); expect(result.current.open).toBe(false); act(() => result.current.onOpenChange(true)); @@ -149,10 +165,75 @@ describe('useUserProfileAddEmailController', () => { expect(result.current.isPending).toBe(true); expect(result.current.open).toBe(true); await waitFor(() => expect(result.current.step).toBe('verify')); - expect(onSend).toHaveBeenCalledExactlyOnceWith('new@example.com'); + expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); + await waitFor(() => expect(email.start).toHaveBeenCalledOnce()); act(() => result.current.onSubmit('123456')); await waitFor(() => expect(result.current.open).toBe(false)); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('new@example.com', '123456'); + expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); + }); + + it('verifies an existing email without creating one', async () => { + const onCreate = vi.fn(created); + const email = verifier(); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate })); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + expect(result.current.open).toBe(true); + expect(result.current.step).toBe('verify'); + expect(result.current.emailAddress).toBe('other@example.com'); + await waitFor(() => expect(email.start).toHaveBeenCalledOnce()); + + act(() => result.current.onSubmit('123456')); + await waitFor(() => expect(result.current.open).toBe(false)); + expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('stays on the code step when sending fails, and lets the user resend at once', async () => { + const email = verifier(); + email.start.mockImplementationOnce(() => ({ + method: 'code', + sent: Promise.reject(saveError('Too many requests')), + })); + const { result } = renderHook(() => useUserProfileAddEmailController({ onCreate: created })); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests')); + expect(result.current.step).toBe('verify'); + expect(result.current.resendSeconds).toBe(0); + + act(() => result.current.onResend()); + await waitFor(() => expect(email.start).toHaveBeenCalledTimes(2)); + }); + + it('shows the error for the field that failed', async () => { + const { result } = renderHook(() => + useUserProfileAddEmailController({ + onCreate: () => Promise.reject(saveError('That email is taken.', 'emailAddress')), + }), + ); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onSubmit()); + await waitFor(() => expect(result.current.errorMessage).toBe('That email is taken.')); + }); + + it('moves from the email step straight to the code step', async () => { + const shown: string[] = []; + const { result } = renderHook(() => { + const controller = useUserProfileAddEmailController({ + onCreate: created, + }); + if (controller.open && shown.at(-1) !== controller.step) { + shown.push(controller.step); + } + return controller; + }); + + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('new@example.com')); + act(() => result.current.onSubmit()); + await waitFor(() => expect(result.current.resendSeconds).toBeGreaterThan(0)); + expect(shown).toEqual(['email', 'verify']); }); }); 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-account-section/user-profile-add-email.controller.ts index 91230c5c20c..cbadbc6a0b6 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-account-section/user-profile-add-email.controller.ts @@ -1,133 +1,132 @@ import { useEffect } from 'react'; -import { useMessages } from '../../../localization'; +import { useForm } from '../../../components/form'; +import { useErrorText } from '../../../localization'; import { setup } from '../../../machine/setup'; +import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/useMachine'; +import type { FormError } from '../../../utils/form-error'; +import { toFormError } from '../../../utils/form-error'; +import type { UserProfileEmailVerifier } from './user-profile-account-section.types'; import type { UserProfileAddEmailDialogProps } from './user-profile-add-email.dialog'; +export type UserProfileAddEmailField = 'emailAddress' | 'code'; + export interface UserProfileAddEmailControllerOptions { initialEmailAddress?: string; - onSend: (emailAddress: string) => Promise; - onVerify: (emailAddress: string, code: string) => Promise; + onCreate?: (emailAddress: string) => Promise; +} + +export interface UserProfileAddEmailController extends UserProfileAddEmailDialogProps { + onVerifyEmail: (emailAddress: string, verifier: UserProfileEmailVerifier) => void; } -interface Context extends UserProfileAddEmailControllerOptions { +interface Context { emailAddress: string; - code: string; - error: unknown; + verifier: UserProfileEmailVerifier | undefined; + error: FormError | undefined; resendSeconds: number; } type Event = - | { type: 'OPEN' } + | { type: 'ADD' } + | { type: 'START'; emailAddress: string; verifier: UserProfileEmailVerifier } + | { type: 'VERIFIED' } | { type: 'CANCEL' } | { type: 'RESEND' } - | { type: 'TICK' } - | { type: 'TYPE_EMAIL'; value: string } - | { type: 'TYPE_CODE'; value: string } - | { type: 'SUBMIT'; code?: string }; + | { type: 'TICK' }; const { createMachine, assign, fromPromise } = setup(); function missingDependency(): Promise { - return Promise.reject(new Error('Add email callbacks are missing')); + return Promise.reject(new Error('Email verification is missing')); } -function errorMessage(cause: unknown, fallback: string): string | undefined { - if (cause === undefined) { - return undefined; - } - return cause instanceof Error ? cause.message : fallback; -} +const CODE_RESEND_SECONDS = 12; -const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; +const start = { + target: 'sending', + actions: assign>((_, event) => ({ + emailAddress: event.emailAddress, + verifier: event.verifier, + error: undefined, + resendSeconds: 0, + })), +}; const machine = createMachine({ id: 'addEmail', initial: 'idle', context: { - onSend: missingDependency, - onVerify: missingDependency, emailAddress: '', - code: '', + verifier: undefined, error: undefined, resendSeconds: 0, }, states: { idle: { on: { - OPEN: { + ADD: { target: 'email', - actions: assign(context => ({ - emailAddress: context.initialEmailAddress ?? '', - code: '', - error: undefined, - resendSeconds: 0, - })), + actions: assign(() => ({ verifier: undefined, error: undefined, resendSeconds: 0 })), }, + START: start, }, }, email: { - on: { - CANCEL: 'idle', - TYPE_EMAIL: { actions: assign((_, event) => ({ emailAddress: event.value, error: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, - }, + on: { CANCEL: 'idle', START: start }, }, sending: { - invoke: fromPromise(context => context.onSend(context.emailAddress), { - onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, + invoke: fromPromise(context => (context.verifier ? context.verifier.start().sent : missingDependency()), { + onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, onError: { - target: 'email', - actions: assign((_, event) => ({ error: event.error })), + target: 'verify', + actions: assign((_, event) => ({ error: toFormError(event.error) })), }, }), }, verify: { on: { CANCEL: 'idle', - TICK: tick, + TICK: { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }, RESEND: { - target: 'resending', + target: 'sending', guard: context => context.resendSeconds === 0, actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, - SUBMIT: { - target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), - }, + VERIFIED: 'idle', }, }, - resending: { - invoke: fromPromise(context => context.onSend(context.emailAddress), { - onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: event.error })), - }, - }), - }, - verifying: { - on: { TICK: tick }, - invoke: fromPromise(context => context.onVerify(context.emailAddress, context.code), { - onDone: 'idle', - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: event.error })), - }, - }), - }, }, }); -export function useUserProfileAddEmailController( - options: UserProfileAddEmailControllerOptions, -): UserProfileAddEmailDialogProps { - const m = useMessages('userProfileAddEmail'); - const [snapshot, send] = useMachine(machine, { context: options }); - const { resendSeconds } = snapshot.context; - const open = snapshot.value !== 'idle'; +export function useUserProfileAddEmailController({ + initialEmailAddress = '', + onCreate, +}: UserProfileAddEmailControllerOptions): UserProfileAddEmailController { + const errorText = useErrorText(); + const [snapshot, send, actor] = useMachine(machine); + const { resendSeconds, error } = snapshot.context; + const emailForm = useForm({ + initialValues: { emailAddress: initialEmailAddress }, + onSubmit: async ({ emailAddress }) => { + if (onCreate) { + send({ type: 'START', emailAddress, verifier: await onCreate(emailAddress) }); + } + }, + }); + const codeForm = useForm({ + initialValues: { code: '' }, + onSubmit: async ({ code }) => { + const { verifier } = actor.getSnapshot().context; + if (verifier) { + await verifier.verifyCode(code); + send({ type: 'VERIFIED' }); + } + }, + }); + const state = snapshot.value; + const open = state !== 'idle'; useEffect(() => { if (!open || resendSeconds === 0) { return; @@ -136,22 +135,55 @@ export function useUserProfileAddEmailController( return () => clearTimeout(timer); }, [open, resendSeconds, send]); + const step = state === 'email' ? 'email' : 'verify'; + const form = step === 'email' ? emailForm : codeForm; + const formError = step === 'email' ? emailForm.fields.emailAddress.feedback : codeForm.fields.code.feedback; + const machineError = error?.global ? errorText(error.global) : undefined; + return { resendSeconds, - isResending: snapshot.value === 'resending', + isResending: state === 'sending', open, - step: - snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' - ? 'verify' - : 'email', - emailAddress: snapshot.context.emailAddress, - code: snapshot.context.code, - errorMessage: errorMessage(snapshot.context.error, m.error), - isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - onEmailAddressChange: value => send({ type: 'TYPE_EMAIL', value }), - onCodeChange: value => send({ type: 'TYPE_CODE', value }), - onSubmit: code => send({ type: 'SUBMIT', code }), - onResend: () => send({ type: 'RESEND' }), + step, + emailAddress: step === 'email' ? emailForm.values.emailAddress : snapshot.context.emailAddress, + canSubmitEmail: emailForm.canSubmit || emailForm.isSubmitting, + code: codeForm.values.code, + errorMessage: formError?.message ?? form.error ?? machineError, + isPending: form.isSubmitting, + onOpenChange: open => { + if (form.isSubmitting) { + return; + } + if (open) { + emailForm.reset(); + codeForm.reset(); + send({ type: 'ADD' }); + } else { + send({ type: 'CANCEL' }); + } + }, + onEmailAddressChange: value => emailForm.setValue('emailAddress', value), + onCodeChange: value => codeForm.setValue('code', value), + onSubmit: code => { + const state = actor.getSnapshot().value; + if (state === 'email') { + emailForm.submit(); + } else if (state === 'verify') { + if (code !== undefined) { + codeForm.setValue('code', code); + } + codeForm.submit(); + } + }, + onResend: () => { + if (!codeForm.isSubmitting && actor.can({ type: 'RESEND' })) { + codeForm.reset(); + send({ type: 'RESEND' }); + } + }, + onVerifyEmail: (emailAddress, verifier) => { + codeForm.reset(); + send({ type: 'START', emailAddress, verifier }); + }, }; } 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-account-section/user-profile-add-email.dialog.tsx index 50d1237fa28..923d78ceea3 100644 --- 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-account-section/user-profile-add-email.dialog.tsx @@ -1,10 +1,11 @@ +import { useMergeRefs } from '@floating-ui/react'; import * as stylex from '@stylexjs/stylex'; import type { FormEvent, Ref } from 'react'; import { useId, useRef } from 'react'; import { Button, SubmitButton } from '../../../components/button'; import { Card } from '../../../components/card'; -import type { DialogTriggerProps } from '../../../components/dialog'; +import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; @@ -16,10 +17,12 @@ import { styles } from '../user-profile-profile-panel.styles'; export interface UserProfileAddEmailDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; + handle?: DialogHandle; + finalFocus?: DialogFocusTarget; step: 'email' | 'verify'; emailAddress: string; onEmailAddressChange: (value: string) => void; + canSubmitEmail?: boolean; code: string; onCodeChange: (value: string) => void; onSubmit: (code?: string) => void; @@ -32,16 +35,18 @@ export interface UserProfileAddEmailDialogProps { export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) { const emailRef = useRef(null); + const codeRef = useRef(null); return ( - {props.trigger ? : null} void; onSubmit: () => void; + canSubmit: boolean; isPending?: boolean; errorMessage?: string; } @@ -138,6 +146,7 @@ function EnterEmailStep(props: EnterEmailStepProps) { @@ -149,6 +158,7 @@ function EnterEmailStep(props: EnterEmailStepProps) { } interface VerifyEmailStepProps { + inputRef: Ref; emailAddress: string; code: string; onCodeChange: (value: string) => void; @@ -163,6 +173,7 @@ interface VerifyEmailStepProps { function VerifyEmailStep(props: VerifyEmailStepProps) { const m = useMessages('userProfileAddEmail'); const verifyFormId = useId(); + const inputRef = useMergeRefs([props.inputRef, useFlowAutoFocus()]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -190,7 +201,7 @@ function VerifyEmailStep(props: VerifyEmailStepProps) { > {m.verify.label} ()} + ref={inputRef} name='code' value={props.code} onValueChange={props.onCodeChange} 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-account-section/user-profile-add-email.messages.ts index d665475f099..4ea7a8340a1 100644 --- 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-account-section/user-profile-add-email.messages.ts @@ -1,11 +1,10 @@ export const userProfileAddEmailMessages = { - error: 'Something went wrong. Please try again.', email: { title: 'Add email', - description: 'We’ll send you a code to verify this email address.', + description: 'You’ll need to verify this email address before it’s added to your account.', label: 'Email', - submit: 'Send code', - pending: 'Sending code', + submit: 'Continue', + pending: 'Adding email', }, verify: { title: 'Verify your email', @@ -15,7 +14,7 @@ export const userProfileAddEmailMessages = { pending: 'Verifying', cancel: 'Cancel', resend: 'Didn’t receive a code? Resend', - resending: 'Sending a new code…', + resending: 'Sending code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, } as const; 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 index 9db44fe83cf..35ca5aaaace 100644 --- 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 @@ -2,12 +2,12 @@ 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 { Text } from '../../../components/text'; import { useListRemovalFocus } from '../../../hooks/useListRemovalFocus'; import { fill, useMessages } from '../../../localization'; -import type { UserProfileEmail } from './user-profile-account-section.types'; -import type { UserProfileAddEmailControllerOptions } from './user-profile-add-email.controller'; +import type { UserProfileEmail, UserProfileEmailVerifier } from './user-profile-account-section.types'; import { useUserProfileAddEmailController } from './user-profile-add-email.controller'; import { UserProfileAddEmailDialog } from './user-profile-add-email.dialog'; import { UserProfileContactListRowView } from './user-profile-contact-list-row.view'; @@ -18,8 +18,8 @@ export interface UserProfileEmailRowViewProps { emails: UserProfileEmail[]; allowMultipleAccounts?: boolean; onAddEmail?: () => void; - onSendEmailCode?: (emailAddress: string) => Promise; - onVerifyEmailCode?: (emailAddress: string, code: string) => Promise; + onCreateEmail?: (emailAddress: string) => Promise; + getEmailVerifier?: (id: string) => UserProfileEmailVerifier; onManageEmail?: (id: string) => void; onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void | Promise; @@ -30,8 +30,8 @@ export function UserProfileEmailRowView({ emails, allowMultipleAccounts = false, onAddEmail, - onSendEmailCode, - onVerifyEmailCode, + onCreateEmail, + getEmailVerifier, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -44,30 +44,55 @@ export function UserProfileEmailRowView({ onRemove: onRemoveEmail, fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, }); + const verification = useUserProfileAddEmailController({ onCreate: onCreateEmail }); + const verificationDialog = useMemo(() => Dialog.createHandle(), []); + const canVerify = Boolean(getEmailVerifier); + const addEmailLabel = ( + <> + {allowMultipleAccounts ? ( + + ) : null} + {allowMultipleAccounts ? m.add : m.email.add} + + ); const addEmailAction = - onSendEmailCode && onVerifyEmailCode ? ( - + canVerify && onCreateEmail ? ( + + } + > + {addEmailLabel} + ) : onAddEmail ? ( ) : undefined; + const verifyingId = useRef(undefined); + const verifyEmail = (id: string) => { + const email = emails.find(email => email.id === id); + if (email && getEmailVerifier) { + verifyingId.current = id; + verification.onVerifyEmail(email.value, getEmailVerifier(id)); + } + }; const removeEmailConfirmation = useMemo(() => Confirmation.createHandle(), []); const primary = useUserProfileSetPrimaryController({ items: emails, @@ -82,15 +107,30 @@ export function UserProfileEmailRowView({ } }; + const dialog = canVerify ? ( + { + const id = verifyingId.current; + verifyingId.current = undefined; + return id ? removalFocus.trigger(id) : null; + }} + /> + ) : null; + if (!allowMultipleAccounts) { return ( - + <> + + {dialog} + ); } @@ -105,8 +145,9 @@ export function UserProfileEmailRowView({ addAction={addEmailAction} onRemove={onRemoveEmail ? removeEmail : undefined} onSetPrimary={primary.onSetPrimary} - onVerify={onVerifyEmail} + onVerify={canVerify ? verifyEmail : onVerifyEmail} /> + {dialog} {primary.error ? ( ); } - -function AddEmail({ options, compact }: { options: UserProfileAddEmailControllerOptions; compact: boolean }) { - const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileAddEmailController(options); - return ( - - {compact ? ( - - ) : null} - {compact ? m.add : m.email.add} - - } - /> - ); -} 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 42d900b0764..40585b69414 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 @@ -60,8 +60,8 @@ export function UserProfileProfilePanelView({ onSubmitName, onSubmitUsername, onAddEmail, - onSendEmailCode, - onVerifyEmailCode, + onCreateEmail, + getEmailVerifier, onManageEmail, onVerifyEmail, onSetPrimaryEmail, @@ -102,10 +102,10 @@ export function UserProfileProfilePanelView({ phones={phones} username={username} onAddEmail={onAddEmail} + onCreateEmail={onCreateEmail} onSendPhoneCode={onSendPhoneCode} onVerifyPhoneCode={onVerifyPhoneCode} - onSendEmailCode={onSendEmailCode} - onVerifyEmailCode={onVerifyEmailCode} + getEmailVerifier={getEmailVerifier} onManageEmail={onManageEmail} onManagePhone={onManagePhone} onProfilePictureChange={onProfilePictureChange} diff --git a/packages/mosaic/src/hooks/useListRemovalFocus.ts b/packages/mosaic/src/hooks/useListRemovalFocus.ts index 1c38c6ae607..73621b38222 100644 --- a/packages/mosaic/src/hooks/useListRemovalFocus.ts +++ b/packages/mosaic/src/hooks/useListRemovalFocus.ts @@ -40,5 +40,7 @@ export function useListRemovalFocus({ return (next ? triggers.current.get(next) : undefined) ?? fallback(); }; - return { registerTrigger, remove, finalFocus }; + const trigger = (id: string) => triggers.current.get(id) ?? null; + + return { registerTrigger, trigger, remove, finalFocus }; } 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 3735331b61d..15feb07e4ac 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -1,28 +1,39 @@ +import type { 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'; -import type { UserProfileAddEmailDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-email.dialog'; interface FixtureOptions { - failAt?: UserProfileAddEmailDialogProps['step']; - onVerified?: (emailAddress: string) => void; + fail?: 'create' | 'verify'; + onCreated?: (id: string, emailAddress: string) => void; + onVerified?: (id: string) => void; } -export function createUserProfileAddEmailFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export function createUserProfileAddEmailFixture({ fail, onCreated, onVerified }: FixtureOptions = {}): Pick< UserProfileAccountSectionViewProps, - 'onSendEmailCode' | 'onVerifyEmailCode' + 'onCreateEmail' | 'getEmailVerifier' > { - return { - onSendEmailCode: async () => { - await new Promise(resolve => setTimeout(resolve, 700)); - if (failAt === 'email') { - throw new Error('We couldn’t send a code. Try again.'); + const verifier = (id: string): UserProfileEmailVerifier => ({ + start: () => ({ method: 'code', sent: delay(700).then(() => undefined) }), + verifyCode: async code => { + await delay(700); + if (fail === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); } + onVerified?.(id); }, - onVerifyEmailCode: async (emailAddress, code) => { - await new Promise(resolve => setTimeout(resolve, 700)); - if (failAt === 'verify' || code === '000000') { - throw new Error('That code is incorrect. Try again.'); + }); + + return { + onCreateEmail: async emailAddress => { + await delay(700); + if (fail === 'create') { + throw new Error('We couldn’t add this email. Try again.'); } - onVerified?.(emailAddress); + const id = `email_${Date.now()}`; + onCreated?.(id, emailAddress); + return verifier(id); }, + getEmailVerifier: verifier, }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index dc9375177fd..42b68223c4b 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -9,9 +9,6 @@ import type { } from '@clerk/mosaic/features/user-profile/user-profile-profile-panel.view'; import { useState } from 'react'; -import { useChaosFixture } from '@/components/ChaosProvider'; -import { chaosEmail, chaosRows, chaosText } from '@/lib/chaos'; - import { usePreviewImage } from './use-preview-image'; import { useUserProfileActiveDevicesFixture } from './user-profile-active-devices'; import { createUserProfileAddEmailFixture } from './user-profile-add-email'; @@ -30,26 +27,6 @@ export interface UserProfileFixtureOptions { onAddEmail?: () => void; } -const exampleEmails: UserProfileEmail[] = [ - { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, -]; - -const examplePhones: UserProfilePhone[] = [ - { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, -]; - -const exampleSubscription: UserProfileSubscription = { - planName: 'Basic Plan', - priceLabel: '$12 / Month', - totalDueLabel: '$12.00', - renewsAtLabel: 'Renews Aug 26', -}; - -const examplePaymentMethods: UserProfilePaymentMethod[] = [ - { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, -]; - /** * 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. @@ -62,43 +39,34 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions const editPassword = useUserProfileEditPasswordFixture(); const mfa = useUserProfileMfaExample(); const [activePage, setActivePage] = useState('account'); - const seedEmails = useChaosFixture(exampleEmails, items => - chaosRows(items, 8).map((email, index) => ({ ...email, value: chaosEmail(index), isDefault: index === 0 })), - ); - const seedPhones = useChaosFixture(examplePhones, items => - chaosRows(items, 6).map((phone, index) => ({ - ...phone, - value: `${phone.value} ext. 1234567890`, - isDefault: index === 0, - })), - ); - const [emails, setEmails] = useState(seedEmails); - const [phones, setPhones] = useState(seedPhones); + const [emails, setEmails] = useState([ + { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'preston.booth@gmail.com', isDefault: false, isVerified: true }, + ]); + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); const passkeys = usePasskeysFixture(); const activeDevices = useUserProfileActiveDevicesFixture(); - const seedSubscription = useChaosFixture(exampleSubscription, subscription => ({ - planName: chaosText(subscription.planName), - priceLabel: '$1,234,567.89 / Month', - totalDueLabel: '$1,234,567.89', - renewsAtLabel: chaosText(subscription.renewsAtLabel), - })); - const seedPaymentMethods = useChaosFixture(examplePaymentMethods, items => - chaosRows(items, 8).map((method, index) => ({ - ...method, - label: chaosText(method.label), - isDefault: index === 0, - })), - ); - const [subscription, setSubscription] = useState(seedSubscription); - const [paymentMethods, setPaymentMethods] = useState(seedPaymentMethods); + const [subscription, setSubscription] = useState({ + planName: 'Basic Plan', + priceLabel: '$12 / Month', + totalDueLabel: '$12.00', + renewsAtLabel: 'Renews Aug 26', + }); + const [paymentMethods, setPaymentMethods] = useState([ + { id: 'visa', label: 'Visa •••• 0644', expiryLabel: 'Expires 02/2029', isDefault: true }, + ]); const [historyPageSize, setHistoryPageSize] = useState(10); const apiKeys = useUserProfileAPIKeysFixture(); 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, isVerified: false }]); + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: false }]); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: true }]), + 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'] = { @@ -121,10 +89,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions emails, phones, onAddEmail, - onSendEmailCode: onAddEmail ? undefined : emailFlow.onSendEmailCode, - onVerifyEmailCode: onAddEmail ? undefined : emailFlow.onVerifyEmailCode, + onCreateEmail: onAddEmail ? undefined : emailFlow.onCreateEmail, + getEmailVerifier: onAddEmail ? undefined : emailFlow.getEmailVerifier, ...createUserProfileAddPhoneFixture({ - onVerified: value => setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isVerified: true }]), + onVerified: value => + setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }), onDeleteAccount: () => Promise.resolve(), onManageEmail: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 56d77d311b6..db0d92f2fd9 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -74,9 +74,10 @@ function AccountSection({ setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); const emailFlow = createUserProfileAddEmailFixture({ - failAt: failEmailVerification ? 'verify' : undefined, - onVerified: value => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), + 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 ( diff --git a/packages/swingset/src/stories/user-profile-profile-panel.mdx b/packages/swingset/src/stories/user-profile-profile-panel.mdx index 7e392ec0679..db825033efb 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.mdx +++ b/packages/swingset/src/stories/user-profile-profile-panel.mdx @@ -55,8 +55,8 @@ import { UserProfileProfilePanelView } from '@clerk/mosaic/features/user-profile }; })} onVerifyEmail={verifyEmail} - onSendEmailCode={sendEmailCode} - onVerifyEmailCode={verifyEmailCode} + onCreateEmail={createEmail} + getEmailVerifier={getEmailVerifier} onSetPrimaryEmail={setPrimaryEmail} onRemoveEmail={removeEmail} onVerifyPhone={verifyPhone} 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 96400c4ec62..c20f1ef71e0 100644 --- a/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx +++ b/packages/swingset/src/stories/user-profile-profile-panel.stories.tsx @@ -42,8 +42,9 @@ export function Default(_args: Record) { const editName = useUserProfileEditNameFixture(); const editUsername = useUserProfileEditUsernameFixture(); const emailFlow = createUserProfileAddEmailFixture({ - onVerified: value => - setEmails(current => [...current, { id: `email_${Date.now()}`, value, isDefault: false, isVerified: true }]), + 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 ( From 03f696b56b48c0554791377bb801e1a0c0c9ef78 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 25 Sep 2026 11:47:30 -0400 Subject: [PATCH 32/63] feat(mosaic): verify user profile emails by link or enterprise SSO --- .../mosaic-user-profile-email-link-sso.md | 2 + ...ser-profile-account-section.model.test.tsx | 77 ++++++- .../user-profile-add-email.dialog.test.tsx | 48 +++++ ...ser-profile-add-email.integration.test.tsx | 77 +++++++ ...-profile-verify-email-link.dialog.test.tsx | 65 ------ ...r-profile-verify-email-sso.dialog.test.tsx | 66 ------ .../user-profile-account-section.model.ts | 55 ++++- .../user-profile-account-section.types.ts | 8 +- .../user-profile-account-section.view.tsx | 1 + .../user-profile-add-email.controller.test.ts | 98 ++++++++- .../user-profile-add-email.controller.ts | 92 ++++++-- .../user-profile-add-email.dialog.tsx | 204 ++++++++++++++++-- .../user-profile-add-email.messages.ts | 14 ++ .../user-profile-email-row.view.tsx | 7 +- .../user-profile-verify-email-link.dialog.tsx | 107 --------- ...user-profile-verify-email-link.messages.ts | 9 - .../user-profile-verify-email-link.styles.ts | 23 -- .../user-profile-verify-email-sso.dialog.tsx | 108 ---------- .../user-profile-verify-email-sso.messages.ts | 7 - .../user-profile-verify-email-sso.styles.ts | 7 - packages/mosaic/src/localization/registry.ts | 4 - packages/swingset/src/lib/registry.ts | 8 +- .../fixtures/user-profile-add-email.ts | 56 ++++- .../user-profile-verify-email-link.ts | 51 ----- .../fixtures/user-profile-verify-email-sso.ts | 54 ----- .../swingset/src/stories/localization.mdx | 4 +- .../src/stories/localization.stories.tsx | 4 +- .../stories/user-profile-account-section.mdx | 26 +-- .../user-profile-account-section.stories.tsx | 71 ++---- 29 files changed, 715 insertions(+), 638 deletions(-) create mode 100644 .changeset/mosaic-user-profile-email-link-sso.md delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages.ts delete mode 100644 packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts delete mode 100644 packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts delete mode 100644 packages/swingset/src/stories/fixtures/user-profile-verify-email-sso.ts diff --git a/.changeset/mosaic-user-profile-email-link-sso.md b/.changeset/mosaic-user-profile-email-link-sso.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-email-link-sso.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 31c0971d14e..c7311254a02 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -11,6 +11,7 @@ interface FakeAttribute { enabled: boolean; required: boolean; immutable?: boolean; + verifications: string[]; used_for_first_factor: boolean; used_for_second_factor: boolean; } @@ -18,6 +19,7 @@ interface FakeAttribute { interface FakeVerification { status: string | null; expireAt?: Date | null; + externalVerificationRedirectURL?: URL | null; } let isUserLoaded: boolean; @@ -45,6 +47,9 @@ let user: { destroy?: ReturnType; prepareVerification?: ReturnType; attemptVerification?: ReturnType; + createEmailLinkFlow?: ReturnType; + matchesSsoConnection?: boolean; + createEnterpriseSSOLinkFlow?: ReturnType; }[]; phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; setProfileImage: ReturnType; @@ -55,9 +60,22 @@ let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; let enterpriseSSOEnabled: boolean; +let emailLinkFlow: { startEmailLinkFlow: ReturnType; cancelEmailLinkFlow: ReturnType }; +let ssoLinkFlow: { + startEnterpriseSSOLinkFlow: ReturnType; + cancelEnterpriseSSOLinkFlow: ReturnType; +}; +const navigate = vi.fn(); function attribute(overrides: Partial = {}): FakeAttribute { - return { enabled: true, required: false, used_for_first_factor: false, used_for_second_factor: false, ...overrides }; + return { + enabled: true, + required: false, + verifications: [], + used_for_first_factor: false, + used_for_second_factor: false, + ...overrides, + }; } vi.mock('@clerk/shared/react', async importOriginal => { @@ -66,8 +84,12 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ + navigate, __internal_environment: environmentHydrated - ? { userSettings: { attributes, usernameSettings, enterpriseSSO: { enabled: enterpriseSSOEnabled } } } + ? { + displayConfig: { userProfileUrl: 'https://accounts.clerk.dev/user' }, + userSettings: { attributes, usernameSettings, enterpriseSSO: { enabled: enterpriseSSOEnabled } }, + } : null, }), }; @@ -112,6 +134,9 @@ beforeEach(() => { isUserLoaded = true; environmentHydrated = true; enterpriseSSOEnabled = true; + emailLinkFlow = { startEmailLinkFlow: vi.fn(() => Promise.resolve()), cancelEmailLinkFlow: vi.fn() }; + ssoLinkFlow = { startEnterpriseSSOLinkFlow: vi.fn(() => Promise.resolve()), cancelEnterpriseSSOLinkFlow: vi.fn() }; + navigate.mockReset(); usernameSettings = { min_length: 4, max_length: 64 }; attributes = { first_name: attribute(), @@ -137,6 +162,8 @@ beforeEach(() => { destroy: vi.fn(() => Promise.resolve()), prepareVerification: vi.fn(() => Promise.resolve()), attemptVerification: vi.fn(() => Promise.resolve()), + createEmailLinkFlow: vi.fn(() => emailLinkFlow), + createEnterpriseSSOLinkFlow: vi.fn(() => ssoLinkFlow), }, { id: 'email_1', @@ -297,6 +324,52 @@ describe('useUserProfileAccountSectionModel', () => { expect(prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); }); + it('sends a link back to the profile when the instance verifies by link', async () => { + attributes.email_address = attribute({ verifications: ['email_code', 'email_link'] }); + const verification = ready().getEmailVerifier?.('email_2').start(); + if (verification?.method !== 'link') { + throw new Error('expected a link verification'); + } + await expect(verification.verified).resolves.toBeUndefined(); + expect(emailLinkFlow.startEmailLinkFlow).toHaveBeenCalledWith({ + redirectUrl: 'https://accounts.clerk.dev/user#/verify', + }); + verification.cancel(); + expect(emailLinkFlow.cancelEmailLinkFlow).toHaveBeenCalledOnce(); + }); + + it('returns the API message when the link cannot be sent', async () => { + attributes.email_address = attribute({ verifications: ['email_link'] }); + emailLinkFlow.startEmailLinkFlow.mockRejectedValue(apiError()); + const verification = ready().getEmailVerifier?.('email_2').start(); + if (verification?.method !== 'link') { + throw new Error('expected a link verification'); + } + await expect(rejection(verification.verified)).resolves.toEqual({ + global: { code: 'form_param_invalid', message: 'That value is invalid.' }, + }); + }); + + it('verifies an email that matches an SSO connection with the provider, even when links are on', async () => { + attributes.email_address = attribute({ verifications: ['email_link'] }); + const email = user?.emailAddresses[0]; + if (!email) { + throw new Error('email missing'); + } + email.matchesSsoConnection = true; + email.verification.externalVerificationRedirectURL = new URL('https://idp.acme.co/sso'); + const verification = ready().getEmailVerifier?.('email_2').start(); + if (verification?.method !== 'sso') { + throw new Error('expected an SSO verification'); + } + await expect(verification.verified).resolves.toBeUndefined(); + expect(ssoLinkFlow.startEnterpriseSSOLinkFlow).toHaveBeenCalledWith({ redirectUrl: window.location.href }); + verification.connect(); + expect(navigate).toHaveBeenCalledWith('https://idp.acme.co/sso'); + verification.cancel(); + expect(ssoLinkFlow.cancelEnterpriseSSOLinkFlow).toHaveBeenCalledOnce(); + }); + it('verifies the code for the chosen email', async () => { await ready().getEmailVerifier?.('email_2').verifyCode('123456'); expect(user?.emailAddresses[0]?.attemptVerification).toHaveBeenCalledWith({ code: '123456' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx index fba6c43ccba..f08f64d7aff 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.dialog.test.tsx @@ -18,6 +18,7 @@ function renderView(overrides: Partial = {}) { onCodeChange: vi.fn(), onSubmit: vi.fn(), onResend: vi.fn(), + onConnect: vi.fn(), ...overrides, }; return { @@ -45,6 +46,7 @@ function VerificationExample({ onSubmit }: Pick undefined} + onConnect={() => undefined} />
); @@ -183,6 +185,52 @@ describe('UserProfileAddEmailDialog', () => { expect(screen.getByRole('button', { name: 'Sending code…' })).toBeDisabled(); }); + it('asks the user to open the link, and lets them send a new one after the countdown', async () => { + const user = userEvent.setup(); + const { props, rerender } = renderView({ step: 'link', resendSeconds: 60, errorMessage: 'Link expired' }); + const dialog = screen.getByRole('dialog', { name: 'Verify your email' }); + expect(dialog).toHaveTextContent('Open the link we sent to person@example.com'); + expect(dialog).toHaveTextContent('Link expired'); + expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend (60)' })); + expect(props.onResend).not.toHaveBeenCalled(); + + rerender( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); + expect(props.onResend).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + + it('asks the user to connect with the SSO provider for their email domain', async () => { + const user = userEvent.setup(); + const { props } = renderView({ + step: 'sso', + emailAddress: 'person@acme.co', + errorMessage: 'Unable to connect. Try again.', + }); + + expect(screen.getByRole('dialog', { name: 'Verify your email' })).toHaveAccessibleDescription( + 'Connect below to verify person@acme.co', + ); + expect(screen.getByText('acme.co')).toBeInTheDocument(); + expect(screen.getByText('Enterprise SSO')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect. Try again.'); + + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(props.onConnect).toHaveBeenCalledOnce(); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); + it('disables Continue when the email cannot be submitted', () => { renderView({ canSubmitEmail: false }); expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx index a054dc8f95d..3b408f913c3 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx @@ -1,3 +1,4 @@ +import { createDeferredPromise } from '@clerk/shared/utils'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -72,4 +73,80 @@ describe('profile add email', () => { expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); await waitFor(() => expect(trigger).toHaveFocus()); }); + + it('waits for the emailed link, then closes and stops waiting', async () => { + const user = userEvent.setup(); + const verified = createDeferredPromise(); + const cancel = vi.fn(); + render( + + ({ + start: () => ({ method: 'link', verified: verified.promise.then(() => undefined), cancel }), + verifyCode: vi.fn(), + })} + /> + , + ); + await user.click(screen.getByRole('button', { name: 'Manage other@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await waitFor(() => expect(dialog).toHaveTextContent('Open the link we sent to other@example.com')); + expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); + verified.resolve(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it('verifies an SSO email by connecting to the provider for its domain', async () => { + const user = userEvent.setup(); + const connect = vi.fn(); + render( + + ({ + start: () => ({ method: 'sso', verified: new Promise(() => {}), cancel: vi.fn(), connect }), + verifyCode: vi.fn(), + })} + /> + , + ); + await user.click(screen.getByRole('button', { name: 'Manage person@acme.co' })); + await user.click(screen.getByRole('menuitem', { name: 'Verify' })); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await waitFor(() => expect(dialog).toHaveTextContent('acme.co')); + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(connect).toHaveBeenCalledOnce(); + }); + + it('does not add the username as an email', async () => { + const user = userEvent.setup(); + const verifier = { start: () => ({ method: 'code', sent: Promise.resolve() }) as const, verifyCode: vi.fn() }; + const onCreate = vi.fn(() => Promise.resolve(verifier)); + render( + + verifier} + /> + , + ); + await user.click(screen.getByRole('button', { name: 'Add email' })); + await user.type(screen.getByRole('textbox', { name: 'Email' }), 'person@example.com'); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx deleted file mode 100644 index 9b2de023a40..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-link.dialog.test.tsx +++ /dev/null @@ -1,65 +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 '../../../MosaicProvider'; -import type { UserProfileVerifyEmailLinkDialogProps } from '../user-profile-account-section/user-profile-verify-email-link.dialog'; -import { UserProfileVerifyEmailLinkDialog } from '../user-profile-account-section/user-profile-verify-email-link.dialog'; - -function renderView(overrides: Partial = {}) { - const props: UserProfileVerifyEmailLinkDialogProps = { - open: true, - onOpenChange: vi.fn(), - emailAddress: 'example@email.com', - onResend: vi.fn(), - ...overrides, - }; - return { - props, - ...render( - - - , - ), - }; -} - -describe('UserProfileVerifyEmailLinkDialog', () => { - it('shows the address awaiting verification and lets the user resend the link', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - expect(screen.getByRole('dialog', { name: 'Verify email address' })).toHaveAccessibleDescription( - 'A verification link was sent to example@email.com', - ); - expect(screen.getByRole('status')).toHaveTextContent('Check your email'); - expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); - expect(props.onResend).toHaveBeenCalledOnce(); - }); - - it.each([ - { resendSeconds: 12, isResending: false, label: 'Didn’t receive a link? Resend (12)' }, - { resendSeconds: 0, isResending: true, label: 'Sending a new link…' }, - ])('prevents resending while $label', async ({ resendSeconds, isResending, label }) => { - const user = userEvent.setup(); - const { props } = renderView({ resendSeconds, isResending }); - const resend = screen.getByRole('button', { name: label }); - - expect(resend).toBeDisabled(); - await user.click(resend); - expect(props.onResend).not.toHaveBeenCalled(); - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - }); - - it('announces a supplied resend error and allows retry', async () => { - const user = userEvent.setup(); - const { props } = renderView({ errorMessage: 'Unable to send the verification link. Try again.' }); - - expect(screen.getByRole('alert')).toHaveTextContent('Unable to send the verification link. Try again.'); - await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' })); - expect(props.onResend).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx deleted file mode 100644 index 747ce6190e4..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-verify-email-sso.dialog.test.tsx +++ /dev/null @@ -1,66 +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 '../../../MosaicProvider'; -import type { UserProfileVerifyEmailSsoDialogProps } from '../user-profile-account-section/user-profile-verify-email-sso.dialog'; -import { UserProfileVerifyEmailSsoDialog } from '../user-profile-account-section/user-profile-verify-email-sso.dialog'; - -function renderView(overrides: Partial = {}) { - const props: UserProfileVerifyEmailSsoDialogProps = { - open: true, - onOpenChange: vi.fn(), - emailAddress: 'example@email.com', - connection: { provider: 'Okta SSO', domain: 'acme.co' }, - onConnect: vi.fn(), - ...overrides, - }; - return { - props, - ...render( - - - , - ), - }; -} - -describe('UserProfileVerifyEmailSsoDialog', () => { - it('shows the matching connection and lets the user connect to verify their email', async () => { - const user = userEvent.setup(); - const { props } = renderView(); - - expect(screen.getByRole('dialog', { name: 'Verify email address' })).toHaveAccessibleDescription( - 'Connect below to verify example@email.com', - ); - expect(screen.getByText('Okta SSO')).toBeInTheDocument(); - expect(screen.getByText('acme.co · Enterprise SSO')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Connect' })); - expect(props.onConnect).toHaveBeenCalledOnce(); - expect(props.onOpenChange).not.toHaveBeenCalled(); - }); - - it('prevents another connection attempt while connecting and still allows cancellation', async () => { - const user = userEvent.setup(); - const { props } = renderView({ isConnecting: true }); - const connect = screen.getByRole('button', { name: 'Connect' }); - - expect(connect).toBeDisabled(); - expect(connect).toHaveAttribute('aria-busy', 'true'); - await user.click(connect); - expect(props.onConnect).not.toHaveBeenCalled(); - - await user.click(screen.getByRole('button', { name: 'Cancel' })); - expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything()); - }); - - it('announces a supplied connection error and allows retry', async () => { - const user = userEvent.setup(); - const { props } = renderView({ errorMessage: 'Unable to connect to Okta. Try again.' }); - - expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect to Okta. Try again.'); - await user.click(screen.getByRole('button', { name: 'Connect' })); - expect(props.onConnect).toHaveBeenCalledOnce(); - }); -}); 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 index f75090a7269..af50288ac9f 100644 --- 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 @@ -3,10 +3,13 @@ import { useUser } from '@clerk/shared/react'; import type { AttributeData, EmailAddressResource, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; +import type { MosaicRouter } from '../../../hooks/useMosaicRouter'; +import { useMosaicRouter } from '../../../hooks/useMosaicRouter'; import { save } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, + UserProfileEmailVerification, UserProfileEmailVerifier, UserProfileNameAttribute, UserProfilePhone, @@ -58,9 +61,49 @@ function emailById(user: UserResource, id: string): EmailAddressResource { return email; } -function toEmailVerifier(email: EmailAddressResource): UserProfileEmailVerifier { +function verifyRedirectUrl(userProfileUrl: string): string { + const url = new URL(userProfileUrl); + url.hash = '/verify'; + return url.toString(); +} + +function startEmailVerification( + email: EmailAddressResource, + linkRedirectUrl: string | undefined, + router: MosaicRouter, +): UserProfileEmailVerification { + if (email.matchesSsoConnection) { + const { startEnterpriseSSOLinkFlow, cancelEnterpriseSSOLinkFlow } = email.createEnterpriseSSOLinkFlow(); + return { + method: 'sso', + verified: save(() => startEnterpriseSSOLinkFlow({ redirectUrl: window.location.href })), + cancel: cancelEnterpriseSSOLinkFlow, + connect: () => { + const url = email.verification.externalVerificationRedirectURL; + if (url) { + void 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: () => ({ method: 'code', sent: save(() => email.prepareVerification({ strategy: 'email_code' })) }), + start: () => startEmailVerification(email, linkRedirectUrl, router), verifyCode: code => save(() => email.attemptVerification({ code }), ADD_EMAIL_FIELDS), }; } @@ -110,6 +153,7 @@ function toPhones(user: UserResource): UserProfilePhone[] { export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { const { isLoaded, user } = useUser(); const environment = useMosaicEnvironment(); + const router = useMosaicRouter(); if (!isLoaded || !environment) { return { status: 'loading' }; @@ -127,7 +171,10 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const showEmails = isAttributeAvailable(attributes.email_address); const emailsImmutable = Boolean(attributes.email_address?.immutable); const canCreateEmail = showEmails && !emailsImmutable && canAddIdentifications(user, enterpriseSSO.enabled); + const verifiesEmailByLink = Boolean(attributes.email_address?.verifications.includes('email_link')); const showPhones = isAttributeAvailable(attributes.phone_number); + const linkRedirectUrl = verifiesEmailByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; + const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); return { status: 'ready', @@ -147,10 +194,10 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo ? async emailAddress => { const request = user.createEmailAddress({ email: emailAddress }); await save(() => request, ADD_EMAIL_FIELDS); - return toEmailVerifier(await request); + return verifierFor(await request); } : undefined, - getEmailVerifier: showEmails ? id => toEmailVerifier(emailById(user, id)) : undefined, + getEmailVerifier: showEmails ? id => verifierFor(emailById(user, id)) : undefined, onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, onRemoveEmail: showEmails && !emailsImmutable ? id => save(() => emailById(user, id).destroy()) : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), 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 index 62b73c573c3..b032ea877a9 100644 --- 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 @@ -17,10 +17,10 @@ export type UserProfileEmail = UserProfileContact; export type UserProfilePhone = UserProfileContact; -export interface UserProfileEmailVerification { - method: 'code'; - sent: Promise; -} +export type UserProfileEmailVerification = + | { method: 'code'; sent: Promise } + | { method: 'link'; verified: Promise; cancel: () => void } + | { method: 'sso'; verified: Promise; cancel: () => void; connect: () => void }; export interface UserProfileEmailVerifier { start: () => UserProfileEmailVerification; 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 index d8b9bd9baff..9ecc9c35740 100644 --- 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 @@ -109,6 +109,7 @@ export function UserProfileAccountSectionView({ const emailRow = emails ? ( ({ method: 'code', sent: Promise.resolve() }); +function sentLink(verified: Promise, cancel = vi.fn()) { + return (): UserProfileEmailVerification => ({ method: 'link', verified, cancel }); +} + function verifier( start: () => UserProfileEmailVerification = sentCode, verifyCode: (code: string) => Promise = () => Promise.resolve(), @@ -218,11 +222,83 @@ describe('useUserProfileAddEmailController', () => { await waitFor(() => expect(result.current.errorMessage).toBe('That email is taken.')); }); - it('moves from the email step straight to the code step', async () => { + it('waits for the link to be opened, then closes', async () => { + const verified = createDeferredPromise(); + const { result } = renderHook(() => useUserProfileAddEmailController({})); + + act(() => + result.current.onVerifyEmail('other@example.com', verifier(sentLink(verified.promise.then(() => undefined)))), + ); + expect(result.current.step).toBe('link'); + expect(result.current.resendSeconds).toBe(60); + + await act(async () => { + verified.resolve(); + await verified.promise; + }); + expect(result.current.open).toBe(false); + }); + + it('stops waiting for the link when closed or unmounted', async () => { + const cancel = vi.fn(); + const email = verifier(sentLink(new Promise(() => {}), cancel)); + const { result, unmount } = renderHook(() => useUserProfileAddEmailController({})); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + await waitFor(() => expect(result.current.step).toBe('link')); + act(() => result.current.onOpenChange(false)); + expect(cancel).toHaveBeenCalledOnce(); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + await waitFor(() => expect(result.current.step).toBe('link')); + unmount(); + expect(cancel).toHaveBeenCalledTimes(2); + }); + + it('shows a link failure and lets the user send a new link', async () => { + const email = verifier(sentLink(Promise.reject(saveError('Link expired')))); + const { result } = renderHook(() => useUserProfileAddEmailController({})); + + act(() => result.current.onVerifyEmail('other@example.com', email)); + await waitFor(() => expect(result.current.errorMessage).toBe('Link expired')); + expect(result.current.step).toBe('link'); + expect(result.current.open).toBe(true); + }); + + it('connects through the SSO provider, closes once verified, and stops waiting when closed', async () => { + const verified = createDeferredPromise(); + const cancel = vi.fn(); + const connect = vi.fn(); + const email = verifier(() => ({ + method: 'sso', + verified: verified.promise.then(() => undefined), + cancel, + connect, + })); + const { result } = renderHook(() => useUserProfileAddEmailController({})); + + act(() => result.current.onVerifyEmail('person@acme.co', email)); + expect(result.current.step).toBe('sso'); + expect(result.current.resendSeconds).toBe(0); + result.current.onConnect(); + expect(connect).toHaveBeenCalledOnce(); + act(() => result.current.onOpenChange(false)); + expect(cancel).toHaveBeenCalledOnce(); + + act(() => result.current.onVerifyEmail('person@acme.co', email)); + await waitFor(() => expect(result.current.step).toBe('sso')); + await act(async () => { + verified.resolve(); + await verified.promise; + }); + expect(result.current.open).toBe(false); + }); + + it('moves from the email step straight to the step for the verification method', async () => { const shown: string[] = []; const { result } = renderHook(() => { const controller = useUserProfileAddEmailController({ - onCreate: created, + onCreate: () => Promise.resolve(verifier(sentLink(new Promise(() => {})))), }); if (controller.open && shown.at(-1) !== controller.step) { shown.push(controller.step); @@ -233,7 +309,21 @@ describe('useUserProfileAddEmailController', () => { act(() => result.current.onOpenChange(true)); act(() => result.current.onEmailAddressChange('new@example.com')); act(() => result.current.onSubmit()); - await waitFor(() => expect(result.current.resendSeconds).toBeGreaterThan(0)); - expect(shown).toEqual(['email', 'verify']); + await waitFor(() => expect(result.current.step).toBe('link')); + expect(shown).toEqual(['email', 'link']); + }); + + it('does not add an email that is the same as the username', () => { + const onCreate = vi.fn(created); + const { result } = renderHook(() => useUserProfileAddEmailController({ username: 'person@example.com', onCreate })); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onEmailAddressChange('person@example.com')); + expect(result.current.canSubmitEmail).toBe(false); + act(() => result.current.onSubmit()); + expect(onCreate).not.toHaveBeenCalled(); + expect(result.current.isPending).toBe(false); + + act(() => result.current.onEmailAddressChange('other@example.com')); + expect(result.current.canSubmitEmail).toBe(true); }); }); 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-account-section/user-profile-add-email.controller.ts index cbadbc6a0b6..b562c319a9e 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-account-section/user-profile-add-email.controller.ts @@ -7,13 +7,14 @@ import type { ErrorInvokeEvent } from '../../../machine/types'; import { useMachine } from '../../../machine/useMachine'; import type { FormError } from '../../../utils/form-error'; import { toFormError } from '../../../utils/form-error'; -import type { UserProfileEmailVerifier } from './user-profile-account-section.types'; +import type { UserProfileEmailVerification, UserProfileEmailVerifier } from './user-profile-account-section.types'; import type { UserProfileAddEmailDialogProps } from './user-profile-add-email.dialog'; export type UserProfileAddEmailField = 'emailAddress' | 'code'; export interface UserProfileAddEmailControllerOptions { initialEmailAddress?: string; + username?: string; onCreate?: (emailAddress: string) => Promise; } @@ -24,6 +25,7 @@ export interface UserProfileAddEmailController extends UserProfileAddEmailDialog interface Context { emailAddress: string; verifier: UserProfileEmailVerifier | undefined; + verification: UserProfileEmailVerification | undefined; error: FormError | undefined; resendSeconds: number; } @@ -43,12 +45,21 @@ function missingDependency(): Promise { } const CODE_RESEND_SECONDS = 12; +const LINK_RESEND_SECONDS = 60; +const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; +const fail = assign((_, event) => ({ error: toFormError(event.error) })); +const resend = { + target: 'starting', + guard: (context: Context) => context.resendSeconds === 0, + actions: assign(() => ({ error: undefined })), +}; const start = { - target: 'sending', + target: 'starting', actions: assign>((_, event) => ({ emailAddress: event.emailAddress, verifier: event.verifier, + verification: undefined, error: undefined, resendSeconds: 0, })), @@ -60,6 +71,7 @@ const machine = createMachine({ context: { emailAddress: '', verifier: undefined, + verification: undefined, error: undefined, resendSeconds: 0, }, @@ -68,7 +80,7 @@ const machine = createMachine({ on: { ADD: { target: 'email', - actions: assign(() => ({ verifier: undefined, error: undefined, resendSeconds: 0 })), + actions: assign(() => ({ verifier: undefined, verification: undefined, error: undefined, resendSeconds: 0 })), }, START: start, }, @@ -76,39 +88,60 @@ const machine = createMachine({ email: { on: { CANCEL: 'idle', START: start }, }, + starting: { + entry: assign(context => ({ + verification: context.verifier ? context.verifier.start() : undefined, + error: undefined, + })), + always: ({ context }) => { + if (context.verification?.method === 'code') { + return { target: 'sending' }; + } + return { + target: 'waiting', + context: { resendSeconds: context.verification?.method === 'link' ? LINK_RESEND_SECONDS : 0 }, + }; + }, + }, sending: { - invoke: fromPromise(context => (context.verifier ? context.verifier.start().sent : missingDependency()), { - onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: toFormError(event.error) })), + invoke: fromPromise( + context => (context.verification?.method === 'code' ? context.verification.sent : missingDependency()), + { + onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, + onError: { target: 'verify', actions: fail }, }, - }), + ), }, - verify: { - on: { - CANCEL: 'idle', - TICK: { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }, - RESEND: { - target: 'sending', - guard: context => context.resendSeconds === 0, - actions: assign(() => ({ error: undefined })), + waiting: { + on: { CANCEL: 'idle', TICK: tick, RESEND: resend }, + invoke: fromPromise( + context => + context.verification && context.verification.method !== 'code' + ? context.verification.verified + : missingDependency(), + { + onDone: 'idle', + onError: { actions: fail }, }, - VERIFIED: 'idle', - }, + ), + }, + verify: { + on: { CANCEL: 'idle', TICK: tick, RESEND: resend, VERIFIED: 'idle' }, }, }, }); export function useUserProfileAddEmailController({ initialEmailAddress = '', + username, onCreate, }: UserProfileAddEmailControllerOptions): UserProfileAddEmailController { const errorText = useErrorText(); const [snapshot, send, actor] = useMachine(machine); - const { resendSeconds, error } = snapshot.context; + const { resendSeconds, error, verification } = snapshot.context; const emailForm = useForm({ initialValues: { emailAddress: initialEmailAddress }, + canSubmit: values => !username || values.emailAddress !== username, onSubmit: async ({ emailAddress }) => { if (onCreate) { send({ type: 'START', emailAddress, verifier: await onCreate(emailAddress) }); @@ -127,6 +160,8 @@ export function useUserProfileAddEmailController({ }); const state = snapshot.value; const open = state !== 'idle'; + const waitingFor = state === 'waiting' && verification?.method !== 'code' ? verification : undefined; + useEffect(() => waitingFor?.cancel, [waitingFor]); useEffect(() => { if (!open || resendSeconds === 0) { return; @@ -135,7 +170,7 @@ export function useUserProfileAddEmailController({ return () => clearTimeout(timer); }, [open, resendSeconds, send]); - const step = state === 'email' ? 'email' : 'verify'; + const step = toStep(state, verification); const form = step === 'email' ? emailForm : codeForm; const formError = step === 'email' ? emailForm.fields.emailAddress.feedback : codeForm.fields.code.feedback; const machineError = error?.global ? errorText(error.global) : undefined; @@ -181,9 +216,24 @@ export function useUserProfileAddEmailController({ send({ type: 'RESEND' }); } }, + onConnect: () => { + if (verification?.method === 'sso') { + verification.connect(); + } + }, onVerifyEmail: (emailAddress, verifier) => { codeForm.reset(); send({ type: 'START', emailAddress, verifier }); }, }; } + +function toStep( + state: string, + verification: UserProfileEmailVerification | undefined, +): UserProfileAddEmailDialogProps['step'] { + if (state === 'email') { + return 'email'; + } + return verification && verification.method !== 'code' ? verification.method : 'verify'; +} 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-account-section/user-profile-add-email.dialog.tsx index 923d78ceea3..b23ec96f671 100644 --- 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-account-section/user-profile-add-email.dialog.tsx @@ -9,17 +9,26 @@ import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog import { Dialog } from '../../../components/dialog'; import { Field } from '../../../components/field'; import { Flow, useFlowAutoFocus } from '../../../components/flow'; +import { Icon } from '../../../components/icon'; import { Input } from '../../../components/input'; +import { Item } from '../../../components/item'; import { Otp } from '../../../components/otp'; +import { Text } from '../../../components/text'; import { fill, rich, useMessages } from '../../../localization'; -import { styles } from '../user-profile-profile-panel.styles'; +import { styles as panelStyles } from '../user-profile-profile-panel.styles'; + +const styles = stylex.create({ + ssoConnection: { + paddingInline: 0, + }, +}); export interface UserProfileAddEmailDialogProps { open: boolean; onOpenChange: (open: boolean) => void; handle?: DialogHandle; finalFocus?: DialogFocusTarget; - step: 'email' | 'verify'; + step: 'email' | 'verify' | 'link' | 'sso'; emailAddress: string; onEmailAddressChange: (value: string) => void; canSubmitEmail?: boolean; @@ -27,6 +36,7 @@ export interface UserProfileAddEmailDialogProps { onCodeChange: (value: string) => void; onSubmit: (code?: string) => void; onResend: () => void; + onConnect: () => void; isPending?: boolean; errorMessage?: string; isResending?: boolean; @@ -45,7 +55,7 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) > + + + + + + {props.errorMessage ? {props.errorMessage} : null} - + @@ -255,3 +267,149 @@ function VerifyEmailStep(props: VerifyEmailStepProps) { ); } + +interface VerifyLinkStepProps { + emailAddress: string; + onResend: () => void; + errorMessage?: string; + resendSeconds?: number; +} + +function VerifyLinkStep(props: VerifyLinkStepProps) { + const m = useMessages('userProfileAddEmail'); + + return ( + <> + + {m.link.title} + {fill(m.link.description, { emailAddress: props.emailAddress })} + + + {props.errorMessage ? ( + + {props.errorMessage} + + ) : null} + + + + + } + > + {m.link.cancel} + + + + ); +} + +interface VerifySsoStepProps { + emailAddress: string; + onConnect: () => void; + errorMessage?: string; +} + +function VerifySsoStep(props: VerifySsoStepProps) { + const m = useMessages('userProfileAddEmail'); + const domain = props.emailAddress.split('@')[1] ?? props.emailAddress; + + return ( + <> + + {m.sso.title} + {fill(m.sso.description, { emailAddress: props.emailAddress })} + + + {props.errorMessage ? ( + + {props.errorMessage} + + ) : null} + + + {domain} + {m.sso.connection} + + + + + + + + + } + > + {m.sso.cancel} + + + + ); +} + +interface ResendButtonProps { + labels: { resend: string; resending?: string; resendCountdown: string }; + onResend: () => void; + disabled?: boolean; + isResending?: boolean; + resendSeconds?: number; +} + +function ResendButton(props: ResendButtonProps) { + const resendSeconds = props.resendSeconds ?? 0; + + return ( + + ); +} 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-account-section/user-profile-add-email.messages.ts index 4ea7a8340a1..7cd6329823a 100644 --- 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-account-section/user-profile-add-email.messages.ts @@ -17,4 +17,18 @@ export const userProfileAddEmailMessages = { resending: 'Sending code…', resendCountdown: 'Didn’t receive a code? Resend ({seconds})', }, + link: { + title: 'Verify your email', + description: 'Open the link we sent to {emailAddress}', + cancel: 'Cancel', + resend: 'Didn’t receive a link? Resend', + resendCountdown: 'Didn’t receive a link? Resend ({seconds})', + }, + sso: { + title: 'Verify your email', + description: 'Connect below to verify {emailAddress}', + connection: 'Enterprise SSO', + connect: 'Connect', + cancel: 'Cancel', + }, } as const; 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 index 35ca5aaaace..984682dbdfa 100644 --- 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 @@ -16,6 +16,7 @@ import { useUserProfileSetPrimaryController } from './user-profile-set-primary.c export interface UserProfileEmailRowViewProps { emails: UserProfileEmail[]; + username?: string; allowMultipleAccounts?: boolean; onAddEmail?: () => void; onCreateEmail?: (emailAddress: string) => Promise; @@ -28,6 +29,7 @@ export interface UserProfileEmailRowViewProps { export function UserProfileEmailRowView({ emails, + username, allowMultipleAccounts = false, onAddEmail, onCreateEmail, @@ -44,7 +46,10 @@ export function UserProfileEmailRowView({ onRemove: onRemoveEmail, fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, }); - const verification = useUserProfileAddEmailController({ onCreate: onCreateEmail }); + const verification = useUserProfileAddEmailController({ + username, + onCreate: onCreateEmail, + }); const verificationDialog = useMemo(() => Dialog.createHandle(), []); const canVerify = Boolean(getEmailVerifier); const addEmailLabel = ( diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx deleted file mode 100644 index daac5c2021f..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { Banner } from '../../../components/banner'; -import { Button } from '../../../components/button'; -import { Card } from '../../../components/card'; -import type { DialogTriggerProps } from '../../../components/dialog'; -import { Dialog } from '../../../components/dialog'; -import { Spinner } from '../../../components/spinner'; -import { Text } from '../../../components/text'; -import { fill, rich, useMessages } from '../../../localization'; -import { styles as profileStyles } from '../user-profile-profile-panel.styles'; -import { styles } from './user-profile-verify-email-link.styles'; - -export interface UserProfileVerifyEmailLinkDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; - emailAddress: string; - onResend: () => void; - isResending?: boolean; - resendSeconds?: number; - errorMessage?: string; -} - -export function UserProfileVerifyEmailLinkDialog({ - open, - onOpenChange, - trigger, - emailAddress, - onResend, - isResending = false, - resendSeconds = 0, - errorMessage, -}: UserProfileVerifyEmailLinkDialogProps) { - const m = useMessages('userProfileVerifyEmailLink'); - return ( - - {trigger ? : null} - - - - {m.title} - - - {errorMessage ? ( - - {errorMessage} - - ) : null} -
- - {m.waiting} -
-
- {fill(m.description, { emailAddress })} - -
-
- - - } - > - {m.cancel} - - -
-
-
- ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts deleted file mode 100644 index 639391a44db..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const userProfileVerifyEmailLinkMessages = { - title: 'Verify email address', - description: 'A verification link was sent to {emailAddress}', - waiting: 'Check your email', - resend: 'Didn’t receive a link? Resend', - resendCountdown: 'Didn’t receive a link? Resend ({seconds})', - resending: 'Sending a new link…', - cancel: 'Cancel', -} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts deleted file mode 100644 index 7a99ffce15c..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-link.styles.ts +++ /dev/null @@ -1,23 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { fontWeightVars, space } from '../../../tokens.stylex'; - -export const styles = stylex.create({ - content: { - gap: space['2.5'], - }, - status: { - gap: space['2.5'], - alignItems: 'flex-start', - display: 'flex', - flexDirection: 'column', - }, - details: { - display: 'grid', - justifyItems: 'start', - overflowWrap: 'anywhere', - }, - emphasis: { - fontWeight: fontWeightVars['--cl-font-medium'], - }, -}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx deleted file mode 100644 index c8207c2707f..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Banner } from '../../../components/banner'; -import { Button } from '../../../components/button'; -import { Card } from '../../../components/card'; -import type { DialogTriggerProps } from '../../../components/dialog'; -import { Dialog } from '../../../components/dialog'; -import { Icon } from '../../../components/icon'; -import { Item } from '../../../components/item'; -import { Spinner } from '../../../components/spinner'; -import { fill, useMessages } from '../../../localization'; -import { UserProfileProviderIcon } from '../user-profile-provider-icon'; -import { styles } from './user-profile-verify-email-sso.styles'; - -export interface UserProfileVerifyEmailSsoDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; - emailAddress: string; - connection: { - provider: string; - domain: string; - iconUrl?: string; - }; - onConnect: () => void; - isConnecting?: boolean; - errorMessage?: string; -} - -export function UserProfileVerifyEmailSsoDialog({ - open, - onOpenChange, - trigger, - emailAddress, - connection, - onConnect, - isConnecting = false, - errorMessage, -}: UserProfileVerifyEmailSsoDialogProps) { - const m = useMessages('userProfileVerifyEmailSso'); - return ( - - {trigger ? : null} - - - - {m.title} - {fill(m.description, { emailAddress })} - - - {errorMessage ? ( - - {errorMessage} - - ) : null} - - {connection.iconUrl ? : null} - - {connection.provider} - {fill(m.connectionDescription, { domain: connection.domain })} - - - - - - - - - } - > - {m.cancel} - - - - - - ); -} diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages.ts deleted file mode 100644 index 901ec8c3d8d..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const userProfileVerifyEmailSsoMessages = { - title: 'Verify email address', - description: 'Connect below to verify {emailAddress}', - connectionDescription: '{domain} · Enterprise SSO', - connect: 'Connect', - cancel: 'Cancel', -} as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts deleted file mode 100644 index c9e34d58858..00000000000 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.styles.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -export const styles = stylex.create({ - connection: { - paddingInline: 0, - }, -}); diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 6e4a6cae586..f5b0b96a5c5 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -13,8 +13,6 @@ import { userProfileMessages } from '../features/user-profile/user-profile.messa 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 { userProfileVerifyEmailLinkMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-link.messages'; -import { userProfileVerifyEmailSsoMessages } from '../features/user-profile/user-profile-account-section/user-profile-verify-email-sso.messages'; import { userProfileActiveDevicesMessages } from '../features/user-profile/user-profile-active-devices.messages'; import { userProfileAddAuthenticatorMessages } from '../features/user-profile/user-profile-add-authenticator.messages'; import { userProfileAddSmsMessages } from '../features/user-profile/user-profile-add-sms.messages'; @@ -48,8 +46,6 @@ export const mosaicMessages = { userProfileActiveDevices: userProfileActiveDevicesMessages, userProfileAddEmail: userProfileAddEmailMessages, userProfileAddPhone: userProfileAddPhoneMessages, - userProfileVerifyEmailLink: userProfileVerifyEmailLinkMessages, - userProfileVerifyEmailSso: userProfileVerifyEmailSsoMessages, userProfileAddAuthenticator: userProfileAddAuthenticatorMessages, userProfileAddSms: userProfileAddSmsMessages, userProfileAuthenticatorSetup: userProfileAuthenticatorSetupMessages, diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index b71e0d0488f..307bad67d34 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -307,11 +307,11 @@ import { AddEmailFails as UserProfileAccountSectionAddEmailFails, AddPhoneFails as UserProfileAccountSectionAddPhoneFails, Default as UserProfileAccountSectionDefault, - EmailLinkResendFails as UserProfileAccountSectionEmailLinkResendFails, + EmailLinkFails as UserProfileAccountSectionEmailLinkFails, EmailLinkVerification as UserProfileAccountSectionEmailLinkVerification, EmailRemovalError as UserProfileAccountSectionEmailRemovalError, EmailRemovalPending as UserProfileAccountSectionEmailRemovalPending, - EmailSsoConnectFails as UserProfileAccountSectionEmailSsoConnectFails, + EmailSsoFails as UserProfileAccountSectionEmailSsoFails, EmailSsoVerification as UserProfileAccountSectionEmailSsoVerification, meta as userProfileAccountSectionMeta, MultipleAccounts as UserProfileAccountSectionMultipleAccounts, @@ -752,9 +752,9 @@ const userProfileAccountSectionModule: StoryModule = { AddPhoneFails: UserProfileAccountSectionAddPhoneFails, AddEmailFails: UserProfileAccountSectionAddEmailFails, EmailLinkVerification: UserProfileAccountSectionEmailLinkVerification, - EmailLinkResendFails: UserProfileAccountSectionEmailLinkResendFails, + EmailLinkFails: UserProfileAccountSectionEmailLinkFails, EmailSsoVerification: UserProfileAccountSectionEmailSsoVerification, - EmailSsoConnectFails: UserProfileAccountSectionEmailSsoConnectFails, + EmailSsoFails: UserProfileAccountSectionEmailSsoFails, EmailRemovalPending: UserProfileAccountSectionEmailRemovalPending, EmailRemovalError: UserProfileAccountSectionEmailRemovalError, PhoneRemovalPending: UserProfileAccountSectionPhoneRemovalPending, 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 15feb07e4ac..5235bc5fdd6 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-email.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-email.ts @@ -1,7 +1,11 @@ -import type { UserProfileEmailVerifier } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-account-section.types'; +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'; interface FixtureOptions { + method?: UserProfileEmailVerification['method']; fail?: 'create' | 'verify'; onCreated?: (id: string, emailAddress: string) => void; onVerified?: (id: string) => void; @@ -9,12 +13,52 @@ interface FixtureOptions { const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -export function createUserProfileAddEmailFixture({ fail, onCreated, onVerified }: FixtureOptions = {}): Pick< - UserProfileAccountSectionViewProps, - 'onCreateEmail' | 'getEmailVerifier' -> { +export function createUserProfileAddEmailFixture({ + method = 'code', + fail, + onCreated, + onVerified, +}: FixtureOptions = {}): Pick { + const startLink = (id: string): UserProfileEmailVerification => { + let cancelled = false; + const verified = delay(fail === 'verify' ? 2000 : 4000).then(() => { + if (fail === 'verify') { + throw new Error('This link has expired. Send a new one.'); + } + if (!cancelled) { + onVerified?.(id); + } + }); + return { method: 'link', verified, cancel: () => (cancelled = true) }; + }; + + const startSso = (id: string): UserProfileEmailVerification => { + let connect = () => undefined; + const verified = new Promise((resolve, reject) => { + connect = () => { + void delay(1200).then(() => { + if (fail === 'verify') { + reject(new Error('Unable to connect. Try again.')); + return; + } + onVerified?.(id); + resolve(); + }); + }; + }); + return { method: 'sso', verified, cancel: () => undefined, connect: () => connect() }; + }; + const verifier = (id: string): UserProfileEmailVerifier => ({ - start: () => ({ method: 'code', sent: delay(700).then(() => undefined) }), + start: () => { + if (method === 'link') { + return startLink(id); + } + if (method === 'sso') { + return startSso(id); + } + return { method: 'code', sent: delay(700).then(() => undefined) }; + }, verifyCode: async code => { await delay(700); if (fail === 'verify' || code === '000000') { diff --git a/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts b/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts deleted file mode 100644 index 4376dee68f5..00000000000 --- a/packages/swingset/src/stories/fixtures/user-profile-verify-email-link.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useChaosFixture } from '@/components/ChaosProvider'; -import { chaosEmail } from '@/lib/chaos'; - -export function useUserProfileVerifyEmailLinkFixture({ failResend = false } = {}) { - const emailAddress = useChaosFixture('example@email.com', () => chaosEmail(0)); - const [open, setOpen] = useState(false); - const [resendSeconds, setResendSeconds] = useState(12); - const [isResending, setIsResending] = useState(false); - const [errorMessage, setErrorMessage] = useState(); - - useEffect(() => { - if (!open) { - return; - } - if (isResending) { - const timer = setTimeout(() => { - setIsResending(false); - if (failResend) { - setErrorMessage('Unable to send the verification link. Try again.'); - } else { - setResendSeconds(12); - } - }, 700); - return () => clearTimeout(timer); - } - if (resendSeconds > 0) { - const timer = setTimeout(() => setResendSeconds(seconds => seconds - 1), 1000); - return () => clearTimeout(timer); - } - }, [open, isResending, resendSeconds, failResend]); - - return { - open, - emailAddress, - resendSeconds, - isResending, - errorMessage, - onOpenChange: (value: boolean) => { - setOpen(value); - setResendSeconds(12); - setIsResending(false); - setErrorMessage(undefined); - }, - onResend: () => { - setErrorMessage(undefined); - setIsResending(true); - }, - }; -} diff --git a/packages/swingset/src/stories/fixtures/user-profile-verify-email-sso.ts b/packages/swingset/src/stories/fixtures/user-profile-verify-email-sso.ts deleted file mode 100644 index 5d712a794ff..00000000000 --- a/packages/swingset/src/stories/fixtures/user-profile-verify-email-sso.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useChaosFixture } from '@/components/ChaosProvider'; -import { chaosEmail, chaosText } from '@/lib/chaos'; - -const exampleConnection = { - provider: 'Okta SSO', - domain: 'acme.co', - iconUrl: 'https://img.clerk.com/static/okta.svg', -}; - -export function useUserProfileVerifyEmailSsoFixture({ failConnect = false } = {}) { - const emailAddress = useChaosFixture('example@email.com', () => chaosEmail(0)); - const connection = useChaosFixture(exampleConnection, item => ({ - ...item, - provider: chaosText(item.provider), - domain: 'engineering.eu-west.very-long-company-name.example.com', - })); - const [open, setOpen] = useState(false); - const [isConnecting, setIsConnecting] = useState(false); - const [errorMessage, setErrorMessage] = useState(); - - useEffect(() => { - if (!isConnecting) { - return; - } - const timer = setTimeout(() => { - setIsConnecting(false); - if (failConnect) { - setErrorMessage('Unable to connect to Okta. Try again.'); - } else { - setOpen(false); - } - }, 1200); - return () => clearTimeout(timer); - }, [isConnecting, failConnect]); - - return { - open, - emailAddress, - connection, - isConnecting, - errorMessage, - onOpenChange: (value: boolean) => { - setOpen(value); - setIsConnecting(false); - setErrorMessage(undefined); - }, - onConnect: () => { - setErrorMessage(undefined); - setIsConnecting(true); - }, - }; -} diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx index f27d877579d..f2b7a286f3b 100644 --- a/packages/swingset/src/stories/localization.mdx +++ b/packages/swingset/src/stories/localization.mdx @@ -90,8 +90,8 @@ is left unused, and a placeholder the values do not cover is rendered literally. "members": { "one": "{count} miembro", "other": "{count} miembros" } } }, - "userProfileVerifyEmailLink": { - "resendCountdown": "¿No recibiste el enlace? Reenviar ({seconds})" + "userProfileAddEmail": { + "link": { "resendCountdown": "¿No recibiste el enlace? Reenviar ({seconds})" } } } ``` diff --git a/packages/swingset/src/stories/localization.stories.tsx b/packages/swingset/src/stories/localization.stories.tsx index 6e1e8924cf7..6230f8a1f14 100644 --- a/packages/swingset/src/stories/localization.stories.tsx +++ b/packages/swingset/src/stories/localization.stories.tsx @@ -127,7 +127,7 @@ function Rows({ rows }: { rows: Array<[string, ReactNode]> }) { // helper its placeholders call for. function Strings() { const m = useMessages('userButton'); - const link = useMessages('userProfileVerifyEmailLink'); + const link = useMessages('userProfileAddEmail'); const locale = useLocale(); return ( 30 } })], + ['rich', rich(link.link.resendCountdown, { values: { seconds: 30 } })], ]} /> ); diff --git a/packages/swingset/src/stories/user-profile-account-section.mdx b/packages/swingset/src/stories/user-profile-account-section.mdx index ee41391ae5d..7ceb2340600 100644 --- a/packages/swingset/src/stories/user-profile-account-section.mdx +++ b/packages/swingset/src/stories/user-profile-account-section.mdx @@ -65,8 +65,9 @@ This example rejects every verification attempt so the error remains visible and ## Email-link verification -The profile uses OTP. This separate view displays an email-link verification in progress, with a -resend countdown and Cancel. Its caller supplies the address, pending state, errors, and callbacks. +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. -### Resend error +### Link error -After the countdown, resend to see the supplied error message. +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, this view presents the provider and a Connect -action. The option composes `Item` with the shared provider icon, label, description, and button. -The caller supplies the connection, pending state, errors, and callback. In this preview, Connect -simulates completion and closes the dialog. +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 supplied error message. The user can retry or cancel. +Connect to see the error. The user can retry or cancel. - + ## Email removal pending diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index db0d92f2fd9..8bb31df6224 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -1,12 +1,10 @@ -import { Button } from '@clerk/mosaic/components/button'; +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 { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; -import { UserProfileVerifyEmailLinkDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-link.dialog'; -import { UserProfileVerifyEmailSsoDialog } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-verify-email-sso.dialog'; import type { FormError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; @@ -17,8 +15,6 @@ import { createUserProfileAddEmailFixture } from './fixtures/user-profile-add-em import { createUserProfileAddPhoneFixture } from './fixtures/user-profile-add-phone'; import { useUserProfileEditNameFixture } from './fixtures/user-profile-edit-name'; import { useUserProfileEditUsernameFixture } from './fixtures/user-profile-edit-username'; -import { useUserProfileVerifyEmailLinkFixture } from './fixtures/user-profile-verify-email-link'; -import { useUserProfileVerifyEmailSsoFixture } from './fixtures/user-profile-verify-email-sso'; export { default as __source } from './user-profile-account-section.stories?raw'; @@ -38,6 +34,7 @@ function AccountSection({ failWith, usernameFailWith, failEmailVerification = false, + emailVerificationMethod, emailRemovalState, phoneRemovalState, nameManagedBy, @@ -47,6 +44,7 @@ function AccountSection({ failWith?: FormError; usernameFailWith?: FormError; failEmailVerification?: boolean; + emailVerificationMethod?: UserProfileEmailVerification['method']; emailRemovalState?: 'pending' | 'error'; phoneRemovalState?: 'pending' | 'error'; nameManagedBy?: { name: string; iconUrl?: string }; @@ -74,6 +72,7 @@ function AccountSection({ setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), }); const emailFlow = createUserProfileAddEmailFixture({ + method: emailVerificationMethod, fail: failEmailVerification ? 'verify' : undefined, onCreated: (id, value) => setEmails(current => [...current, { id, value, isDefault: false, isVerified: false }]), onVerified: id => @@ -154,69 +153,39 @@ export function AddEmailFails() { } export function EmailLinkVerification() { - const fixture = useUserProfileVerifyEmailLinkFixture(); return ( - - Verify email link - - } + ); } -export function EmailLinkResendFails() { - const fixture = useUserProfileVerifyEmailLinkFixture({ failResend: true }); +export function EmailLinkFails() { return ( - - Verify email link - - } + ); } export function EmailSsoVerification() { - const fixture = useUserProfileVerifyEmailSsoFixture(); return ( - - Verify with SSO - - } + ); } -export function EmailSsoConnectFails() { - const fixture = useUserProfileVerifyEmailSsoFixture({ failConnect: true }); +export function EmailSsoFails() { return ( - - Verify with SSO - - } + ); } From 9b514e9cafb75dcc202e3287deb6b1141c3ededd Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 15:11:56 -0400 Subject: [PATCH 33/63] feat(mosaic): wire user profile phone numbers to Clerk Add, verify, reorder, promote and remove phone numbers through the user resource instead of the mock fixtures. Covers the flow with a feature test against a faked FAPI and drops the per-layer tests it supersedes. --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 108 ++++++++++ packages/mosaic/src/__tests__/feature/fapi.ts | 53 ++++- .../__tests__/user-button.feature.test.tsx | 13 +- ...r-profile-account-section.feature.test.tsx | 166 +++++++++++++++ .../user-profile-add-phone.controller.test.ts | 179 ++++------------ ...ser-profile-add-phone.integration.test.tsx | 42 ---- .../user-profile-phone-actions.test.tsx | 6 +- .../user-profile-profile-panel.view.test.tsx | 12 +- .../user-profile-account-section.model.ts | 80 ++++--- .../user-profile-account-section.types.ts | 5 + .../user-profile-account-section.utils.ts | 15 ++ .../user-profile-account-section.view.tsx | 13 +- .../user-profile-add-phone.controller.ts | 200 ++++++++++-------- .../user-profile-add-phone.dialog.tsx | 8 +- .../user-profile-phone-row.view.tsx | 108 ++++++---- .../user-profile-profile-panel.view.tsx | 8 +- .../fixtures/user-profile-add-phone.ts | 41 ++-- .../src/stories/fixtures/user-profile.ts | 10 +- .../user-profile-account-section.stories.tsx | 14 +- 19 files changed, 673 insertions(+), 408 deletions(-) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index f622ef0d6a3..4a30f182757 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -2,7 +2,9 @@ import type { ClientJSON, OrganizationMembershipJSON, OrganizationSuggestionJSON, + PhoneNumberJSON, SessionJSON, + UserJSON, UserOrganizationInvitationJSON, } from '@clerk/shared/types'; import { http, HttpResponse, type JsonBodyType } from 'msw'; @@ -15,7 +17,9 @@ import { fapiMembership, fapiOrganization, fapiPage, + fapiPhoneNumber, fapiToken, + fapiVerification, } from './fapi'; export const PUBLISHABLE_KEY = 'pk_live_Y2xlcmsuYWJjZWYuMTIzNDUucHJvZC5sY2xjbGVyay5jb20k'; @@ -75,6 +79,58 @@ function missing() { return HttpResponse.json({ errors: [{ code: 'resource_not_found', message: 'not found' }] }, { status: 404 }); } +function rejected(code: string, message: string) { + return HttpResponse.json({ errors: [{ code, message, long_message: message }] }, { status: 400 }); +} + +export const VERIFICATION_CODE = '424242'; + +const USER_FIELDS = [ + 'first_name', + 'last_name', + 'username', + 'primary_email_address_id', + 'primary_phone_number_id', +] as const satisfies readonly (keyof UserJSON)[]; + +function patchUser(user: UserJSON, body: URLSearchParams): UserJSON { + const patch: Partial> = {}; + for (const field of USER_FIELDS) { + if (body.has(field)) { + patch[field] = body.get(field) || null; + } + } + return { ...user, ...patch }; +} + +function activeSession(state: FakeFapiState): SessionJSON | undefined { + return state.client.sessions.find(session => session.id === state.client.last_active_session_id); +} + +function updateUser(state: FakeFapiState, next: (user: UserJSON) => UserJSON): UserJSON | undefined { + const session = activeSession(state); + if (!session) { + return undefined; + } + const user = next(session.user); + state.client = { + ...state.client, + sessions: state.client.sessions.map(s => (s.id === session.id ? { ...s, user } : s)), + }; + return user; +} + +function findPhone(state: FakeFapiState, id: unknown): PhoneNumberJSON | undefined { + return activeSession(state)?.user.phone_numbers.find(phone => phone.id === id); +} + +function replacePhone(state: FakeFapiState, phone: PhoneNumberJSON) { + updateUser(state, user => ({ + ...user, + phone_numbers: user.phone_numbers.map(p => (p.id === phone.id ? phone : p)), + })); +} + export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const state: FakeFapiState = { environment: fapiEnvironment(), @@ -84,6 +140,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { suggestions: [], ...seed, }; + let identifications = 0; worker.use( http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), @@ -131,6 +188,57 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { state.client = { ...state.client, sessions: [], last_active_session_id: null }; return envelope(state.client, state.client); }), + http.post(fapiUrl('/v1/me'), async ({ request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'PATCH') { + return undefined; + } + const body = new URLSearchParams(await request.text()); + const user = updateUser(state, user => patchUser(user, body)); + return user ? envelope(user, state.client) : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers'), async ({ request }) => { + const body = new URLSearchParams(await request.text()); + const phone = fapiPhoneNumber({ id: `idn_${++identifications}`, phone_number: body.get('phone_number') ?? '' }); + const user = updateUser(state, user => ({ ...user, phone_numbers: [...user.phone_numbers, phone] })); + return user ? envelope(phone, state.client) : missing(); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id/prepare_verification'), ({ params }) => { + const phone = findPhone(state, params.id); + if (!phone) { + return missing(); + } + const prepared = { ...phone, verification: fapiVerification({ status: 'unverified' }) }; + replacePhone(state, prepared); + return envelope(prepared, state.client); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id/attempt_verification'), async ({ params, request }) => { + const phone = findPhone(state, params.id); + if (!phone) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + if (body.get('code') !== VERIFICATION_CODE) { + return rejected('form_code_incorrect', 'Incorrect code'); + } + const verified = { ...phone, verification: fapiVerification() }; + replacePhone(state, verified); + return envelope(verified, state.client); + }), + http.post(fapiUrl('/v1/me/phone_numbers/:id'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + const phone = findPhone(state, params.id); + if (!phone) { + return missing(); + } + updateUser(state, user => ({ + ...user, + phone_numbers: user.phone_numbers.filter(p => p.id !== phone.id), + primary_phone_number_id: user.primary_phone_number_id === phone.id ? null : user.primary_phone_number_id, + })); + return envelope({ object: 'phone_number', id: phone.id, deleted: true }, state.client); + }), http.get(fapiUrl('/v1/me/organization_memberships'), ({ request }) => envelope(page(state.memberships, new URL(request.url)), null), ), diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index 1473d6e67c6..ee5e25503af 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -28,7 +28,10 @@ export type FapiEnvironment = Omit; }; +export type FapiAttributeOverrides = Partial>>; + export interface FapiEnvironmentOverrides { + attributes?: FapiAttributeOverrides; auth_config?: Partial; display_config?: Partial; organization_settings?: Partial>; @@ -74,23 +77,24 @@ function attribute(overrides: Partial = {}): AttributeDataJSO }; } -function attributes(): AttributesJSON { +function attributes(overrides: FapiAttributeOverrides = {}): AttributesJSON { return { email_address: attribute({ verifications: ['email_code'], used_for_first_factor: true, first_factors: ['email_code'], verify_at_sign_up: true, + ...overrides.email_address, }), - phone_number: attribute({ enabled: false }), - username: attribute(), - first_name: attribute(), - last_name: attribute(), - password: attribute(), - web3_wallet: attribute({ enabled: false }), - authenticator_app: attribute({ enabled: false }), - backup_code: attribute({ enabled: false }), - passkey: attribute({ enabled: false }), + phone_number: attribute({ enabled: false, verifications: ['phone_code'], ...overrides.phone_number }), + username: attribute(overrides.username), + first_name: attribute(overrides.first_name), + last_name: attribute(overrides.last_name), + password: attribute(overrides.password), + web3_wallet: attribute({ enabled: false, ...overrides.web3_wallet }), + authenticator_app: attribute({ enabled: false, ...overrides.authenticator_app }), + backup_code: attribute({ enabled: false, ...overrides.backup_code }), + passkey: attribute({ enabled: false, ...overrides.passkey }), }; } @@ -182,7 +186,7 @@ export function fapiEnvironment(overrides: FapiEnvironmentOverrides = {}): FapiE ...overrides.organization_settings, }, user_settings: { - attributes: attributes(), + attributes: attributes(overrides.attributes), actions: { delete_self: true, create_organization: true }, social: {}, enterprise_sso: { enabled: false, self_serve_sso: false, self_serve_directory_sync: false }, @@ -227,6 +231,33 @@ export function fapiEmailAddress( }; } +export function fapiVerification(overrides: Partial = {}): VerificationJSON { + return { + object: 'verification', + id: 'ver_1', + status: 'verified', + strategy: 'phone_code', + verified_at_client: '', + attempts: 0, + expire_at: farFuture, + error: { code: '', message: '' }, + ...overrides, + }; +} + +export function fapiPhoneNumber( + overrides: Partial & Pick, +): PhoneNumberJSON { + return { + object: 'phone_number', + reserved_for_second_factor: false, + default_second_factor: false, + linked_to: [], + verification: null, + ...overrides, + }; +} + export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index f731379b977..5b0af791821 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1,5 +1,5 @@ import { UNSAFE_PortalProvider as PortalProvider } from '@clerk/shared/react'; -import type { CustomPage, PhoneNumberJSON, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; +import type { CustomPage, UserJSON, Web3WalletJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; @@ -12,6 +12,7 @@ import { fapiInvitation, fapiMembership, fapiOrganization, + fapiPhoneNumber, fapiSession, fapiSuggestion, fapiUser, @@ -851,15 +852,7 @@ describe('UserButton', () => { }); describe('naming the account', () => { - const phone: PhoneNumberJSON = { - object: 'phone_number', - id: 'idn_phone', - phone_number: '+15550100', - reserved_for_second_factor: false, - default_second_factor: false, - linked_to: [], - verification: null, - }; + const phone = fapiPhoneNumber({ id: 'idn_phone', phone_number: '+15550100' }); const wallet: Web3WalletJSON = { object: 'web3_wallet', id: 'idn_wallet', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx new file mode 100644 index 00000000000..f11161110ca --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -0,0 +1,166 @@ +import type { 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, serveFapi, VERIFICATION_CODE } from '../../../__tests__/feature/fake-fapi'; +import type { FapiAttributeOverrides } from '../../../__tests__/feature/fapi'; +import { + fapiClient, + fapiEmailAddress, + fapiEnvironment, + fapiPhoneNumber, + fapiSession, + fapiUser, + fapiVerification, +} from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; + +const HOME = fapiPhoneNumber({ id: 'idn_home', phone_number: '+18015550100', verification: fapiVerification() }); +const WORK = fapiPhoneNumber({ id: 'idn_work', phone_number: '+18015550199', verification: fapiVerification() }); +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', + 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) { + await actor.click(await screen.findByRole('textbox', { name: 'Verification code' })); + 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('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: 'Add 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(screen.getByRole('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('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(); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts index d7715ed1ad2..8b52ee436de 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts @@ -1,158 +1,57 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useUserProfileAddPhoneController } from '../user-profile-account-section/user-profile-add-phone.controller'; -describe('useUserProfileAddPhoneController', () => { - afterEach(() => vi.useRealTimers()); +const sendCode = vi.fn(() => Promise.resolve()); +const onCreate = vi.fn(() => Promise.resolve({ sendCode, verifyCode: () => Promise.resolve() })); - it('keeps the resend countdown running while verification is pending', async () => { - vi.useFakeTimers(); - const verification = createDeferredPromise(); - const { result } = renderHook(() => - useUserProfileAddPhoneController({ - onSend: () => Promise.resolve(), - onVerify: async () => { - await verification.promise; - }, - }), - ); - act(() => result.current.onOpenChange(true)); - await act(async () => { - result.current.onSubmit(); - await Promise.resolve(); - }); - act(() => result.current.onSubmit('123456')); - for (let second = 0; second < 12; second++) { - await act(async () => vi.advanceTimersByTimeAsync(1000)); - } - expect(result.current.resendSeconds).toBe(0); - await act(async () => { - verification.reject(new Error('Incorrect code')); - await Promise.resolve(); - }); - expect(result.current.errorMessage).toBe('Incorrect code'); - expect(result.current.resendSeconds).toBe(0); +async function sendFirstCode() { + const { result } = renderHook(() => useUserProfileAddPhoneController({ onCreate })); + act(() => result.current.onOpenChange(true)); + act(() => result.current.onPhoneNumberChange('+18015550100')); + await act(async () => { + result.current.onSubmit(); + await vi.advanceTimersByTimeAsync(0); }); + return result; +} - it('starts with the supplied phone number', () => { - const { result } = renderHook(() => - useUserProfileAddPhoneController({ - initialPhoneNumber: '+18015558181', - onSend: () => Promise.resolve(), - onVerify: () => Promise.resolve(), - }), - ); - act(() => result.current.onOpenChange(true)); - expect(result.current.phoneNumber).toBe('+18015558181'); - }); +async function waitOutCountdown() { + for (let second = 0; second < 12; second++) { + await act(async () => vi.advanceTimersByTimeAsync(1000)); + } +} - it('ignores cancellation and duplicate submissions while sending, then resets on reopen', async () => { - const request = createDeferredPromise(); - const onSend = vi.fn(async () => { - await request.promise; - }); - const { result } = renderHook(() => - useUserProfileAddPhoneController({ onSend, onVerify: () => Promise.resolve() }), - ); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onPhoneNumberChange('+18015550100')); - act(() => { - result.current.onSubmit(); - result.current.onSubmit(); - result.current.onPhoneNumberChange('+18015550200'); - result.current.onOpenChange(false); - }); - expect(result.current.open).toBe(true); - expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); - await act(async () => { - request.resolve(); - await request.promise; - }); - act(() => result.current.onCodeChange('123')); - act(() => result.current.onOpenChange(false)); - expect(result.current.open).toBe(false); - act(() => result.current.onOpenChange(true)); - expect(result.current.step).toBe('phone'); - expect(result.current.code).toBe(''); - expect(result.current.resendSeconds).toBe(0); - expect(result.current.errorMessage).toBeUndefined(); +describe('useUserProfileAddPhoneController', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); }); + afterEach(() => vi.useRealTimers()); + + it('counts the resend down once a code has been sent', async () => { + const result = await sendFirstCode(); - it('waits before resending, blocks overlapping requests, and restarts the countdown', async () => { - vi.useFakeTimers(); - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify })); - act(() => result.current.onOpenChange(true)); - await act(async () => { - result.current.onSubmit(); - await Promise.resolve(); - }); expect(result.current.resendSeconds).toBe(12); + await act(async () => vi.advanceTimersByTimeAsync(1000)); + expect(result.current.resendSeconds).toBe(11); + }); + + it('ignores a resend until the countdown runs out, then restarts it', async () => { + const result = await sendFirstCode(); + act(() => result.current.onResend()); - expect(onSend).toHaveBeenCalledTimes(1); - for (let second = 0; second < 12; second++) { - await act(async () => vi.advanceTimersByTimeAsync(1000)); - } + expect(sendCode).toHaveBeenCalledTimes(1); + + await waitOutCountdown(); expect(result.current.resendSeconds).toBe(0); - act(() => result.current.onCodeChange('123')); + await act(async () => { result.current.onResend(); - result.current.onResend(); - result.current.onSubmit('123456'); - result.current.onOpenChange(false); - await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); }); - expect(onSend).toHaveBeenCalledTimes(2); - expect(onVerify).not.toHaveBeenCalled(); - expect(result.current.open).toBe(true); - expect(result.current.code).toBe(''); + expect(sendCode).toHaveBeenCalledTimes(2); expect(result.current.resendSeconds).toBe(12); }); - it.each(['phone', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { - const operation = vi.fn().mockRejectedValueOnce(new Error('Try again')).mockResolvedValue(undefined); - const { result } = renderHook(() => - useUserProfileAddPhoneController({ - onSend: step === 'phone' ? operation : () => Promise.resolve(), - onVerify: step === 'verify' ? operation : () => Promise.resolve(), - }), - ); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onPhoneNumberChange('+18015550100')); - act(() => result.current.onSubmit()); - if (step === 'verify') { - await waitFor(() => expect(result.current.step).toBe('verify')); - act(() => result.current.onSubmit('000000')); - } - await waitFor(() => expect(result.current.errorMessage).toBe('Try again')); - expect(result.current.isPending).toBe(false); - expect(result.current.step).toBe(step); - expect(result.current.phoneNumber).toBe('+18015550100'); - if (step === 'verify') { - expect(result.current.code).toBe('000000'); - } - act(() => result.current.onSubmit()); - await waitFor(() => expect(operation).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(result.current.errorMessage).toBeUndefined()); - }); - it('sends a code, verifies the submitted code, and closes on success', async () => { - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - const { result } = renderHook(() => useUserProfileAddPhoneController({ onSend, onVerify })); - - expect(result.current.open).toBe(false); - act(() => result.current.onOpenChange(true)); - act(() => result.current.onPhoneNumberChange('+18015550100')); - act(() => result.current.onSubmit()); - expect(result.current.isPending).toBe(true); - expect(result.current.open).toBe(true); - await waitFor(() => expect(result.current.step).toBe('verify')); - expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); - - act(() => result.current.onSubmit('123456')); - await waitFor(() => expect(result.current.open).toBe(false)); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); - }); }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx deleted file mode 100644 index 38918699fe8..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.integration.test.tsx +++ /dev/null @@ -1,42 +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 '../../../MosaicProvider'; -import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; - -describe('profile add phone', () => { - it.each([false, true])( - 'owns the dialog and returns focus with multiple accounts = %s', - async allowMultipleAccounts => { - const user = userEvent.setup(); - const onSend = vi.fn(() => Promise.resolve()); - const onVerify = vi.fn(() => Promise.resolve()); - render( - - - , - ); - const trigger = screen.getByRole('button', { name: 'Add phone number' }); - await user.click(trigger); - expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeInTheDocument(); - await user.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); - await user.click(screen.getByRole('button', { name: 'Send code' })); - const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(codeInput).toHaveFocus()); - await user.keyboard('123456'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onSend).toHaveBeenCalledExactlyOnceWith('+18015550100'); - expect(onVerify).toHaveBeenCalledExactlyOnceWith('+18015550100', '123456'); - await waitFor(() => expect(trigger).toHaveFocus()); - }, - ); -}); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx index 3c9c8dfb4d3..8a1e78a595c 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -22,6 +22,8 @@ function renderPhone(overrides: Partial = {} ); } +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(); @@ -165,8 +167,8 @@ describe('phone actions', () => { username='test' emails={[]} phones={phones} - onSendPhoneCode={() => Promise.resolve()} - onVerifyPhoneCode={() => Promise.resolve()} + getPhoneVerifier={() => phoneVerifier} + onCreatePhone={() => Promise.resolve(phoneVerifier)} onRemovePhone={id => setPhones(current => current.filter(phone => phone.id !== id))} /> 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 b5cf906b150..64704459a74 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 @@ -29,6 +29,8 @@ function renderView(overrides: Partial = {}) { ); } +const phoneVerifier = { sendCode: () => Promise.resolve(), verifyCode: () => Promise.resolve() }; + describe('UserProfileProfilePanelView', () => { it('hides connected accounts when only providers without a connect callback are supplied', () => { renderView({ @@ -220,8 +222,8 @@ describe('UserProfileProfilePanelView', () => { renderView({ emails: [{ id: 'email_1', value: 'item1@clerk.dev', isDefault: true, isVerified: true }], onAddEmail: vi.fn(), - onSendPhoneCode: () => Promise.resolve(), - onVerifyPhoneCode: () => Promise.resolve(), + onCreatePhone: () => Promise.resolve(phoneVerifier), + getPhoneVerifier: () => phoneVerifier, }); const accountSection = screen.getByRole('region', { name: 'Account' }); @@ -274,7 +276,11 @@ describe('UserProfileProfilePanelView', () => { }); it('renders an actionable empty state when no phone number exists', () => { - renderView({ phones: [], onSendPhoneCode: () => Promise.resolve(), onVerifyPhoneCode: () => Promise.resolve() }); + renderView({ + phones: [], + onCreatePhone: () => Promise.resolve(phoneVerifier), + getPhoneVerifier: () => phoneVerifier, + }); const phoneSection = screen.getByRole('region', { name: 'Phone' }); const emptyState = within(phoneSection).getByText('No phone numbers added'); 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 index af50288ac9f..3faa45cbcad 100644 --- 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 @@ -1,6 +1,12 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; -import type { AttributeData, EmailAddressResource, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; +import type { + AttributeData, + EmailAddressResource, + EnterpriseAccountResource, + PhoneNumberResource, + UserResource, +} from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; import type { MosaicRouter } from '../../../hooks/useMosaicRouter'; @@ -8,15 +14,15 @@ import { useMosaicRouter } from '../../../hooks/useMosaicRouter'; import { save } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { - UserProfileEmail, UserProfileEmailVerification, UserProfileEmailVerifier, UserProfileNameAttribute, - UserProfilePhone, + UserProfilePhoneVerifier, } from './user-profile-account-section.types'; -import { isAttributeAvailable, sortByVerification } from './user-profile-account-section.utils'; +import { isAttributeAvailable, 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'; @@ -38,6 +44,10 @@ type UserProfileAccountSectionData = Pick< | 'getEmailVerifier' | 'onSetPrimaryEmail' | 'onRemoveEmail' + | 'onCreatePhone' + | 'getPhoneVerifier' + | 'onSetPrimaryPhone' + | 'onRemovePhone' | 'onProfilePictureChange' | 'onRemoveProfilePicture' | 'onSubmitName' @@ -52,13 +62,14 @@ export type UserProfileAccountSectionModel = 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 emailById(user: UserResource, id: string): EmailAddressResource { - const email = user.emailAddresses.find(email => email.id === id); - if (!email) { - throw new Error(`No email address with id ${id}`); +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 email; + return item; } function verifyRedirectUrl(userProfileUrl: string): string { @@ -132,22 +143,11 @@ function toNameAttribute(attribute: AttributeData | undefined): UserProfileNameA return { enabled: attribute?.enabled ?? false, required: attribute?.required ?? false }; } -function toEmails(user: UserResource): UserProfileEmail[] { - return sortByVerification(user.emailAddresses, user.primaryEmailAddressId).map(email => ({ - id: email.id, - value: email.emailAddress, - isDefault: email.id === user.primaryEmailAddressId, - isVerified: email.verification.status === 'verified', - })); -} - -function toPhones(user: UserResource): UserProfilePhone[] { - return sortByVerification(user.phoneNumbers, user.primaryPhoneNumberId).map(phone => ({ - id: phone.id, - value: phone.phoneNumber, - isDefault: phone.id === user.primaryPhoneNumberId, - isVerified: phone.verification.status === 'verified', - })); +function toPhoneVerifier(phone: PhoneNumberResource): UserProfilePhoneVerifier { + return { + sendCode: () => save(() => phone.prepareVerification(), ADD_PHONE_FIELDS), + verifyCode: code => save(() => phone.attemptVerification({ code }), ADD_PHONE_FIELDS), + }; } export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { @@ -173,6 +173,8 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const canCreateEmail = showEmails && !emailsImmutable && canAddIdentifications(user, enterpriseSSO.enabled); const verifiesEmailByLink = Boolean(attributes.email_address?.verifications.includes('email_link')); const showPhones = isAttributeAvailable(attributes.phone_number); + const phonesImmutable = Boolean(attributes.phone_number?.immutable); + const canCreatePhone = showPhones && !phonesImmutable && canAddIdentifications(user, enterpriseSSO.enabled); const linkRedirectUrl = verifiesEmailByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); @@ -188,8 +190,12 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, - emails: showEmails ? toEmails(user) : undefined, - phones: showPhones ? toPhones(user) : undefined, + emails: showEmails + ? toContacts(user.emailAddresses, user.primaryEmailAddressId, email => email.emailAddress) + : undefined, + phones: showPhones + ? toContacts(user.phoneNumbers, user.primaryPhoneNumberId, phone => phone.phoneNumber) + : undefined, onCreateEmail: canCreateEmail ? async emailAddress => { const request = user.createEmailAddress({ email: emailAddress }); @@ -197,9 +203,25 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return verifierFor(await request); } : undefined, - getEmailVerifier: showEmails ? id => verifierFor(emailById(user, id)) : undefined, + getEmailVerifier: showEmails ? id => verifierFor(byId(user.emailAddresses, id, 'email address')) : undefined, onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, - onRemoveEmail: showEmails && !emailsImmutable ? id => save(() => emailById(user, id).destroy()) : undefined, + onRemoveEmail: + showEmails && !emailsImmutable + ? id => save(() => byId(user.emailAddresses, id, 'email address').destroy()) + : undefined, + onCreatePhone: canCreatePhone + ? async phoneNumber => { + const request = user.createPhoneNumber({ phoneNumber }); + await save(() => request, ADD_PHONE_FIELDS); + return toPhoneVerifier(await request); + } + : undefined, + getPhoneVerifier: showPhones ? id => toPhoneVerifier(byId(user.phoneNumbers, id, 'phone number')) : undefined, + onSetPrimaryPhone: showPhones ? id => save(() => user.update({ primaryPhoneNumberId: id })) : undefined, + onRemovePhone: + showPhones && !phonesImmutable + ? id => save(() => byId(user.phoneNumbers, id, 'phone number').destroy()) + : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameManagedBy 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 index b032ea877a9..a9809bbbc99 100644 --- 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 @@ -26,3 +26,8 @@ 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-account-section/user-profile-account-section.utils.ts index 9a085302c44..86f9342b2c5 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-account-section/user-profile-account-section.utils.ts @@ -1,5 +1,7 @@ import type { AttributeData, 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); } @@ -21,3 +23,16 @@ export function sortByVerification(items: T[], primary return [...items.filter(item => item.id === primaryId), ...verified, ...pending, ...unstarted]; } + +export function toContacts( + items: T[], + primaryId: string | null, + value: (item: T) => string, +): UserProfileContact[] { + return sortByVerification(items, primaryId).map(item => ({ + id: item.id, + value: value(item), + isDefault: item.id === primaryId, + isVerified: item.verification.status === 'verified', + })); +} 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 index 9ecc9c35740..d22202ce01c 100644 --- 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 @@ -10,6 +10,7 @@ import type { 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'; @@ -52,8 +53,8 @@ export interface UserProfileAccountSectionViewProps { onVerifyEmail?: (id: string) => void; onSetPrimaryEmail?: (id: string) => void | Promise; onRemoveEmail?: (id: string) => void | Promise; - onSendPhoneCode?: (phoneNumber: string) => Promise; - onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; + onCreatePhone?: (phoneNumber: string) => Promise; + getPhoneVerifier?: (id: string) => UserProfilePhoneVerifier; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; onSetPrimaryPhone?: (id: string) => void | Promise; @@ -85,8 +86,8 @@ export function UserProfileAccountSectionView({ onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onSendPhoneCode, - onVerifyPhoneCode, + onCreatePhone, + getPhoneVerifier, onManagePhone, onVerifyPhone, onSetPrimaryPhone, @@ -98,8 +99,8 @@ export function UserProfileAccountSectionView({ Promise; - onVerify: (phoneNumber: string, code: string) => Promise; + onCreate?: (phoneNumber: string) => Promise; +} + +export interface UserProfileAddPhoneController extends UserProfileAddPhoneDialogProps { + onVerifyPhone: (phoneNumber: string, verifier: UserProfilePhoneVerifier) => void; } -interface Context extends UserProfileAddPhoneControllerOptions { +interface Context { phoneNumber: string; - code: string; - error: unknown; + verifier: UserProfilePhoneVerifier | undefined; + error: FormError | undefined; resendSeconds: number; } type Event = - | { type: 'OPEN' } + | { type: 'ADD' } + | { type: 'START'; phoneNumber: string; verifier: UserProfilePhoneVerifier } + | { type: 'VERIFIED' } | { type: 'CANCEL' } | { type: 'RESEND' } - | { type: 'TICK' } - | { type: 'TYPE_PHONE'; value: string } - | { type: 'TYPE_CODE'; value: string } - | { type: 'SUBMIT'; code?: string }; + | { type: 'TICK' }; const { createMachine, assign, fromPromise } = setup(); function missingDependency(): Promise { - return Promise.reject(new Error('Add phone callbacks are missing')); + return Promise.reject(new Error('Phone verification is missing')); } -function errorMessage(cause: unknown, fallback: string): string | undefined { - if (cause === undefined) { - return undefined; - } - return cause instanceof Error ? cause.message : fallback; -} +const CODE_RESEND_SECONDS = 12; const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; +const fail = assign((_, event) => ({ error: toFormError(event.error) })); +const start = { + target: 'sending', + actions: assign>((_, event) => ({ + phoneNumber: event.phoneNumber, + verifier: event.verifier, + error: undefined, + resendSeconds: 0, + })), +}; const machine = createMachine({ id: 'addPhone', initial: 'idle', context: { - onSend: missingDependency, - onVerify: missingDependency, phoneNumber: '', - code: '', + verifier: undefined, error: undefined, resendSeconds: 0, }, states: { idle: { on: { - OPEN: { + ADD: { target: 'phone', - actions: assign(context => ({ - phoneNumber: context.initialPhoneNumber ?? '', - code: '', - error: undefined, - resendSeconds: 0, - })), + actions: assign(() => ({ verifier: undefined, error: undefined, resendSeconds: 0 })), }, + START: start, }, }, phone: { - on: { - CANCEL: 'idle', - TYPE_PHONE: { actions: assign((_, event) => ({ phoneNumber: event.value, error: undefined })) }, - SUBMIT: { target: 'sending', actions: assign(() => ({ error: undefined })) }, - }, + on: { CANCEL: 'idle', START: start }, }, sending: { - invoke: fromPromise(context => context.onSend(context.phoneNumber), { - onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, - onError: { - target: 'phone', - actions: assign((_, event) => ({ error: event.error })), - }, + invoke: fromPromise(context => (context.verifier ? context.verifier.sendCode() : missingDependency()), { + onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, + onError: { target: 'verify', actions: fail }, }), }, verify: { on: { CANCEL: 'idle', TICK: tick, + VERIFIED: 'idle', RESEND: { - target: 'resending', + target: 'sending', guard: context => context.resendSeconds === 0, actions: assign(() => ({ error: undefined })), }, - TYPE_CODE: { actions: assign((_, event) => ({ code: event.value, error: undefined })) }, - SUBMIT: { - target: 'verifying', - actions: assign((context, event) => ({ code: event.code ?? context.code, error: undefined })), - }, }, }, - resending: { - invoke: fromPromise(context => context.onSend(context.phoneNumber), { - onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: event.error })), - }, - }), - }, - verifying: { - on: { TICK: tick }, - invoke: fromPromise(context => context.onVerify(context.phoneNumber, context.code), { - onDone: 'idle', - onError: { - target: 'verify', - actions: assign((_, event) => ({ error: event.error })), - }, - }), - }, }, }); -export function useUserProfileAddPhoneController( - options: UserProfileAddPhoneControllerOptions, -): UserProfileAddPhoneDialogProps { - const m = useMessages('userProfileAddPhone'); - const [snapshot, send] = useMachine(machine, { context: options }); - const { resendSeconds } = snapshot.context; - const open = snapshot.value !== 'idle'; +export function useUserProfileAddPhoneController({ + onCreate, +}: UserProfileAddPhoneControllerOptions): UserProfileAddPhoneController { + const errorText = useErrorText(); + const [snapshot, send, actor] = useMachine(machine); + const { resendSeconds, error } = snapshot.context; + const phoneForm = useForm({ + initialValues: { phoneNumber: '' }, + onSubmit: async ({ phoneNumber }) => { + if (onCreate) { + send({ type: 'START', phoneNumber, verifier: await onCreate(phoneNumber) }); + } + }, + }); + const codeForm = useForm({ + initialValues: { code: '' }, + onSubmit: async ({ code }) => { + const { verifier } = actor.getSnapshot().context; + if (verifier) { + await verifier.verifyCode(code); + send({ type: 'VERIFIED' }); + } + }, + }); + const state = snapshot.value; + const open = state !== 'idle'; useEffect(() => { if (!open || resendSeconds === 0) { return; @@ -136,22 +132,54 @@ export function useUserProfileAddPhoneController( return () => clearTimeout(timer); }, [open, resendSeconds, send]); + const step = state === 'phone' ? 'phone' : 'verify'; + const form = step === 'phone' ? phoneForm : codeForm; + const formError = step === 'phone' ? phoneForm.fields.phoneNumber.feedback : codeForm.fields.code.feedback; + const machineError = error?.global ? errorText(error.global) : undefined; + return { resendSeconds, - isResending: snapshot.value === 'resending', + isResending: state === 'sending', open, - step: - snapshot.value === 'verify' || snapshot.value === 'verifying' || snapshot.value === 'resending' - ? 'verify' - : 'phone', - phoneNumber: snapshot.context.phoneNumber, - code: snapshot.context.code, - errorMessage: errorMessage(snapshot.context.error, m.error), - isPending: snapshot.value === 'sending' || snapshot.value === 'verifying', - onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), - onPhoneNumberChange: value => send({ type: 'TYPE_PHONE', value }), - onCodeChange: value => send({ type: 'TYPE_CODE', value }), - onSubmit: code => send({ type: 'SUBMIT', code }), - onResend: () => send({ type: 'RESEND' }), + step, + phoneNumber: step === 'phone' ? phoneForm.values.phoneNumber : snapshot.context.phoneNumber, + code: codeForm.values.code, + errorMessage: formError?.message ?? form.error ?? machineError, + isPending: form.isSubmitting, + onOpenChange: open => { + if (form.isSubmitting) { + return; + } + if (open) { + phoneForm.reset(); + codeForm.reset(); + send({ type: 'ADD' }); + } else { + send({ type: 'CANCEL' }); + } + }, + onPhoneNumberChange: value => phoneForm.setValue('phoneNumber', value), + onCodeChange: value => codeForm.setValue('code', value), + onSubmit: code => { + const state = actor.getSnapshot().value; + if (state === 'phone') { + phoneForm.submit(); + } else if (state === 'verify') { + if (code !== undefined) { + codeForm.setValue('code', code); + } + codeForm.submit(); + } + }, + onResend: () => { + if (!codeForm.isSubmitting && actor.can({ type: 'RESEND' })) { + codeForm.reset(); + send({ type: 'RESEND' }); + } + }, + onVerifyPhone: (phoneNumber, verifier) => { + codeForm.reset(); + send({ type: 'START', phoneNumber, verifier }); + }, }; } 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-account-section/user-profile-add-phone.dialog.tsx index 99e0fb56bba..bbf3b93a4be 100644 --- 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-account-section/user-profile-add-phone.dialog.tsx @@ -1,7 +1,7 @@ import { useRef } from 'react'; import { Card } from '../../../components/card'; -import type { DialogTriggerProps } from '../../../components/dialog'; +import type { DialogFocusTarget, DialogHandle } from '../../../components/dialog'; import { Dialog } from '../../../components/dialog'; import { Flow } from '../../../components/flow'; import { EnterPhoneStep, VerifyPhoneStep } from '../user-profile-phone.steps'; @@ -9,7 +9,8 @@ import { EnterPhoneStep, VerifyPhoneStep } from '../user-profile-phone.steps'; export interface UserProfileAddPhoneDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - trigger?: DialogTriggerProps['render']; + handle?: DialogHandle; + finalFocus?: DialogFocusTarget; step: 'phone' | 'verify'; phoneNumber: string; onPhoneNumberChange: (value: string) => void; @@ -28,13 +29,14 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) return ( - {props.trigger ? : null} Promise; - onVerifyPhoneCode?: (phoneNumber: string, code: string) => Promise; + onCreatePhone?: (phoneNumber: string) => Promise; + getPhoneVerifier?: (id: string) => UserProfilePhoneVerifier; onManagePhone?: (id: string) => void; onVerifyPhone?: (id: string) => void; onSetPrimaryPhone?: (id: string) => void | Promise; @@ -29,8 +29,8 @@ export interface UserProfilePhoneRowViewProps { export function UserProfilePhoneRowView({ phones, allowMultipleAccounts = false, - onSendPhoneCode, - onVerifyPhoneCode, + onCreatePhone, + getPhoneVerifier, onManagePhone, onVerifyPhone, onSetPrimaryPhone, @@ -43,13 +43,40 @@ export function UserProfilePhoneRowView({ onRemove: onRemovePhone, fallback: () => row.current?.querySelector('button:not([disabled])') ?? row.current, }); + const verification = useUserProfileAddPhoneController({ onCreate: onCreatePhone }); + const verificationDialog = useMemo(() => Dialog.createHandle(), []); + const canVerify = Boolean(getPhoneVerifier); const addPhoneAction = - onSendPhoneCode && onVerifyPhoneCode ? ( - + canVerify && onCreatePhone ? ( + + } + > + {allowMultipleAccounts ? ( + + ) : null} + {allowMultipleAccounts ? m.add : m.phone.add} + ) : undefined; + const verifyingId = useRef(undefined); + const verifyPhone = (id: string) => { + const phone = phones.find(phone => phone.id === id); + if (phone && getPhoneVerifier) { + verifyingId.current = id; + verification.onVerifyPhone(phone.value, getPhoneVerifier(id)); + } + }; const removePhoneConfirmation = useMemo(() => Confirmation.createHandle(), []); const primary = useUserProfileSetPrimaryController({ items: phones, @@ -68,15 +95,30 @@ export function UserProfilePhoneRowView({ value: stringToFormattedPhoneString(phone.value), })); + const dialog = canVerify ? ( + { + const id = verifyingId.current; + verifyingId.current = undefined; + return id ? removalFocus.trigger(id) : null; + }} + /> + ) : null; + if (!allowMultipleAccounts) { return ( - + <> + + {dialog} + ); } @@ -91,8 +133,9 @@ export function UserProfilePhoneRowView({ addAction={addPhoneAction} onRemove={onRemovePhone ? removePhone : undefined} onSetPrimary={primary.onSetPrimary} - onVerify={onVerifyPhone} + onVerify={canVerify ? verifyPhone : onVerifyPhone} /> + {dialog} {primary.error ? ( ); } - -function AddPhone({ options, compact }: { options: UserProfileAddPhoneControllerOptions; compact: boolean }) { - const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileAddPhoneController(options); - return ( - - {compact ? ( - - ) : null} - {compact ? m.add : m.phone.add} - - } - /> - ); -} 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 40585b69414..ed1efb4d04c 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 @@ -66,8 +66,8 @@ export function UserProfileProfilePanelView({ onVerifyEmail, onSetPrimaryEmail, onRemoveEmail, - onSendPhoneCode, - onVerifyPhoneCode, + onCreatePhone, + getPhoneVerifier, onManagePhone, onVerifyPhone, onSetPrimaryPhone, @@ -103,8 +103,8 @@ export function UserProfileProfilePanelView({ username={username} onAddEmail={onAddEmail} onCreateEmail={onCreateEmail} - onSendPhoneCode={onSendPhoneCode} - onVerifyPhoneCode={onVerifyPhoneCode} + onCreatePhone={onCreatePhone} + getPhoneVerifier={getPhoneVerifier} getEmailVerifier={getEmailVerifier} onManageEmail={onManageEmail} onManagePhone={onManagePhone} 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 1e995db7c3a..ac1957fa72d 100644 --- a/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts +++ b/packages/swingset/src/stories/fixtures/user-profile-add-phone.ts @@ -1,28 +1,39 @@ +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 { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; interface FixtureOptions { - failAt?: UserProfileAddPhoneDialogProps['step']; - onVerified?: (phoneNumber: string) => void; + fail?: 'create' | 'verify'; + onCreated?: (id: string, phoneNumber: string) => void; + onVerified?: (id: string) => void; } -export function createUserProfileAddPhoneFixture({ failAt, onVerified }: FixtureOptions = {}): Pick< +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +export function createUserProfileAddPhoneFixture({ fail, onCreated, onVerified }: FixtureOptions = {}): Pick< UserProfileAccountSectionViewProps, - 'onSendPhoneCode' | 'onVerifyPhoneCode' + 'onCreatePhone' | 'getPhoneVerifier' > { - return { - onSendPhoneCode: async () => { - await new Promise(resolve => setTimeout(resolve, 700)); - if (failAt === 'phone') { - throw new Error('We couldn’t send a code. Try again.'); + const verifier = (id: string): UserProfilePhoneVerifier => ({ + sendCode: () => delay(700).then(() => undefined), + verifyCode: async code => { + await delay(700); + if (fail === 'verify' || code === '000000') { + throw new Error('That code is incorrect. Try again.'); } + onVerified?.(id); }, - onVerifyPhoneCode: async (phoneNumber, code) => { - await new Promise(resolve => setTimeout(resolve, 700)); - if (failAt === 'verify' || code === '000000') { - throw new Error('That code is incorrect. Try again.'); + }); + + return { + onCreatePhone: async phoneNumber => { + await delay(700); + if (fail === 'create') { + throw new Error('We couldn’t add this phone number. Try again.'); } - onVerified?.(phoneNumber); + const id = `phone_${Date.now()}`; + onCreated?.(id, phoneNumber); + return verifier(id); }, + getPhoneVerifier: verifier, }; } diff --git a/packages/swingset/src/stories/fixtures/user-profile.ts b/packages/swingset/src/stories/fixtures/user-profile.ts index 42b68223c4b..de939dc405f 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.ts +++ b/packages/swingset/src/stories/fixtures/user-profile.ts @@ -63,6 +63,11 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions 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 => @@ -91,10 +96,7 @@ export function useUserProfileFixture({ onAddEmail }: UserProfileFixtureOptions onAddEmail, onCreateEmail: onAddEmail ? undefined : emailFlow.onCreateEmail, getEmailVerifier: onAddEmail ? undefined : emailFlow.getEmailVerifier, - ...createUserProfileAddPhoneFixture({ - onVerified: value => - setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), - }), + ...phoneFlow, onDeleteAccount: () => Promise.resolve(), onManageEmail: () => undefined, onManagePhone: () => undefined, diff --git a/packages/swingset/src/stories/user-profile-account-section.stories.tsx b/packages/swingset/src/stories/user-profile-account-section.stories.tsx index 8bb31df6224..9dc0c23a7f7 100644 --- a/packages/swingset/src/stories/user-profile-account-section.stories.tsx +++ b/packages/swingset/src/stories/user-profile-account-section.stories.tsx @@ -4,7 +4,6 @@ import type { 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 { UserProfileAddPhoneDialogProps } from '@clerk/mosaic/features/user-profile/user-profile-account-section/user-profile-add-phone.dialog'; import type { FormError } from '@clerk/mosaic/utils/form-error'; import { useState } from 'react'; @@ -30,7 +29,7 @@ export const meta: StoryMeta = { function AccountSection({ allowMultipleAccounts, - failAt, + failPhone, failWith, usernameFailWith, failEmailVerification = false, @@ -40,7 +39,7 @@ function AccountSection({ nameManagedBy, }: { allowMultipleAccounts: boolean; - failAt?: UserProfileAddPhoneDialogProps['step']; + failPhone?: 'create' | 'verify'; failWith?: FormError; usernameFailWith?: FormError; failEmailVerification?: boolean; @@ -67,9 +66,10 @@ function AccountSection({ ]); const { imageUrl, showFile, clearImage } = usePreviewImage('https://avatars.githubusercontent.com/u/51144033?v=4'); const addPhone = createUserProfileAddPhoneFixture({ - failAt, - onVerified: value => - setPhones(current => [...current, { id: `phone_${Date.now()}`, value, isDefault: false, isVerified: true }]), + 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, @@ -219,7 +219,7 @@ export function AddPhoneFails() { return ( ); } From a707a6eed769834e9248b0f56cf2abcb4015f6a2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 15:22:02 -0400 Subject: [PATCH 34/63] refactor(mosaic): drive the remaining account section actions with useForm Replace the hand-rolled in-flight guard, pending flag and error mapping in the set-primary and profile picture controllers with useForm, which already owns all three. A failure that is not a SaveError now shows the generic message and is logged, rather than rendering a raw thrown message as user copy. --- .../mosaic-user-profile-add-email-code.md | 2 - .../mosaic-user-profile-email-link-sso.md | 2 - .../mosaic-user-profile-email-wireup.md | 2 - .../user-profile-email-actions.test.tsx | 20 +++++- .../user-profile-phone-actions.test.tsx | 20 +++++- .../user-profile-picture.controller.test.ts | 8 +-- .../user-profile-account-section.messages.ts | 2 - .../user-profile-email-row.view.tsx | 1 - .../user-profile-phone-row.view.tsx | 1 - .../user-profile-picture-row.view.tsx | 12 ++-- .../user-profile-picture.controller.ts | 70 ++++++------------- .../user-profile-set-primary.controller.ts | 46 +++++------- 12 files changed, 85 insertions(+), 101 deletions(-) delete mode 100644 .changeset/mosaic-user-profile-add-email-code.md delete mode 100644 .changeset/mosaic-user-profile-email-link-sso.md delete mode 100644 .changeset/mosaic-user-profile-email-wireup.md diff --git a/.changeset/mosaic-user-profile-add-email-code.md b/.changeset/mosaic-user-profile-add-email-code.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/mosaic-user-profile-add-email-code.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/mosaic-user-profile-email-link-sso.md b/.changeset/mosaic-user-profile-email-link-sso.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/mosaic-user-profile-email-link-sso.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/mosaic-user-profile-email-wireup.md b/.changeset/mosaic-user-profile-email-wireup.md deleted file mode 100644 index a845151cc84..00000000000 --- a/.changeset/mosaic-user-profile-email-wireup.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx index 6e4afe99a66..93013742be2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-email-actions.test.tsx @@ -5,6 +5,7 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; +import { SaveError } from '../../../utils/form-error'; 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'; @@ -79,17 +80,30 @@ describe('email actions', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Add email' })).toHaveFocus()); }); - it('shows a primary update error without opening a dialog', async () => { + it('shows why the primary update failed, without opening a dialog', async () => { const user = userEvent.setup(); - const onSetPrimaryEmail = vi.fn().mockRejectedValue(new Error('Unable to update primary email.')); + const onSetPrimaryEmail = vi.fn().mockRejectedValue(new SaveError({ global: { message: 'Not verified yet.' } })); renderEmail({ onSetPrimaryEmail }); await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryEmail).toHaveBeenCalledExactlyOnceWith('email_1'); - expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary email.'); + expect(await screen.findByRole('alert')).toHaveTextContent('Not verified yet.'); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); + it('shows the generic message and logs a primary update that threw unexpectedly', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + const failure = new TypeError('boom'); + const onSetPrimaryEmail = vi.fn().mockRejectedValue(failure); + renderEmail({ onSetPrimaryEmail }); + await user.click(screen.getByRole('button', { name: 'Manage test@example.com' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); + }); + it('keeps removal pending and lets the user retry a failure in the dialog', async () => { const user = userEvent.setup(); const removal = createDeferredPromise(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx index 8a1e78a595c..4558a9cd0c7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-phone-actions.test.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { MosaicProvider } from '../../../MosaicProvider'; +import { SaveError } from '../../../utils/form-error'; 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'; @@ -210,16 +211,29 @@ describe('phone actions', () => { await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); }); - it('shows a primary update error without opening a dialog', async () => { + it('shows why the primary update failed, without opening a dialog', async () => { const user = userEvent.setup(); - const onSetPrimaryPhone = vi.fn().mockRejectedValue(new Error('Unable to update primary phone.')); + const onSetPrimaryPhone = vi.fn().mockRejectedValue(new SaveError({ global: { message: 'Not verified yet.' } })); renderPhone({ onSetPrimaryPhone }); await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); expect(onSetPrimaryPhone).toHaveBeenCalledExactlyOnceWith('phone_1'); - expect(await screen.findByRole('alert')).toHaveTextContent('Unable to update primary phone.'); + expect(await screen.findByRole('alert')).toHaveTextContent('Not verified yet.'); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); + + it('shows the generic message and logs a primary update that threw unexpectedly', async () => { + const log = vi.spyOn(console, 'error').mockImplementation(() => {}); + const user = userEvent.setup(); + const failure = new TypeError('boom'); + const onSetPrimaryPhone = vi.fn().mockRejectedValue(failure); + renderPhone({ onSetPrimaryPhone }); + await user.click(screen.getByRole('button', { name: 'Manage +1 (801) 555-0100' })); + await user.click(screen.getByRole('menuitem', { name: 'Set as primary' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Something went wrong. Please try again.'); + expect(log).toHaveBeenCalledWith(failure); + log.mockRestore(); + }); it('requires confirmation before removing a phone number', async () => { const user = userEvent.setup(); const onRemovePhone = vi.fn(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts index c5abf01570d..9fdafe3ea71 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-picture.controller.test.ts @@ -73,21 +73,21 @@ describe('useUserProfilePictureController', () => { const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.error).toEqual({ code: 'avatar_file_size_exceeded', message: 'Too big.' }); + expect(result.current.error).toBe('File size exceeds the maximum limit of 10MB. Please choose a smaller file.'); expect(result.current.isPending).toBe(false); await act(async () => result.current.onChange?.(file)); expect(result.current.error).toBeUndefined(); }); - it('shows the generic error and logs an unexpected throw', async () => { + it('shows the generic message and logs an unexpected throw', async () => { const log = vi.spyOn(console, 'error').mockImplementation(() => {}); const failure = new TypeError('boom'); const onChange = vi.fn().mockRejectedValue(failure); const { result } = renderController({ onChange }); await act(async () => result.current.onChange?.(file)); - expect(result.current.error).toEqual({ code: 'generic' }); + expect(result.current.error).toBe('Something went wrong. Please try again.'); expect(log).toHaveBeenCalledWith(failure); log.mockRestore(); }); @@ -97,7 +97,7 @@ describe('useUserProfilePictureController', () => { const { result } = renderController({ onRemove }); await act(async () => result.current.onRemove?.()); - expect(result.current.error).toEqual({ message: 'Nope.' }); + expect(result.current.error).toBe('Nope.'); }); it('shows the picked file while the upload is still running', async () => { 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-account-section/user-profile-account-section.messages.ts index e030e57cf39..85b9c96a097 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-account-section/user-profile-account-section.messages.ts @@ -54,7 +54,6 @@ export const userProfileAccountSectionMessages = { add: 'Add email', verify: 'Verify', remove: 'Remove email', - primaryError: 'Unable to set the primary email address. Try again.', removeDialog: { title: 'Remove email address?', description: '{emailAddress} will be removed from your account.', @@ -70,7 +69,6 @@ export const userProfileAccountSectionMessages = { add: 'Add phone number', verify: 'Verify phone number', remove: 'Remove phone number', - primaryError: 'Unable to set the primary phone number. Try again.', removeDialog: { title: 'Remove phone number?', description: '{phoneNumber} will be removed from your account.', 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 index 984682dbdfa..c40ff4573de 100644 --- 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 @@ -102,7 +102,6 @@ export function UserProfileEmailRowView({ const primary = useUserProfileSetPrimaryController({ items: emails, onSetPrimary: onSetPrimaryEmail, - fallbackError: m.email.primaryError, }); const removeEmail = (id: string) => { 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 index 2d9e3c9447f..edfc7602be7 100644 --- 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 @@ -81,7 +81,6 @@ export function UserProfilePhoneRowView({ const primary = useUserProfileSetPrimaryController({ items: phones, onSetPrimary: onSetPrimaryPhone, - fallbackError: m.phone.primaryError, }); const removePhone = (id: string) => { 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-account-section/user-profile-picture-row.view.tsx index b18c69fbe3d..4ccc2ff2184 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-account-section/user-profile-picture-row.view.tsx @@ -43,12 +43,12 @@ export function UserProfilePictureRowView({ const errorText = useErrorText(); const controller = useUserProfilePictureController({ onChange, onRemove }); const [rejection, setRejection] = useState(); - const error = rejection ?? controller.error; + const error = rejection ? errorText(rejection) : controller.error; const remove = controller.onRemove; const handleRemove = remove ? () => { setRejection(undefined); - return remove(); + remove(); } : undefined; const initials = name @@ -74,7 +74,7 @@ export function UserProfilePictureRowView({ const file = files[0]; if (file) { setRejection(undefined); - void controller.onChange?.(file); + controller.onChange?.(file); } }} > @@ -98,7 +98,7 @@ export function UserProfilePictureRowView({ onRemove={handleRemove} /> - {error ? errorText(error) : undefined} + {error}
); } @@ -110,7 +110,7 @@ function ProfilePictureActions({ }: { hasImage: boolean; canChange: boolean; - onRemove?: () => Promise; + onRemove?: () => void; }) { const m = useMessages('userProfileAccountSection'); const { openFilePicker } = FileUpload.useFileUpload(); @@ -121,7 +121,7 @@ function ProfilePictureActions({ } if (hasImage && onRemove) { - actions.push({ label: m.picture.remove, icon: 'x', onClick: () => void onRemove() }); + actions.push({ label: m.picture.remove, icon: 'x', onClick: onRemove }); } if (actions.length > 0) { 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-account-section/user-profile-picture.controller.ts index 765c08e981d..cb9b9b288a4 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-account-section/user-profile-picture.controller.ts @@ -1,8 +1,5 @@ -import { useRef, useState } from 'react'; - -import type { LocalizableError } from '../../../localization'; +import { useForm } from '../../../components/form'; import { FileUpload } from '../../../primitives/file-upload'; -import { toFormError } from '../../../utils/form-error'; export interface UserProfilePictureControllerOptions { onChange?: (file: File) => Promise; @@ -10,61 +7,38 @@ export interface UserProfilePictureControllerOptions { } export interface UserProfilePictureController { - onChange?: (file: File) => Promise; - onRemove?: () => Promise; + onChange?: (file: File) => void; + onRemove?: () => void; isPending: boolean; previewUrl: string | undefined; - error: LocalizableError | undefined; + error: string | undefined; } export function useUserProfilePictureController({ onChange, onRemove, }: UserProfilePictureControllerOptions): UserProfilePictureController { - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(); - const [preview, setPreview] = useState(); - const previewUrl = FileUpload.useObjectUrl(preview); - const inFlight = useRef(false); - - const run = async (action: () => Promise, revert?: () => void) => { - if (inFlight.current) { - return; - } - inFlight.current = true; - setIsPending(true); - setError(undefined); - try { - await action(); - } catch (cause) { - revert?.(); - setError(toFormError(cause).global); - } finally { - inFlight.current = false; - setIsPending(false); - } + const form = useForm<{ file: File | undefined }>({ + initialValues: { file: undefined }, + onSubmit: async ({ file }) => { + if (file) { + await onChange?.(file); + } else { + await onRemove?.(); + } + }, + }); + const previewUrl = FileUpload.useObjectUrl(form.error ? undefined : form.values.file); + const picked = (file: File | undefined) => { + form.setValue('file', file); + form.submit(); }; return { - onChange: onChange - ? file => - run( - () => { - setPreview(file); - return onChange(file); - }, - () => setPreview(undefined), - ) - : undefined, - onRemove: onRemove - ? () => - run(async () => { - await onRemove(); - setPreview(undefined); - }) - : undefined, - isPending, + onChange: onChange ? picked : undefined, + onRemove: onRemove ? () => picked(undefined) : undefined, + isPending: form.isSubmitting, previewUrl, - error, + error: form.error, }; } diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts index a0d71966e02..3dde2315c20 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-set-primary.controller.ts @@ -1,11 +1,9 @@ -import { useRef, useState } from 'react'; - +import { useForm } from '../../../components/form'; import type { UserProfileContact } from './user-profile-account-section.types'; export interface UserProfileSetPrimaryControllerOptions { items: UserProfileContact[]; onSetPrimary?: (id: string) => void | Promise; - fallbackError: string; } export interface UserProfileSetPrimaryController { @@ -16,32 +14,26 @@ export interface UserProfileSetPrimaryController { export function useUserProfileSetPrimaryController({ items, onSetPrimary, - fallbackError, }: UserProfileSetPrimaryControllerOptions): UserProfileSetPrimaryController { - const [isPending, setIsPending] = useState(false); - const [error, setError] = useState(); - const pending = useRef(false); - - const setPrimary = async (id: string) => { - const item = items.find(item => item.id === id); - if (!onSetPrimary || !item?.isVerified || item.isDefault || pending.current) { - return; - } - pending.current = true; - setIsPending(true); - setError(undefined); - try { - await onSetPrimary(id); - } catch (error) { - setError(error instanceof Error ? error.message : fallbackError); - } finally { - pending.current = false; - setIsPending(false); - } - }; + const form = useForm({ + initialValues: { id: '' }, + canSubmit: ({ id }) => { + const item = items.find(item => item.id === id); + return item?.isVerified === true && !item.isDefault; + }, + onSubmit: async ({ id }) => { + await onSetPrimary?.(id); + }, + }); return { - onSetPrimary: onSetPrimary && !isPending ? id => void setPrimary(id) : undefined, - error, + onSetPrimary: + onSetPrimary && !form.isSubmitting + ? id => { + form.setValue('id', id); + form.submit(); + } + : undefined, + error: form.error, }; } From b491318378cc5a5b88ee29ab9c6a0ef2d6d97c13 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 15:51:19 -0400 Subject: [PATCH 35/63] fix(mosaic): hide a contact row the user can neither add to nor fill An immutable email or phone attribute with no contacts listed rendered a heading and an empty description with nothing the user could act on. Resolve show, create and remove for a contact attribute in one place so the row is left out when it has nothing to list and nothing to add. --- ...r-profile-account-section.feature.test.tsx | 26 ++++++++++++ ...ofile-account-section.integration.test.tsx | 1 + .../user-profile-account-section.model.ts | 41 ++++++++----------- .../user-profile-account-section.utils.ts | 17 ++++++++ 4 files changed, 62 insertions(+), 23 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index f11161110ca..3928f4ec72e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -164,3 +164,29 @@ describe('the user profile phone numbers', () => { expect(screen.queryByRole('menuitem', { name: 'Remove phone number' })).not.toBeInTheDocument(); }); }); + +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('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/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index e26e926d863..4928b60b3cd 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -36,6 +36,7 @@ vi.mock('@clerk/shared/react', async importOriginal => { username: { enabled: true, required: false }, }, usernameSettings: { min_length: 4, max_length: 64 }, + enterpriseSSO: { enabled: false }, }, }, }), 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 index 3faa45cbcad..55d46cef1f6 100644 --- 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 @@ -19,7 +19,7 @@ import type { UserProfileNameAttribute, UserProfilePhoneVerifier, } from './user-profile-account-section.types'; -import { isAttributeAvailable, toContacts } from './user-profile-account-section.utils'; +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'; @@ -168,13 +168,10 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo const usernameImmutable = Boolean(usernameAttribute?.immutable); const showUsername = isAttributeAvailable(usernameAttribute) && !(usernameImmutable && !user.username); const nameManagedBy = toManagedBy(user.enterpriseAccounts.find(account => account.active)); - const showEmails = isAttributeAvailable(attributes.email_address); - const emailsImmutable = Boolean(attributes.email_address?.immutable); - const canCreateEmail = showEmails && !emailsImmutable && canAddIdentifications(user, enterpriseSSO.enabled); + 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 showPhones = isAttributeAvailable(attributes.phone_number); - const phonesImmutable = Boolean(attributes.phone_number?.immutable); - const canCreatePhone = showPhones && !phonesImmutable && canAddIdentifications(user, enterpriseSSO.enabled); const linkRedirectUrl = verifiesEmailByLink ? verifyRedirectUrl(environment.displayConfig.userProfileUrl) : undefined; const verifierFor = (email: EmailAddressResource) => toEmailVerifier(email, linkRedirectUrl, router); @@ -190,38 +187,36 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, - emails: showEmails + emails: emailAccess.show ? toContacts(user.emailAddresses, user.primaryEmailAddressId, email => email.emailAddress) : undefined, - phones: showPhones + phones: phoneAccess.show ? toContacts(user.phoneNumbers, user.primaryPhoneNumberId, phone => phone.phoneNumber) : undefined, - onCreateEmail: canCreateEmail + onCreateEmail: emailAccess.canCreate ? async emailAddress => { const request = user.createEmailAddress({ email: emailAddress }); await save(() => request, ADD_EMAIL_FIELDS); return verifierFor(await request); } : undefined, - getEmailVerifier: showEmails ? id => verifierFor(byId(user.emailAddresses, id, 'email address')) : undefined, - onSetPrimaryEmail: showEmails ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, - onRemoveEmail: - showEmails && !emailsImmutable - ? id => save(() => byId(user.emailAddresses, id, 'email address').destroy()) - : undefined, - onCreatePhone: canCreatePhone + getEmailVerifier: emailAccess.show ? id => verifierFor(byId(user.emailAddresses, id, 'email address')) : undefined, + onSetPrimaryEmail: emailAccess.show ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, + onRemoveEmail: emailAccess.canRemove + ? id => save(() => byId(user.emailAddresses, id, 'email address').destroy()) + : undefined, + onCreatePhone: phoneAccess.canCreate ? async phoneNumber => { const request = user.createPhoneNumber({ phoneNumber }); await save(() => request, ADD_PHONE_FIELDS); return toPhoneVerifier(await request); } : undefined, - getPhoneVerifier: showPhones ? id => toPhoneVerifier(byId(user.phoneNumbers, id, 'phone number')) : undefined, - onSetPrimaryPhone: showPhones ? id => save(() => user.update({ primaryPhoneNumberId: id })) : undefined, - onRemovePhone: - showPhones && !phonesImmutable - ? id => save(() => byId(user.phoneNumbers, id, 'phone number').destroy()) - : undefined, + getPhoneVerifier: phoneAccess.show ? id => toPhoneVerifier(byId(user.phoneNumbers, id, 'phone number')) : undefined, + onSetPrimaryPhone: phoneAccess.show ? id => save(() => user.update({ primaryPhoneNumberId: id })) : undefined, + onRemovePhone: phoneAccess.canRemove + ? id => save(() => byId(user.phoneNumbers, id, 'phone number').destroy()) + : undefined, onProfilePictureChange: file => save(() => user.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, onSubmitName: nameManagedBy 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-account-section/user-profile-account-section.utils.ts index 86f9342b2c5..ae2c51786a9 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-account-section/user-profile-account-section.utils.ts @@ -6,6 +6,23 @@ export function isAttributeAvailable(attribute: AttributeData | undefined): bool return Boolean(attribute?.enabled || attribute?.used_for_first_factor || attribute?.used_for_second_factor); } +export interface UserProfileContactAccess { + show: boolean; + canCreate: boolean; + canRemove: boolean; +} + +export function toContactAccess( + attribute: AttributeData | undefined, + count: number, + canAddIdentifications: boolean, +): UserProfileContactAccess { + const available = isAttributeAvailable(attribute); + const mutable = available && !attribute?.immutable; + const canCreate = mutable && canAddIdentifications; + return { show: available && (canCreate || count > 0), canCreate, canRemove: mutable }; +} + type Identification = { id: string; verification: Pick }; export function sortByVerification(items: T[], primaryId: string | null): T[] { From 30cae867e625ee7367eac67885fe4f1d099c1e8c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 16:01:53 -0400 Subject: [PATCH 36/63] test(mosaic): cover the name, username and picture rows with a feature test Drive them through a real Clerk against a faked FAPI, like the contact rows, instead of a hand-written useUser and useClerk mock asserting on call arguments. Serve the profile image endpoint so an upload lands on the user. --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 13 ++ ...r-profile-account-section.feature.test.tsx | 73 +++++++++- ...ofile-account-section.integration.test.tsx | 134 ------------------ 3 files changed, 85 insertions(+), 135 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 4a30f182757..000982a2d70 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -84,6 +84,7 @@ function rejected(code: string, message: string) { } export const VERIFICATION_CODE = '424242'; +export const PROFILE_IMAGE_URL = 'https://img.clerk.com/uploaded.png'; const USER_FIELDS = [ 'first_name', @@ -196,6 +197,18 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const user = updateUser(state, user => patchUser(user, body)); return user ? envelope(user, state.client) : missing(); }), + http.post(fapiUrl('/v1/me/profile_image'), async ({ request }) => { + if (new URL(request.url).searchParams.get('_method') === 'DELETE') { + const user = updateUser(state, user => ({ ...user, image_url: '', has_image: false })); + return user ? envelope({ id: 'img_1', name: null, public_url: null }, state.client) : missing(); + } + const file = (await request.formData()).get('file'); + if (!(file instanceof File)) { + return rejected('form_param_missing', 'file is required'); + } + const user = updateUser(state, user => ({ ...user, image_url: PROFILE_IMAGE_URL, has_image: true })); + return user ? envelope({ id: 'img_1', name: file.name, public_url: PROFILE_IMAGE_URL }, state.client) : missing(); + }), http.post(fapiUrl('/v1/me/phone_numbers'), async ({ request }) => { const body = new URLSearchParams(await request.text()); const phone = fapiPhoneNumber({ id: `idn_${++identifications}`, phone_number: body.get('phone_number') ?? '' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 3928f4ec72e..439eb83bfcc 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -1,9 +1,10 @@ import type { PhoneNumberJSON } from '@clerk/shared/types'; import { 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, serveFapi, VERIFICATION_CODE } from '../../../__tests__/feature/fake-fapi'; +import { type FakeFapiSeed, fapiUrl, serveFapi, VERIFICATION_CODE, worker } from '../../../__tests__/feature/fake-fapi'; import type { FapiAttributeOverrides } from '../../../__tests__/feature/fapi'; import { fapiClient, @@ -27,6 +28,7 @@ function signedIn(phones: PhoneNumberJSON[] = [], overrides: Partial { }); }); +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( + '/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' })); + + expect(await screen.findByRole('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({ diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx deleted file mode 100644 index 4928b60b3cd..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; - -let user: { - firstName: string; - lastName: string; - username: string; - imageUrl: string; - hasImage: boolean; - enterpriseAccounts: never[]; - primaryEmailAddressId: null; - primaryPhoneNumberId: null; - emailAddresses: never[]; - phoneNumbers: never[]; - setProfileImage: ReturnType; - update: ReturnType; -}; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: true, user }), - useClerk: () => ({ - __internal_environment: { - userSettings: { - attributes: { - first_name: { enabled: true, required: false }, - last_name: { enabled: true, required: false }, - username: { enabled: true, required: false }, - }, - usernameSettings: { min_length: 4, max_length: 64 }, - enterpriseSSO: { enabled: false }, - }, - }, - }), - }; -}); - -function renderSection() { - return render( - - - , - ); -} - -beforeEach(() => { - user = { - firstName: 'Preston', - lastName: 'Booth', - username: 'prestonxyz', - imageUrl: 'https://img.clerk.com/preston.png', - hasImage: false, - enterpriseAccounts: [], - primaryEmailAddressId: null, - primaryPhoneNumberId: null, - emailAddresses: [], - phoneNumbers: [], - setProfileImage: vi.fn(() => Promise.resolve({})), - update: vi.fn(() => Promise.resolve(user)), - }; -}); - -describe('UserProfileAccountSection', () => { - it('saves an edited name and closes the dialog', async () => { - const actor = userEvent.setup(); - 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'), 'Pres'); - await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(user.update).toHaveBeenCalledExactlyOnceWith({ firstName: 'Pres', lastName: 'Booth' }); - }); - - it('keeps the username dialog open on the error the server names it for', async () => { - user.update.mockRejectedValue( - new ClerkAPIResponseError('failed', { - data: [ - { - code: 'form_identifier_exists', - message: 'Taken', - long_message: 'That username is taken. Please try another.', - meta: { param_name: 'username' }, - }, - ], - status: 422, - }), - ); - const actor = userEvent.setup(); - renderSection(); - - 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(); - expect(user.update).toHaveBeenCalledExactlyOnceWith({ username: 'prestonxyz2' }); - }); - - it('uploads a picked picture and shows why it failed', async () => { - user.setProfileImage.mockRejectedValue( - new ClerkAPIResponseError('failed', { - data: [{ code: 'avatar_file_size_exceeded', message: 'Too large', long_message: 'Too large.' }], - status: 413, - }), - ); - const actor = userEvent.setup(); - const { container } = renderSection(); - const file = new File(['x'], 'me.png', { type: 'image/png' }); - - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new Error('expected a file input'); - } - await actor.upload(input, file); - - expect(await screen.findByRole('alert')).toHaveTextContent( - 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', - ); - expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); - }); -}); From ed1dd8506113cdfe0e077ec595251ab188d7d777 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 16:41:10 -0400 Subject: [PATCH 37/63] test(mosaic): cover the menu holding its items through the exit The fix shipped in #9813 without a regression test; this covers it. --- .../mosaic/src/primitives/menu/menu.test.tsx | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/mosaic/src/primitives/menu/menu.test.tsx b/packages/mosaic/src/primitives/menu/menu.test.tsx index d7fde5da50e..2d47dbc53df 100644 --- a/packages/mosaic/src/primitives/menu/menu.test.tsx +++ b/packages/mosaic/src/primitives/menu/menu.test.tsx @@ -1,5 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Popover } from '../popover'; @@ -823,6 +824,47 @@ describe('Menu', () => { }); }); + describe('popup', () => { + it('holds the items at their last frame while the popup exits', async () => { + const user = userEvent.setup(); + + function Actions() { + const [primary, setPrimary] = React.useState('home'); + return ( + + Actions + + + {primary === 'home' ? ( + setPrimary('work')} + > + Set as primary + + ) : null} + Remove + + + + ); + } + + render(); + + await user.click(screen.getByText('Actions')); + const popup = screen.getByTestId('menu-popup'); + Object.defineProperty(popup, 'getAnimations', { + value: () => [{ finished: new Promise(() => {}) }], + }); + + await user.click(screen.getByText('Set as primary')); + + expect(popup).toHaveAttribute('data-closed', ''); + expect(screen.getByText('Set as primary')).toBeInTheDocument(); + }); + }); + describe('accessibility (axe)', () => { it('has no violations when closed', async () => { const { container } = render( From 5fb1d62c0e18bc0e6e181ee30488db803fe29b63 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 16:47:31 -0400 Subject: [PATCH 38/63] fix(mosaic): keep a form's error up while the next attempt runs The error was cleared on the way into submitting and set again when the submission rejected, so retrying a form that fails the same way twice tore the message down and rebuilt it. Hold it until the attempt settles, and clear it on the one that succeeds. --- .../mosaic/src/components/form/form.machine.ts | 7 +++++-- .../mosaic/src/components/form/use-form.test.ts | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index c90577f3d88..c1639300033 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -113,7 +113,7 @@ function submitOrStay( if (isValidating(next)) { return { context: { ...patch, submitQueued: true } }; } - return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; + return { target: 'submitting', context: { ...patch, submitQueued: false } }; } function displayableFields( @@ -240,7 +240,10 @@ export function createFormMachine(deps: FormDeps ctx.onSubmit(ctx.values), { - onDone: 'editing', + onDone: { + target: 'editing', + actions: assign(() => ({ error: undefined })), + }, onError: { target: 'editing', actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index 448fafd7248..5c657017279 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -220,7 +220,7 @@ describe('useForm', () => { expect(result.current.canSubmit).toBe(false); }); - it('clears the message on the next submit', async () => { + it('holds the message until the next submit settles', async () => { let fail = true; const { result } = renderHook(() => useForm({ @@ -233,8 +233,19 @@ describe('useForm', () => { await flush(); }); expect(result.current.error).toBe('Nope'); - fail = false; + act(() => result.current.submit()); + expect(result.current.isSubmitting).toBe(true); + expect(result.current.error).toBe('Nope'); + + await act(async () => flush()); + expect(result.current.error).toBe('Nope'); + + fail = false; + await act(async () => { + result.current.submit(); + await flush(); + }); expect(result.current.error).toBeUndefined(); }); From 8367bf243669a003abcfd6488be0c5c89d4d416f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 29 Sep 2026 16:52:26 -0400 Subject: [PATCH 39/63] fix(mosaic): say a malformed email or phone is invalid in the user's words The catalog carried no entry for form_param_format_invalid, so the API's own message reached the user: "`phone_number` must be a `phone_number`." Take the wording the localizations package already ships for these two codes. --- packages/mosaic/src/localization/errors.messages.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index ce13a6a65ec..d80b964c290 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -5,6 +5,8 @@ export const errorMessages: { readonly generic: string } & Readonly Date: Wed, 30 Sep 2026 15:25:26 -0400 Subject: [PATCH 40/63] docs(mosaic): record that the SSO step cannot name its connection yet --- .../user-profile-add-email.dialog.tsx | 7 +++++++ 1 file changed, 7 insertions(+) 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-account-section/user-profile-add-email.dialog.tsx index b23ec96f671..c0ccb925cd5 100644 --- 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-account-section/user-profile-add-email.dialog.tsx @@ -341,6 +341,13 @@ function VerifySsoStep(props: VerifySsoStepProps) { {props.errorMessage} ) : null} + {/* + TODO: show the connection the way the designs do — its logo, its name as the label, and + `{domain} · Enterprise SSO` as the description. `EmailAddressResource` carries only + `matchesSsoConnection`, so the domain here is split off the address and the name and logo + are not available at all. FAPI has to name the matching connection on the email address + first, the way `EnterpriseAccountResource` already carries `enterpriseConnection`. + */} {domain} From 8b708e0ae2b313192fc71fbe0b64cea19119203a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 17:13:31 -0400 Subject: [PATCH 41/63] docs(mosaic): point the SSO step TODO at the field that will carry the connections --- .../user-profile-add-email.dialog.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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-account-section/user-profile-add-email.dialog.tsx index c0ccb925cd5..e09d3d15276 100644 --- 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-account-section/user-profile-add-email.dialog.tsx @@ -342,11 +342,13 @@ function VerifySsoStep(props: VerifySsoStepProps) { ) : null} {/* - TODO: show the connection the way the designs do — its logo, its name as the label, and - `{domain} · Enterprise SSO` as the description. `EmailAddressResource` carries only - `matchesSsoConnection`, so the domain here is split off the address and the name and logo - are not available at all. FAPI has to name the matching connection on the email address - first, the way `EnterpriseAccountResource` already carries `enterpriseConnection`. + TODO: show the connections the way the designs do — a row each, with the logo, the name as + the label, and `{domain} · Enterprise SSO` as the description. The domain here is split off + the address because `EmailAddressResource` carries only `matchesSsoConnection`, and the one + endpoint that lists connections returns their full configuration, so it is not sent to the + frontend. clerk_go#22625 adds `enterprise_connections` ({ id, name, provider, + logoPublicUrl }) to the email address — the connections sign-in would offer for it, oldest + first — which is the data to render once it lands. */} From 92f2aaede19c5ad52bcc0457ed02ed828ac35db0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 17:34:04 -0400 Subject: [PATCH 42/63] fix(mosaic): build the email link redirect from a relative user profile url display_config.user_profile_url is relative on most instances, so new URL() threw and took the whole account section down whenever the instance verifies email by link. buildURL resolves it against the current location. --- ...ser-profile-add-email.integration.test.tsx | 152 ------------------ .../user-profile-account-section.model.ts | 5 +- 2 files changed, 2 insertions(+), 155 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx deleted file mode 100644 index 3b408f913c3..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-email.integration.test.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { createDeferredPromise } from '@clerk/shared/utils'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../MosaicProvider'; -import { UserProfileProfilePanelView } from '../user-profile-profile-panel.view'; - -describe('profile add email', () => { - it.each([false, true])( - 'owns the dialog and returns focus with multiple accounts = %s', - async allowMultipleAccounts => { - const user = userEvent.setup(); - const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const); - const verifyCode = vi.fn(() => Promise.resolve()); - const onCreate = vi.fn(() => Promise.resolve({ start, verifyCode })); - render( - - ({ start, verifyCode })} - /> - , - ); - const trigger = screen.getByRole('button', { name: 'Add email' }); - await user.click(trigger); - expect(screen.getByRole('dialog', { name: 'Add email' })).toBeInTheDocument(); - await user.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); - await user.click(screen.getByRole('button', { name: 'Continue' })); - const codeInput = await screen.findByRole('textbox', { name: 'Verification code' }); - await waitFor(() => expect(codeInput).toHaveFocus()); - await user.keyboard('123456'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com'); - expect(start).toHaveBeenCalledOnce(); - expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); - await waitFor(() => expect(trigger).toHaveFocus()); - }, - ); - - it('verifies an unverified email from its menu and returns focus to the menu', async () => { - const user = userEvent.setup(); - const start = vi.fn(() => ({ method: 'code', sent: Promise.resolve() }) as const); - const verifyCode = vi.fn(() => Promise.resolve()); - const getEmailVerifier = vi.fn(() => ({ start, verifyCode })); - render( - - - , - ); - expect(screen.queryByRole('button', { name: 'Add email' })).not.toBeInTheDocument(); - const trigger = screen.getByRole('button', { name: 'Manage other@example.com' }); - await user.click(trigger); - await user.click(screen.getByRole('menuitem', { name: 'Verify' })); - const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - expect(dialog).toHaveTextContent('other@example.com'); - expect(getEmailVerifier).toHaveBeenCalledExactlyOnceWith('email_2'); - await waitFor(() => expect(start).toHaveBeenCalledOnce()); - await user.keyboard('123456'); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); - await waitFor(() => expect(trigger).toHaveFocus()); - }); - - it('waits for the emailed link, then closes and stops waiting', async () => { - const user = userEvent.setup(); - const verified = createDeferredPromise(); - const cancel = vi.fn(); - render( - - ({ - start: () => ({ method: 'link', verified: verified.promise.then(() => undefined), cancel }), - verifyCode: vi.fn(), - })} - /> - , - ); - await user.click(screen.getByRole('button', { name: 'Manage other@example.com' })); - await user.click(screen.getByRole('menuitem', { name: 'Verify' })); - const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - await waitFor(() => expect(dialog).toHaveTextContent('Open the link we sent to other@example.com')); - expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); - verified.resolve(); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(cancel).toHaveBeenCalledOnce(); - }); - - it('verifies an SSO email by connecting to the provider for its domain', async () => { - const user = userEvent.setup(); - const connect = vi.fn(); - render( - - ({ - start: () => ({ method: 'sso', verified: new Promise(() => {}), cancel: vi.fn(), connect }), - verifyCode: vi.fn(), - })} - /> - , - ); - await user.click(screen.getByRole('button', { name: 'Manage person@acme.co' })); - await user.click(screen.getByRole('menuitem', { name: 'Verify' })); - const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - await waitFor(() => expect(dialog).toHaveTextContent('acme.co')); - await user.click(screen.getByRole('button', { name: 'Connect' })); - expect(connect).toHaveBeenCalledOnce(); - }); - - it('does not add the username as an email', async () => { - const user = userEvent.setup(); - const verifier = { start: () => ({ method: 'code', sent: Promise.resolve() }) as const, verifyCode: vi.fn() }; - const onCreate = vi.fn(() => Promise.resolve(verifier)); - render( - - verifier} - /> - , - ); - await user.click(screen.getByRole('button', { name: 'Add email' })); - await user.type(screen.getByRole('textbox', { name: 'Email' }), 'person@example.com'); - expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); - }); -}); 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 index 55d46cef1f6..fdd6d45d388 100644 --- 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 @@ -1,3 +1,4 @@ +import { buildURL } from '@clerk/shared/internal/clerk-js/url'; import { getFullName } from '@clerk/shared/internal/clerk-js/user'; import { useUser } from '@clerk/shared/react'; import type { @@ -73,9 +74,7 @@ function byId(items: T[], id: string, kind: string): T } function verifyRedirectUrl(userProfileUrl: string): string { - const url = new URL(userProfileUrl); - url.hash = '/verify'; - return url.toString(); + return buildURL({ base: userProfileUrl, hashPath: '/verify' }, { stringify: true }); } function startEmailVerification( From 170bf4a8870da8f5b211f9ccddde3d271011c5d4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 17:34:11 -0400 Subject: [PATCH 43/63] test(mosaic): cover email addresses at the FAPI boundary The add-email integration test rendered the view with mocked callbacks, which tested the props between layers rather than a behavior. Feature tests drive the real Clerk against a faked FAPI instead, with the instance's verification strategy as a seed axis, so the code, link and SSO branches are each exercised end to end. --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 86 +++++++++++ .../mosaic/src/__tests__/feature/render.tsx | 3 + ...r-profile-account-section.feature.test.tsx | 140 +++++++++++++++++- 3 files changed, 227 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 000982a2d70..ef3038cb35e 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -1,5 +1,6 @@ import type { ClientJSON, + EmailAddressJSON, OrganizationMembershipJSON, OrganizationSuggestionJSON, PhoneNumberJSON, @@ -12,6 +13,7 @@ import { setupWorker } from 'msw/browser'; import { fapiClient, + fapiEmailAddress, type FapiEnvironment, fapiEnvironment, fapiMembership, @@ -132,6 +134,28 @@ function replacePhone(state: FakeFapiState, phone: PhoneNumberJSON) { })); } +function findEmail(state: FakeFapiState, id: unknown): EmailAddressJSON | undefined { + return activeSession(state)?.user.email_addresses.find(email => email.id === id); +} + +function replaceEmail(state: FakeFapiState, email: EmailAddressJSON) { + updateUser(state, user => ({ + ...user, + email_addresses: user.email_addresses.map(e => (e.id === email.id ? email : e)), + })); +} + +export function verifyEmailOutOfBand(state: FakeFapiState, id: string) { + const email = findEmail(state, id); + if (!email) { + throw new Error(`No email address ${id} to verify`); + } + replaceEmail(state, { + ...email, + verification: { ...(email.verification ?? fapiVerification()), status: 'verified' }, + }); +} + export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const state: FakeFapiState = { environment: fapiEnvironment(), @@ -209,6 +233,68 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { const user = updateUser(state, user => ({ ...user, image_url: PROFILE_IMAGE_URL, has_image: true })); return user ? envelope({ id: 'img_1', name: file.name, public_url: PROFILE_IMAGE_URL }, state.client) : missing(); }), + http.post(fapiUrl('/v1/me/email_addresses'), async ({ request }) => { + const body = new URLSearchParams(await request.text()); + const email = fapiEmailAddress({ + id: `idn_${++identifications}`, + email_address: body.get('email_address') ?? '', + }); + const user = updateUser(state, user => ({ ...user, email_addresses: [...user.email_addresses, email] })); + return user ? envelope(email, state.client) : missing(); + }), + http.get(fapiUrl('/v1/me/email_addresses/:id'), ({ params }) => { + const email = findEmail(state, params.id); + return email ? envelope(email, state.client) : missing(); + }), + http.post(fapiUrl('/v1/me/email_addresses/:id/prepare_verification'), async ({ params, request }) => { + const email = findEmail(state, params.id); + if (!email) { + return missing(); + } + const strategy = new URLSearchParams(await request.text()).get('strategy'); + if (strategy !== 'email_code' && strategy !== 'email_link' && strategy !== 'enterprise_sso') { + return rejected('strategy_invalid', `Unsupported strategy ${strategy}`); + } + const domain = email.email_address.split('@')[1] ?? 'acme.co'; + const prepared: EmailAddressJSON = { + ...email, + verification: fapiVerification({ + status: 'unverified', + strategy, + external_verification_redirect_url: strategy === 'enterprise_sso' ? `https://idp.${domain}/sso` : null, + }), + }; + replaceEmail(state, prepared); + return envelope(prepared, state.client); + }), + http.post(fapiUrl('/v1/me/email_addresses/:id/attempt_verification'), async ({ params, request }) => { + const email = findEmail(state, params.id); + if (!email) { + return missing(); + } + const body = new URLSearchParams(await request.text()); + if (body.get('code') !== VERIFICATION_CODE) { + return rejected('form_code_incorrect', 'Incorrect code'); + } + const verified: EmailAddressJSON = { ...email, verification: fapiVerification({ strategy: 'email_code' }) }; + replaceEmail(state, verified); + return envelope(verified, state.client); + }), + http.post(fapiUrl('/v1/me/email_addresses/:id'), ({ params, request }) => { + if (new URL(request.url).searchParams.get('_method') !== 'DELETE') { + return undefined; + } + const email = findEmail(state, params.id); + if (!email) { + return missing(); + } + updateUser(state, user => ({ + ...user, + email_addresses: user.email_addresses.filter(e => e.id !== email.id), + primary_email_address_id: user.primary_email_address_id === email.id ? null : user.primary_email_address_id, + })); + return envelope({ object: 'email_address', id: email.id, deleted: true }, state.client); + }), http.post(fapiUrl('/v1/me/phone_numbers'), async ({ request }) => { const body = new URLSearchParams(await request.text()); const phone = fapiPhoneNumber({ id: `idn_${++identifications}`, phone_number: body.get('phone_number') ?? '' }); diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index 3558a2df6f5..fee1a03c649 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -10,6 +10,8 @@ import { PUBLISHABLE_KEY } from './fake-fapi'; export async function renderWithClerk(ui: ReactElement) { const clerk = new Clerk(PUBLISHABLE_KEY); const navigate = vi.fn((_to: string) => Promise.resolve()); + const windowNavigate = vi.fn((_to: URL | string) => {}); + clerk.__internal_windowNavigate = windowNavigate; const wrap = (element: ReactElement) => ( { current = element; view.rerender(wrap(current)); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 439eb83bfcc..24743e7a166 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -1,10 +1,17 @@ -import type { PhoneNumberJSON } from '@clerk/shared/types'; +import type { EmailAddressJSON, PhoneNumberJSON } from '@clerk/shared/types'; import { 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, serveFapi, VERIFICATION_CODE, worker } from '../../../__tests__/feature/fake-fapi'; +import { + type FakeFapiSeed, + fapiUrl, + serveFapi, + VERIFICATION_CODE, + verifyEmailOutOfBand, + worker, +} from '../../../__tests__/feature/fake-fapi'; import type { FapiAttributeOverrides } from '../../../__tests__/feature/fapi'; import { fapiClient, @@ -167,6 +174,135 @@ describe('the user profile phone numbers', () => { }); }); +const VERIFIED = fapiVerification({ strategy: 'email_code' }); +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, verifies the code it was sent, and returns focus to the trigger', async () => { + const { actor } = await renderSection(signedInWithEmails([])); + const trigger = screen.getByRole('button', { name: 'Add email' }); + expect(emailRow()).toHaveTextContent('No email addresses added'); + + await actor.click(trigger); + 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'); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('verifies an address left unverified and returns focus to its menu', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending])); + const trigger = within(emailRow()).getByRole('button', { name: 'Manage pending@example.com' }); + 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'), + ); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + 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)); + + 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(); + + verifyEmailOutOfBand(fapi, 'idn_pending'); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 }); + }); + + 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', async () => { + const sso = fapiEmailAddress({ + id: 'idn_sso', + email_address: 'alice@acme.co', + matches_sso_connection: true, + }); + const { actor, windowNavigate } = await renderSection(signedInWithEmails([PRIMARY, sso])); + + await manageEmail(actor, 'alice@acme.co', 'Verify'); + const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); + await waitFor(() => 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)) { From 4f20686989e56835e12f0facec22b94ace85ad38 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 17:41:08 -0400 Subject: [PATCH 44/63] test(mosaic): drop the model cases the email feature tests now own Six mocked-layer cases repeated what the feature tests drive end to end against the faked FAPI. The SSO feature test now seeds an email_link instance too, so the strategy precedence stays covered where the flow is exercised. Also records why the link redirect is not routing-aware yet (#9843). --- ...r-profile-account-section.feature.test.tsx | 4 +- ...ser-profile-account-section.model.test.tsx | 74 ------------------- .../user-profile-account-section.model.ts | 7 ++ 3 files changed, 9 insertions(+), 76 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 24743e7a166..b9f9d9d2114 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -261,13 +261,13 @@ describe('the user profile email addresses', () => { 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', async () => { + 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, windowNavigate } = await renderSection(signedInWithEmails([PRIMARY, sso])); + const { actor, windowNavigate } = await renderSection(signedInWithEmails([PRIMARY, sso], verifiesByLink)); await manageEmail(actor, 'alice@acme.co', 'Verify'); const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index c7311254a02..e93f2698ae2 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -48,8 +48,6 @@ let user: { prepareVerification?: ReturnType; attemptVerification?: ReturnType; createEmailLinkFlow?: ReturnType; - matchesSsoConnection?: boolean; - createEnterpriseSSOLinkFlow?: ReturnType; }[]; phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; setProfileImage: ReturnType; @@ -61,10 +59,6 @@ let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; let enterpriseSSOEnabled: boolean; let emailLinkFlow: { startEmailLinkFlow: ReturnType; cancelEmailLinkFlow: ReturnType }; -let ssoLinkFlow: { - startEnterpriseSSOLinkFlow: ReturnType; - cancelEnterpriseSSOLinkFlow: ReturnType; -}; const navigate = vi.fn(); function attribute(overrides: Partial = {}): FakeAttribute { @@ -135,7 +129,6 @@ beforeEach(() => { environmentHydrated = true; enterpriseSSOEnabled = true; emailLinkFlow = { startEmailLinkFlow: vi.fn(() => Promise.resolve()), cancelEmailLinkFlow: vi.fn() }; - ssoLinkFlow = { startEnterpriseSSOLinkFlow: vi.fn(() => Promise.resolve()), cancelEnterpriseSSOLinkFlow: vi.fn() }; navigate.mockReset(); usernameSettings = { min_length: 4, max_length: 64 }; attributes = { @@ -163,7 +156,6 @@ beforeEach(() => { prepareVerification: vi.fn(() => Promise.resolve()), attemptVerification: vi.fn(() => Promise.resolve()), createEmailLinkFlow: vi.fn(() => emailLinkFlow), - createEnterpriseSSOLinkFlow: vi.fn(() => ssoLinkFlow), }, { id: 'email_1', @@ -258,15 +250,6 @@ describe('useUserProfileAccountSectionModel', () => { expect(model.phones?.map(phone => phone.id)).toEqual(['phone_primary', 'phone_verified', 'phone_unstarted']); }); - it('leaves out the contacts the instance does not collect', () => { - attributes.email_address = attribute({ enabled: false }); - attributes.phone_number = attribute({ enabled: false }); - const model = ready(); - - expect(model.emails).toBeUndefined(); - expect(model.phones).toBeUndefined(); - }); - describe('emails', () => { it('sets the chosen email as primary', async () => { await ready().onSetPrimaryEmail?.('email_2'); @@ -306,38 +289,6 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('sends a code to the chosen email', async () => { - const verification = ready().getEmailVerifier?.('email_2').start(); - if (verification?.method !== 'code') { - throw new Error('expected a code verification'); - } - await expect(verification.sent).resolves.toBeUndefined(); - expect(user?.emailAddresses[0]?.prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); - }); - - it('creates an email and verifies the created address before the user reloads', async () => { - const prepareVerification = vi.fn(() => Promise.resolve()); - user?.createEmailAddress.mockResolvedValue({ id: 'email_new', prepareVerification }); - const email = await ready().onCreateEmail?.('new@clerk.dev'); - expect(user?.createEmailAddress).toHaveBeenCalledWith({ email: 'new@clerk.dev' }); - email?.start(); - expect(prepareVerification).toHaveBeenCalledWith({ strategy: 'email_code' }); - }); - - it('sends a link back to the profile when the instance verifies by link', async () => { - attributes.email_address = attribute({ verifications: ['email_code', 'email_link'] }); - const verification = ready().getEmailVerifier?.('email_2').start(); - if (verification?.method !== 'link') { - throw new Error('expected a link verification'); - } - await expect(verification.verified).resolves.toBeUndefined(); - expect(emailLinkFlow.startEmailLinkFlow).toHaveBeenCalledWith({ - redirectUrl: 'https://accounts.clerk.dev/user#/verify', - }); - verification.cancel(); - expect(emailLinkFlow.cancelEmailLinkFlow).toHaveBeenCalledOnce(); - }); - it('returns the API message when the link cannot be sent', async () => { attributes.email_address = attribute({ verifications: ['email_link'] }); emailLinkFlow.startEmailLinkFlow.mockRejectedValue(apiError()); @@ -350,31 +301,6 @@ describe('useUserProfileAccountSectionModel', () => { }); }); - it('verifies an email that matches an SSO connection with the provider, even when links are on', async () => { - attributes.email_address = attribute({ verifications: ['email_link'] }); - const email = user?.emailAddresses[0]; - if (!email) { - throw new Error('email missing'); - } - email.matchesSsoConnection = true; - email.verification.externalVerificationRedirectURL = new URL('https://idp.acme.co/sso'); - const verification = ready().getEmailVerifier?.('email_2').start(); - if (verification?.method !== 'sso') { - throw new Error('expected an SSO verification'); - } - await expect(verification.verified).resolves.toBeUndefined(); - expect(ssoLinkFlow.startEnterpriseSSOLinkFlow).toHaveBeenCalledWith({ redirectUrl: window.location.href }); - verification.connect(); - expect(navigate).toHaveBeenCalledWith('https://idp.acme.co/sso'); - verification.cancel(); - expect(ssoLinkFlow.cancelEnterpriseSSOLinkFlow).toHaveBeenCalledOnce(); - }); - - it('verifies the code for the chosen email', async () => { - await ready().getEmailVerifier?.('email_2').verifyCode('123456'); - expect(user?.emailAddresses[0]?.attemptVerification).toHaveBeenCalledWith({ code: '123456' }); - }); - it('maps a wrong code onto the code field', async () => { user?.emailAddresses[0]?.attemptVerification?.mockRejectedValue(apiError('code')); await expect(rejection(ready().getEmailVerifier?.('email_2').verifyCode('000000'))).resolves.toEqual({ 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 index fdd6d45d388..d640054ffb8 100644 --- 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 @@ -73,6 +73,13 @@ function byId(items: T[], id: string, kind: string): T 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. +*/ function verifyRedirectUrl(userProfileUrl: string): string { return buildURL({ base: userProfileUrl, hashPath: '/verify' }, { stringify: true }); } From c0960f5fc68a9f97a726046b0d2e54bd9efa0c20 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 18:23:38 -0400 Subject: [PATCH 45/63] test(mosaic): pin the email link redirect to the host origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature fake ignores redirect_url, so the existing link tests only proved the URL did not throw — never that it pointed anywhere sensible. Hold the prepare_verification request and assert the built URL, which is what the relative-base fix was about. --- ...r-profile-account-section.feature.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index b9f9d9d2114..193f83dae03 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'; import { type FakeFapiSeed, fapiUrl, + holdRequests, serveFapi, VERIFICATION_CODE, verifyEmailOutOfBand, @@ -249,6 +250,25 @@ describe('the user profile email addresses', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument(), { timeout: 5000 }); }); + 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)); From c172a2b9c40eb716d356938e08ba737a88ea068d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 18:23:45 -0400 Subject: [PATCH 46/63] test(mosaic): drive the enterprise connection cases through FAPI fapiUser always seeded enterprise_accounts as empty, so disableAdditionalIdentifications and the managed-by name had only ever run against hand-built mocks. Add a fapiEnterpriseAccount factory and cover both against JSON-parsed resources, with the permissive connection as the control. --- packages/mosaic/src/__tests__/feature/fapi.ts | 42 ++++++++++++++++ ...r-profile-account-section.feature.test.tsx | 50 ++++++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index ee5e25503af..8efb6896502 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -5,6 +5,8 @@ import type { ClientJSON, DisplayConfigJSON, EmailAddressJSON, + EnterpriseAccountConnectionJSON, + EnterpriseAccountJSON, EnvironmentJSON, OAuthProviders, OrganizationJSON, @@ -258,6 +260,46 @@ export function fapiPhoneNumber( }; } +export function fapiEnterpriseAccount( + overrides: Partial & Pick, + connection: Partial = {}, +): EnterpriseAccountJSON { + const domain = overrides.email_address.split('@')[1] ?? 'acme.co'; + return { + object: 'enterprise_account', + active: true, + first_name: null, + last_name: null, + protocol: 'saml', + provider: 'saml_okta', + provider_user_id: null, + public_metadata: {}, + verification: null, + last_authenticated_at: null, + enterprise_connection_id: `entc_${overrides.id}`, + enterprise_connection: { + object: 'enterprise_account_connection', + id: `entc_${overrides.id}`, + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: false, + domain, + logo_public_url: null, + name: domain, + protocol: 'saml', + provider: 'saml_okta', + sync_user_attributes: true, + allow_organization_account_linking: false, + created_at: createdAt, + updated_at: createdAt, + enterprise_connection_id: null, + ...connection, + }, + ...overrides, + }; +} + export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 193f83dae03..ba7c70f1027 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -1,4 +1,4 @@ -import type { EmailAddressJSON, PhoneNumberJSON } from '@clerk/shared/types'; +import type { EmailAddressJSON, EnterpriseAccountConnectionJSON, PhoneNumberJSON } from '@clerk/shared/types'; import { screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -17,6 +17,7 @@ import type { FapiAttributeOverrides } from '../../../__tests__/feature/fapi'; import { fapiClient, fapiEmailAddress, + fapiEnterpriseAccount, fapiEnvironment, fapiPhoneNumber, fapiSession, @@ -409,6 +410,53 @@ describe('a contact row the user cannot add to', () => { }); }); +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()); + + expect(await screen.findByRole('button', { name: 'Add email' })).toBeInTheDocument(); + expect(within(row()).getByRole('button', { name: 'Add phone number' })).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'); From a68da7d9e56acb85bfa02d2528f279f7bb668c20 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 18:37:36 -0400 Subject: [PATCH 47/63] fix(mosaic): hold the code resend for the 30 seconds legacy does Legacy's resend button takes TimerButton's default throttle of 30s for OTP codes; Mosaic sat at 12s, letting the user hammer a send FAPI throttles on its own schedule. Email links already matched legacy at 60s and are unchanged. --- .../__tests__/user-profile-add-phone.controller.test.ts | 8 ++++---- .../user-profile-add-email.controller.test.ts | 8 ++++---- .../user-profile-add-email.controller.ts | 2 +- .../user-profile-add-phone.controller.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts index 8b52ee436de..4b9537c7ade 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-add-phone.controller.test.ts @@ -18,7 +18,7 @@ async function sendFirstCode() { } async function waitOutCountdown() { - for (let second = 0; second < 12; second++) { + for (let second = 0; second < 30; second++) { await act(async () => vi.advanceTimersByTimeAsync(1000)); } } @@ -33,9 +33,9 @@ describe('useUserProfileAddPhoneController', () => { it('counts the resend down once a code has been sent', async () => { const result = await sendFirstCode(); - expect(result.current.resendSeconds).toBe(12); + expect(result.current.resendSeconds).toBe(30); await act(async () => vi.advanceTimersByTimeAsync(1000)); - expect(result.current.resendSeconds).toBe(11); + expect(result.current.resendSeconds).toBe(29); }); it('ignores a resend until the countdown runs out, then restarts it', async () => { @@ -52,6 +52,6 @@ describe('useUserProfileAddPhoneController', () => { await vi.advanceTimersByTimeAsync(0); }); expect(sendCode).toHaveBeenCalledTimes(2); - expect(result.current.resendSeconds).toBe(12); + expect(result.current.resendSeconds).toBe(30); }); }); 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-account-section/user-profile-add-email.controller.test.ts index bf7e901b04a..94d4f91c293 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-account-section/user-profile-add-email.controller.test.ts @@ -47,7 +47,7 @@ describe('useUserProfileAddEmailController', () => { await Promise.resolve(); }); act(() => result.current.onSubmit('123456')); - for (let second = 0; second < 12; second++) { + for (let second = 0; second < 30; second++) { await act(async () => vi.advanceTimersByTimeAsync(1000)); } expect(result.current.resendSeconds).toBe(0); @@ -111,10 +111,10 @@ describe('useUserProfileAddEmailController', () => { result.current.onSubmit(); await vi.advanceTimersByTimeAsync(0); }); - expect(result.current.resendSeconds).toBe(12); + expect(result.current.resendSeconds).toBe(30); act(() => result.current.onResend()); expect(email.start).toHaveBeenCalledTimes(1); - for (let second = 0; second < 12; second++) { + for (let second = 0; second < 30; second++) { await act(async () => vi.advanceTimersByTimeAsync(1000)); } expect(result.current.resendSeconds).toBe(0); @@ -130,7 +130,7 @@ describe('useUserProfileAddEmailController', () => { expect(email.verifyCode).not.toHaveBeenCalled(); expect(result.current.open).toBe(true); expect(result.current.code).toBe(''); - expect(result.current.resendSeconds).toBe(12); + expect(result.current.resendSeconds).toBe(30); }); it.each(['email', 'verify'] as const)('keeps the %s input after failure and allows retrying', async step => { const operation = vi.fn().mockRejectedValueOnce(saveError('Try again')).mockResolvedValue(verifier()); 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-account-section/user-profile-add-email.controller.ts index b562c319a9e..e14686005f0 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-account-section/user-profile-add-email.controller.ts @@ -44,7 +44,7 @@ function missingDependency(): Promise { return Promise.reject(new Error('Email verification is missing')); } -const CODE_RESEND_SECONDS = 12; +const CODE_RESEND_SECONDS = 30; const LINK_RESEND_SECONDS = 60; const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; 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-account-section/user-profile-add-phone.controller.ts index a34e70801d7..f8c6d75144a 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-account-section/user-profile-add-phone.controller.ts @@ -41,7 +41,7 @@ function missingDependency(): Promise { return Promise.reject(new Error('Phone verification is missing')); } -const CODE_RESEND_SECONDS = 12; +const CODE_RESEND_SECONDS = 30; const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; const fail = assign((_, event) => ({ error: toFormError(event.error) })); From 4ec071ecd93527cc706d501635990383fff8c192 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 18:37:37 -0400 Subject: [PATCH 48/63] docs(mosaic): record the verify landing page and modal-state gaps Neither was tracked. The routing TODO covered picking the redirect base but not the fact that nothing in Mosaic serves the /verify the link lands on, and the SSO redirect drops the mounting mode legacy encodes for modal profiles. --- .../user-profile-account-section.model.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 index d640054ffb8..8f0c0377e63 100644 --- 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 @@ -79,6 +79,10 @@ function byId(items: T[], id: string, kind: string): T 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 }); @@ -93,6 +97,12 @@ function startEmailVerification( const { startEnterpriseSSOLinkFlow, cancelEnterpriseSSOLinkFlow } = email.createEnterpriseSSOLinkFlow(); return { method: 'sso', + /* + 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. + */ verified: save(() => startEnterpriseSSOLinkFlow({ redirectUrl: window.location.href })), cancel: cancelEnterpriseSSOLinkFlow, connect: () => { From 5b6c001c18be49d0a60133131423821a8cc76033 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 30 Sep 2026 19:20:19 -0400 Subject: [PATCH 49/63] fix(mosaic): hold the connect label while the connection runs The enterprise account row swapped its arrow icon for a spinner, so the button changed width mid-action and the spinner was decorative. SubmitButton keeps the label boxed at zero opacity, holds the width, waits out the spin delay, and announces the run as a named progressbar. --- ...-enterprise-accounts-section.view.test.tsx | 6 ++++- ...er-profile-enterprise-account-row.view.tsx | 25 ++++++++----------- ...le-enterprise-accounts-section.messages.ts | 1 + 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx index d265a034b6d..3679e791433 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-enterprise-accounts-section.view.test.tsx @@ -52,7 +52,10 @@ describe('UserProfileEnterpriseAccountsSectionView', () => { pendingConnectionId='okta' />, ); - expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toHaveAttribute('aria-busy', 'true'); + const pending = screen.getByRole('button', { name: 'Connect Acme Okta' }); + expect(pending).toHaveAttribute('aria-busy', 'true'); + expect(pending).toHaveTextContent('Connect'); + expect(screen.getByRole('progressbar', { name: 'Connecting' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeDisabled(); await user.click(screen.getByRole('button', { name: 'Connect Custom SAML' })); expect(onConnect).not.toHaveBeenCalled(); @@ -64,6 +67,7 @@ describe('UserProfileEnterpriseAccountsSectionView', () => { />, ); expect(screen.getByRole('alert')).toHaveTextContent('Unable to connect'); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Connect Acme Okta' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Connect Custom SAML' })).toBeEnabled(); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx index b37b3e4ac29..3b5b6f89528 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-account-row.view.tsx @@ -1,10 +1,9 @@ import * as stylex from '@stylexjs/stylex'; import { Badge } from '../../../components/badge'; -import { Button } from '../../../components/button'; +import { SubmitButton } from '../../../components/button'; import { Icon, IconFrame } from '../../../components/icon'; import { Section } from '../../../components/section'; -import { Spinner } from '../../../components/spinner'; import { fill, useMessages } from '../../../localization'; import { styles } from './user-profile-enterprise-accounts-section.styles'; import type { UserProfileEnterpriseAccount } from './user-profile-enterprise-accounts-section.types'; @@ -65,26 +64,24 @@ export function UserProfileEnterpriseAccountRowView({ {onConnect ? ( - + + ) : null} diff --git a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts index a601b7bada9..a947ea40940 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-enterprise-accounts-section/user-profile-enterprise-accounts-section.messages.ts @@ -2,5 +2,6 @@ export const userProfileEnterpriseAccountsMessages = { title: 'Enterprise accounts', connect: 'Connect', connectProvider: 'Connect {provider}', + connecting: 'Connecting', requiresAction: 'Requires action', } as const; From 76680cafcf9282029aa2abf28dcac851e5bf441d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 12:08:49 -0400 Subject: [PATCH 50/63] fix(mosaic): scope account-section saves to the user they were composed for The section's dialogs hold a draft across renders, so a user switch left Alice's typed name submitting against Bob. Key the view on the user id so the draft is discarded, and re-read the active user at invocation time so a save that races the switch is refused rather than misapplied. Also pass the instance's username requiredness through, so an optional username can be cleared, and stop the profile panel from substituting empty defaults for the attributes the section gates its rows on. --- ...ofile-account-section.integration.test.tsx | 5 ++ ...ser-profile-account-section.model.test.tsx | 47 +++++++++++++++ ...r-profile-edit-username.controller.test.ts | 25 ++++++-- .../user-profile-profile-panel.view.test.tsx | 15 +++++ .../user-profile-account-section.model.ts | 41 ++++++++++--- .../user-profile-account-section.tsx | 9 ++- .../user-profile-account-section.view.tsx | 3 + .../user-profile-edit-username.controller.ts | 4 +- .../user-profile-username-row.view.tsx | 16 ++++- .../user-profile-profile-panel.view.tsx | 58 +------------------ 10 files changed, 149 insertions(+), 74 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx index e26e926d863..4b862eed41e 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx @@ -8,6 +8,7 @@ import { MosaicProvider } from '../../../MosaicProvider'; import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; let user: { + id: string; firstName: string; lastName: string; username: string; @@ -28,6 +29,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: true, user }), useClerk: () => ({ + get user() { + return user; + }, __internal_environment: { userSettings: { attributes: { @@ -52,6 +56,7 @@ function renderSection() { beforeEach(() => { user = { + id: 'user_1', firstName: 'Preston', lastName: 'Booth', username: 'prestonxyz', diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx index 233ffb158e1..5485c1364b7 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx @@ -17,6 +17,7 @@ interface FakeAttribute { let isUserLoaded: boolean; let user: { + id: string; firstName: string | null; lastName: string | null; username: string | null; @@ -34,6 +35,7 @@ let user: { setProfileImage: ReturnType; update: ReturnType; } | null; +let activeUser: typeof user; let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; let usernameSettings: { min_length: number; max_length: number }; let environmentHydrated: boolean; @@ -48,6 +50,9 @@ vi.mock('@clerk/shared/react', async importOriginal => { ...actual, useUser: () => ({ isLoaded: isUserLoaded, user }), useClerk: () => ({ + get user() { + return activeUser; + }, __internal_environment: environmentHydrated ? { userSettings: { attributes, usernameSettings } } : null, }), }; @@ -100,6 +105,7 @@ beforeEach(() => { phone_number: attribute(), }; user = { + id: 'user_1', firstName: 'Preston', lastName: 'Booth', username: 'prestonxyz', @@ -116,6 +122,7 @@ beforeEach(() => { setProfileImage: vi.fn(() => Promise.resolve({})), update: vi.fn(() => Promise.resolve(user)), }; + activeUser = user; }); describe('useUserProfileAccountSectionModel', () => { @@ -133,6 +140,39 @@ describe('useUserProfileAccountSectionModel', () => { expect(renderModel()).toEqual({ status: 'hidden' }); }); + describe('the user it was rendered for', () => { + const saves: [string, (model: ReturnType) => Promise | undefined][] = [ + ['the picture upload', model => model.onProfilePictureChange?.(new File(['x'], 'me.png', { type: 'image/png' }))], + ['the picture removal', model => model.onRemoveProfilePicture?.()], + ['the name', model => model.onSubmitName?.({ firstName: 'Pres', lastName: 'B' })], + ['the username', model => model.onSubmitUsername?.('ada')], + ]; + + it('is named, so the surface holding a draft can be scoped to it', () => { + expect(ready().userId).toBe('user_1'); + }); + + it.each(saves)('refuses %s once someone else is active', async (_name, runSave) => { + const model = ready(); + activeUser = user && { ...user, id: 'user_2', update: vi.fn(), setProfileImage: vi.fn() }; + + await expect(rejection(runSave(model))).resolves.toEqual({ global: { code: 'generic' } }); + expect(user?.update).not.toHaveBeenCalled(); + expect(user?.setProfileImage).not.toHaveBeenCalled(); + expect(activeUser?.update).not.toHaveBeenCalled(); + expect(activeUser?.setProfileImage).not.toHaveBeenCalled(); + }); + + it.each(saves)('refuses %s once nobody is signed in', async (_name, runSave) => { + const model = ready(); + activeUser = null; + + await expect(rejection(runSave(model))).resolves.toEqual({ global: { code: 'generic' } }); + expect(user?.update).not.toHaveBeenCalled(); + expect(user?.setProfileImage).not.toHaveBeenCalled(); + }); + }); + it('maps the user to plain row data', () => { const model = ready(); @@ -264,6 +304,13 @@ describe('useUserProfileAccountSectionModel', () => { }); }); + it('passes on whether the instance requires one, so an optional username can be cleared', () => { + expect(ready().usernameRequired).toBe(false); + + attributes.username = attribute({ required: true }); + expect(ready().usernameRequired).toBe(true); + }); + it('is hidden when the instance does not use usernames', () => { attributes.username = attribute({ enabled: false }); expect(ready().username).toBeUndefined(); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts index 018df00b0ae..4f5b7f27fd0 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-username.controller.test.ts @@ -12,8 +12,10 @@ function deferred() { return { promise, resolve }; } -function renderController(onSubmit: (username: string) => Promise = () => Promise.resolve()) { - const { result } = renderHook(() => useUserProfileEditUsernameController({ username: 'prestonxyz', onSubmit })); +function renderController(onSubmit: (username: string) => Promise = () => Promise.resolve(), required = false) { + const { result } = renderHook(() => + useUserProfileEditUsernameController({ username: 'prestonxyz', required, onSubmit }), + ); act(() => result.current.onOpenChange(true)); return result; } @@ -31,17 +33,30 @@ describe('useUserProfileEditUsernameController', () => { expect(result.current.form.values.username).toBe('prestonxyz'); }); - it('withholds the save until the value moves, and on an empty value', () => { + it('withholds the save until the value moves', () => { const result = renderController(); expect(result.current.form.canSubmit).toBe(false); + act(() => result.current.form.setValue('username', 'ada')); + expect(result.current.form.canSubmit).toBe(true); + }); + + it('lets an optional username be cleared', () => { + const result = renderController(); + act(() => result.current.form.setValue('username', '')); - expect(result.current.form.canSubmit).toBe(false); - act(() => result.current.form.setValue('username', 'ada')); expect(result.current.form.canSubmit).toBe(true); }); + it('withholds the save on an empty value when the instance requires a username', () => { + const result = renderController(() => Promise.resolve(), true); + + act(() => result.current.form.setValue('username', '')); + + expect(result.current.form.canSubmit).toBe(false); + }); + it('saves the value it is holding, stays open while saving, then closes', async () => { const request = deferred(); const onSubmit = vi.fn(() => request.promise); 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 930f4dbd15e..b4fd4577913 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 @@ -37,6 +37,21 @@ function renderView(overrides: Partial = {}) { } describe('UserProfileProfilePanelView', () => { + it('names the connection managing the name, as the section does on its own', () => { + renderView({ nameManagedBy: { name: 'Okta' }, onSubmitName: undefined }); + + expect(screen.getByText('Managed by Okta')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Edit name' })).not.toBeInTheDocument(); + }); + + it('drops the rows the instance does not collect', () => { + renderView({ username: undefined, emails: undefined, phones: undefined }); + + expect(screen.queryByText('Username')).not.toBeInTheDocument(); + expect(screen.queryByText('item1@clerk.dev')).not.toBeInTheDocument(); + expect(screen.queryByText('+1 801-888-8181')).not.toBeInTheDocument(); + }); + it('hides connected accounts when only providers without a connect callback are supplied', () => { renderView({ connectedAccounts: [], 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 index 20b9e6cec92..4a4d927ba98 100644 --- 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 @@ -1,9 +1,10 @@ import { getFullName } from '@clerk/shared/internal/clerk-js/user'; -import { useUser } from '@clerk/shared/react'; +import { useClerk, useUser } from '@clerk/shared/react'; import type { AttributeData, EnterpriseAccountResource, UserResource } from '@clerk/shared/types'; import { useMosaicEnvironment } from '../../../hooks/useMosaicEnvironment'; -import { save } from '../../../utils/form-error'; +import type { MessageValues } from '../../../localization'; +import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/form-error'; import type { UserProfileManagedBy } from '../user-profile-managed-by'; import type { UserProfileEmail, @@ -27,6 +28,7 @@ type UserProfileAccountSectionData = Pick< | 'lastNameAttribute' | 'nameManagedBy' | 'username' + | 'usernameRequired' | 'emails' | 'phones' | 'onProfilePictureChange' @@ -38,7 +40,7 @@ type UserProfileAccountSectionData = Pick< export type UserProfileAccountSectionModel = | { status: 'loading' } | { status: 'hidden' } - | (UserProfileAccountSectionData & { status: 'ready' }); + | (UserProfileAccountSectionData & { status: 'ready'; userId: string }); const NAME_FIELDS: readonly UserProfileEditNameField[] = ['firstName', 'lastName']; const USERNAME_FIELDS: readonly UserProfileEditUsernameField[] = ['username']; @@ -79,6 +81,7 @@ function toPhones(user: UserResource): UserProfilePhone[] { export function useUserProfileAccountSectionModel(): UserProfileAccountSectionModel { const { isLoaded, user } = useUser(); + const clerk = useClerk(); const environment = useMosaicEnvironment(); if (!isLoaded || !environment) { @@ -89,6 +92,25 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'hidden' }; } + const userId = user.id; + + const saveAsUser = ( + run: (current: UserResource) => Promise, + fields: readonly TField[] = [], + params?: MessageValues, + ): Promise => + save( + () => { + const current = clerk.user; + if (!current || current.id !== userId) { + throw new SaveError({ global: UNEXPECTED_ERROR }); + } + return run(current); + }, + fields, + params, + ); + const { attributes, usernameSettings } = environment.userSettings; const usernameAttribute = attributes.username; const usernameImmutable = Boolean(usernameAttribute?.immutable); @@ -99,6 +121,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo return { status: 'ready', + userId, allowMultipleAccounts: true, name: getFullName(user), firstName: user.firstName ?? '', @@ -109,17 +132,21 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo imageUrl: user.imageUrl, hasImage: user.hasImage, username: showUsername ? (user.username ?? '') : undefined, + usernameRequired: Boolean(usernameAttribute?.required), emails: showEmails ? toEmails(user) : undefined, phones: showPhones ? toPhones(user) : undefined, - onProfilePictureChange: file => save(() => user.setProfileImage({ file })), - onRemoveProfilePicture: user.hasImage ? () => save(() => user.setProfileImage({ file: null })) : undefined, + onProfilePictureChange: file => saveAsUser(current => current.setProfileImage({ file })), + onRemoveProfilePicture: user.hasImage + ? () => saveAsUser(current => current.setProfileImage({ file: null })) + : undefined, onSubmitName: nameManagedBy ? undefined - : value => save(() => user.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), + : value => + saveAsUser(current => current.update({ firstName: value.firstName, lastName: value.lastName }), NAME_FIELDS), onSubmitUsername: showUsername && !usernameImmutable ? username => - save(() => user.update({ username }), USERNAME_FIELDS, { + saveAsUser(current => current.update({ username }), USERNAME_FIELDS, { min_length: usernameSettings.min_length, max_length: usernameSettings.max_length, }) 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 index 4b902832b22..8271ba43f3d 100644 --- 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 @@ -8,7 +8,12 @@ export function UserProfileAccountSection() { return null; } - const { status: _status, ...viewProps } = model; + const { status: _status, userId, ...viewProps } = model; - return ; + return ( + + ); } 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 index 0d170270aaf..ef2920a06e0 100644 --- 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 @@ -27,6 +27,7 @@ export interface UserProfileAccountSectionViewProps { hasImage?: boolean; name: string; username?: string; + usernameRequired?: boolean; /** Passed alongside `name`, which cannot be split back into its two halves. */ firstName?: string; lastName?: string; @@ -62,6 +63,7 @@ export function UserProfileAccountSectionView({ hasImage = false, name, username, + usernameRequired = false, firstName, lastName, firstNameAttribute, @@ -145,6 +147,7 @@ export function UserProfileAccountSectionView({ {username !== undefined ? ( ) : null} 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-account-section/user-profile-edit-username.controller.ts index c272a103d7e..41adea869dc 100644 --- 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-account-section/user-profile-edit-username.controller.ts @@ -6,6 +6,7 @@ import type { UserProfileEditUsernameValue } from './user-profile-edit-username. export interface UserProfileEditUsernameControllerOptions { username?: string; + required?: boolean; onSubmit: (username: string) => Promise; } @@ -17,12 +18,13 @@ export interface UserProfileEditUsernameController { export function useUserProfileEditUsernameController({ username = '', + required = false, onSubmit, }: UserProfileEditUsernameControllerOptions): UserProfileEditUsernameController { const [isOpen, setIsOpen] = useState(false); const form = useForm({ initialValues: { username }, - canSubmit: values => values.username !== username && values.username !== '', + canSubmit: values => values.username !== username && (!required || values.username !== ''), onSubmit: async values => { await onSubmit(values.username); setIsOpen(false); 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 index ef3decc5cab..48d0b78df0a 100644 --- 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 @@ -6,10 +6,11 @@ import { UserProfileEditUsernameDialog } from './user-profile-edit-username.dial export interface UserProfileUsernameRowViewProps { username: string; + required?: boolean; onSubmit?: (username: string) => Promise; } -export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUsernameRowViewProps) { +export function UserProfileUsernameRowView({ username, required = false, onSubmit }: UserProfileUsernameRowViewProps) { const m = useMessages('userProfileAccountSection'); return ( @@ -22,6 +23,7 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs @@ -31,9 +33,17 @@ export function UserProfileUsernameRowView({ username, onSubmit }: UserProfileUs ); } -function EditUsername({ username, onSubmit }: { username: string; onSubmit: (username: string) => Promise }) { +function EditUsername({ + username, + required, + onSubmit, +}: { + username: string; + required: boolean; + onSubmit: (username: string) => Promise; +}) { const m = useMessages('userProfileAccountSection'); - const controller = useUserProfileEditUsernameController({ username, onSubmit }); + const controller = useUserProfileEditUsernameController({ username, required, onSubmit }); const isSet = Boolean(username); return ( 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 6bfceb31d9a..926abce7705 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 @@ -37,46 +37,19 @@ export interface UserProfileProfilePanelViewProps extends UserProfileAccountSect } export function UserProfileProfilePanelView({ - allowMultipleAccounts, - imageUrl, - hasImage, name = '', - username = '', - firstName, - lastName, - firstNameAttribute, - lastNameAttribute, - emails = [], - phones = [], connectedAccounts = [], availableConnectionProviders = [], onReconnectAccount, web3Wallets = [], availableWeb3Providers = [], - onProfilePictureChange, - onProfilePictureReject, - onRemoveProfilePicture, - onSubmitName, - onSubmitUsername, - onAddEmail, - onSendEmailCode, - onVerifyEmailCode, - onManageEmail, - onVerifyEmail, - onSetPrimaryEmail, - onRemoveEmail, - onSendPhoneCode, - onVerifyPhoneCode, - onManagePhone, - onVerifyPhone, - onSetPrimaryPhone, - onRemovePhone, onConnectAccount, onRemoveConnectedAccount, onConnectWeb3Wallet, onSetPrimaryWeb3Wallet, onRemoveWeb3Wallet, deleteAccountSlot, + ...account }: UserProfileProfilePanelViewProps): ReactElement { const pageTitle = useRef(null); return ( @@ -89,35 +62,8 @@ export function UserProfileProfilePanelView({ pageTitle.current} From 6fe38adb45bdeacf5169c8ab4cf5a8b879c782fd Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 15:10:19 -0400 Subject: [PATCH 51/63] style(mosaic): format the name dialog's field-only banner test --- .../__tests__/user-profile-edit-name.dialog.test.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx index dfab0026b04..c65ac8c800a 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-edit-name.dialog.test.tsx @@ -146,8 +146,7 @@ describe('UserProfileEditNameDialog', () => { it('leaves the banner empty when only a field is blamed', async () => { const user = userEvent.setup(); renderView({ - onSubmit: () => - Promise.reject(new SaveError({ fields: { firstName: { message: 'First name is required.' } } })), + onSubmit: () => Promise.reject(new SaveError({ fields: { firstName: { message: 'First name is required.' } } })), }); await user.type(lastNameField(), 'x'); From f265e4e36423097c4e628f933ebebf679407eb7f Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 15:45:08 -0400 Subject: [PATCH 52/63] test(mosaic): drop the account-section integration test the feature test replaced --- ...ofile-account-section.integration.test.tsx | 138 ------------------ 1 file changed, 138 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx deleted file mode 100644 index 6443dcf464c..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.integration.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type * as SharedReact from '@clerk/shared/react'; -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { MosaicProvider } from '../../../mosaic-provider'; -import { UserProfileAccountSection } from '../user-profile-account-section/user-profile-account-section'; - -let user: { - id: string; - firstName: string; - lastName: string; - username: string; - imageUrl: string; - hasImage: boolean; - enterpriseAccounts: never[]; - primaryEmailAddressId: null; - primaryPhoneNumberId: null; - emailAddresses: never[]; - phoneNumbers: never[]; - setProfileImage: ReturnType; - update: ReturnType; -}; - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: true, user }), - useClerk: () => ({ - get user() { - return user; - }, - __internal_environment: { - userSettings: { - attributes: { - first_name: { enabled: true, required: false }, - last_name: { enabled: true, required: false }, - username: { enabled: true, required: false }, - }, - usernameSettings: { min_length: 4, max_length: 64 }, - }, - }, - }), - }; -}); - -function renderSection() { - return render( - - - , - ); -} - -beforeEach(() => { - user = { - id: 'user_1', - firstName: 'Preston', - lastName: 'Booth', - username: 'prestonxyz', - imageUrl: 'https://img.clerk.com/preston.png', - hasImage: false, - enterpriseAccounts: [], - primaryEmailAddressId: null, - primaryPhoneNumberId: null, - emailAddresses: [], - phoneNumbers: [], - setProfileImage: vi.fn(() => Promise.resolve({})), - update: vi.fn(() => Promise.resolve(user)), - }; -}); - -describe('UserProfileAccountSection', () => { - it('saves an edited name and closes the dialog', async () => { - const actor = userEvent.setup(); - 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'), 'Pres'); - await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); - - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(user.update).toHaveBeenCalledExactlyOnceWith({ firstName: 'Pres', lastName: 'Booth' }); - }); - - it('keeps the username dialog open on the error the server names it for', async () => { - user.update.mockRejectedValue( - new ClerkAPIResponseError('failed', { - data: [ - { - code: 'form_identifier_exists', - message: 'Taken', - long_message: 'That username is taken. Please try another.', - meta: { param_name: 'username' }, - }, - ], - status: 422, - }), - ); - const actor = userEvent.setup(); - renderSection(); - - 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(); - expect(user.update).toHaveBeenCalledExactlyOnceWith({ username: 'prestonxyz2' }); - }); - - it('uploads a picked picture and shows why it failed', async () => { - user.setProfileImage.mockRejectedValue( - new ClerkAPIResponseError('failed', { - data: [{ code: 'avatar_file_size_exceeded', message: 'Too large', long_message: 'Too large.' }], - status: 413, - }), - ); - const actor = userEvent.setup(); - const { container } = renderSection(); - const file = new File(['x'], 'me.png', { type: 'image/png' }); - - const input = container.querySelector('input[type="file"]'); - if (!(input instanceof HTMLInputElement)) { - throw new Error('expected a file input'); - } - await actor.upload(input, file); - - expect(await screen.findByRole('alert')).toHaveTextContent( - 'File size exceeds the maximum limit of 10MB. Please choose a smaller file.', - ); - expect(user.setProfileImage).toHaveBeenCalledExactlyOnceWith({ file }); - }); -}); From 48299f7c744379a371c4a270665a9cc52eb9c3c4 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 16:22:08 -0400 Subject: [PATCH 53/63] test(mosaic): keep the feature fixtures as main defines them The merge into this branch replaced two shared fixtures with this branch's variants. `fapiVerification` traded an `as` cast for a literal `error: { code: '', message: '' }`, which made every external account parse as carrying a verification error, and `fapiEnterpriseAccount` flipped `disable_additional_identifications` to false. Together they broke nine connected-accounts tests and the phone resend countdown. Both helpers go back to main's versions, so the only remaining change to them is the additive `connection` parameter the enterprise cases here need. The account section's permissive SSO case now asks for that default explicitly rather than leaning on it. --- .../mosaic-user-profile-email-link-sso.md | 2 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 30 +++---- packages/mosaic/src/__tests__/feature/fapi.ts | 87 +++++++++---------- ...r-profile-account-section.feature.test.tsx | 40 ++++++++- 4 files changed, 94 insertions(+), 65 deletions(-) create mode 100644 .changeset/mosaic-user-profile-email-link-sso.md diff --git a/.changeset/mosaic-user-profile-email-link-sso.md b/.changeset/mosaic-user-profile-email-link-sso.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-email-link-sso.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 557c8c2a6be..90c23aa3ada 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -98,14 +98,7 @@ function error(code: string, status = 400) { } function activeUser(state: FakeFapiState): UserJSON | undefined { - return findSession(state, state.client.last_active_session_id)?.user; -} - -function updateUser(state: FakeFapiState, user: UserJSON): void { - state.client = { - ...state.client, - sessions: state.client.sessions.map(session => (session.user.id === user.id ? { ...session, user } : session)), - }; + return activeSession(state)?.user; } function missing() { @@ -233,15 +226,15 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { external_verification_redirect_url: 'https://accounts.example/authorize', }), }); - updateUser(state, { - ...user, + updateUser(state, current => ({ + ...current, external_accounts: [ - ...user.external_accounts.filter( + ...current.external_accounts.filter( item => item.provider !== provider || item.verification?.status === 'verified', ), account, ], - }); + })); return envelope(account, state.client); }), http.post(fapiUrl('/v1/me/external_accounts/:id/reauthorize'), ({ params, request }) => { @@ -260,10 +253,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { external_verification_redirect_url: 'https://accounts.example/consent', }), }; - updateUser(state, { - ...user, - external_accounts: user.external_accounts.map(item => (item.id === pending.id ? pending : item)), - }); + updateUser(state, current => ({ + ...current, + external_accounts: current.external_accounts.map(item => (item.id === pending.id ? pending : item)), + })); return envelope(pending, state.client); }), http.post(fapiUrl('/v1/me/external_accounts/:id'), ({ params, request }) => { @@ -275,7 +268,10 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { if (!user || !account) { return missing(); } - updateUser(state, { ...user, external_accounts: user.external_accounts.filter(item => item.id !== account.id) }); + updateUser(state, current => ({ + ...current, + external_accounts: current.external_accounts.filter(item => item.id !== account.id), + })); return envelope({ ...account, object: 'external_account' }, state.client); }), http.post(fapiUrl('/v1/client/sessions/:id/tokens'), ({ params }) => { diff --git a/packages/mosaic/src/__tests__/feature/fapi.ts b/packages/mosaic/src/__tests__/feature/fapi.ts index d34d6ce62d4..d7dc67961ba 100644 --- a/packages/mosaic/src/__tests__/feature/fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fapi.ts @@ -276,48 +276,6 @@ export function fapiPhoneNumber( }; } -export function fapiEnterpriseAccount( - overrides: Partial & Pick, - connection: Partial = {}, -): EnterpriseAccountJSON { - const emailAddress = overrides.email_address ?? 'sso@example.com'; - const domain = emailAddress.split('@')[1] ?? 'example.com'; - return { - object: 'enterprise_account', - active: true, - email_address: emailAddress, - first_name: null, - last_name: null, - protocol: 'saml', - provider: 'saml_okta', - provider_user_id: null, - public_metadata: {}, - verification: null, - last_authenticated_at: null, - enterprise_connection_id: `entc_${overrides.id}`, - enterprise_connection: { - object: 'enterprise_account_connection', - id: `entc_${overrides.id}`, - active: true, - allow_idp_initiated: false, - allow_subdomains: false, - disable_additional_identifications: false, - domain, - logo_public_url: null, - name: 'Company SSO', - protocol: 'saml', - provider: 'saml_okta', - sync_user_attributes: true, - allow_organization_account_linking: false, - created_at: createdAt, - updated_at: createdAt, - enterprise_connection_id: null, - ...connection, - }, - ...overrides, - }; -} - export function fapiUser(overrides: Partial & Pick): UserJSON { return { object: 'user', @@ -355,6 +313,46 @@ export function fapiUser(overrides: Partial & Pick): U }; } +export function fapiEnterpriseAccount( + overrides: Partial & Pick, + connection: Partial = {}, +): EnterpriseAccountJSON { + return { + object: 'enterprise_account', + active: true, + email_address: 'sso@example.com', + enterprise_connection: { + object: 'enterprise_connection', + id: 'sso_1', + active: true, + allow_idp_initiated: false, + allow_subdomains: false, + disable_additional_identifications: true, + domain: 'example.com', + logo_public_url: null, + name: 'Company SSO', + protocol: 'saml', + provider: 'saml_okta', + sync_user_attributes: false, + allow_organization_account_linking: false, + created_at: createdAt, + updated_at: createdAt, + enterprise_connection_id: 'sso_1', + ...connection, + }, + first_name: null, + last_name: null, + protocol: 'saml', + provider: 'saml_okta', + provider_user_id: null, + public_metadata: {}, + verification: null, + last_authenticated_at: null, + enterprise_connection_id: 'sso_1', + ...overrides, + }; +} + export function fapiSession(overrides: Partial & Pick): SessionJSON { const { user } = overrides; const primaryEmail = user.email_addresses.find(email => email.id === user.primary_email_address_id); @@ -405,16 +403,13 @@ export function fapiPasskeyRequestOptions(): PublicKeyCredentialRequestOptionsJS export function fapiVerification(strategy: string, overrides: Partial = {}): VerificationJSON { return { object: verificationObjects[strategy] ?? 'verification', - id: `ver_${strategy}`, status: 'unverified', strategy, - verified_at_client: '', attempts: 0, expire_at: farFuture, - error: { code: '', message: '' }, ...(strategy === 'passkey' ? { nonce: JSON.stringify(fapiPasskeyRequestOptions()) } : {}), ...overrides, - }; + } as VerificationJSON; } export function fapiSessionVerification( diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index cbc7a50f945..621a64c9765 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -1,5 +1,5 @@ import type { EmailAddressJSON, EnterpriseAccountConnectionJSON, PhoneNumberJSON } from '@clerk/shared/types'; -import { screen, waitFor, within } from '@testing-library/react'; +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'; @@ -458,13 +458,49 @@ describe('a user signed in through an enterprise connection', () => { }); it('still lets the user add contacts when the connection allows them', async () => { - await renderSection(signedInThroughSso()); + 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'); From 8056fa4156012910fe0fd50ff9d2bbdd66c8ba6a Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 16:22:41 -0400 Subject: [PATCH 54/63] fix(mosaic): read the user back before each contact write The account section captured `user` at render and wrote through it, so a write begun before the active user changed landed on the user the surface was composed for. `currentUser()` re-reads `clerk.user` at invocation and refuses when the id no longer matches; the contact writes now resolve their resource lists off that user rather than the captured one. `onCreateEmail` and `onCreatePhone` call it directly because they need the created resource back, which `save()` does not return. The verifier getters stay unguarded: the views call them synchronously from click handlers, where a throw has no form banner to land in. --- .../user-profile-account-section.model.ts | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) 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 index dd4216ed97b..fab2f87422e 100644 --- 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 @@ -181,22 +181,19 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo 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( - () => { - const current = clerk.user; - if (!current || current.id !== userId) { - throw new SaveError({ global: UNEXPECTED_ERROR }); - } - return run(current); - }, - fields, - params, - ); + ): Promise => save(() => run(currentUser()), fields, params); const { attributes, usernameSettings, enterpriseSSO } = environment.userSettings; const usernameAttribute = attributes.username; @@ -232,27 +229,31 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo : undefined, onCreateEmail: emailAccess.canCreate ? async emailAddress => { - const request = user.createEmailAddress({ email: emailAddress }); + const request = currentUser().createEmailAddress({ email: emailAddress }); await save(() => request, ADD_EMAIL_FIELDS); return verifierFor(await request); } : undefined, getEmailVerifier: emailAccess.show ? id => verifierFor(byId(user.emailAddresses, id, 'email address')) : undefined, - onSetPrimaryEmail: emailAccess.show ? id => save(() => user.update({ primaryEmailAddressId: id })) : undefined, + onSetPrimaryEmail: emailAccess.show + ? id => saveAsUser(current => current.update({ primaryEmailAddressId: id })) + : undefined, onRemoveEmail: emailAccess.canRemove - ? id => save(() => byId(user.emailAddresses, id, 'email address').destroy()) + ? id => saveAsUser(current => byId(current.emailAddresses, id, 'email address').destroy()) : undefined, onCreatePhone: phoneAccess.canCreate ? async phoneNumber => { - const request = user.createPhoneNumber({ phoneNumber }); + const request = currentUser().createPhoneNumber({ phoneNumber }); await save(() => request, ADD_PHONE_FIELDS); return toPhoneVerifier(await request); } : undefined, getPhoneVerifier: phoneAccess.show ? id => toPhoneVerifier(byId(user.phoneNumbers, id, 'phone number')) : undefined, - onSetPrimaryPhone: phoneAccess.show ? id => save(() => user.update({ primaryPhoneNumberId: id })) : undefined, + onSetPrimaryPhone: phoneAccess.show + ? id => saveAsUser(current => current.update({ primaryPhoneNumberId: id })) + : undefined, onRemovePhone: phoneAccess.canRemove - ? id => save(() => byId(user.phoneNumbers, id, 'phone number').destroy()) + ? id => saveAsUser(current => byId(current.phoneNumbers, id, 'phone number').destroy()) : undefined, onProfilePictureChange: file => saveAsUser(current => current.setProfileImage({ file })), onRemoveProfilePicture: user.hasImage From 6df6add70ef64025b908798c2b7c3c21b19a2ad6 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 16:22:42 -0400 Subject: [PATCH 55/63] test(mosaic): cover contact ordering against the util that does it The ordering rule (primary, then verified, then pending by soonest expiry, then never started) was only asserted through a model test that mocked the Clerk hooks. `toContacts` is a pure function, so it is tested as one. --- ...user-profile-account-section.utils.test.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.utils.test.ts 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-account-section.utils.test.ts new file mode 100644 index 00000000000..e2c2e71331f --- /dev/null +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { toContacts } from '../user-profile-account-section/user-profile-account-section.utils'; + +type Contact = { id: string; verification: { status: 'verified' | 'unverified' | null; expireAt: Date | null } }; + +function contact(id: string, status: Contact['verification']['status'], expireAt: Date | null = null): Contact { + return { id, verification: { status, expireAt } }; +} + +describe('toContacts', () => { + it('orders primary first, then verified, then pending by expiry, then never started', () => { + const items = [ + contact('idn_unstarted', null), + contact('idn_pending_late', 'unverified', new Date(2026, 0, 2)), + contact('idn_verified_b', 'verified'), + contact('idn_primary', 'verified'), + contact('idn_pending_soon', 'unverified', new Date(2026, 0, 1)), + contact('idn_verified_a', 'verified'), + ]; + + const contacts = toContacts(items, 'idn_primary', item => item.id); + + expect(contacts.map(item => item.id)).toEqual([ + 'idn_primary', + 'idn_verified_a', + 'idn_verified_b', + 'idn_pending_soon', + 'idn_pending_late', + 'idn_unstarted', + ]); + }); + + it('marks which contact is the primary one and which are verified', () => { + const contacts = toContacts( + [contact('idn_primary', 'verified'), contact('idn_pending', 'unverified')], + 'idn_primary', + item => `${item.id}@clerk.dev`, + ); + + expect(contacts).toEqual([ + { id: 'idn_primary', value: 'idn_primary@clerk.dev', isDefault: true, isVerified: true }, + { id: 'idn_pending', value: 'idn_pending@clerk.dev', isDefault: false, isVerified: false }, + ]); + }); + + it('keeps the list stable when there is no primary yet', () => { + const contacts = toContacts([contact('idn_b', 'verified'), contact('idn_a', 'verified')], null, item => item.id); + + expect(contacts.map(item => item.id)).toEqual(['idn_a', 'idn_b']); + expect(contacts.every(item => !item.isDefault)).toBe(true); + }); +}); From f1758aa5485c686cf6277caad787ff069d71c3c5 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 16:46:27 -0400 Subject: [PATCH 56/63] test(mosaic): spy on window navigation per test instead of in the render helper Stubbing clerk.__internal_windowNavigate inside renderWithClerk no-opped the modern hard-navigation path that the router feature tests drive for real. --- packages/mosaic/src/__tests__/feature/render.tsx | 3 --- .../user-profile-account-section.feature.test.tsx | 7 ++++--- packages/swingset/src/stories/fixtures/user-profile.tsx | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/render.tsx b/packages/mosaic/src/__tests__/feature/render.tsx index ba23feef2ba..0b4e0694f69 100644 --- a/packages/mosaic/src/__tests__/feature/render.tsx +++ b/packages/mosaic/src/__tests__/feature/render.tsx @@ -10,8 +10,6 @@ import { PUBLISHABLE_KEY } from './fake-fapi'; export async function renderWithClerk(ui: ReactElement, options?: Parameters[0]) { const clerk = new Clerk(PUBLISHABLE_KEY); const navigate = vi.fn((_to: string) => Promise.resolve()); - const windowNavigate = vi.fn((_to: URL | string) => {}); - clerk.__internal_windowNavigate = windowNavigate; const wrap = (element: ReactElement) => ( { current = element; view.rerender(wrap(current)); diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 621a64c9765..9fece3f8cc8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -2,7 +2,7 @@ import type { EmailAddressJSON, EnterpriseAccountConnectionJSON, PhoneNumberJSON 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 { describe, expect, it, vi } from 'vitest'; import { type FakeFapiSeed, @@ -131,7 +131,7 @@ describe('the user profile phone numbers', () => { await addPhone(actor, '8015550100'); - expect(screen.getByRole('button', { name: /Didn’t receive a code\? Resend \(\d+\)/ })).toBeDisabled(); + expect(await screen.findByRole('button', { name: /Didn’t receive a code\? Resend \(\d+\)/ })).toBeDisabled(); }); it('verifies a number that was left unverified', async () => { @@ -296,7 +296,8 @@ describe('the user profile email addresses', () => { email_address: 'alice@acme.co', matches_sso_connection: true, }); - const { actor, windowNavigate } = await renderSection(signedInWithEmails([PRIMARY, sso], verifiesByLink)); + 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' }); diff --git a/packages/swingset/src/stories/fixtures/user-profile.tsx b/packages/swingset/src/stories/fixtures/user-profile.tsx index b9fa6627e98..ef2ac3ad3b3 100644 --- a/packages/swingset/src/stories/fixtures/user-profile.tsx +++ b/packages/swingset/src/stories/fixtures/user-profile.tsx @@ -42,7 +42,7 @@ export interface UserProfileFixtureOptions { const exampleEmails: UserProfileEmail[] = [ { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, + { id: 'email_2', value: 'preston.booth@gmail.com', isDefault: false, isVerified: true }, ]; const examplePhones: UserProfilePhone[] = [ From 7a674d55c3052f61f49dfc282f964f0194521b7c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 16:55:06 -0400 Subject: [PATCH 57/63] test(mosaic): drop the mocked account-section model test The feature suite drives the same behavior through real FAPI, and the one piece of pure ordering logic it uniquely covered now has a mock-free unit test. --- ...ser-profile-account-section.model.test.tsx | 517 ------------------ 1 file changed, 517 deletions(-) delete mode 100644 packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx deleted file mode 100644 index 6ed8e044bcc..00000000000 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.model.test.tsx +++ /dev/null @@ -1,517 +0,0 @@ -import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; -import type * as SharedReact from '@clerk/shared/react'; -import { renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import type { FormError } from '../../../utils/form-error'; -import { SaveError } from '../../../utils/form-error'; -import { useUserProfileAccountSectionModel } from '../user-profile-account-section/user-profile-account-section.model'; - -interface FakeAttribute { - enabled: boolean; - required: boolean; - immutable?: boolean; - verifications: string[]; - used_for_first_factor: boolean; - used_for_second_factor: boolean; -} - -interface FakeVerification { - status: string | null; - expireAt?: Date | null; - externalVerificationRedirectURL?: URL | null; -} - -let isUserLoaded: boolean; -let user: { - id: string; - firstName: string | null; - lastName: string | null; - username: string | null; - imageUrl: string; - hasImage: boolean; - enterpriseAccounts: { - active: boolean; - provider: string; - enterpriseConnection: { - name: string; - logoPublicUrl: string | null; - disableAdditionalIdentifications?: boolean; - } | null; - }[]; - primaryEmailAddressId: string | null; - primaryPhoneNumberId: string | null; - emailAddresses: { - id: string; - emailAddress: string; - verification: FakeVerification; - destroy?: ReturnType; - prepareVerification?: ReturnType; - attemptVerification?: ReturnType; - createEmailLinkFlow?: ReturnType; - }[]; - phoneNumbers: { id: string; phoneNumber: string; verification: FakeVerification }[]; - setProfileImage: ReturnType; - update: ReturnType; - createEmailAddress: ReturnType; -} | null; -let activeUser: typeof user; -let attributes: Record<'first_name' | 'last_name' | 'username' | 'email_address' | 'phone_number', FakeAttribute>; -let usernameSettings: { min_length: number; max_length: number }; -let environmentHydrated: boolean; -let enterpriseSSOEnabled: boolean; -let emailLinkFlow: { startEmailLinkFlow: ReturnType; cancelEmailLinkFlow: ReturnType }; -const navigate = vi.fn(); - -function attribute(overrides: Partial = {}): FakeAttribute { - return { - enabled: true, - required: false, - verifications: [], - used_for_first_factor: false, - used_for_second_factor: false, - ...overrides, - }; -} - -vi.mock('@clerk/shared/react', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - useUser: () => ({ isLoaded: isUserLoaded, user }), - useClerk: () => ({ - navigate, - get user() { - return activeUser; - }, - __internal_environment: environmentHydrated - ? { - displayConfig: { userProfileUrl: 'https://accounts.clerk.dev/user' }, - userSettings: { attributes, usernameSettings, enterpriseSSO: { enabled: enterpriseSSOEnabled } }, - } - : null, - }), - }; -}); - -function renderModel() { - return renderHook(() => useUserProfileAccountSectionModel()).result.current; -} - -async function rejection(save: void | Promise | undefined): Promise { - try { - await save; - return undefined; - } catch (cause) { - return cause instanceof SaveError ? cause.formError : undefined; - } -} - -function ready() { - const model = renderModel(); - if (model.status !== 'ready') { - throw new Error(`expected ready, got ${model.status}`); - } - return model; -} - -function apiError(paramName?: string) { - return new ClerkAPIResponseError('failed', { - data: [ - { - code: 'form_param_invalid', - message: 'Is invalid', - long_message: 'That value is invalid.', - meta: paramName ? { param_name: paramName } : undefined, - }, - ], - status: 422, - }); -} - -beforeEach(() => { - isUserLoaded = true; - environmentHydrated = true; - enterpriseSSOEnabled = true; - emailLinkFlow = { startEmailLinkFlow: vi.fn(() => Promise.resolve()), cancelEmailLinkFlow: vi.fn() }; - navigate.mockReset(); - usernameSettings = { min_length: 4, max_length: 64 }; - attributes = { - first_name: attribute(), - last_name: attribute(), - username: attribute(), - email_address: attribute(), - phone_number: attribute(), - }; - user = { - id: 'user_1', - firstName: 'Preston', - lastName: 'Booth', - username: 'prestonxyz', - imageUrl: 'https://img.clerk.com/preston.png', - hasImage: true, - enterpriseAccounts: [], - primaryEmailAddressId: 'email_1', - primaryPhoneNumberId: null, - emailAddresses: [ - { - id: 'email_2', - emailAddress: 'other@clerk.dev', - verification: { status: null }, - destroy: vi.fn(() => Promise.resolve()), - prepareVerification: vi.fn(() => Promise.resolve()), - attemptVerification: vi.fn(() => Promise.resolve()), - createEmailLinkFlow: vi.fn(() => emailLinkFlow), - }, - { - id: 'email_1', - emailAddress: 'preston@clerk.dev', - verification: { status: 'verified' }, - destroy: vi.fn(() => Promise.resolve()), - }, - ], - phoneNumbers: [{ id: 'phone_1', phoneNumber: '+18018888181', verification: { status: 'verified' } }], - setProfileImage: vi.fn(() => Promise.resolve({})), - update: vi.fn(() => Promise.resolve(user)), - createEmailAddress: vi.fn(() => Promise.resolve({ id: 'email_new' })), - }; - activeUser = user; -}); - -describe('useUserProfileAccountSectionModel', () => { - it('waits for the user and the environment', () => { - isUserLoaded = false; - expect(renderModel()).toEqual({ status: 'loading' }); - - isUserLoaded = true; - environmentHydrated = false; - expect(renderModel()).toEqual({ status: 'loading' }); - }); - - it('is hidden when nobody is signed in', () => { - user = null; - expect(renderModel()).toEqual({ status: 'hidden' }); - }); - - describe('the user it was rendered for', () => { - const saves: [string, (model: ReturnType) => Promise | undefined][] = [ - ['the picture upload', model => model.onProfilePictureChange?.(new File(['x'], 'me.png', { type: 'image/png' }))], - ['the picture removal', model => model.onRemoveProfilePicture?.()], - ['the name', model => model.onSubmitName?.({ firstName: 'Pres', lastName: 'B' })], - ['the username', model => model.onSubmitUsername?.('ada')], - ]; - - it('is named, so the surface holding a draft can be scoped to it', () => { - expect(ready().userId).toBe('user_1'); - }); - - it.each(saves)('refuses %s once someone else is active', async (_name, runSave) => { - const model = ready(); - activeUser = user && { ...user, id: 'user_2', update: vi.fn(), setProfileImage: vi.fn() }; - - await expect(rejection(runSave(model))).resolves.toEqual({ global: { code: 'generic' } }); - expect(user?.update).not.toHaveBeenCalled(); - expect(user?.setProfileImage).not.toHaveBeenCalled(); - expect(activeUser?.update).not.toHaveBeenCalled(); - expect(activeUser?.setProfileImage).not.toHaveBeenCalled(); - }); - - it.each(saves)('refuses %s once nobody is signed in', async (_name, runSave) => { - const model = ready(); - activeUser = null; - - await expect(rejection(runSave(model))).resolves.toEqual({ global: { code: 'generic' } }); - expect(user?.update).not.toHaveBeenCalled(); - expect(user?.setProfileImage).not.toHaveBeenCalled(); - }); - }); - - it('maps the user to plain row data', () => { - const model = ready(); - - expect(model).toMatchObject({ - name: 'Preston Booth', - firstName: 'Preston', - lastName: 'Booth', - username: 'prestonxyz', - imageUrl: 'https://img.clerk.com/preston.png', - hasImage: true, - firstNameAttribute: { enabled: true, required: false }, - lastNameAttribute: { enabled: true, required: false }, - emails: [ - { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, - { id: 'email_2', value: 'other@clerk.dev', isDefault: false, isVerified: false }, - ], - phones: [{ id: 'phone_1', value: '+18018888181', isDefault: false, isVerified: true }], - }); - }); - - it('orders contacts primary first, then verified, then pending by expiry, then never started', () => { - if (!user) { - throw new Error('expected a user'); - } - user.primaryEmailAddressId = 'email_primary'; - user.primaryPhoneNumberId = 'phone_primary'; - user.emailAddresses = [ - { id: 'email_unstarted', emailAddress: 'd@clerk.dev', verification: { status: null } }, - { - id: 'email_late', - emailAddress: 'e@clerk.dev', - verification: { status: 'unverified', expireAt: new Date(2000) }, - }, - { id: 'email_verified_b', emailAddress: 'b@clerk.dev', verification: { status: 'verified' } }, - { - id: 'email_primary', - emailAddress: 'a@clerk.dev', - verification: { status: 'unverified', expireAt: new Date(3000) }, - }, - { - id: 'email_early', - emailAddress: 'f@clerk.dev', - verification: { status: 'unverified', expireAt: new Date(1000) }, - }, - { id: 'email_verified_a', emailAddress: 'c@clerk.dev', verification: { status: 'verified' } }, - ]; - user.phoneNumbers = [ - { id: 'phone_unstarted', phoneNumber: '+18018888183', verification: { status: null } }, - { id: 'phone_verified', phoneNumber: '+18018888182', verification: { status: 'verified' } }, - { id: 'phone_primary', phoneNumber: '+18018888181', verification: { status: 'verified' } }, - ]; - const model = ready(); - - expect(model.emails?.map(email => email.id)).toEqual([ - 'email_primary', - 'email_verified_a', - 'email_verified_b', - 'email_early', - 'email_late', - 'email_unstarted', - ]); - expect(model.phones?.map(phone => phone.id)).toEqual(['phone_primary', 'phone_verified', 'phone_unstarted']); - }); - - describe('emails', () => { - it('sets the chosen email as primary', async () => { - await ready().onSetPrimaryEmail?.('email_2'); - expect(user?.update).toHaveBeenCalledWith({ primaryEmailAddressId: 'email_2' }); - }); - - it('returns the API message when setting the primary fails', async () => { - user?.update.mockRejectedValue(apiError()); - await expect(rejection(ready().onSetPrimaryEmail?.('email_2'))).resolves.toEqual({ - global: { code: 'form_param_invalid', message: 'That value is invalid.' }, - }); - }); - - it('removes the chosen email', async () => { - await ready().onRemoveEmail?.('email_2'); - expect(user?.emailAddresses[0]?.destroy).toHaveBeenCalled(); - expect(user?.emailAddresses[1]?.destroy).not.toHaveBeenCalled(); - }); - - it('returns the API message when removing fails', async () => { - user?.emailAddresses[0]?.destroy?.mockRejectedValue(apiError()); - await expect(rejection(ready().onRemoveEmail?.('email_2'))).resolves.toEqual({ - global: { code: 'form_param_invalid', message: 'That value is invalid.' }, - }); - }); - - it('maps a create error onto the email field', async () => { - user?.createEmailAddress.mockRejectedValue(apiError('email_address')); - await expect(rejection(ready().onCreateEmail?.('taken@clerk.dev'))).resolves.toEqual({ - fields: { - emailAddress: { - code: 'form_param_invalid', - paramName: 'email_address', - message: 'That value is invalid.', - }, - }, - }); - }); - - it('returns the API message when the link cannot be sent', async () => { - attributes.email_address = attribute({ verifications: ['email_link'] }); - emailLinkFlow.startEmailLinkFlow.mockRejectedValue(apiError()); - const verification = ready().getEmailVerifier?.('email_2').start(); - if (verification?.method !== 'link') { - throw new Error('expected a link verification'); - } - await expect(rejection(verification.verified)).resolves.toEqual({ - global: { code: 'form_param_invalid', message: 'That value is invalid.' }, - }); - }); - - it('maps a wrong code onto the code field', async () => { - user?.emailAddresses[0]?.attemptVerification?.mockRejectedValue(apiError('code')); - await expect(rejection(ready().getEmailVerifier?.('email_2').verifyCode('000000'))).resolves.toEqual({ - fields: { code: { code: 'form_param_invalid', paramName: 'code', message: 'That value is invalid.' } }, - }); - }); - - it('offers no new email when the email address is immutable', () => { - attributes.email_address = attribute({ immutable: true }); - expect(ready().onCreateEmail).toBeUndefined(); - }); - - it('offers no new email when the active enterprise connection forbids more identifications', () => { - user?.enterpriseAccounts.push({ - active: true, - provider: 'saml_okta', - enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true }, - }); - expect(ready().onCreateEmail).toBeUndefined(); - }); - - it('ignores the enterprise restriction when the instance has enterprise SSO off', () => { - enterpriseSSOEnabled = false; - user?.enterpriseAccounts.push({ - active: true, - provider: 'saml_okta', - enterpriseConnection: { name: 'Okta', logoPublicUrl: null, disableAdditionalIdentifications: true }, - }); - expect(ready().onCreateEmail).toBeDefined(); - }); - - it('still offers a new primary but no removal when the email address is immutable', () => { - attributes.email_address = attribute({ immutable: true }); - const model = ready(); - expect(model.onSetPrimaryEmail).toBeDefined(); - expect(model.onRemoveEmail).toBeUndefined(); - }); - }); - - describe('profile picture', () => { - it('uploads the picked file', async () => { - const file = new File(['x'], 'me.png', { type: 'image/png' }); - await ready().onProfilePictureChange?.(file); - expect(user?.setProfileImage).toHaveBeenCalledWith({ file }); - }); - - it('removes an uploaded picture', async () => { - await ready().onRemoveProfilePicture?.(); - expect(user?.setProfileImage).toHaveBeenCalledWith({ file: null }); - }); - - it('offers no remove while the avatar is the generated default', () => { - if (user) { - user.hasImage = false; - } - expect(ready().onRemoveProfilePicture).toBeUndefined(); - }); - - it('returns the API message when the upload fails', async () => { - user?.setProfileImage.mockRejectedValue(apiError()); - const file = new File(['x'], 'me.png', { type: 'image/png' }); - await expect(rejection(ready().onProfilePictureChange?.(file))).resolves.toEqual({ - global: { code: 'form_param_invalid', message: 'That value is invalid.' }, - }); - }); - }); - - describe('name', () => { - it('saves both halves of the name', async () => { - await ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }); - expect(user?.update).toHaveBeenCalledWith({ firstName: 'Pres', lastName: 'B' }); - }); - - it('maps a field error onto the control that caused it', async () => { - user?.update.mockRejectedValue(apiError('first_name')); - await expect(rejection(ready().onSubmitName?.({ firstName: '', lastName: 'B' }))).resolves.toEqual({ - fields: { - firstName: { code: 'form_param_invalid', paramName: 'first_name', message: 'That value is invalid.' }, - }, - }); - }); - - it('names the connection managing the name instead of offering to edit it', () => { - user?.enterpriseAccounts.push({ - active: true, - provider: 'saml_okta', - enterpriseConnection: { name: 'Okta', logoPublicUrl: 'https://img.clerk.com/okta.svg' }, - }); - expect(ready()).toMatchObject({ - nameManagedBy: { name: 'Okta' }, - onSubmitName: undefined, - }); - }); - - it('falls back to the provider when the active account carries no connection', () => { - user?.enterpriseAccounts.push({ active: true, provider: 'saml_okta', enterpriseConnection: null }); - expect(ready().nameManagedBy).toEqual({ name: 'okta' }); - }); - - it('passes the instance name attributes through for the row to hide itself', () => { - attributes.first_name = attribute({ enabled: false }); - attributes.last_name = attribute({ required: true }); - expect(ready()).toMatchObject({ - firstNameAttribute: { enabled: false, required: false }, - lastNameAttribute: { enabled: true, required: true }, - }); - }); - }); - - it('returns a Clerk runtime failure by its code, without the developer message', async () => { - user?.update.mockRejectedValue(new ClerkRuntimeError('Network down.', { code: 'network_error' })); - await expect(rejection(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' }))).resolves.toEqual({ - global: { code: 'network_error' }, - }); - }); - - it('rethrows a failure that is not from Clerk', async () => { - user?.update.mockRejectedValue(new TypeError('boom')); - await expect(ready().onSubmitName?.({ firstName: 'Pres', lastName: 'B' })).rejects.toThrow('boom'); - }); - - describe('username', () => { - it('saves the username', async () => { - await ready().onSubmitUsername?.('preston'); - expect(user?.update).toHaveBeenCalledWith({ username: 'preston' }); - }); - - it('maps a username error onto the field, with the configured length bounds to fill its message', async () => { - user?.update.mockRejectedValue(apiError('username')); - await expect(rejection(ready().onSubmitUsername?.('x'))).resolves.toEqual({ - fields: { - username: { - code: 'form_param_invalid', - paramName: 'username', - message: 'That value is invalid.', - params: { min_length: 4, max_length: 64 }, - }, - }, - }); - }); - - it('passes on whether the instance requires one, so an optional username can be cleared', () => { - expect(ready().usernameRequired).toBe(false); - - attributes.username = attribute({ required: true }); - expect(ready().usernameRequired).toBe(true); - }); - - it('is hidden when the instance does not use usernames', () => { - attributes.username = attribute({ enabled: false }); - expect(ready().username).toBeUndefined(); - }); - - it('stays available when usernames only sign in', () => { - attributes.username = attribute({ enabled: false, used_for_first_factor: true }); - expect(ready().username).toBe('prestonxyz'); - }); - - it('shows an immutable username without offering to edit it', () => { - attributes.username = attribute({ immutable: true }); - const model = ready(); - expect(model.username).toBe('prestonxyz'); - expect(model.onSubmitUsername).toBeUndefined(); - }); - - it('is hidden when immutable and never set', () => { - attributes.username = attribute({ immutable: true }); - if (user) { - user.username = null; - } - expect(ready().username).toBeUndefined(); - }); - }); -}); From 064c578ac000d6595bfbabd4b41dec65421d17fa Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 18:32:18 -0400 Subject: [PATCH 58/63] fix(mosaic): start the phone input on the located country Legacy reads the geo-IP country off the Clerk instance and seeds the phone input with it. Mosaic's PhoneInput took a defaultCountry but nothing passed one, so it always started on 'us' and a non-US user typing a national number submitted a +1 number. The account section's model now narrows clerk.__internal_country through a new toCountryIso and threads it down to the input as defaultCountry. The fake FAPI gained a country seed that serves x-country, so the feature test exercises the real clerk-js path that populates the value. --- .../mosaic/src/__tests__/feature/fake-fapi.ts | 5 ++- .../src/components/phone-input/index.ts | 2 +- .../phone-input/phone-input.test.tsx | 31 +++++++++++++++++++ .../components/phone-input/phone-input.tsx | 5 +++ ...r-profile-account-section.feature.test.tsx | 9 ++++++ .../user-profile-account-section.model.ts | 3 ++ .../user-profile-account-section.view.tsx | 4 +++ .../user-profile-add-phone.dialog.tsx | 3 ++ .../user-profile-phone-row.view.tsx | 4 +++ .../user-profile/user-profile-phone.steps.tsx | 3 ++ 10 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 2c33f8e190f..ba6fb901f1b 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -45,6 +45,7 @@ export const fapiUrl = (path: string) => `${FAPI}${path}`; export interface FakeFapiState { environment: FapiEnvironment; client: ClientJSON; + country?: string; memberships: OrganizationMembershipJSON[]; invitations: UserOrganizationInvitationJSON[]; suggestions: OrganizationSuggestionJSON[]; @@ -211,7 +212,9 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { worker.use( ...verificationHandlers(state, fapiUrl), ...enterpriseHandlers(state, fapiUrl), - http.get(fapiUrl('/v1/environment'), () => HttpResponse.json(state.environment)), + http.get(fapiUrl('/v1/environment'), () => + HttpResponse.json(state.environment, state.country ? { headers: { 'x-country': state.country } } : undefined), + ), http.get(fapiUrl('/v1/client'), () => envelope(state.client, null)), http.get(fapiUrl('/v1/me'), () => { const user = activeUser(state); diff --git a/packages/mosaic/src/components/phone-input/index.ts b/packages/mosaic/src/components/phone-input/index.ts index baa6564b056..9cf08936ee5 100644 --- a/packages/mosaic/src/components/phone-input/index.ts +++ b/packages/mosaic/src/components/phone-input/index.ts @@ -1,3 +1,3 @@ export type { CountryIso } from '@clerk/shared/phone'; -export { PhoneInput } from './phone-input'; +export { PhoneInput, toCountryIso } from './phone-input'; export type { PhoneInputProps } from './phone-input'; diff --git a/packages/mosaic/src/components/phone-input/phone-input.test.tsx b/packages/mosaic/src/components/phone-input/phone-input.test.tsx index 859585380a7..ad010ece7bb 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.test.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.test.tsx @@ -87,6 +87,37 @@ describe('Mosaic PhoneInput', () => { expect(document.querySelector('.cl-phone-input')).toHaveAttribute('data-size', 'md'); }); + it('starts on the given default country and dials the number with its code', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + expect(screen.getByRole('button', { name: 'Country, Germany' })).toBeInTheDocument(); + expect(screen.getByText('+49')).toHaveClass('cl-phone-input-prefix'); + + await user.type(screen.getByRole('textbox', { name: 'Phone number' }), '15123456789'); + + expect(onValueChange).toHaveBeenLastCalledWith('+4915123456789'); + }); + + it('prefers the number it is given over the default country', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Country, United States' })).toBeInTheDocument(); + }); + it('emits an E.164 value while displaying the national number', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); diff --git a/packages/mosaic/src/components/phone-input/phone-input.tsx b/packages/mosaic/src/components/phone-input/phone-input.tsx index 2b7134b8033..f48310b827f 100644 --- a/packages/mosaic/src/components/phone-input/phone-input.tsx +++ b/packages/mosaic/src/components/phone-input/phone-input.tsx @@ -35,6 +35,11 @@ function getCountry(iso: CountryIso | undefined): CountryEntry { return country ?? fallback; } +export function toCountryIso(value: string | null | undefined): CountryIso | undefined { + const iso = value?.toLowerCase(); + return iso ? countryOptions.find(country => country.iso === iso)?.iso : undefined; +} + function getInitialCountry(value: string | undefined, defaultCountry: CountryIso | undefined): CountryIso { return value ? parsePhoneString(value).iso : getCountry(defaultCountry).iso; } diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 9fece3f8cc8..77842d3e8de 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -116,6 +116,15 @@ describe('the user profile phone numbers', () => { 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(); 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 index fab2f87422e..828f13f0c53 100644 --- 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 @@ -9,6 +9,7 @@ import type { 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'; @@ -43,6 +44,7 @@ type UserProfileAccountSectionData = Pick< | 'usernameRequired' | 'emails' | 'phones' + | 'defaultPhoneCountry' | 'onCreateEmail' | 'getEmailVerifier' | 'onSetPrimaryEmail' @@ -227,6 +229,7 @@ export function useUserProfileAccountSectionModel(): UserProfileAccountSectionMo phones: phoneAccess.show ? toContacts(user.phoneNumbers, user.primaryPhoneNumberId, phone => phone.phoneNumber) : undefined, + defaultPhoneCountry: toCountryIso(clerk.__internal_country), onCreateEmail: emailAccess.canCreate ? async emailAddress => { const request = currentUser().createEmailAddress({ email: emailAddress }); 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 index f51d906f2ae..47a72796f11 100644 --- 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 @@ -1,3 +1,4 @@ +import type { CountryIso } from '../../../components/phone-input'; import { Section } from '../../../components/section'; import { useMessages } from '../../../localization'; import type { FileRejection } from '../../../primitives/file-upload'; @@ -39,6 +40,7 @@ export interface UserProfileAccountSectionViewProps { /** 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; @@ -73,6 +75,7 @@ export function UserProfileAccountSectionView({ nameManagedBy, emails, phones, + defaultPhoneCountry, onProfilePictureChange, onProfilePictureReject, onRemoveProfilePicture, @@ -97,6 +100,7 @@ export function UserProfileAccountSectionView({ const phoneRow = phones ? ( void; + defaultCountry?: CountryIso; code: string; onCodeChange: (value: string) => void; onSubmit: (code?: string) => void; @@ -53,6 +55,7 @@ export function UserProfileAddPhoneDialog(props: UserProfileAddPhoneDialogProps) inputRef={phoneRef} phoneNumber={current.phoneNumber} onPhoneNumberChange={current.onPhoneNumberChange} + defaultCountry={current.defaultCountry} onSubmit={current.onSubmit} isPending={current.isPending} errorMessage={current.errorMessage} 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 index 35b5bf5a8ac..d64c186ba80 100644 --- 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 @@ -5,6 +5,7 @@ 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 { Text } from '../../../components/text'; import { useListRemovalFocus } from '../../../hooks/use-list-removal-focus'; import { fill, useMessages } from '../../../localization'; @@ -17,6 +18,7 @@ import { useUserProfileSetPrimaryController } from './user-profile-set-primary.c export interface UserProfilePhoneRowViewProps { phones: UserProfilePhone[]; + defaultPhoneCountry?: CountryIso; allowMultipleAccounts?: boolean; onCreatePhone?: (phoneNumber: string) => Promise; getPhoneVerifier?: (id: string) => UserProfilePhoneVerifier; @@ -28,6 +30,7 @@ export interface UserProfilePhoneRowViewProps { export function UserProfilePhoneRowView({ phones, + defaultPhoneCountry, allowMultipleAccounts = false, onCreatePhone, getPhoneVerifier, @@ -97,6 +100,7 @@ export function UserProfilePhoneRowView({ const dialog = canVerify ? ( { const id = verifyingId.current; diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx index 6d38cdf923c..d1fa0836ab1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone.steps.tsx @@ -10,6 +10,7 @@ import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; import { useFlowAutoFocus } from '../../components/flow'; import { Otp } from '../../components/otp'; +import type { CountryIso } from '../../components/phone-input'; import { PhoneInput } from '../../components/phone-input'; import { fill, rich, useMessages } from '../../localization'; import { styles } from './user-profile-profile-panel.styles'; @@ -19,6 +20,7 @@ interface EnterPhoneStepProps { secondaryAction?: ReactNode; phoneNumber: string; onPhoneNumberChange: (value: string) => void; + defaultCountry?: CountryIso; onSubmit: () => void; isPending?: boolean; errorMessage?: string; @@ -61,6 +63,7 @@ export function EnterPhoneStep(props: EnterPhoneStepProps) { name='phoneNumber' value={props.phoneNumber} onValueChange={props.onPhoneNumberChange} + defaultCountry={props.defaultCountry} /> {props.errorMessage} From 30416c3c9c3ded44525c6a8acc0a31c79cd9f08d Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 2 Oct 2026 18:32:29 -0400 Subject: [PATCH 59/63] fix(mosaic): localize the reason a confirmation was refused The confirmation block rendered the raw rejection message and never reached the error catalog, so a mapped code like action_blocked lost its copy and a SaveError built from UNEXPECTED_ERROR surfaced the developer string "Save failed". Nine views share the block, so all of them showed it. The machine now holds the LocalizableError and the hook localizes it with errorText, mirroring how useForm does it. Reading a rejection into a LocalizableError is the same shape fourteen other sites hand-roll, so it lands in utils/form-error.ts as toLocalizableError rather than here; the remaining sites still need migrating. A plain Error still shows its own message, which keeps working for the callers that localize before throwing. --- .../confirmation.controller.test.ts | 31 ++++++++++++++++--- .../confirmation/confirmation.controller.ts | 15 ++++----- ...r-profile-account-section.feature.test.tsx | 15 +++++++++ packages/mosaic/src/utils/form-error.ts | 8 +++++ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.test.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.test.ts index 9480a74c191..51f73eb9f18 100644 --- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.test.ts +++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.test.ts @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { createActor } from '../../machine/create-actor'; +import { SaveError, UNEXPECTED_ERROR } from '../../utils/form-error'; import { confirmationMachine, useConfirmationController } from './confirmation.controller'; function start() { @@ -46,21 +47,29 @@ describe('confirmationMachine', () => { actor.send({ type: 'CONFIRM', run: () => Promise.reject(new Error('Google is your only way to sign in.')) }); await vi.waitFor(() => expect(actor.getSnapshot().value).toBe('confirming')); - expect(actor.getSnapshot().context.error).toBe('Google is your only way to sign in.'); + expect(actor.getSnapshot().context.error).toEqual({ message: 'Google is your only way to sign in.' }); }); - it('falls back to generic copy when the rejection is not an Error', async () => { + it('keeps the code a failed save was refused with, so the copy can be looked up', async () => { + const actor = start(); + const formError = { global: { code: 'action_blocked', message: 'Raw server sentence.' } }; + actor.send({ type: 'CONFIRM', run: () => Promise.reject(new SaveError(formError)) }); + + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual(formError.global)); + }); + + it('falls back to the generic error when the rejection is not an Error', async () => { const actor = start(); // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- a non-Error rejection is the case under test actor.send({ type: 'CONFIRM', run: () => Promise.reject('nope') }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toBe('Something went wrong. Please try again.')); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual(UNEXPECTED_ERROR)); }); it('drops the error when cancelled, so the next open starts clean', async () => { const actor = start(); actor.send({ type: 'CONFIRM', run: () => Promise.reject(new Error('nope')) }); - await vi.waitFor(() => expect(actor.getSnapshot().context.error).toBe('nope')); + await vi.waitFor(() => expect(actor.getSnapshot().context.error).toEqual({ message: 'nope' })); actor.send({ type: 'CANCEL' }); @@ -95,4 +104,18 @@ describe('useConfirmationController', () => { expect(result.current.isOpen).toBe(true); expect(result.current.isConfirming).toBe(false); }); + + it('shows the copy for the code a refused save carries, not the raw server sentence', async () => { + const { result } = renderHook(() => useConfirmationController()); + act(() => result.current.onOpenChange(true)); + + act(() => + result.current.onConfirm(() => + Promise.reject(new SaveError({ global: { code: 'action_blocked', message: 'Raw server sentence.' } })), + ), + ); + + await waitFor(() => expect(result.current.errorMessage).toMatch(/contact support/)); + expect(result.current.errorMessage).not.toBe('Raw server sentence.'); + }); }); diff --git a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts index be5c6200f4c..109f8624a34 100644 --- a/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts +++ b/packages/mosaic/src/blocks/confirmation/confirmation.controller.ts @@ -1,9 +1,12 @@ +import type { LocalizableError } from '../../localization'; +import { useErrorText } from '../../localization'; import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/use-machine'; +import { toLocalizableError } from '../../utils/form-error'; export interface ConfirmationContext { run: () => Promise; - error: string | undefined; + error: LocalizableError | undefined; } export type ConfirmationEvent = { type: 'OPEN' } | { type: 'CONFIRM'; run: () => Promise } | { type: 'CANCEL' }; @@ -14,10 +17,6 @@ function notSeated(): Promise { return Promise.reject(new Error('confirmation run is not seated')); } -function toMessage(cause: unknown): string { - return cause instanceof Error ? cause.message : 'Something went wrong. Please try again.'; -} - export const confirmationMachine = createMachine({ id: 'confirmation', initial: 'idle', @@ -42,7 +41,7 @@ export const confirmationMachine = createMachine({ onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, onError: { target: 'confirming', - actions: assign((_, event) => ({ error: toMessage(event.error) })), + actions: assign((_, event) => ({ error: toLocalizableError(event.error) })), }, }), }, @@ -59,12 +58,14 @@ export interface ConfirmationController { export function useConfirmationController(): ConfirmationController { const [snapshot, send] = useMachine(confirmationMachine); + const errorText = useErrorText(); + const { error } = snapshot.context; return { isOpen: snapshot.value === 'confirming' || snapshot.value === 'pending', onOpenChange: open => send({ type: open ? 'OPEN' : 'CANCEL' }), onConfirm: run => send({ type: 'CONFIRM', run }), isConfirming: snapshot.value === 'pending', - errorMessage: snapshot.context.error, + errorMessage: error ? errorText(error) : undefined, }; } diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index 77842d3e8de..a59187e8ff8 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -175,6 +175,21 @@ describe('the user profile phone numbers', () => { 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 })); diff --git a/packages/mosaic/src/utils/form-error.ts b/packages/mosaic/src/utils/form-error.ts index 45dd494ae4d..8b458dec711 100644 --- a/packages/mosaic/src/utils/form-error.ts +++ b/packages/mosaic/src/utils/form-error.ts @@ -79,6 +79,14 @@ export async function save( } } +/** Reads what any rejection says, keeping a failed save's code so the copy stays localizable. */ +export function toLocalizableError(cause: unknown): LocalizableError { + if (cause instanceof SaveError) { + return cause.formError.global ?? UNEXPECTED_ERROR; + } + return cause instanceof Error ? { message: cause.message } : UNEXPECTED_ERROR; +} + /** Reads what a rejected save left for the view. An unrecognized rejection is the generic error. */ export function toFormError(cause: unknown): FormError { if (cause instanceof SaveError) { From f2f7c04803598c8d6e39f452bc64077a9130eb33 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 13:01:48 -0400 Subject: [PATCH 60/63] test(mosaic): wait for the verification code field to accept input The helper clicked the field as soon as it mounted, but the verify step disables it while the code is being sent and the disabled style sets pointer-events: none, so on a slow runner the click was refused. --- .../__tests__/user-profile-account-section.feature.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index a59187e8ff8..da1343ec809 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -87,7 +87,9 @@ async function addPhone(actor: Actor, digits: string) { } async function enterCode(actor: Actor, code: string) { - await actor.click(await screen.findByRole('textbox', { name: 'Verification code' })); + const input = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(input).toBeEnabled()); + await actor.click(input); await actor.keyboard(code); } From cd0d868bbd2839cd11fe46652abb8f709254b448 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 13:01:57 -0400 Subject: [PATCH 61/63] fix(mosaic): prepare the enterprise SSO redirect when the user asks for it The SSO step prepared the verification on entry and the Connect button read the redirect URL off the resource when clicked, so a click that beat the response found no URL and silently did nothing. Connect now performs the prepare itself and navigates to the URL it answers with, so there is nothing to race and a failure is reported instead of swallowed. --- ...r-profile-account-section.feature.test.tsx | 2 +- .../user-profile-account-section.model.ts | 25 +++++++----- .../user-profile-account-section.types.ts | 2 +- .../user-profile-add-email.controller.test.ts | 31 ++++++--------- .../user-profile-add-email.controller.ts | 39 +++++++++++-------- .../user-profile-add-email.dialog.tsx | 3 ++ .../fixtures/user-profile-add-email.ts | 26 +++++-------- 7 files changed, 65 insertions(+), 63 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx index da1343ec809..0f3cc99332b 100644 --- a/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/__tests__/user-profile-account-section.feature.test.tsx @@ -327,7 +327,7 @@ describe('the user profile email addresses', () => { await manageEmail(actor, 'alice@acme.co', 'Verify'); const dialog = await screen.findByRole('dialog', { name: 'Verify your email' }); - await waitFor(() => expect(dialog).toHaveTextContent('acme.co')); + expect(dialog).toHaveTextContent('acme.co'); expect(screen.queryByRole('textbox', { name: 'Verification code' })).not.toBeInTheDocument(); await actor.click(within(dialog).getByRole('button', { name: 'Connect' })); 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 index 828f13f0c53..a65f14d08f4 100644 --- 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 @@ -98,23 +98,30 @@ function startEmailVerification( router: MosaicRouter, ): UserProfileEmailVerification { if (email.matchesSsoConnection) { - const { startEnterpriseSSOLinkFlow, cancelEnterpriseSSOLinkFlow } = email.createEnterpriseSSOLinkFlow(); 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. */ - verified: save(() => startEnterpriseSSOLinkFlow({ redirectUrl: window.location.href })), - cancel: cancelEnterpriseSSOLinkFlow, - connect: () => { - const url = email.verification.externalVerificationRedirectURL; - if (url) { - void router.navigate(url.href); - } - }, + 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) { 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 index da919b79b95..12c3559b055 100644 --- 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 @@ -38,7 +38,7 @@ export type UserProfilePhone = UserProfileContact; export type UserProfileEmailVerification = | { method: 'code'; sent: Promise } | { method: 'link'; verified: Promise; cancel: () => void } - | { method: 'sso'; verified: Promise; cancel: () => void; connect: () => void }; + | { method: 'sso'; connect: () => Promise }; export interface UserProfileEmailVerifier { start: () => UserProfileEmailVerification; 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-account-section/user-profile-add-email.controller.test.ts index 94d4f91c293..f61b11c1c6a 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-account-section/user-profile-add-email.controller.test.ts @@ -265,33 +265,24 @@ describe('useUserProfileAddEmailController', () => { expect(result.current.open).toBe(true); }); - it('connects through the SSO provider, closes once verified, and stops waiting when closed', async () => { - const verified = createDeferredPromise(); - const cancel = vi.fn(); - const connect = vi.fn(); - const email = verifier(() => ({ - method: 'sso', - verified: verified.promise.then(() => undefined), - cancel, - connect, - })); + it('connects once per click and stays on the SSO step when connecting fails', async () => { + const connect = vi.fn(() => Promise.reject(saveError('Connection unavailable'))); + const email = verifier(() => ({ method: 'sso', connect })); const { result } = renderHook(() => useUserProfileAddEmailController({})); act(() => result.current.onVerifyEmail('person@acme.co', email)); expect(result.current.step).toBe('sso'); expect(result.current.resendSeconds).toBe(0); - result.current.onConnect(); - expect(connect).toHaveBeenCalledOnce(); - act(() => result.current.onOpenChange(false)); - expect(cancel).toHaveBeenCalledOnce(); - act(() => result.current.onVerifyEmail('person@acme.co', email)); - await waitFor(() => expect(result.current.step).toBe('sso')); - await act(async () => { - verified.resolve(); - await verified.promise; + act(() => { + result.current.onConnect(); + result.current.onConnect(); }); - expect(result.current.open).toBe(false); + expect(connect).toHaveBeenCalledOnce(); + + await waitFor(() => expect(result.current.errorMessage).toBe('Connection unavailable')); + expect(result.current.step).toBe('sso'); + expect(result.current.open).toBe(true); }); it('moves from the email step straight to the step for the verification method', async () => { 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-account-section/user-profile-add-email.controller.ts index 9f70a05ab74..30633178d79 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-account-section/user-profile-add-email.controller.ts @@ -35,6 +35,7 @@ type Event = | { type: 'START'; emailAddress: string; verifier: UserProfileEmailVerifier } | { type: 'VERIFIED' } | { type: 'CANCEL' } + | { type: 'CONNECT' } | { type: 'RESEND' } | { type: 'TICK' }; @@ -94,13 +95,14 @@ const machine = createMachine({ error: undefined, })), always: ({ context }) => { - if (context.verification?.method === 'code') { + const method = context.verification?.method; + if (method === 'code') { return { target: 'sending' }; } - return { - target: 'waiting', - context: { resendSeconds: context.verification?.method === 'link' ? LINK_RESEND_SECONDS : 0 }, - }; + if (method === 'sso') { + return { target: 'connecting' }; + } + return { target: 'waiting', context: { resendSeconds: method === 'link' ? LINK_RESEND_SECONDS : 0 } }; }, }, sending: { @@ -115,16 +117,25 @@ const machine = createMachine({ waiting: { on: { CANCEL: 'idle', TICK: tick, RESEND: resend }, invoke: fromPromise( - context => - context.verification && context.verification.method !== 'code' - ? context.verification.verified - : missingDependency(), + context => (context.verification?.method === 'link' ? context.verification.verified : missingDependency()), { onDone: 'idle', onError: { actions: fail }, }, ), }, + connecting: { + on: { CANCEL: 'idle', CONNECT: 'redirecting' }, + }, + redirecting: { + invoke: fromPromise( + context => (context.verification?.method === 'sso' ? context.verification.connect() : missingDependency()), + { + onDone: 'connecting', + onError: { target: 'connecting', actions: fail }, + }, + ), + }, verify: { on: { CANCEL: 'idle', TICK: tick, RESEND: resend, VERIFIED: 'idle' }, }, @@ -160,7 +171,7 @@ export function useUserProfileAddEmailController({ }); const state = snapshot.value; const open = state !== 'idle'; - const waitingFor = state === 'waiting' && verification?.method !== 'code' ? verification : undefined; + const waitingFor = state === 'waiting' && verification?.method === 'link' ? verification : undefined; useEffect(() => waitingFor?.cancel, [waitingFor]); useEffect(() => { if (!open || resendSeconds === 0) { @@ -184,7 +195,7 @@ export function useUserProfileAddEmailController({ canSubmitEmail: emailForm.canSubmit || emailForm.isSubmitting, code: codeForm.values.code, errorMessage: formError?.message ?? form.error ?? machineError, - isPending: form.isSubmitting, + isPending: form.isSubmitting || state === 'redirecting', onOpenChange: open => { if (form.isSubmitting) { return; @@ -216,11 +227,7 @@ export function useUserProfileAddEmailController({ send({ type: 'RESEND' }); } }, - onConnect: () => { - if (verification?.method === 'sso') { - verification.connect(); - } - }, + onConnect: () => send({ type: 'CONNECT' }), onVerifyEmail: (emailAddress, verifier) => { codeForm.reset(); send({ type: 'START', emailAddress, verifier }); 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-account-section/user-profile-add-email.dialog.tsx index e09d3d15276..dcc7dbff2ba 100644 --- 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-account-section/user-profile-add-email.dialog.tsx @@ -91,6 +91,7 @@ export function UserProfileAddEmailDialog(props: UserProfileAddEmailDialogProps) @@ -319,6 +320,7 @@ function VerifyLinkStep(props: VerifyLinkStepProps) { interface VerifySsoStepProps { emailAddress: string; onConnect: () => void; + isPending?: boolean; errorMessage?: string; } @@ -359,6 +361,7 @@ function VerifySsoStep(props: VerifySsoStepProps) {