diff --git a/.changeset/busy-wolves-rush.md b/.changeset/busy-wolves-rush.md new file mode 100644 index 00000000000..f0920c7e7be --- /dev/null +++ b/.changeset/busy-wolves-rush.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/react': minor +--- + +Add support for email link based verification to SignUpFuture diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 3bd20ec6fee..272648511b7 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,7 +4,7 @@ { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, - { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, + { "path": "./dist/clerk.no-rhc.js", "maxSize": "307KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, { "path": "./dist/coinbase*.js", "maxSize": "36KB" }, diff --git a/packages/clerk-js/src/core/resources/SignUp.ts b/packages/clerk-js/src/core/resources/SignUp.ts index a5a2f0c5d4d..944369ae5c6 100644 --- a/packages/clerk-js/src/core/resources/SignUp.ts +++ b/packages/clerk-js/src/core/resources/SignUp.ts @@ -1,3 +1,4 @@ +import { inBrowser } from '@clerk/shared/browser'; import { type ClerkError, ClerkRuntimeError, isCaptchaError, isClerkAPIResponseError } from '@clerk/shared/error'; import { createValidatePassword } from '@clerk/shared/internal/clerk-js/passwords/password'; import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; @@ -24,6 +25,7 @@ import type { SignUpField, SignUpFutureCreateParams, SignUpFutureEmailCodeVerifyParams, + SignUpFutureEmailLinkSendParams, SignUpFutureFinalizeParams, SignUpFuturePasswordParams, SignUpFuturePhoneCodeSendParams, @@ -591,7 +593,12 @@ export class SignUp extends BaseResource implements SignUpResource { type SignUpFutureVerificationsMethods = Pick< SignUpFutureVerifications, - 'sendEmailCode' | 'verifyEmailCode' | 'sendPhoneCode' | 'verifyPhoneCode' + | 'sendEmailCode' + | 'verifyEmailCode' + | 'sendEmailLink' + | 'waitForEmailLinkVerification' + | 'sendPhoneCode' + | 'verifyPhoneCode' >; class SignUpFutureVerifications implements SignUpFutureVerificationsType { @@ -599,6 +606,8 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType { sendEmailCode: SignUpFutureVerificationsType['sendEmailCode']; verifyEmailCode: SignUpFutureVerificationsType['verifyEmailCode']; + sendEmailLink: SignUpFutureVerificationsType['sendEmailLink']; + waitForEmailLinkVerification: SignUpFutureVerificationsType['waitForEmailLinkVerification']; sendPhoneCode: SignUpFutureVerificationsType['sendPhoneCode']; verifyPhoneCode: SignUpFutureVerificationsType['verifyPhoneCode']; @@ -606,6 +615,8 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType { this.#resource = resource; this.sendEmailCode = methods.sendEmailCode; this.verifyEmailCode = methods.verifyEmailCode; + this.sendEmailLink = methods.sendEmailLink; + this.waitForEmailLinkVerification = methods.waitForEmailLinkVerification; this.sendPhoneCode = methods.sendPhoneCode; this.verifyPhoneCode = methods.verifyPhoneCode; } @@ -625,6 +636,30 @@ class SignUpFutureVerifications implements SignUpFutureVerificationsType { get externalAccount() { return this.#resource.verifications.externalAccount; } + + get emailLinkVerification() { + if (!inBrowser()) { + return null; + } + + const status = getClerkQueryParam('__clerk_status') as 'verified' | 'expired' | 'failed' | 'client_mismatch'; + const createdSessionId = getClerkQueryParam('__clerk_created_session'); + + if (!status || !createdSessionId) { + return null; + } + + const verifiedFromTheSameClient = + status === 'verified' && + typeof SignUp.clerk.client !== 'undefined' && + SignUp.clerk.client.sessions.some(s => s.id === createdSessionId); + + return { + status, + createdSessionId, + verifiedFromTheSameClient, + }; + } } class SignUpFuture implements SignUpFutureResource { @@ -638,6 +673,8 @@ class SignUpFuture implements SignUpFutureResource { this.verifications = new SignUpFutureVerifications(this.#resource, { sendEmailCode: this.sendEmailCode.bind(this), verifyEmailCode: this.verifyEmailCode.bind(this), + sendEmailLink: this.sendEmailLink.bind(this), + waitForEmailLinkVerification: this.waitForEmailLinkVerification.bind(this), sendPhoneCode: this.sendPhoneCode.bind(this), verifyPhoneCode: this.verifyPhoneCode.bind(this), }); @@ -833,6 +870,46 @@ class SignUpFuture implements SignUpFutureResource { }); } + async sendEmailLink(params: SignUpFutureEmailLinkSendParams): Promise<{ error: ClerkError | null }> { + const { verificationUrl } = params; + return runAsyncResourceTask(this.#resource, async () => { + let absoluteVerificationUrl = verificationUrl; + try { + new URL(verificationUrl); + } catch { + absoluteVerificationUrl = window.location.origin + verificationUrl; + } + + await this.#resource.__internal_basePost({ + body: { strategy: 'email_link', redirectUrl: absoluteVerificationUrl }, + action: 'prepare_verification', + }); + }); + } + + async waitForEmailLinkVerification(): Promise<{ error: ClerkError | null }> { + return runAsyncResourceTask(this.#resource, async () => { + const { run, stop } = Poller(); + await new Promise((resolve, reject) => { + void run(() => { + return this.#resource + .reload() + .then(res => { + const status = res.verifications.emailAddress.status; + if (status === 'verified' || status === 'expired') { + stop(); + resolve(res); + } + }) + .catch(err => { + stop(); + reject(err); + }); + }); + }); + }); + } + async sendPhoneCode(params: SignUpFuturePhoneCodeSendParams): Promise<{ error: ClerkError | null }> { const { phoneNumber, channel = 'sms' } = params; return runAsyncResourceTask(this.#resource, async () => { diff --git a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts index a55a90adcc1..270af93a3e3 100644 --- a/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts @@ -179,6 +179,156 @@ describe('SignUp', () => { }); }); + describe('sendEmailLink', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + }); + + it('prepares email link verification with absolute redirectUrl', async () => { + vi.stubGlobal('window', { location: { origin: 'https://example.com' } }); + + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.__internal_future.verifications.sendEmailLink({ verificationUrl: '/verify' }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ups/signup_123/prepare_verification', + body: { + strategy: 'email_link', + redirectUrl: 'https://example.com/verify', + }, + }); + }); + + it('keeps absolute verificationUrl unchanged', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + client: null, + response: { id: 'signup_123' }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.__internal_future.verifications.sendEmailLink({ + verificationUrl: 'https://other.com/verify', + }); + + expect(mockFetch).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sign_ups/signup_123/prepare_verification', + body: { + strategy: 'email_link', + redirectUrl: 'https://other.com/verify', + }, + }); + }); + }); + + describe('waitForEmailLinkVerification', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('polls until email verification status is verified', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + verifications: { email_address: { status: 'unverified' } }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + verifications: { email_address: { status: 'verified' } }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.__internal_future.verifications.waitForEmailLinkVerification(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/client/sign_ups/signup_123', + }), + expect.anything(), + ); + }); + + it('polls until email verification status is expired', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + verifications: { email_address: { status: 'unverified' } }, + }, + }) + .mockResolvedValueOnce({ + client: null, + response: { + id: 'signup_123', + verifications: { email_address: { status: 'expired' } }, + }, + }); + BaseResource._fetch = mockFetch; + + const signUp = new SignUp({ id: 'signup_123' } as any); + await signUp.__internal_future.verifications.waitForEmailLinkVerification(); + + expect(mockFetch).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + path: '/client/sign_ups/signup_123', + }), + expect.anything(), + ); + }); + }); + + describe('emailLinkVerification', () => { + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + SignUp.clerk = {} as any; + }); + + it('returns verification data when query params are present', () => { + vi.stubGlobal('window', { + location: { + href: 'https://example.com?__clerk_status=verified&__clerk_created_session=sess_123', + }, + }); + + SignUp.clerk = { + client: { + sessions: [{ id: 'sess_123' }], + }, + } as any; + + const signUp = new SignUp(); + const verification = signUp.__internal_future.verifications.emailLinkVerification; + + expect(verification).toEqual({ + status: 'verified', + createdSessionId: 'sess_123', + verifiedFromTheSameClient: true, + }); + }); + }); + describe('sso', () => { afterEach(() => { vi.clearAllMocks(); diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index 3482368dfee..6508c533046 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -325,13 +325,21 @@ export class StateProxy implements State { verifications: this.wrapStruct( () => target().verifications, - ['sendEmailCode', 'verifyEmailCode', 'sendPhoneCode', 'verifyPhoneCode'] as const, - ['emailAddress', 'phoneNumber', 'web3Wallet', 'externalAccount'] as const, + [ + 'sendEmailCode', + 'verifyEmailCode', + 'sendEmailLink', + 'waitForEmailLinkVerification', + 'sendPhoneCode', + 'verifyPhoneCode', + ] as const, + ['emailAddress', 'phoneNumber', 'web3Wallet', 'externalAccount', 'emailLinkVerification'] as const, { emailAddress: defaultSignUpVerificationResource(), phoneNumber: defaultSignUpVerificationResource(), web3Wallet: defaultSignUpVerificationResource(), externalAccount: defaultSignUpVerificationResource(), + emailLinkVerification: null, }, ), }, diff --git a/packages/shared/src/types/signUpFuture.ts b/packages/shared/src/types/signUpFuture.ts index b9b7a2e1551..3ae653b29de 100644 --- a/packages/shared/src/types/signUpFuture.ts +++ b/packages/shared/src/types/signUpFuture.ts @@ -85,6 +85,13 @@ export interface SignUpFutureEmailCodeVerifyParams { code: string; } +export interface SignUpFutureEmailLinkSendParams { + /** + * The full URL that the user will be redirected to when they visit the email link. + */ + verificationUrl: string; +} + export type SignUpFuturePasswordParams = SignUpFutureAdditionalParams & { /** * The user's password. Only supported if @@ -277,6 +284,26 @@ export interface SignUpFutureVerifications { */ readonly externalAccount: VerificationResource; + /** + * The verification status for email link flows. + */ + readonly emailLinkVerification: { + /** + * The verification status. + */ + status: 'verified' | 'expired' | 'failed' | 'client_mismatch'; + + /** + * The created session ID. + */ + createdSessionId: string; + + /** + * Whether the verification was from the same client. + */ + verifiedFromTheSameClient: boolean; + } | null; + /** * Used to send an email code to verify an email address. */ @@ -287,6 +314,16 @@ export interface SignUpFutureVerifications { */ verifyEmailCode: (params: SignUpFutureEmailCodeVerifyParams) => Promise<{ error: ClerkError | null }>; + /** + * Used to send an email link to verify an email address. + */ + sendEmailLink: (params: SignUpFutureEmailLinkSendParams) => Promise<{ error: ClerkError | null }>; + + /** + * Will wait for email link verification to complete or expire. + */ + waitForEmailLinkVerification: () => Promise<{ error: ClerkError | null }>; + /** * Used to send a phone code to verify a phone number. */