Skip to content
Draft
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 @@
---
---
27 changes: 23 additions & 4 deletions packages/clerk-js/src/core/resources/EmailAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
EmailAddressResource,
IdentificationLinkResource,
PrepareEmailAddressVerificationParams,
StartEmailLinkFlowParams,
StartEmailAddressLinkFlowParams,
StartEnterpriseSSOLinkFlowParams,
VerificationResource,
} from '@clerk/shared/types';
Expand Down Expand Up @@ -51,15 +51,28 @@ export class EmailAddress extends BaseResource implements EmailAddressResource {
});
};

createEmailLinkFlow = (): CreateEmailLinkFlowReturn<StartEmailLinkFlowParams, EmailAddressResource> => {
createEmailLinkFlow = (): CreateEmailLinkFlowReturn<StartEmailAddressLinkFlowParams, EmailAddressResource> => {
const { run, stop } = Poller();

const startEmailLinkFlow = async ({ redirectUrl }: StartEmailLinkFlowParams): Promise<EmailAddressResource> => {
let operation = 0;

const startEmailLinkFlow = async ({
redirectUrl,
onPrepared,
}: StartEmailAddressLinkFlowParams): Promise<EmailAddressResource> => {
const currentOperation = ++operation;
await this.prepareVerification({
strategy: 'email_link',
redirectUrl: redirectUrl,
});
return new Promise((resolve, reject) => {
if (currentOperation !== operation) {
return;
}
onPrepared?.();
if (currentOperation !== operation) {
return;
}
void run(() => {
return this.reload()
.then(res => {
Expand All @@ -75,7 +88,13 @@ export class EmailAddress extends BaseResource implements EmailAddressResource {
});
});
};
return { startEmailLinkFlow, cancelEmailLinkFlow: stop };
return {
startEmailLinkFlow,
cancelEmailLinkFlow: () => {
operation++;
stop();
},
};
};

createEnterpriseSSOLinkFlow = (): CreateEnterpriseSSOLinkFlowReturn<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,67 @@ describe('EmailAddress', () => {
});
});
});

describe('email link readiness', () => {
afterEach(() => vi.restoreAllMocks());

it('signals successful preparation without completing the verification promise', async () => {
const email = new EmailAddress({ id: 'email_123' }, '/me/email_addresses');
const held = createDeferredPromise();
vi.spyOn(email, 'prepareVerification').mockImplementation(async () => {
await held.promise;
return email;
});
const reload = vi.spyOn(email, 'reload').mockImplementation(() => new Promise(() => {}));
const prepared = vi.fn();
const complete = vi.fn();
const flow = email.createEmailLinkFlow();
void flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared }).then(complete);
expect(prepared).not.toHaveBeenCalled();
held.resolve();
await vi.waitFor(() => expect(prepared).toHaveBeenCalledOnce());
expect(reload).toHaveBeenCalledOnce();
expect(complete).not.toHaveBeenCalled();
flow.cancelEmailLinkFlow();
});

it.each(['during preparation', 'from readiness'] as const)(
'does not start polling when cancelled %s',
async phase => {
const email = new EmailAddress({ id: 'email_123' }, '/me/email_addresses');
const held = createDeferredPromise();
vi.spyOn(email, 'prepareVerification').mockImplementation(async () => {
await held.promise;
return email;
});
const reload = vi.spyOn(email, 'reload');
const flow = email.createEmailLinkFlow();
const prepared = vi.fn(() => flow.cancelEmailLinkFlow());
void flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared });
if (phase === 'during preparation') {
flow.cancelEmailLinkFlow();
}
held.resolve();
await held.promise;
await Promise.resolve();
await Promise.resolve();
expect(prepared).toHaveBeenCalledTimes(phase === 'during preparation' ? 0 : 1);
expect(reload).not.toHaveBeenCalled();
},
);

it('rejects failed preparation without reporting readiness and allows retry', async () => {
const email = new EmailAddress({ id: 'email_123' }, '/me/email_addresses');
vi.spyOn(email, 'prepareVerification').mockRejectedValueOnce(new Error('send failed')).mockResolvedValue(email);
email.verification.status = 'verified';
vi.spyOn(email, 'reload').mockResolvedValue(email);
const prepared = vi.fn();
const flow = email.createEmailLinkFlow();
await expect(flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared })).rejects.toThrow(
'send failed',
);
expect(prepared).not.toHaveBeenCalled();
await expect(flow.startEmailLinkFlow({ redirectUrl: '/verify', onPrepared: prepared })).resolves.toBe(email);
expect(prepared).toHaveBeenCalledOnce();
});
});
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,117 @@ 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('keeps preparation failure at the source and retries without creating another email', async () => {
const email = verifier();
email.start.mockImplementationOnce(() => ({
method: 'code',
sent: Promise.reject(saveError('Too many requests')),
}));
email.prepare.mockRejectedValueOnce(saveError('Too many requests'));
const onCreate = vi.fn(async () => email);
const { result } = renderHook(() =>
useUserProfileAddEmailController({ reverification: idleReverification, onCreate: created }),
useUserProfileAddEmailController({ reverification: idleReverification, onCreate }),
);

act(() => result.current.onVerifyEmail(other, email));
act(() => result.current.onOpenChange(true));
act(() => result.current.onEmailAddressChange('new@example.com'));
await act(() => result.current.onSubmit());
await waitFor(() => expect(result.current.errorMessage).toBe('Too many requests'));
expect(result.current.step).toBe('verify');
expect(result.current.step).toBe('email');
expect(result.current.resendSeconds).toBe(0);
await act(() => result.current.onSubmit());
await waitFor(() => expect(result.current.step).toBe('verify'));
expect(onCreate).toHaveBeenCalledOnce();
expect(email.prepare).toHaveBeenCalledTimes(2);
});

it('creates a different email when the value changes after failed preparation', async () => {
const email = verifier();
email.prepare.mockRejectedValueOnce(saveError('Try again'));
const onCreate = vi.fn(async () => email);
const { result } = renderHook(() =>
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']]);
});

it.each(['code', 'link'] as const)(
'keeps the ready %s destination through failed resend and starts cooldown after success',
async method => {
vi.useFakeTimers();
const email = verifier(method === 'link' ? sentLink(new Promise(() => {})) : sentCode);
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
await act(() => result.current.onVerifyEmail(other, email));
for (let second = 0; second < (method === 'link' ? 60 : 30); second++) {
await act(async () => vi.advanceTimersByTimeAsync(1000));
}
const held = createDeferredPromise();
email.prepare.mockImplementationOnce(async () => {
await held.promise;
return sentCode();
});
act(() => result.current.onResend());
expect(result.current.step).toBe(method === 'link' ? 'link' : 'verify');
expect(result.current.isResending).toBe(true);
expect(result.current.resendSeconds).toBe(0);
await act(async () => {
held.reject(saveError('Send failed'));
await Promise.resolve();
});
expect(result.current.step).toBe(method === 'link' ? 'link' : 'verify');
expect(result.current.errorMessage).toBe('Send failed');
expect(result.current.resendSeconds).toBe(0);
await act(() => result.current.onResend());
expect(result.current.resendSeconds).toBe(method === 'link' ? 60 : 30);
expect(result.current.errorMessage).toBeUndefined();
},
);

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);
});

it('cancels a held link resend without reopening the dialog when it settles', async () => {
vi.useFakeTimers();
const email = verifier(sentLink(new Promise(() => {})));
const { result } = renderHook(() => useUserProfileAddEmailController({ reverification: idleReverification }));
await act(() => result.current.onVerifyEmail(other, email));
for (let second = 0; second < 60; second++) {
await act(async () => vi.advanceTimersByTimeAsync(1000));
}
const held = createDeferredPromise();
email.prepare.mockImplementationOnce(async () => {
await held.promise;
return sentLink(new Promise(() => {}))();
});
act(() => result.current.onResend());
await waitFor(() => expect(email.start).toHaveBeenCalledTimes(2));
act(() => result.current.onOpenChange(false));
expect(email.cancel).toHaveBeenCalled();
expect(result.current.open).toBe(false);
await act(async () => {
held.resolve();
await held.promise;
});
expect(result.current.open).toBe(false);
});

it('shows the error for the field that failed', async () => {
Expand All @@ -270,7 +368,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 +386,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 +401,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 +412,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
Loading
Loading