Skip to content

Commit 1bd1747

Browse files
dstaleyjacekradko
andauthored
feat(clerk-js,shared): SignUp email link verification (#7745)
Co-authored-by: Jacek Radko <jacek@clerk.dev>
1 parent 8902e21 commit 1bd1747

5 files changed

Lines changed: 282 additions & 3 deletions

File tree

.changeset/busy-wolves-rush.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/clerk-js': minor
3+
'@clerk/shared': minor
4+
'@clerk/react': minor
5+
---
6+
7+
Add support for email link based verification to SignUpFuture

packages/clerk-js/src/core/resources/SignUp.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { inBrowser } from '@clerk/shared/browser';
12
import { type ClerkError, ClerkRuntimeError, isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error';
23
import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password';
34
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
@@ -24,6 +25,7 @@ import type {
2425
SignUpField,
2526
SignUpFutureCreateParams,
2627
SignUpFutureEmailCodeVerifyParams,
28+
SignUpFutureEmailLinkSendParams,
2729
SignUpFutureFinalizeParams,
2830
SignUpFuturePasswordParams,
2931
SignUpFuturePhoneCodeSendParams,
@@ -591,21 +593,30 @@ export class SignUp extends BaseResource implements SignUpResource {
591593

592594
type SignUpFutureVerificationsMethods = Pick<
593595
SignUpFutureVerifications,
594-
'sendEmailCode' | 'verifyEmailCode' | 'sendPhoneCode' | 'verifyPhoneCode'
596+
| 'sendEmailCode'
597+
| 'verifyEmailCode'
598+
| 'sendEmailLink'
599+
| 'waitForEmailLinkVerification'
600+
| 'sendPhoneCode'
601+
| 'verifyPhoneCode'
595602
>;
596603

597604
class SignUpFutureVerifications implements SignUpFutureVerificationsType {
598605
#resource: SignUp;
599606

600607
sendEmailCode: SignUpFutureVerificationsType['sendEmailCode'];
601608
verifyEmailCode: SignUpFutureVerificationsType['verifyEmailCode'];
609+
sendEmailLink: SignUpFutureVerificationsType['sendEmailLink'];
610+
waitForEmailLinkVerification: SignUpFutureVerificationsType['waitForEmailLinkVerification'];
602611
sendPhoneCode: SignUpFutureVerificationsType['sendPhoneCode'];
603612
verifyPhoneCode: SignUpFutureVerificationsType['verifyPhoneCode'];
604613

605614
constructor(resource: SignUp, methods: SignUpFutureVerificationsMethods) {
606615
this.#resource = resource;
607616
this.sendEmailCode = methods.sendEmailCode;
608617
this.verifyEmailCode = methods.verifyEmailCode;
618+
this.sendEmailLink = methods.sendEmailLink;
619+
this.waitForEmailLinkVerification = methods.waitForEmailLinkVerification;
609620
this.sendPhoneCode = methods.sendPhoneCode;
610621
this.verifyPhoneCode = methods.verifyPhoneCode;
611622
}
@@ -625,6 +636,30 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType {
625636
get externalAccount() {
626637
return this.#resource.verifications.externalAccount;
627638
}
639+
640+
get emailLinkVerification() {
641+
if (!inBrowser()) {
642+
return null;
643+
}
644+
645+
const status = getClerkQueryParam('__clerk_status') as 'verified' | 'expired' | 'failed' | 'client_mismatch';
646+
const createdSessionId = getClerkQueryParam('__clerk_created_session');
647+
648+
if (!status || !createdSessionId) {
649+
return null;
650+
}
651+
652+
const verifiedFromTheSameClient =
653+
status === 'verified' &&
654+
typeof SignUp.clerk.client !== 'undefined' &&
655+
SignUp.clerk.client.sessions.some(s => s.id === createdSessionId);
656+
657+
return {
658+
status,
659+
createdSessionId,
660+
verifiedFromTheSameClient,
661+
};
662+
}
628663
}
629664

630665
class SignUpFuture implements SignUpFutureResource {
@@ -638,6 +673,8 @@ class SignUpFuture implements SignUpFutureResource {
638673
this.verifications = new SignUpFutureVerifications(this.#resource, {
639674
sendEmailCode: this.sendEmailCode.bind(this),
640675
verifyEmailCode: this.verifyEmailCode.bind(this),
676+
sendEmailLink: this.sendEmailLink.bind(this),
677+
waitForEmailLinkVerification: this.waitForEmailLinkVerification.bind(this),
641678
sendPhoneCode: this.sendPhoneCode.bind(this),
642679
verifyPhoneCode: this.verifyPhoneCode.bind(this),
643680
});
@@ -833,6 +870,46 @@ class SignUpFuture implements SignUpFutureResource {
833870
});
834871
}
835872

873+
async sendEmailLink(params: SignUpFutureEmailLinkSendParams): Promise<{ error: ClerkError | null }> {
874+
const { verificationUrl } = params;
875+
return runAsyncResourceTask(this.#resource, async () => {
876+
let absoluteVerificationUrl = verificationUrl;
877+
try {
878+
new URL(verificationUrl);
879+
} catch {
880+
absoluteVerificationUrl = window.location.origin + verificationUrl;
881+
}
882+
883+
await this.#resource.__internal_basePost({
884+
body: { strategy: 'email_link', redirectUrl: absoluteVerificationUrl },
885+
action: 'prepare_verification',
886+
});
887+
});
888+
}
889+
890+
async waitForEmailLinkVerification(): Promise<{ error: ClerkError | null }> {
891+
return runAsyncResourceTask(this.#resource, async () => {
892+
const { run, stop } = Poller();
893+
await new Promise((resolve, reject) => {
894+
void run(() => {
895+
return this.#resource
896+
.reload()
897+
.then(res => {
898+
const status = res.verifications.emailAddress.status;
899+
if (status === 'verified' || status === 'expired') {
900+
stop();
901+
resolve(res);
902+
}
903+
})
904+
.catch(err => {
905+
stop();
906+
reject(err);
907+
});
908+
});
909+
});
910+
});
911+
}
912+
836913
async sendPhoneCode(params: SignUpFuturePhoneCodeSendParams): Promise<{ error: ClerkError | null }> {
837914
const { phoneNumber, channel = 'sms' } = params;
838915
return runAsyncResourceTask(this.#resource, async () => {

packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,156 @@ describe('SignUp', () => {
179179
});
180180
});
181181

182+
describe('sendEmailLink', () => {
183+
afterEach(() => {
184+
vi.clearAllMocks();
185+
vi.unstubAllGlobals();
186+
});
187+
188+
it('prepares email link verification with absolute redirectUrl', async () => {
189+
vi.stubGlobal('window', { location: { origin: 'https://example.com' } });
190+
191+
const mockFetch = vi.fn().mockResolvedValue({
192+
client: null,
193+
response: { id: 'signup_123' },
194+
});
195+
BaseResource._fetch = mockFetch;
196+
197+
const signUp = new SignUp({ id: 'signup_123' } as any);
198+
await signUp.__internal_future.verifications.sendEmailLink({ verificationUrl: '/verify' });
199+
200+
expect(mockFetch).toHaveBeenCalledWith({
201+
method: 'POST',
202+
path: '/client/sign_ups/signup_123/prepare_verification',
203+
body: {
204+
strategy: 'email_link',
205+
redirectUrl: 'https://example.com/verify',
206+
},
207+
});
208+
});
209+
210+
it('keeps absolute verificationUrl unchanged', async () => {
211+
const mockFetch = vi.fn().mockResolvedValue({
212+
client: null,
213+
response: { id: 'signup_123' },
214+
});
215+
BaseResource._fetch = mockFetch;
216+
217+
const signUp = new SignUp({ id: 'signup_123' } as any);
218+
await signUp.__internal_future.verifications.sendEmailLink({
219+
verificationUrl: 'https://other.com/verify',
220+
});
221+
222+
expect(mockFetch).toHaveBeenCalledWith({
223+
method: 'POST',
224+
path: '/client/sign_ups/signup_123/prepare_verification',
225+
body: {
226+
strategy: 'email_link',
227+
redirectUrl: 'https://other.com/verify',
228+
},
229+
});
230+
});
231+
});
232+
233+
describe('waitForEmailLinkVerification', () => {
234+
afterEach(() => {
235+
vi.clearAllMocks();
236+
});
237+
238+
it('polls until email verification status is verified', async () => {
239+
const mockFetch = vi
240+
.fn()
241+
.mockResolvedValueOnce({
242+
client: null,
243+
response: {
244+
id: 'signup_123',
245+
verifications: { email_address: { status: 'unverified' } },
246+
},
247+
})
248+
.mockResolvedValueOnce({
249+
client: null,
250+
response: {
251+
id: 'signup_123',
252+
verifications: { email_address: { status: 'verified' } },
253+
},
254+
});
255+
BaseResource._fetch = mockFetch;
256+
257+
const signUp = new SignUp({ id: 'signup_123' } as any);
258+
await signUp.__internal_future.verifications.waitForEmailLinkVerification();
259+
260+
expect(mockFetch).toHaveBeenCalledWith(
261+
expect.objectContaining({
262+
method: 'GET',
263+
path: '/client/sign_ups/signup_123',
264+
}),
265+
expect.anything(),
266+
);
267+
});
268+
269+
it('polls until email verification status is expired', async () => {
270+
const mockFetch = vi
271+
.fn()
272+
.mockResolvedValueOnce({
273+
client: null,
274+
response: {
275+
id: 'signup_123',
276+
verifications: { email_address: { status: 'unverified' } },
277+
},
278+
})
279+
.mockResolvedValueOnce({
280+
client: null,
281+
response: {
282+
id: 'signup_123',
283+
verifications: { email_address: { status: 'expired' } },
284+
},
285+
});
286+
BaseResource._fetch = mockFetch;
287+
288+
const signUp = new SignUp({ id: 'signup_123' } as any);
289+
await signUp.__internal_future.verifications.waitForEmailLinkVerification();
290+
291+
expect(mockFetch).toHaveBeenCalledWith(
292+
expect.objectContaining({
293+
method: 'GET',
294+
path: '/client/sign_ups/signup_123',
295+
}),
296+
expect.anything(),
297+
);
298+
});
299+
});
300+
301+
describe('emailLinkVerification', () => {
302+
afterEach(() => {
303+
vi.clearAllMocks();
304+
vi.unstubAllGlobals();
305+
SignUp.clerk = {} as any;
306+
});
307+
308+
it('returns verification data when query params are present', () => {
309+
vi.stubGlobal('window', {
310+
location: {
311+
href: 'https://example.com?__clerk_status=verified&__clerk_created_session=sess_123',
312+
},
313+
});
314+
315+
SignUp.clerk = {
316+
client: {
317+
sessions: [{ id: 'sess_123' }],
318+
},
319+
} as any;
320+
321+
const signUp = new SignUp();
322+
const verification = signUp.__internal_future.verifications.emailLinkVerification;
323+
324+
expect(verification).toEqual({
325+
status: 'verified',
326+
createdSessionId: 'sess_123',
327+
verifiedFromTheSameClient: true,
328+
});
329+
});
330+
});
331+
182332
describe('sso', () => {
183333
afterEach(() => {
184334
vi.clearAllMocks();

packages/react/src/stateProxy.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,13 +325,21 @@ export class StateProxy implements State {
325325

326326
verifications: this.wrapStruct(
327327
() => target().verifications,
328-
['sendEmailCode', 'verifyEmailCode', 'sendPhoneCode', 'verifyPhoneCode'] as const,
329-
['emailAddress', 'phoneNumber', 'web3Wallet', 'externalAccount'] as const,
328+
[
329+
'sendEmailCode',
330+
'verifyEmailCode',
331+
'sendEmailLink',
332+
'waitForEmailLinkVerification',
333+
'sendPhoneCode',
334+
'verifyPhoneCode',
335+
] as const,
336+
['emailAddress', 'phoneNumber', 'web3Wallet', 'externalAccount', 'emailLinkVerification'] as const,
330337
{
331338
emailAddress: defaultSignUpVerificationResource(),
332339
phoneNumber: defaultSignUpVerificationResource(),
333340
web3Wallet: defaultSignUpVerificationResource(),
334341
externalAccount: defaultSignUpVerificationResource(),
342+
emailLinkVerification: null,
335343
},
336344
),
337345
},

packages/shared/src/types/signUpFuture.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ export interface SignUpFutureEmailCodeVerifyParams {
8585
code: string;
8686
}
8787

88+
export interface SignUpFutureEmailLinkSendParams {
89+
/**
90+
* The full URL that the user will be redirected to when they visit the email link.
91+
*/
92+
verificationUrl: string;
93+
}
94+
8895
export type SignUpFuturePasswordParams = SignUpFutureAdditionalParams & {
8996
/**
9097
* The user's password. Only supported if
@@ -277,6 +284,26 @@ export interface SignUpFutureVerifications {
277284
*/
278285
readonly externalAccount: VerificationResource;
279286

287+
/**
288+
* The verification status for email link flows.
289+
*/
290+
readonly emailLinkVerification: {
291+
/**
292+
* The verification status.
293+
*/
294+
status: 'verified' | 'expired' | 'failed' | 'client_mismatch';
295+
296+
/**
297+
* The created session ID.
298+
*/
299+
createdSessionId: string;
300+
301+
/**
302+
* Whether the verification was from the same client.
303+
*/
304+
verifiedFromTheSameClient: boolean;
305+
} | null;
306+
280307
/**
281308
* Used to send an email code to verify an email address.
282309
*/
@@ -287,6 +314,16 @@ export interface SignUpFutureVerifications {
287314
*/
288315
verifyEmailCode: (params: SignUpFutureEmailCodeVerifyParams) => Promise<{ error: ClerkError | null }>;
289316

317+
/**
318+
* Used to send an email link to verify an email address.
319+
*/
320+
sendEmailLink: (params: SignUpFutureEmailLinkSendParams) => Promise<{ error: ClerkError | null }>;
321+
322+
/**
323+
* Will wait for email link verification to complete or expire.
324+
*/
325+
waitForEmailLinkVerification: () => Promise<{ error: ClerkError | null }>;
326+
290327
/**
291328
* Used to send a phone code to verify a phone number.
292329
*/

0 commit comments

Comments
 (0)