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
7 changes: 7 additions & 0 deletions .changeset/busy-wolves-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/react': minor
---

Add support for email link based verification to SignUpFuture
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hyphenate the compound adjective.

The phrase "email link based" should be hyphenated as "email-link-based" when used as a compound adjective modifying "verification".

✏️ Proposed fix
-Add support for email link based verification to SignUpFuture
+Add support for email-link-based verification to SignUpFuture
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Add support for email link based verification to SignUpFuture
Add support for email-link-based verification to SignUpFuture
🧰 Tools
🪛 LanguageTool

[grammar] ~7-~7: Use a hyphen to join words.
Context: ...': minor --- Add support for email link based verification to SignUpFuture

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
In @.changeset/busy-wolves-rush.md at line 7, Update the phrase "email link
based verification" to the hyphenated compound adjective "email-link-based
verification" in the changelog text (the sentence containing "Add support for
email link based verification"); search for that exact phrase and replace it
with the hyphenated form to ensure proper grammar.

2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
79 changes: 78 additions & 1 deletion packages/clerk-js/src/core/resources/SignUp.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -24,6 +25,7 @@ import type {
SignUpField,
SignUpFutureCreateParams,
SignUpFutureEmailCodeVerifyParams,
SignUpFutureEmailLinkSendParams,
SignUpFutureFinalizeParams,
SignUpFuturePasswordParams,
SignUpFuturePhoneCodeSendParams,
Expand Down Expand Up @@ -591,21 +593,30 @@ 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 {
#resource: SignUp;

sendEmailCode: SignUpFutureVerificationsType['sendEmailCode'];
verifyEmailCode: SignUpFutureVerificationsType['verifyEmailCode'];
sendEmailLink: SignUpFutureVerificationsType['sendEmailLink'];
waitForEmailLinkVerification: SignUpFutureVerificationsType['waitForEmailLinkVerification'];
sendPhoneCode: SignUpFutureVerificationsType['sendPhoneCode'];
verifyPhoneCode: SignUpFutureVerificationsType['verifyPhoneCode'];

constructor(resource: SignUp, methods: SignUpFutureVerificationsMethods) {
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;
}
Expand All @@ -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' &&
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is SignUp.clerk guaranteed to be there?

SignUp.clerk.client.sessions.some(s => s.id === createdSessionId);

return {
status,
createdSessionId,
verifiedFromTheSameClient,
};
}
}

class SignUpFuture implements SignUpFutureResource {
Expand All @@ -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),
});
Expand Down Expand Up @@ -833,6 +870,46 @@ class SignUpFuture implements SignUpFutureResource {
});
}

async sendEmailLink(params: SignUpFutureEmailLinkSendParams): Promise<{ error: ClerkError | null }> {
const { verificationUrl } = params;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need a client-only guard?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a what

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

inBrowser() check

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, none of the other methods do that so probably no?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. This one uses window explicitly, hence the question

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') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these all of the statuses that should short-circuit the poller? Saw these also 'failed' | 'client_mismatch'

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, the existing createEmailLinkFlow only checks for those statuses https://github.com/clerk/javascript/blob/main/packages/clerk-js/src/core/resources/SignIn.ts#L285

I think (maybe?) those statuses are possible in the __clerk_status parameter, not the verifications property on the SignUp resource.

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 () => {
Expand Down
150 changes: 150 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/SignUp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions packages/react/src/stateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
),
},
Expand Down
37 changes: 37 additions & 0 deletions packages/shared/src/types/signUpFuture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand Down
Loading