Skip to content
Closed
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/email-link-prepared-signal.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 @@ -10,12 +10,13 @@ export type UserProfileEmail = UserProfileContact;
export type UserProfilePhone = UserProfileContact;

export type UserProfileEmailVerification =
| { method: 'code'; sent: Promise<void> }
| { method: 'code' }
| { method: 'link'; verified: Promise<void>; cancel: () => void }
| { method: 'sso'; connect: () => Promise<void> };

export interface UserProfileEmailVerifier {
start: () => UserProfileEmailVerification;
prepare: () => Promise<UserProfileEmailVerification>;
cancel: () => void;
verifyCode: (code: string) => Promise<void>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { useUserProfileAddEmailController } from '../user-profile-add-email.cont

const idleReverification = { status: 'idle', visible: false } satisfies ReverificationController;

const sentCode = (): UserProfileEmailVerification => ({ method: 'code', sent: Promise.resolve() });
const sentCode = (): UserProfileEmailVerification => ({ method: 'code' });

function sentLink(verified: Promise<void>, cancel = vi.fn()) {
return (): UserProfileEmailVerification => ({ method: 'link', verified, cancel });
Expand All @@ -19,7 +19,11 @@ function verifier(
start: () => UserProfileEmailVerification = sentCode,
verifyCode: (code: string) => Promise<void> = () => Promise.resolve(),
) {
return { start: vi.fn(start), verifyCode: vi.fn(verifyCode) } satisfies UserProfileEmailVerifier;
return {
prepare: vi.fn(async () => start()),
cancel: vi.fn(),
verifyCode: vi.fn(verifyCode),
} satisfies UserProfileEmailVerifier;
}

const created = () => Promise.resolve(verifier());
Expand Down Expand Up @@ -125,7 +129,7 @@ describe('useUserProfileAddEmailController', () => {
});
expect(result.current.resendSeconds).toBe(30);
act(() => result.current.onResend());
expect(email.start).toHaveBeenCalledTimes(1);
expect(email.prepare).toHaveBeenCalledTimes(1);
for (let second = 0; second < 30; second++) {
await act(async () => vi.advanceTimersByTimeAsync(1000));
}
Expand All @@ -138,7 +142,7 @@ describe('useUserProfileAddEmailController', () => {
result.current.onOpenChange(false);
await Promise.resolve();
});
expect(email.start).toHaveBeenCalledTimes(2);
expect(email.prepare).toHaveBeenCalledTimes(2);
expect(email.verifyCode).not.toHaveBeenCalled();
expect(result.current.open).toBe(true);
expect(result.current.code).toBe('');
Expand Down Expand Up @@ -185,7 +189,7 @@ describe('useUserProfileAddEmailController', () => {
expect(result.current.open).toBe(true);
await waitFor(() => expect(result.current.step).toBe('verify'));
expect(onCreate).toHaveBeenCalledExactlyOnceWith('new@example.com');
await waitFor(() => expect(email.start).toHaveBeenCalledOnce());
await waitFor(() => expect(email.prepare).toHaveBeenCalledOnce());

act(() => result.current.onSubmit('123456'));
await waitFor(() => expect(result.current.open).toBe(false));
Expand All @@ -199,11 +203,11 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
);

act(() => result.current.onVerifyEmail(other, email));
await act(() => result.current.onVerifyEmail(other, 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());
await waitFor(() => expect(email.prepare).toHaveBeenCalledOnce());

act(() => result.current.onSubmit('123456'));
await waitFor(() => expect(result.current.open).toBe(false));
Expand All @@ -218,11 +222,11 @@ describe('useUserProfileAddEmailController', () => {
);
expect(result.current.verifyingId).toBeUndefined();

act(() => result.current.onVerifyEmail(other, email));
await act(() => result.current.onVerifyEmail(other, email));
expect(result.current.verifyingId).toBe('email_2');
await waitFor(() => expect(result.current.step).toBe('verify'));

act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
await act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
expect(result.current.verifyingId).toBe('email_2');
expect(result.current.emailAddress).toBe('other@example.com');

Expand All @@ -234,23 +238,62 @@ describe('useUserProfileAddEmailController', () => {
expect(result.current.verifyingId).toBeUndefined();
});

it('stays on the code step when sending fails, and lets the user resend at once', async () => {
it('creates a different email when the value changes after failed preparation', async () => {
const email = verifier();
email.start.mockImplementationOnce(() => ({
method: 'code',
sent: Promise.reject(saveError('Too many requests')),
}));
email.prepare.mockRejectedValueOnce(saveError('Try again'));
const onCreate = vi.fn(async () => email);
const { result } = renderHook(() =>
useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }),
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
);
act(() => result.current.onOpenChange(true));
act(() => result.current.onEmailAddressChange('first@example.com'));
await act(() => result.current.onSubmit());
await waitFor(() => expect(result.current.errorMessage).toBe('Try again'));
act(() => result.current.onEmailAddressChange('second@example.com'));
await act(() => result.current.onSubmit());
await waitFor(() => expect(result.current.step).toBe('verify'));
expect(onCreate.mock.calls).toEqual([['first@example.com'], ['second@example.com']]);
});

act(() => result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests'));
it('keeps the code input and zero cooldown after failed resend', async () => {
vi.useFakeTimers();
const email = verifier();
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
await act(() => result.current.onVerifyEmail(other, email));
for (let second = 0; second < 30; second++) {
await act(async () => vi.advanceTimersByTimeAsync(1000));
}
email.prepare.mockRejectedValueOnce(saveError('Send failed'));
await act(() => result.current.onResend());
expect(result.current.step).toBe('verify');
expect(result.current.errorMessage).toBe('Send failed');
expect(result.current.resendSeconds).toBe(0);
});

act(() => result.current.onResend());
await waitFor(() => expect(email.start).toHaveBeenCalledTimes(2));
it('preserves a failed resend error after the previous link expired', async () => {
vi.useFakeTimers();
const expired = createDeferredPromise();
const email = verifier(sentLink(expired.promise.then(() => undefined)));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
await act(() => result.current.onVerifyEmail(other, email));
await act(async () => {
expired.reject(saveError('Link expired'));
await Promise.resolve();
});
expect(result.current.errorMessage).toBe('Link expired');
for (let second = 0; second < 60; second++) {
await act(async () => vi.advanceTimersByTimeAsync(1000));
}
email.prepare.mockRejectedValueOnce(saveError('Too many requests'));
await act(() => result.current.onResend());
expect(result.current.step).toBe('link');
expect(result.current.errorMessage).toBe('Too many requests');
expect(result.current.resendSeconds).toBe(0);
email.prepare.mockResolvedValueOnce(sentLink(new Promise(() => {}))());
await act(() => result.current.onResend());
expect(result.current.step).toBe('link');
expect(result.current.resendSeconds).toBe(60);
expect(result.current.errorMessage).toBeUndefined();
});

it('shows the error for the field that failed', async () => {
Expand All @@ -270,7 +313,7 @@ describe('useUserProfileAddEmailController', () => {
const verified = createDeferredPromise();
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));

act(() => result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined)))));
await act(() => result.current.onVerifyEmail(other, verifier(sentLink(verified.promise.then(() => undefined)))));
expect(result.current.step).toBe('link');
expect(result.current.resendSeconds).toBe(60);

Expand All @@ -288,12 +331,12 @@ describe('useUserProfileAddEmailController', () => {
useUserProfileAddEmailController({ reverification: idleReverification }),
);

act(() => result.current.onVerifyEmail(other, email));
await act(() => result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.step).toBe('link'));
act(() => result.current.onOpenChange(false));
expect(cancel).toHaveBeenCalledOnce();

act(() => result.current.onVerifyEmail(other, email));
await act(() => result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.step).toBe('link'));
unmount();
expect(cancel).toHaveBeenCalledTimes(2);
Expand All @@ -303,7 +346,7 @@ describe('useUserProfileAddEmailController', () => {
const email = verifier(sentLink(Promise.reject(saveError('Link expired'))));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));

act(() => result.current.onVerifyEmail(other, email));
await act(() => result.current.onVerifyEmail(other, email));
await waitFor(() => expect(result.current.errorMessage).toBe('Link expired'));
expect(result.current.step).toBe('link');
expect(result.current.open).toBe(true);
Expand All @@ -314,7 +357,7 @@ describe('useUserProfileAddEmailController', () => {
const email = verifier(() => ({ method: 'sso', connect }));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));

act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
await act(() => result.current.onVerifyEmail({ id: 'email_3', value: 'person@acme.co' }, email));
expect(result.current.step).toBe('sso');
expect(result.current.resendSeconds).toBe(0);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,17 @@ describe('UserProfileAddEmailDialog', () => {
await user.click(screen.getByRole('button', { name: 'Didn’t receive a link? Resend' }));
expect(props.onResend).toHaveBeenCalledOnce();

rerender(
<MosaicProvider>
<UserProfileAddEmailDialog
{...props}
resendSeconds={0}
isResending
/>
</MosaicProvider>,
);
expect(screen.getByRole('button', { name: 'Sending link…' })).toBeDisabled();

await user.click(screen.getByRole('button', { name: 'Cancel' }));
expect(props.onOpenChange).toHaveBeenCalledWith(false, expect.anything());
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import type { ReadyEmailSectionModel } from '../user-profile-email-section.types
import { UserProfileEmailSectionView } from '../user-profile-email-section.view';

const codeVerifier = {
start: () => ({ method: 'code', sent: Promise.resolve() }) as const,
prepare: async () => ({ method: 'code' }) as const,
cancel: () => {},
verifyCode: () => Promise.resolve(),
};

Expand Down
Loading
Loading