Skip to content

Commit b971d0b

Browse files
authored
feat(clerk-js,react,shared): Add support for reset password via phone code (#7824)
1 parent da2c6a6 commit b971d0b

File tree

5 files changed

+227
-0
lines changed

5 files changed

+227
-0
lines changed

.changeset/lazy-eagles-lay.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 resetting a password via phone code.

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import type {
4848
SignInFuturePasswordParams,
4949
SignInFuturePhoneCodeSendParams,
5050
SignInFuturePhoneCodeVerifyParams,
51+
SignInFutureResetPasswordPhoneCodeSendParams,
52+
SignInFutureResetPasswordPhoneCodeVerifyParams,
5153
SignInFutureResetPasswordSubmitParams,
5254
SignInFutureResource,
5355
SignInFutureSSOParams,
@@ -640,6 +642,12 @@ class SignInFuture implements SignInFutureResource {
640642
submitPassword: this.submitResetPassword.bind(this),
641643
};
642644

645+
resetPasswordPhoneCode = {
646+
sendCode: this.sendResetPasswordPhoneCode.bind(this),
647+
verifyCode: this.verifyResetPasswordPhoneCode.bind(this),
648+
submitPassword: this.submitResetPassword.bind(this),
649+
};
650+
643651
phoneCode = {
644652
sendCode: this.sendPhoneCode.bind(this),
645653
verifyCode: this.verifyPhoneCode.bind(this),
@@ -751,6 +759,51 @@ class SignInFuture implements SignInFutureResource {
751759
});
752760
}
753761

762+
async sendResetPasswordPhoneCode(
763+
params: SignInFutureResetPasswordPhoneCodeSendParams = {},
764+
): Promise<{ error: ClerkError | null }> {
765+
const { phoneNumber } = params;
766+
if (!this.#resource.id && !phoneNumber) {
767+
throw new Error(
768+
'signIn.resetPasswordPhoneCode.sendCode() cannot be called without a phoneNumber if an existing signIn does not exist.',
769+
);
770+
}
771+
772+
return runAsyncResourceTask(this.#resource, async () => {
773+
if (phoneNumber) {
774+
await this._create({ identifier: phoneNumber });
775+
}
776+
777+
const resetPasswordPhoneCodeFactor = this.#resource.supportedFirstFactors?.find(
778+
f => f.strategy === 'reset_password_phone_code',
779+
);
780+
781+
if (!resetPasswordPhoneCodeFactor) {
782+
throw new ClerkRuntimeError('Reset password phone code factor not found', {
783+
code: 'factor_not_found',
784+
});
785+
}
786+
787+
const { phoneNumberId } = resetPasswordPhoneCodeFactor;
788+
await this.#resource.__internal_basePost({
789+
body: { phoneNumberId, strategy: 'reset_password_phone_code' },
790+
action: 'prepare_first_factor',
791+
});
792+
});
793+
}
794+
795+
async verifyResetPasswordPhoneCode(
796+
params: SignInFutureResetPasswordPhoneCodeVerifyParams,
797+
): Promise<{ error: ClerkError | null }> {
798+
const { code } = params;
799+
return runAsyncResourceTask(this.#resource, async () => {
800+
await this.#resource.__internal_basePost({
801+
body: { code, strategy: 'reset_password_phone_code' },
802+
action: 'attempt_first_factor',
803+
});
804+
});
805+
}
806+
754807
async submitResetPassword(params: SignInFutureResetPasswordSubmitParams): Promise<{ error: ClerkError | null }> {
755808
const { password, signOutOfOtherSessions = true } = params;
756809
return runAsyncResourceTask(this.#resource, async () => {

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

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,133 @@ describe('SignIn', () => {
890890
});
891891
});
892892

893+
describe('sendResetPasswordPhoneCode', () => {
894+
afterEach(() => {
895+
vi.clearAllMocks();
896+
});
897+
898+
it('creates signIn with phoneNumber when no existing signIn', async () => {
899+
const mockFetch = vi
900+
.fn()
901+
.mockResolvedValueOnce({
902+
client: null,
903+
response: {
904+
id: 'signin_123',
905+
identifier: '+15551234567',
906+
supported_first_factors: [
907+
{
908+
strategy: 'reset_password_phone_code',
909+
phone_number_id: 'phone_123',
910+
safe_identifier: '+15551234567',
911+
},
912+
],
913+
},
914+
})
915+
.mockResolvedValueOnce({
916+
client: null,
917+
response: { id: 'signin_123' },
918+
});
919+
BaseResource._fetch = mockFetch;
920+
921+
const signIn = new SignIn();
922+
await signIn.__internal_future.resetPasswordPhoneCode.sendCode({ phoneNumber: '+15551234567' });
923+
924+
expect(mockFetch).toHaveBeenNthCalledWith(1, {
925+
method: 'POST',
926+
path: '/client/sign_ins',
927+
body: { identifier: '+15551234567' },
928+
});
929+
930+
expect(mockFetch).toHaveBeenNthCalledWith(2, {
931+
method: 'POST',
932+
path: '/client/sign_ins/signin_123/prepare_first_factor',
933+
body: {
934+
phoneNumberId: 'phone_123',
935+
strategy: 'reset_password_phone_code',
936+
},
937+
});
938+
});
939+
940+
it('prepares first factor with reset password phone code', async () => {
941+
const mockFetch = vi.fn().mockResolvedValue({
942+
client: null,
943+
response: { id: 'signin_123' },
944+
});
945+
BaseResource._fetch = mockFetch;
946+
947+
const signIn = new SignIn({
948+
id: 'signin_123',
949+
supported_first_factors: [
950+
{
951+
strategy: 'reset_password_phone_code',
952+
phone_number_id: 'phone_123',
953+
safe_identifier: '+15551234567',
954+
},
955+
],
956+
} as any);
957+
await signIn.__internal_future.resetPasswordPhoneCode.sendCode();
958+
959+
expect(mockFetch).toHaveBeenCalledWith({
960+
method: 'POST',
961+
path: '/client/sign_ins/signin_123/prepare_first_factor',
962+
body: {
963+
phoneNumberId: 'phone_123',
964+
strategy: 'reset_password_phone_code',
965+
},
966+
});
967+
});
968+
969+
it('throws error when no signIn ID and no phoneNumber', async () => {
970+
const signIn = new SignIn();
971+
972+
await expect(signIn.__internal_future.resetPasswordPhoneCode.sendCode()).rejects.toThrow();
973+
});
974+
975+
it('returns error when reset password phone code factor not found', async () => {
976+
const mockFetch = vi.fn().mockResolvedValue({
977+
client: null,
978+
response: {
979+
id: 'signin_123',
980+
identifier: '+15551234567',
981+
supported_first_factors: [{ strategy: 'password' }],
982+
},
983+
});
984+
BaseResource._fetch = mockFetch;
985+
986+
const signIn = new SignIn();
987+
const result = await signIn.__internal_future.resetPasswordPhoneCode.sendCode({ phoneNumber: '+15551234567' });
988+
989+
expect(result.error).toBeTruthy();
990+
expect(result.error?.code).toBe('factor_not_found');
991+
});
992+
});
993+
994+
describe('verifyResetPasswordPhoneCode', () => {
995+
afterEach(() => {
996+
vi.clearAllMocks();
997+
});
998+
999+
it('attempts first factor with reset password phone code', async () => {
1000+
const mockFetch = vi.fn().mockResolvedValue({
1001+
client: null,
1002+
response: { id: 'signin_123' },
1003+
});
1004+
BaseResource._fetch = mockFetch;
1005+
1006+
const signIn = new SignIn({ id: 'signin_123' } as any);
1007+
await signIn.__internal_future.resetPasswordPhoneCode.verifyCode({ code: '123456' });
1008+
1009+
expect(mockFetch).toHaveBeenCalledWith({
1010+
method: 'POST',
1011+
path: '/client/sign_ins/signin_123/attempt_first_factor',
1012+
body: {
1013+
code: '123456',
1014+
strategy: 'reset_password_phone_code',
1015+
},
1016+
});
1017+
});
1018+
});
1019+
8931020
describe('submitResetPassword', () => {
8941021
afterEach(() => {
8951022
vi.clearAllMocks();

packages/react/src/stateProxy.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ export class StateProxy implements State {
225225
'verifyCode',
226226
'submitPassword',
227227
] as const),
228+
resetPasswordPhoneCode: this.wrapMethods(() => target().resetPasswordPhoneCode, [
229+
'sendCode',
230+
'verifyCode',
231+
'submitPassword',
232+
] as const),
228233
phoneCode: this.wrapMethods(() => target().phoneCode, ['sendCode', 'verifyCode'] as const),
229234
mfa: this.wrapMethods(() => target().mfa, [
230235
'sendPhoneCode',

packages/shared/src/types/signInFuture.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,14 @@ export interface SignInFutureResetPasswordSubmitParams {
138138
signOutOfOtherSessions?: boolean;
139139
}
140140

141+
export interface SignInFutureResetPasswordPhoneCodeSendParams {
142+
/**
143+
* The user's phone number in [E.164 format](https://en.wikipedia.org/wiki/E.164). Only supported if
144+
* [phone number](https://clerk.com/docs/guides/configure/auth-strategies/sign-up-sign-in-options#phone) is enabled.
145+
*/
146+
phoneNumber?: string;
147+
}
148+
141149
export type SignInFuturePhoneCodeSendParams = {
142150
/**
143151
* The mechanism to use to send the code to the provided phone number. Defaults to `'sms'`.
@@ -168,6 +176,13 @@ export interface SignInFuturePhoneCodeVerifyParams {
168176
code: string;
169177
}
170178

179+
export interface SignInFutureResetPasswordPhoneCodeVerifyParams {
180+
/**
181+
* The one-time code that was sent to the user.
182+
*/
183+
code: string;
184+
}
185+
171186
export interface SignInFutureSSOParams {
172187
/**
173188
* The strategy to use for authentication.
@@ -456,6 +471,26 @@ export interface SignInFutureResource {
456471
submitPassword: (params: SignInFutureResetPasswordSubmitParams) => Promise<{ error: ClerkError | null }>;
457472
};
458473

474+
/**
475+
*
476+
*/
477+
resetPasswordPhoneCode: {
478+
/**
479+
* Used to send a password reset code to the first phone number on the account
480+
*/
481+
sendCode: (params?: SignInFutureResetPasswordPhoneCodeSendParams) => Promise<{ error: ClerkError | null }>;
482+
483+
/**
484+
* Used to verify a password reset code sent via phone. Will cause `signIn.status` to become `'needs_new_password'`.
485+
*/
486+
verifyCode: (params: SignInFutureResetPasswordPhoneCodeVerifyParams) => Promise<{ error: ClerkError | null }>;
487+
488+
/**
489+
* Used to submit a new password, and move the `signIn.status` to `'complete'`.
490+
*/
491+
submitPassword: (params: SignInFutureResetPasswordSubmitParams) => Promise<{ error: ClerkError | null }>;
492+
};
493+
459494
/**
460495
* Used to perform OAuth authentication.
461496
*/

0 commit comments

Comments
 (0)