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
2 changes: 2 additions & 0 deletions .changeset/contact-verify-pending.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -33,6 +34,7 @@ export function UserProfileContactListRowView({
items,
onAdd,
onVerify,
pendingVerifyId,
onSetPrimary,
onRemove,
addAction,
Expand Down Expand Up @@ -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) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface UserProfileAddEmailControllerOptions {
export interface UserProfileAddEmailController extends UserProfileAddEmailDialogProps {
handle: DialogHandle<unknown>;
verifyingId: string | undefined;
pendingVerifyId: string | undefined;
onVerifyEmail: (email: Pick<UserProfileEmail, 'id' | 'value'>, verifier: UserProfileEmailVerifier) => void;
}

Expand All @@ -39,6 +40,7 @@ interface Context {
verification: UserProfileEmailVerification | undefined;
error: FormError | undefined;
resendSeconds: number;
held: boolean;
}

type Event =
Expand Down Expand Up @@ -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',
Expand All @@ -88,6 +101,7 @@ const machine = createMachine({
verification: undefined,
error: undefined,
resendSeconds: 0,
held: false,
},
states: {
idle: {
Expand All @@ -102,7 +116,7 @@ const machine = createMachine({
resendSeconds: 0,
})),
},
START: start,
START: { ...start, actions: [start.actions, assign(() => ({ held: true }))] },
},
},
email: {
Expand All @@ -116,23 +130,16 @@ 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' };
}
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(
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function UserProfileEmailSectionView({
onRemove={remove ? email => remove.open(email) : undefined}
onSetPrimary={onSetPrimary}
onVerify={onVerify}
pendingVerifyId={verification.pendingVerifyId}
>
<Section.Error>{error}</Section.Error>
</UserProfileContactListRowView>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface UserProfileAddPhoneControllerOptions {
export interface UserProfileAddPhoneController extends UserProfileAddPhoneDialogProps {
handle: DialogHandle<unknown>;
verifyingId: string | undefined;
pendingVerifyId: string | undefined;
onVerifyPhone: (phone: Pick<UserProfilePhone, 'id' | 'value'>, verifier: UserProfilePhoneVerifier) => void;
}

Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function UserProfilePhoneSectionView({
onRemove={remove ? phone => remove.open(phone) : undefined}
onSetPrimary={onSetPrimary}
onVerify={onVerify}
pendingVerifyId={verification.pendingVerifyId}
>
<Section.Error>{error}</Section.Error>
</UserProfileContactListRowView>
Expand Down
Loading