diff --git a/.changeset/contact-verify-pending.md b/.changeset/contact-verify-pending.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/contact-verify-pending.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx index d4ed46d1525..46e05dfc421 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-contact-list-row.view.tsx @@ -22,6 +22,7 @@ export interface UserProfileContactListRowViewProps { items: UserProfileContact[]; onAdd?: () => void; onVerify?: (id: string) => void; + pendingVerifyId?: string; onSetPrimary?: (id: string) => void; onRemove?: (item: UserProfileContact) => void; children?: ReactNode; @@ -33,6 +34,7 @@ export function UserProfileContactListRowView({ items, onAdd, onVerify, + pendingVerifyId, onSetPrimary, onRemove, addAction, @@ -89,6 +91,7 @@ export function UserProfileContactListRowView({ actions.push({ label: item.isDefault ? m.completeVerification : m[kind].verify, onClick: () => onVerify(item.id), + status: pendingVerifyId === item.id ? 'pending' : 'idle', }); } else if (item.isVerified && !item.isDefault && onSetPrimary) { actions.push({ label: m.setPrimary, onClick: () => onSetPrimary(item.id) }); 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 f17f65de695..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 @@ -192,18 +192,27 @@ describe('useUserProfileAddEmailController', () => { expect(email.verifyCode).toHaveBeenCalledExactlyOnceWith('123456'); }); - it('verifies an existing email without creating one', async () => { + it('verifies an existing email without creating one, opening once its code is sent', async () => { const onCreate = vi.fn(created); - const email = verifier(); + const sent = createDeferredPromise(); + const email = verifier(() => ({ method: 'code', sent: sent.promise.then(() => undefined) })); const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification, onCreate }), ); act(() => result.current.onVerifyEmail(other, email)); + expect(email.start).toHaveBeenCalledOnce(); + expect(result.current.open).toBe(false); + expect(result.current.pendingVerifyId).toBe('email_2'); + + await act(async () => { + sent.resolve(); + await sent.promise; + }); expect(result.current.open).toBe(true); + expect(result.current.pendingVerifyId).toBeUndefined(); 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)); 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 5086ae72501..caff235f122 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 @@ -114,6 +114,27 @@ describe('the user profile email section', () => { ); }); + it('holds the menu open with a spinner on Verify until the code is sent', async () => { + const pending = fapiEmailAddress({ id: 'idn_pending', email_address: 'pending@example.com' }); + const { actor } = await renderSection(signedInWithEmails([PRIMARY, pending])); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + + try { + await manageEmail(actor, 'pending@example.com', 'Verify'); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + expect(screen.getByRole('menu')).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Verify' })).toHaveAttribute('aria-busy', 'true'); + expect(await screen.findByRole('progressbar', { name: 'Verify' })).toBeVisible(); + expect(screen.queryByRole('dialog')).toBeNull(); + } finally { + prepare.release(); + } + + await enterCode(actor, VERIFICATION_CODE); + expect(screen.queryByRole('menu')).toBeNull(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + 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)); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts index 5b48368eb39..cf7ca679023 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts @@ -29,6 +29,7 @@ export interface UserProfileAddEmailControllerOptions { export interface UserProfileAddEmailController extends UserProfileAddEmailDialogProps { handle: DialogHandle; verifyingId: string | undefined; + pendingVerifyId: string | undefined; onVerifyEmail: (email: Pick, verifier: UserProfileEmailVerifier) => void; } @@ -39,6 +40,7 @@ interface Context { verification: UserProfileEmailVerification | undefined; error: FormError | undefined; resendSeconds: number; + held: boolean; } type Event = @@ -75,9 +77,20 @@ const start = { verification: undefined, error: undefined, resendSeconds: 0, + held: false, })), }; +const sendCode = { + invoke: fromPromise( + (context: Context) => (context.verification?.method === 'code' ? context.verification.sent : missingDependency()), + { + onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS, held: false })) }, + onError: { target: 'verify', actions: [fail, assign(() => ({ held: false }))] }, + }, + ), +}; + const machine = createMachine({ id: 'addEmail', initial: 'idle', @@ -88,6 +101,7 @@ const machine = createMachine({ verification: undefined, error: undefined, resendSeconds: 0, + held: false, }, states: { idle: { @@ -102,7 +116,7 @@ const machine = createMachine({ resendSeconds: 0, })), }, - START: start, + START: { ...start, actions: [start.actions, assign(() => ({ held: true }))] }, }, }, email: { @@ -116,7 +130,7 @@ const machine = createMachine({ always: ({ context }) => { const method = context.verification?.method; if (method === 'code') { - return { target: 'sending' }; + return { target: context.held ? 'preparing' : 'sending' }; } if (method === 'sso') { return { target: 'connecting' }; @@ -124,15 +138,8 @@ const machine = createMachine({ return { target: 'waiting', context: { resendSeconds: method === 'link' ? LINK_RESEND_SECONDS : 0 } }; }, }, - sending: { - 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 }, - }, - ), - }, + preparing: sendCode, + sending: sendCode, waiting: { on: { CANCEL: 'idle', TICK: tick, RESEND: resend }, invoke: fromPromise( @@ -194,7 +201,7 @@ export function useUserProfileAddEmailController({ }, }); const state = snapshot.value; - const open = state !== 'idle'; + const open = state !== 'idle' && state !== 'preparing'; const waitingFor = state === 'waiting' && verification?.method === 'link' ? verification : undefined; useEffect(() => waitingFor?.cancel, [waitingFor]); useEffect(() => { @@ -260,6 +267,7 @@ export function useUserProfileAddEmailController({ }, onConnect: () => send({ type: 'CONNECT' }), verifyingId: snapshot.context.verifyingId, + pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined, onVerifyEmail: (email, verifier) => { codeForm.reset(); send({ type: 'START', id: email.id, emailAddress: email.value, verifier }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx index 11c03774c45..2e3de1fd1ce 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-email-section.view.tsx @@ -59,6 +59,7 @@ export function UserProfileEmailSectionView({ onRemove={remove ? email => remove.open(email) : undefined} onSetPrimary={onSetPrimary} onVerify={onVerify} + pendingVerifyId={verification.pendingVerifyId} > {error} 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 1fd442dabb0..b796c5bf17d 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 @@ -155,6 +155,27 @@ describe('the user profile phone section', () => { await waitFor(() => expect(within(row()).getByText(WORK_LABEL).parentElement).not.toHaveTextContent('Unverified')); }); + it('holds the menu open with a spinner on Verify until the code is sent', async () => { + const pending = fapiPhoneNumber({ id: 'idn_new', phone_number: '+18015550199' }); + const { actor } = await renderSection(signedIn([HOME, pending])); + const prepare = holdRequests('post', '/v1/me/phone_numbers/:id/prepare_verification'); + + try { + await manage(actor, WORK_LABEL, 'Verify phone number'); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + expect(screen.getByRole('menu')).toBeVisible(); + expect(screen.getByRole('menuitem', { name: 'Verify phone number' })).toHaveAttribute('aria-busy', 'true'); + expect(await screen.findByRole('progressbar', { name: 'Verify phone number' })).toBeVisible(); + expect(screen.queryByRole('dialog')).toBeNull(); + } finally { + prepare.release(); + } + + await enterCode(actor, VERIFICATION_CODE); + expect(screen.queryByRole('menu')).toBeNull(); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + it('makes another verified number the primary one', async () => { const { actor } = await renderSection(signedIn([HOME, WORK])); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts index 7e6bf1d0a0b..12690e02cd0 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-add-phone.controller.ts @@ -23,6 +23,7 @@ export interface UserProfileAddPhoneControllerOptions { export interface UserProfileAddPhoneController extends UserProfileAddPhoneDialogProps { handle: DialogHandle; verifyingId: string | undefined; + pendingVerifyId: string | undefined; onVerifyPhone: (phone: Pick, verifier: UserProfilePhoneVerifier) => void; } @@ -63,6 +64,13 @@ const start = { })), }; +const sendCode = { + invoke: fromPromise((context: Context) => (context.verifier ? context.verifier.sendCode() : missingDependency()), { + onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, + onError: { target: 'verify', actions: fail }, + }), +}; + const machine = createMachine({ id: 'addPhone', initial: 'idle', @@ -80,18 +88,14 @@ const machine = createMachine({ target: 'phone', actions: assign(() => ({ verifyingId: undefined, verifier: undefined, error: undefined, resendSeconds: 0 })), }, - START: start, + START: { ...start, target: 'preparing' }, }, }, phone: { on: { CANCEL: 'idle', START: start }, }, - sending: { - invoke: fromPromise(context => (context.verifier ? context.verifier.sendCode() : missingDependency()), { - onDone: { target: 'verify', actions: assign(() => ({ resendSeconds: CODE_RESEND_SECONDS })) }, - onError: { target: 'verify', actions: fail }, - }), - }, + preparing: sendCode, + sending: sendCode, verify: { on: { CANCEL: 'idle', @@ -134,7 +138,7 @@ export function useUserProfileAddPhoneController({ }, }); const state = snapshot.value; - const open = state !== 'idle'; + const open = state !== 'idle' && state !== 'preparing'; useEffect(() => { if (!open || resendSeconds === 0) { return; @@ -195,6 +199,7 @@ export function useUserProfileAddPhoneController({ } }, verifyingId: snapshot.context.verifyingId, + pendingVerifyId: state === 'preparing' ? snapshot.context.verifyingId : undefined, onVerifyPhone: (phone, verifier) => { codeForm.reset(); send({ type: 'START', id: phone.id, phoneNumber: phone.value, verifier }); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx index cd13d1f4408..ffcfe7bf5dc 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/user-profile-phone-section.view.tsx @@ -60,6 +60,7 @@ export function UserProfilePhoneSectionView({ onRemove={remove ? phone => remove.open(phone) : undefined} onSetPrimary={onSetPrimary} onVerify={onVerify} + pendingVerifyId={verification.pendingVerifyId} > {error}