Skip to content

Commit bcf24f2

Browse files
authored
feat(clerk-js,clerk-react,types): Signal email link sign in support (#6766)
1 parent b6a3b10 commit bcf24f2

File tree

5 files changed

+198
-1
lines changed

5 files changed

+198
-1
lines changed

.changeset/free-rockets-flow.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/clerk-react': minor
4+
'@clerk/types': minor
5+
---
6+
7+
[Experimental] Signal email link support

packages/clerk-js/bundlewatch.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"files": [
33
{ "path": "./dist/clerk.js", "maxSize": "819KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "79KB" },
5-
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "120.2KB" },
5+
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "121KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "61KB" },
77
{ "path": "./dist/ui-common*.js", "maxSize": "117.1KB" },
88
{ "path": "./dist/ui-common*.legacy.*.js", "maxSize": "120KB" },

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { inBrowser } from '@clerk/shared/browser';
12
import { ClerkWebAuthnError } from '@clerk/shared/error';
23
import { Poller } from '@clerk/shared/poller';
34
import { deepCamelToSnake, deepSnakeToCamel } from '@clerk/shared/underscore';
@@ -31,6 +32,7 @@ import type {
3132
SignInFutureCreateParams,
3233
SignInFutureEmailCodeSendParams,
3334
SignInFutureEmailCodeVerifyParams,
35+
SignInFutureEmailLinkSendParams,
3436
SignInFutureFinalizeParams,
3537
SignInFutureMFAPhoneCodeVerifyParams,
3638
SignInFuturePasswordParams,
@@ -61,6 +63,7 @@ import {
6163
generateSignatureWithMetamask,
6264
generateSignatureWithOKXWallet,
6365
getBaseIdentifier,
66+
getClerkQueryParam,
6467
getCoinbaseWalletIdentifier,
6568
getMetamaskIdentifier,
6669
getOKXWalletIdentifier,
@@ -140,6 +143,14 @@ export class SignIn extends BaseResource implements SignInResource {
140143
*/
141144
__internal_basePost = this._basePost.bind(this);
142145

146+
/**
147+
* @internal Only used for internal purposes, and is not intended to be used directly.
148+
*
149+
* This property is used to provide access to underlying Client methods to `SignInFuture`, which wraps an instance
150+
* of `SignIn`.
151+
*/
152+
__internal_baseGet = this._baseGet.bind(this);
153+
143154
constructor(data: SignInJSON | SignInJSONSnapshot | null = null) {
144155
super();
145156
this.fromJSON(data);
@@ -539,6 +550,34 @@ class SignInFuture implements SignInFutureResource {
539550
verifyCode: this.verifyEmailCode.bind(this),
540551
};
541552

553+
emailLink = {
554+
sendLink: this.sendEmailLink.bind(this),
555+
waitForVerification: this.waitForEmailLinkVerification.bind(this),
556+
get verification() {
557+
if (!inBrowser()) {
558+
return null;
559+
}
560+
561+
const status = getClerkQueryParam('__clerk_status') as 'verified' | 'expired' | 'failed' | 'client_mismatch';
562+
const createdSessionId = getClerkQueryParam('__clerk_created_session');
563+
564+
if (!status || !createdSessionId) {
565+
return null;
566+
}
567+
568+
const verifiedFromTheSameClient =
569+
status === 'verified' &&
570+
typeof SignIn.clerk.client !== 'undefined' &&
571+
SignIn.clerk.client.sessions.some(s => s.id === createdSessionId);
572+
573+
return {
574+
status,
575+
createdSessionId,
576+
verifiedFromTheSameClient,
577+
};
578+
},
579+
};
580+
542581
resetPasswordEmailCode = {
543582
sendCode: this.sendResetPasswordEmailCode.bind(this),
544583
verifyCode: this.verifyResetPasswordEmailCode.bind(this),
@@ -583,6 +622,10 @@ class SignInFuture implements SignInFutureResource {
583622
return undefined;
584623
}
585624

625+
get firstFactorVerification() {
626+
return this.resource.firstFactorVerification;
627+
}
628+
586629
async sendResetPasswordEmailCode(): Promise<{ error: unknown }> {
587630
return runAsyncResourceTask(this.resource, async () => {
588631
if (!this.resource.id) {
@@ -680,6 +723,56 @@ class SignInFuture implements SignInFutureResource {
680723
});
681724
}
682725

726+
async sendEmailLink(params: SignInFutureEmailLinkSendParams): Promise<{ error: unknown }> {
727+
const { email, verificationUrl } = params;
728+
return runAsyncResourceTask(this.resource, async () => {
729+
if (!this.resource.id) {
730+
await this.create({ identifier: email });
731+
}
732+
733+
const emailLinkFactor = this.resource.supportedFirstFactors?.find(f => f.strategy === 'email_link');
734+
735+
if (!emailLinkFactor) {
736+
throw new Error('Email link factor not found');
737+
}
738+
739+
const { emailAddressId } = emailLinkFactor;
740+
741+
let absoluteVerificationUrl = verificationUrl;
742+
try {
743+
new URL(verificationUrl);
744+
} catch {
745+
absoluteVerificationUrl = window.location.origin + verificationUrl;
746+
}
747+
748+
await this.resource.__internal_basePost({
749+
body: { emailAddressId, redirectUrl: absoluteVerificationUrl, strategy: 'email_link' },
750+
action: 'prepare_first_factor',
751+
});
752+
});
753+
}
754+
755+
async waitForEmailLinkVerification(): Promise<{ error: unknown }> {
756+
return runAsyncResourceTask(this.resource, async () => {
757+
const { run, stop } = Poller();
758+
await new Promise((resolve, reject) => {
759+
void run(async () => {
760+
try {
761+
const res = await this.resource.__internal_baseGet();
762+
const status = res.firstFactorVerification.status;
763+
if (status === 'verified' || status === 'expired') {
764+
stop();
765+
resolve(res);
766+
}
767+
} catch (err) {
768+
stop();
769+
reject(err);
770+
}
771+
});
772+
});
773+
});
774+
}
775+
683776
async sendPhoneCode(params: SignInFuturePhoneCodeSendParams): Promise<{ error: unknown }> {
684777
const { phoneNumber, channel = 'sms' } = params;
685778
return runAsyncResourceTask(this.resource, async () => {
@@ -787,6 +880,9 @@ class SignInFuture implements SignInFutureResource {
787880
throw new Error('Cannot finalize sign-in without a created session.');
788881
}
789882

883+
// Reload the client to prevent an issue where the created session is not picked up.
884+
await SignIn.clerk.client?.reload();
885+
790886
await SignIn.clerk.setActive({ session: this.resource.createdSessionId, navigate });
791887
});
792888
}

packages/react/src/stateProxy.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class StateProxy implements State {
3535
}
3636

3737
private buildSignInProxy() {
38+
const gateProperty = this.gateProperty.bind(this);
3839
const target = () => this.client.signIn.__internal_future;
3940

4041
return {
@@ -44,13 +45,40 @@ export class StateProxy implements State {
4445
status: 'needs_identifier' as const,
4546
availableStrategies: [],
4647
isTransferable: false,
48+
get firstFactorVerification() {
49+
return gateProperty(target, 'firstFactorVerification', {
50+
status: null,
51+
error: null,
52+
expireAt: null,
53+
externalVerificationRedirectURL: null,
54+
nonce: null,
55+
attempts: null,
56+
message: null,
57+
strategy: null,
58+
verifiedAtClient: null,
59+
verifiedFromTheSameClient: () => false,
60+
__internal_toSnapshot: () => {
61+
throw new Error('__internal_toSnapshot called before Clerk is loaded');
62+
},
63+
pathRoot: '',
64+
reload: () => {
65+
throw new Error('__internal_toSnapshot called before Clerk is loaded');
66+
},
67+
});
68+
},
4769

4870
create: this.gateMethod(target, 'create'),
4971
password: this.gateMethod(target, 'password'),
5072
sso: this.gateMethod(target, 'sso'),
5173
finalize: this.gateMethod(target, 'finalize'),
5274

5375
emailCode: this.wrapMethods(() => target().emailCode, ['sendCode', 'verifyCode'] as const),
76+
emailLink: this.wrapStruct(
77+
() => target().emailLink,
78+
['sendLink', 'waitForVerification'] as const,
79+
['verification'] as const,
80+
{ verification: null },
81+
),
5482
resetPasswordEmailCode: this.wrapMethods(() => target().resetPasswordEmailCode, [
5583
'sendCode',
5684
'verifyCode',
@@ -148,4 +176,27 @@ export class StateProxy implements State {
148176
): Pick<T, K[number]> {
149177
return Object.fromEntries(keys.map(k => [k, this.gateMethod(getTarget, k)])) as Pick<T, K[number]>;
150178
}
179+
180+
private wrapStruct<T extends object, M extends readonly (keyof T)[], G extends readonly (keyof T)[]>(
181+
getTarget: () => T,
182+
methods: M,
183+
getters: G,
184+
fallbacks: Pick<T, G[number]>,
185+
): Pick<T, M[number] | G[number]> & {
186+
[K in M[number]]: T[K] extends (...args: any[]) => any ? T[K] : never;
187+
} & {
188+
[K in G[number]]: T[K] extends (...args: any[]) => any ? never : T[K];
189+
} {
190+
const out: any = {};
191+
for (const m of methods) {
192+
out[m] = this.gateMethod(getTarget, m as any);
193+
}
194+
for (const g of getters) {
195+
Object.defineProperty(out, g, {
196+
get: () => this.gateProperty(getTarget, g as any, (fallbacks as any)[g]),
197+
enumerable: true,
198+
});
199+
}
200+
return out;
201+
}
151202
}

packages/types/src/signInFuture.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { SetActiveNavigate } from './clerk';
22
import type { PhoneCodeChannel } from './phoneCodeChannel';
33
import type { SignInFirstFactor, SignInStatus } from './signInCommon';
44
import type { OAuthStrategy } from './strategies';
5+
import type { VerificationResource } from './verification';
56

67
export interface SignInFutureCreateParams {
78
identifier?: string;
@@ -41,6 +42,11 @@ export interface SignInFutureEmailCodeSendParams {
4142
email: string;
4243
}
4344

45+
export interface SignInFutureEmailLinkSendParams {
46+
email: string;
47+
verificationUrl: string;
48+
}
49+
4450
export interface SignInFutureEmailCodeVerifyParams {
4551
code: string;
4652
}
@@ -111,6 +117,8 @@ export interface SignInFutureResource {
111117

112118
readonly existingSession?: { sessionId: string };
113119

120+
readonly firstFactorVerification: VerificationResource;
121+
114122
/**
115123
* Used to supply an identifier for the sign-in attempt. Calling this method will populate data on the sign-in
116124
* attempt, such as `signIn.resource.supportedFirstFactors`.
@@ -137,6 +145,41 @@ export interface SignInFutureResource {
137145
verifyCode: (params: SignInFutureEmailCodeVerifyParams) => Promise<{ error: unknown }>;
138146
};
139147

148+
/**
149+
*
150+
*/
151+
emailLink: {
152+
/**
153+
* Used to send an email link to sign-in
154+
*/
155+
sendLink: (params: SignInFutureEmailLinkSendParams) => Promise<{ error: unknown }>;
156+
157+
/**
158+
* Will wait for verification to complete or expire
159+
*/
160+
waitForVerification: () => Promise<{ error: unknown }>;
161+
162+
/**
163+
* The verification status
164+
*/
165+
verification: {
166+
/**
167+
* The verification status
168+
*/
169+
status: 'verified' | 'expired' | 'failed' | 'client_mismatch';
170+
171+
/**
172+
* The created session ID
173+
*/
174+
createdSessionId: string;
175+
176+
/**
177+
* Whether the verification was from the same client
178+
*/
179+
verifiedFromTheSameClient: boolean;
180+
} | null;
181+
};
182+
140183
/**
141184
*
142185
*/

0 commit comments

Comments
 (0)