From 2b0265d789658570f340316fedc68d12298f7abb Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 22:34:55 -0400 Subject: [PATCH 1/2] fix(mosaic): show the generic error when a verification send fails without details --- .changeset/mosaic-add-contact-empty-error.md | 5 +++++ .../user-profile-add-email.controller.test.ts | 12 ++++++++++++ .../user-profile-add-phone.controller.test.ts | 12 ++++++++++++ packages/mosaic/src/utils/__tests__/errors.test.tsx | 8 +++++++- packages/mosaic/src/utils/errors.ts | 2 +- 5 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 .changeset/mosaic-add-contact-empty-error.md diff --git a/.changeset/mosaic-add-contact-empty-error.md b/.changeset/mosaic-add-contact-empty-error.md new file mode 100644 index 00000000000..0486c63b88b --- /dev/null +++ b/.changeset/mosaic-add-contact-empty-error.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': patch +--- + +Show the generic error message in the `UserProfile` add email and add phone dialogs when sending a verification fails with a Clerk response that carries no error details. Previously these dialogs showed no message. diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts index 1f38a4fa3d1..4654d96b0f9 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts @@ -262,6 +262,18 @@ describe('useUserProfileAddEmailController', () => { await waitFor(() => expect(email.start).toHaveBeenCalledTimes(2)); }); + it('tells the user when sending fails with a save that carries no errors', async () => { + const email = verifier(() => ({ method: 'code', sent: Promise.reject(new SaveError({})) })); + const { result } = renderHook(() => + useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }), + ); + + act(() => result.current.onVerifyEmail(other, email)); + await waitFor(() => expect(result.current.open).toBe(true)); + expect(result.current.step).toBe('verify'); + expect(result.current.errorMessage).toBe('Something went wrong. Please try again.'); + }); + it('shows the error for the field that failed', async () => { const { result } = renderHook(() => useUserProfileAddEmailController({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts index 3340d462079..163cef56ced 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts @@ -1,6 +1,8 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { save } from '../../../../utils/errors'; import { useUserProfileAddPhoneController } from '../user-profile-add-phone.controller'; const sendCode = vi.fn(() => Promise.resolve()); @@ -76,4 +78,14 @@ describe('useUserProfileAddPhoneController', () => { expect(sendCode).toHaveBeenCalledTimes(2); expect(result.current.resendSeconds).toBe(30); }); + + it('tells the user when sending a code fails with a Clerk response that carries no errors', async () => { + const empty = new ClerkAPIResponseError('Server error', { status: 500, data: [] }); + sendCode.mockImplementationOnce(() => save(() => Promise.reject(empty))); + + const result = await sendFirstCode(); + + expect(result.current.step).toBe('verify'); + expect(result.current.errorMessage).toBe('Something went wrong. Please try again.'); + }); }); diff --git a/packages/mosaic/src/utils/__tests__/errors.test.tsx b/packages/mosaic/src/utils/__tests__/errors.test.tsx index 9c8ce5c9fd4..7c4eb880b63 100644 --- a/packages/mosaic/src/utils/__tests__/errors.test.tsx +++ b/packages/mosaic/src/utils/__tests__/errors.test.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { isLocalizableError, MosaicLocalizationProvider, resolveLocalization, useErrorText } from '../../localization'; -import { save, SaveError, toLocalizableError, toLocalizableErrors } from '../errors'; +import { save, SaveError, toFormError, toLocalizableError, toLocalizableErrors, UNEXPECTED_ERROR } from '../errors'; function errorText() { const wrapper = ({ children }: { children: ReactNode }) => ( @@ -32,6 +32,12 @@ describe('save', () => { }); }); +describe('toFormError', () => { + it('reads a save that carried nothing as the generic error', () => { + expect(toFormError(new SaveError({})).global).toEqual(UNEXPECTED_ERROR); + }); +}); + describe('toLocalizableError', () => { it('reads the global error a save left, code and all', () => { const cause = new SaveError({ global: { code: 'action_blocked', message: 'Raw server sentence.' } }); diff --git a/packages/mosaic/src/utils/errors.ts b/packages/mosaic/src/utils/errors.ts index b30ce0eb457..fe4c771a3ec 100644 --- a/packages/mosaic/src/utils/errors.ts +++ b/packages/mosaic/src/utils/errors.ts @@ -126,7 +126,7 @@ export function toLocalizableErrors(cause: unknown): ErrorDescription[] { export function toFormError(cause: unknown): FormError { if (cause instanceof SaveError) { const { global, fields } = cause.formError; - return { global, fields }; + return global || fields ? { global, fields } : { global: UNEXPECTED_ERROR }; } console.error('[Clerk] Could not localize error', cause); return { global: UNEXPECTED_ERROR }; From d7a4a5f5be5d72d1fe253b5ca2778852201cc0f9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Nov 2026 19:08:34 -0500 Subject: [PATCH 2/2] test(mosaic): cover the empty send error in the add email and phone dialogs --- .../user-profile-add-email.controller.test.ts | 12 ------------ ...user-profile-email-section.feature.test.tsx | 18 ++++++++++++++++++ .../user-profile-add-phone.controller.test.ts | 12 ------------ ...user-profile-phone-section.feature.test.tsx | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts index 4654d96b0f9..1f38a4fa3d1 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-add-email.controller.test.ts @@ -262,18 +262,6 @@ describe('useUserProfileAddEmailController', () => { await waitFor(() => expect(email.start).toHaveBeenCalledTimes(2)); }); - it('tells the user when sending fails with a save that carries no errors', async () => { - const email = verifier(() => ({ method: 'code', sent: Promise.reject(new SaveError({})) })); - const { result } = renderHook(() => - useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }), - ); - - act(() => result.current.onVerifyEmail(other, email)); - await waitFor(() => expect(result.current.open).toBe(true)); - expect(result.current.step).toBe('verify'); - expect(result.current.errorMessage).toBe('Something went wrong. Please try again.'); - }); - it('shows the error for the field that failed', async () => { const { result } = renderHook(() => useUserProfileAddEmailController({ diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx index bd739117a6b..b9185a45480 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.feature.test.tsx @@ -1,15 +1,18 @@ import type { EmailAddressJSON, EnterpriseAccountConnectionJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { describe, expect, it, vi } from 'vitest'; import { demandReverificationOnce, type FakeFapiSeed, + fapiUrl, holdRequests, serveFapi, VERIFICATION_CODE, verifyEmailOutOfBand, + worker, } from '../../../../__tests__/feature/fake-fapi'; import { fapiClient, @@ -83,6 +86,21 @@ describe('the user profile email section', () => { expect(emailRow()).not.toHaveTextContent('Unverified'); }); + it('says something went wrong when sending the code fails without details', async () => { + const { actor } = await renderSection(signedInWithEmails([])); + worker.use( + http.post(fapiUrl('/v1/me/email_addresses/:id/prepare_verification'), () => + HttpResponse.json({ errors: [] }, { status: 500 }), + ), + ); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await actor.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByText('Something went wrong. Please try again.')).toBeInTheDocument(); + }); + it('holds the code field until the code has been sent', async () => { const { actor } = await renderSection(signedInWithEmails([])); const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts index 163cef56ced..3340d462079 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-add-phone.controller.test.ts @@ -1,8 +1,6 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; import { act, renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { save } from '../../../../utils/errors'; import { useUserProfileAddPhoneController } from '../user-profile-add-phone.controller'; const sendCode = vi.fn(() => Promise.resolve()); @@ -78,14 +76,4 @@ describe('useUserProfileAddPhoneController', () => { expect(sendCode).toHaveBeenCalledTimes(2); expect(result.current.resendSeconds).toBe(30); }); - - it('tells the user when sending a code fails with a Clerk response that carries no errors', async () => { - const empty = new ClerkAPIResponseError('Server error', { status: 500, data: [] }); - sendCode.mockImplementationOnce(() => save(() => Promise.reject(empty))); - - const result = await sendFirstCode(); - - expect(result.current.step).toBe('verify'); - expect(result.current.errorMessage).toBe('Something went wrong. Please try again.'); - }); }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx index 5bc04540616..4abe0f4d86b 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.feature.test.tsx @@ -1,14 +1,17 @@ import type { EnterpriseAccountConnectionJSON, PhoneNumberJSON } from '@clerk/shared/types'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; import { describe, expect, it } from 'vitest'; import { demandReverificationOnce, type FakeFapiSeed, + fapiUrl, holdRequests, serveFapi, VERIFICATION_CODE, + worker, } from '../../../../__tests__/feature/fake-fapi'; import type { FapiAttributeOverrides } from '../../../../__tests__/feature/fapi'; import { @@ -125,6 +128,19 @@ describe('the user profile phone section', () => { expect(screen.getByText('+49')).toBeInTheDocument(); }); + it('says something went wrong when sending the code fails without details', async () => { + const { actor } = await renderSection(); + worker.use( + http.post(fapiUrl('/v1/me/phone_numbers/:id/prepare_verification'), () => + HttpResponse.json({ errors: [] }, { status: 500 }), + ), + ); + + await addPhone(actor, '8015550100'); + + expect(await screen.findByText('Something went wrong. Please try again.')).toBeInTheDocument(); + }); + it('keeps the dialog open and says the code was wrong', async () => { const { actor } = await renderSection();