Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-phones-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/mosaic': patch
---

Keep the phone entry screen pending until the first verification code is sent in `UserProfile`. Disable Cancel while verifying or resending a phone verification code.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import {
demandReverificationOnce,
Expand Down Expand Up @@ -125,6 +125,55 @@ describe('the user profile phone section', () => {
expect(screen.getByText('+49')).toBeInTheDocument();
});

it('holds the phone entry step until the first code is sent', async () => {
const { actor, fapi } = await renderSection();
const prepare = holdRequests('post', '/v1/me/phone_numbers/:id/prepare_verification');

try {
await addPhone(actor, '8015550100');
await waitFor(() => expect(prepare.requests).toHaveLength(1));
expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeVisible();
expect(screen.getByRole('textbox', { name: 'Phone' })).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByRole('button', { name: 'Send code' })).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('button', { name: 'Send code' })).toHaveAttribute('aria-disabled', 'true');
expect(screen.queryByRole('textbox', { name: 'Verification code' })).toBeNull();
await actor.keyboard('{Escape}{Enter}');
expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeVisible();
expect(prepare.requests).toHaveLength(1);
expect(fapi.client.sessions[0]?.user.phone_numbers).toHaveLength(1);
} finally {
prepare.release();
}

await enterCode(actor, VERIFICATION_CODE);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(listed()).toEqual([HOME_LABEL]);
});

it('recovers from a failed first code without creating the phone again', async () => {
const { actor, fapi } = await renderSection();
const prepare = holdRequests('post', '/v1/me/phone_numbers/:id/prepare_verification');

try {
await addPhone(actor, '8015550100');
await waitFor(() => expect(prepare.requests).toHaveLength(1));
expect(screen.getByRole('dialog', { name: 'Add phone number' })).toBeVisible();
prepare.fail('phone_number_invalid', 'Unable to send a code.');
} finally {
prepare.release();
}

expect(await screen.findByText('Unable to send a code.')).toBeVisible();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
const retryFapi = serveFapi(fapi);
await actor.click(screen.getByRole('button', { name: 'Resend' }));
expect(await screen.findByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled();
await enterCode(actor, VERIFICATION_CODE);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(retryFapi.client.sessions[0]?.user.phone_numbers).toHaveLength(1);
expect(listed()).toEqual([HOME_LABEL]);
});

it('keeps the dialog open and says the code was wrong', async () => {
const { actor } = await renderSection();

Expand All @@ -143,6 +192,56 @@ describe('the user profile phone section', () => {
expect(await screen.findByRole('button', { name: /Resend \(\d+\)/ })).toBeDisabled();
});

it('disables Cancel while verifying a phone code and allows cancellation after an error', async () => {
const { actor } = await renderSection();
await addPhone(actor, '8015550100');
const attempt = holdRequests('post', '/v1/me/phone_numbers/:id/attempt_verification');

try {
await enterCode(actor, '000000');
await waitFor(() => expect(attempt.requests).toHaveLength(1));
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBeVisible();
} finally {
attempt.release();
}

expect(await screen.findByText('Incorrect code')).toBeVisible();
const cancel = screen.getByRole('button', { name: 'Cancel' });
expect(cancel).toBeEnabled();
await actor.click(cancel);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});

it('disables Cancel while resending a phone code and allows cancellation once sent', async ({ onTestFinished }) => {
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => vi.useRealTimers());
await renderSection();
const actor = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await addPhone(actor, '8015550100');
await screen.findByRole('textbox', { name: 'Verification code' });
const prepare = holdRequests('post', '/v1/me/phone_numbers/:id/prepare_verification');

try {
for (let second = 0; second < 30; second++) {
await act(() => vi.advanceTimersByTimeAsync(1000));
}
const resend = screen.getByRole('button', { name: /Resend/ });
await waitFor(() => expect(resend).toBeEnabled());
await actor.click(resend);
await waitFor(() => expect(prepare.requests).toHaveLength(1));
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByRole('dialog', { name: 'Verify your phone number' })).toBeVisible();
} finally {
prepare.release();
}

const cancel = screen.getByRole('button', { name: 'Cancel' });
await waitFor(() => expect(cancel).toBeEnabled());
await actor.click(cancel);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});

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]));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const CODE_RESEND_SECONDS = 30;
const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) };
const fail = assign<ErrorInvokeEvent>((_, event) => ({ error: toFormError(event.error) }));
const start = {
target: 'sending',
target: 'sendingFirstCode',
actions: assign<Extract<Event, { type: 'START' }>>((_, event) => ({
verifyingId: event.id,
phoneNumber: event.phoneNumber,
Expand Down Expand Up @@ -95,6 +95,7 @@ const machine = createMachine({
on: { CANCEL: 'idle', START: start },
},
preparing: sendCode,
sendingFirstCode: sendCode,
sending: sendCode,
verify: {
on: {
Expand Down Expand Up @@ -148,9 +149,10 @@ export function useUserProfileAddPhoneController({
}, [open, resendSeconds, send]);

const isReverifying = phoneForm.isSubmitting && Boolean(reverification?.visible);
const step = isReverifying ? 'reverify' : state === 'phone' ? 'phone' : 'verify';
const form = state === 'phone' ? phoneForm : codeForm;
const formError = state === 'phone' ? phoneForm.fields.phoneNumber.feedback : codeForm.fields.code.feedback;
const isPhoneStep = state === 'phone' || state === 'sendingFirstCode';
const step = isReverifying ? 'reverify' : isPhoneStep ? 'phone' : 'verify';
const form = isPhoneStep ? phoneForm : codeForm;
const formError = isPhoneStep ? phoneForm.fields.phoneNumber.feedback : codeForm.fields.code.feedback;
const machineError = error?.global ? errorText(error.global) : undefined;

return {
Expand All @@ -160,10 +162,10 @@ export function useUserProfileAddPhoneController({
open,
step,
reverification,
phoneNumber: state === 'phone' ? phoneForm.values.phoneNumber : snapshot.context.phoneNumber,
phoneNumber: isPhoneStep ? phoneForm.values.phoneNumber : snapshot.context.phoneNumber,
code: codeForm.values.code,
errorMessage: formError?.message ?? form.error ?? machineError,
isPending: form.isSubmitting,
isPending: form.isSubmitting || state === 'sendingFirstCode',
onOpenChange: open => {
if (form.isSubmitting) {
if (open || state !== 'phone' || !reverification?.onCancel) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export function VerifyPhoneStep(props: VerifyPhoneStepProps) {
<Card.Footer>
{props.secondaryAction ?? (
<Dialog.Close
disabled={props.isPending || props.isResending}
render={
<Button
variant='outline'
Expand Down
Loading