Skip to content

Commit f4d28c1

Browse files
authored
feat: add forgot password methods (#7611)
* feat: add forgot password methods * fix: filter forgotPasswordMethods based on available connectors * refactor: make forgotPasswordMethods nullable with fallback * fix: fix test * fix: fix integration test * refactor: maintain forgotPassword API * refactor(console,core): align forgotPassword logic and add comments * fix: fix experience test * refactor: revert changes in experience * fix: fix unit test
1 parent 96d0bf2 commit f4d28c1

File tree

24 files changed

+381
-58
lines changed

24 files changed

+381
-58
lines changed

packages/console/src/components/SignInExperiencePreview/index.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { LanguageTag } from '@logto/language-kit';
2-
import { Theme, ConnectorType } from '@logto/schemas';
2+
import { Theme, ConnectorType, ForgotPasswordMethod } from '@logto/schemas';
33
import type { ConnectorMetadata, ConnectorResponse } from '@logto/schemas';
44
import { conditional } from '@silverhand/essentials';
55
import classNames from 'classnames';
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next';
99
import useSWR from 'swr';
1010

1111
import PhoneInfo from '@/assets/images/phone-info.svg?react';
12+
import { isDevFeaturesEnabled } from '@/consts/env';
1213
import { AppDataContext } from '@/contexts/AppDataProvider';
1314
import type { RequestError } from '@/hooks/use-api';
1415
import useUiLanguages from '@/hooks/use-ui-languages';
@@ -70,17 +71,41 @@ function SignInExperiencePreview({
7071
);
7172

7273
const hasEmailConnector = allConnectors.some(({ type }) => type === ConnectorType.Email);
73-
7474
const hasSmsConnector = allConnectors.some(({ type }) => type === ConnectorType.Sms);
7575

76+
/**
77+
* Generate forgot password object based on available connectors and configured methods.
78+
* This logic aligns with the core library implementation in sign-in-experience/index.ts
79+
*/
80+
const forgotPassword = (() => {
81+
// If forgotPasswordMethods is null (production compatibility) or dev features are not enabled,
82+
// fall back to connector-based availability only
83+
if (!signInExperience.forgotPasswordMethods || !isDevFeaturesEnabled) {
84+
return {
85+
email: hasEmailConnector,
86+
phone: hasSmsConnector,
87+
};
88+
}
89+
90+
// When methods are explicitly configured and dev features are enabled,
91+
// require both method inclusion and connector availability
92+
return {
93+
email:
94+
signInExperience.forgotPasswordMethods.includes(
95+
ForgotPasswordMethod.EmailVerificationCode
96+
) && hasEmailConnector,
97+
phone:
98+
signInExperience.forgotPasswordMethods.includes(
99+
ForgotPasswordMethod.PhoneVerificationCode
100+
) && hasSmsConnector,
101+
};
102+
})();
103+
76104
return {
77105
signInExperience: {
78106
...signInExperience,
79107
socialConnectors,
80-
forgotPassword: {
81-
email: hasEmailConnector,
82-
sms: hasSmsConnector,
83-
},
108+
forgotPassword,
84109
},
85110
language,
86111
mode,

packages/core/src/__mocks__/sign-in-experience.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,4 +106,5 @@ export const mockSignInExperience: SignInExperience = {
106106
captchaPolicy: {},
107107
sentinelPolicy: {},
108108
emailBlocklistPolicy: {},
109+
forgotPasswordMethods: null,
109110
};

packages/core/src/libraries/sign-in-experience/index.test.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { CaptchaType, type CreateSignInExperience, type SignInExperience } from
44
import { TtlCache } from '@logto/shared';
55

66
import {
7+
mockAliyunDmConnector,
8+
mockAliyunSmsConnector,
79
mockCaptchaProvider,
810
mockCustomProfileFields,
911
mockGithubConnector,
@@ -180,10 +182,6 @@ describe('getFullSignInExperience()', () => {
180182
...mockSignInExperience,
181183
socialConnectors: [],
182184
socialSignInConnectorTargets: ['github', 'facebook', 'wechat'],
183-
forgotPassword: {
184-
email: false,
185-
phone: false,
186-
},
187185
ssoConnectors: [
188186
{
189187
id: wellConfiguredSsoConnector.id,
@@ -196,6 +194,10 @@ describe('getFullSignInExperience()', () => {
196194
googleOneTap: undefined,
197195
captchaConfig: undefined,
198196
customProfileFields: mockCustomProfileFields,
197+
forgotPassword: {
198+
email: false,
199+
phone: false,
200+
},
199201
});
200202
});
201203

@@ -219,10 +221,6 @@ describe('getFullSignInExperience()', () => {
219221
{ ...mockGoogleConnector.metadata, id: mockGoogleConnector.dbEntry.id },
220222
],
221223
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
222-
forgotPassword: {
223-
email: false,
224-
phone: false,
225-
},
226224
ssoConnectors: [
227225
{
228226
id: wellConfiguredSsoConnector.id,
@@ -239,6 +237,10 @@ describe('getFullSignInExperience()', () => {
239237
connectorId: 'google',
240238
},
241239
captchaConfig: undefined,
240+
forgotPassword: {
241+
email: false,
242+
phone: false,
243+
},
242244
});
243245
});
244246
});
@@ -331,3 +333,37 @@ describe('findCaptchaPublicConfig', () => {
331333
expect(captchaPublicConfig).toBeUndefined();
332334
});
333335
});
336+
337+
describe('forgot password methods', () => {
338+
it('should return connector-based methods when forgotPasswordMethods is null', async () => {
339+
findDefaultSignInExperience.mockResolvedValueOnce({
340+
...mockSignInExperience,
341+
forgotPasswordMethods: null, // Test null case
342+
});
343+
getLogtoConnectors.mockResolvedValueOnce([mockAliyunDmConnector, mockAliyunSmsConnector]);
344+
mockSsoConnectorLibrary.getAvailableSsoConnectors.mockResolvedValueOnce([]);
345+
346+
const fullSignInExperience = await getFullSignInExperience({ locale: 'en' });
347+
348+
expect(fullSignInExperience.forgotPassword).toEqual({
349+
email: true,
350+
phone: true,
351+
});
352+
});
353+
354+
it('should return false values when forgotPasswordMethods is null and no connectors available', async () => {
355+
findDefaultSignInExperience.mockResolvedValueOnce({
356+
...mockSignInExperience,
357+
forgotPasswordMethods: null,
358+
});
359+
getLogtoConnectors.mockResolvedValueOnce([]); // No connectors
360+
mockSsoConnectorLibrary.getAvailableSsoConnectors.mockResolvedValueOnce([]);
361+
362+
const fullSignInExperience = await getFullSignInExperience({ locale: 'en' });
363+
364+
expect(fullSignInExperience.forgotPassword).toEqual({
365+
email: false,
366+
phone: false,
367+
});
368+
});
369+
});

packages/core/src/libraries/sign-in-experience/index.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
SignInExperience,
88
SsoConnectorMetadata,
99
} from '@logto/schemas';
10-
import { ConnectorType, ReservedPlanId } from '@logto/schemas';
10+
import { ConnectorType, ForgotPasswordMethod, ReservedPlanId } from '@logto/schemas';
1111
import { cond, conditional, deduplicate, pick, trySafe } from '@silverhand/essentials';
1212
import deepmerge from 'deepmerge';
1313

@@ -203,11 +203,6 @@ export const createSignInExperienceLibrary = (
203203
? await getActiveSsoConnectors(locale)
204204
: [];
205205

206-
const forgotPassword = {
207-
phone: logtoConnectors.some(({ type }) => type === ConnectorType.Sms),
208-
email: logtoConnectors.some(({ type }) => type === ConnectorType.Email),
209-
};
210-
211206
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
212207
ConnectorMetadata[]
213208
>((previous, connectorTarget) => {
@@ -264,14 +259,54 @@ export const createSignInExperienceLibrary = (
264259
return findCaptchaPublicConfig();
265260
};
266261

262+
/**
263+
* Generate forgot password object based on available connectors and configured methods.
264+
*
265+
* This function determines which forgot password methods are available by checking:
266+
* 1. Whether the required connectors (email/SMS) are configured and available
267+
* 2. Whether specific methods are explicitly enabled in the sign-in experience configuration
268+
*
269+
* The logic handles two scenarios:
270+
* - Legacy/fallback mode: When forgotPasswordMethods is null or dev features are disabled,
271+
* availability is determined solely by connector presence
272+
* - Explicit configuration mode: When dev features are enabled and methods are configured,
273+
* both method inclusion and connector availability must be satisfied
274+
*/
275+
const getForgotPassword = () => {
276+
// Check availability of required connectors
277+
const hasEmailConnector = logtoConnectors.some(({ type }) => type === ConnectorType.Email);
278+
const hasSmsConnector = logtoConnectors.some(({ type }) => type === ConnectorType.Sms);
279+
280+
// If forgotPasswordMethods is null (production compatibility) or dev features are not enabled,
281+
// fall back to connector-based availability only
282+
if (!signInExperience.forgotPasswordMethods || !EnvSet.values.isDevFeaturesEnabled) {
283+
return {
284+
email: hasEmailConnector,
285+
phone: hasSmsConnector,
286+
};
287+
}
288+
289+
// When methods are explicitly configured, require both method inclusion and connector availability
290+
return {
291+
email:
292+
signInExperience.forgotPasswordMethods.includes(
293+
ForgotPasswordMethod.EmailVerificationCode
294+
) && hasEmailConnector,
295+
phone:
296+
signInExperience.forgotPasswordMethods.includes(
297+
ForgotPasswordMethod.PhoneVerificationCode
298+
) && hasSmsConnector,
299+
};
300+
};
301+
267302
return {
268303
...deepmerge(
269304
deepmerge(signInExperience, getAppSignInExperience()),
270305
organizationOverride ?? {}
271306
),
272307
socialConnectors,
273308
ssoConnectors,
274-
forgotPassword,
309+
forgotPassword: getForgotPassword(),
275310
isDevelopmentTenant,
276311
googleOneTap: getGoogleOneTap(),
277312
captchaConfig: await getCaptchaConfig(),

packages/core/src/queries/sign-in-experience.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ describe('sign-in-experience query', () => {
3939
captchaPolicy: JSON.stringify(mockSignInExperience.captchaPolicy),
4040
sentinelPolicy: JSON.stringify(mockSignInExperience.sentinelPolicy),
4141
emailBlocklistPolicy: JSON.stringify(mockSignInExperience.emailBlocklistPolicy),
42+
forgotPasswordMethods: JSON.stringify(mockSignInExperience.forgotPasswordMethods),
4243
};
4344

4445
it('findDefaultSignInExperience', async () => {
4546
/* eslint-disable sql/no-unsafe-query */
4647
const expectSql = `
47-
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "agree_to_terms_policy", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "custom_ui_assets", "password_policy", "mfa", "single_sign_on_enabled", "support_email", "support_website_url", "unknown_session_redirect_url", "captcha_policy", "sentinel_policy", "email_blocklist_policy"
48+
select "tenant_id", "id", "color", "branding", "language_info", "terms_of_use_url", "privacy_policy_url", "agree_to_terms_policy", "sign_in", "sign_up", "social_sign_in", "social_sign_in_connector_targets", "sign_in_mode", "custom_css", "custom_content", "custom_ui_assets", "password_policy", "mfa", "single_sign_on_enabled", "support_email", "support_website_url", "unknown_session_redirect_url", "captcha_policy", "sentinel_policy", "email_blocklist_policy", "forgot_password_methods"
4849
from "sign_in_experiences"
4950
where "id"=$1
5051
`;

packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
AlternativeSignUpIdentifier,
3+
ForgotPasswordMethod,
34
InteractionEvent,
45
MissingProfile,
56
type SignInExperience,
@@ -8,6 +9,7 @@ import {
89
VerificationType,
910
} from '@logto/schemas';
1011

12+
import { EnvSet } from '#src/env-set/index.js';
1113
import RequestError from '#src/errors/RequestError/index.js';
1214
import { validateEmailAgainstBlocklistPolicy } from '#src/libraries/sign-in-experience/index.js';
1315
import type Libraries from '#src/tenants/Libraries.js';
@@ -139,7 +141,7 @@ export class SignInExperienceValidator {
139141
break;
140142
}
141143
case InteractionEvent.ForgotPassword: {
142-
this.guardForgotPasswordVerificationMethod(verificationRecord);
144+
await this.guardForgotPasswordVerificationMethod(verificationRecord);
143145
break;
144146
}
145147
}
@@ -342,11 +344,32 @@ export class SignInExperienceValidator {
342344
}
343345

344346
/** Forgot password only supports verification code type verification record */
345-
private guardForgotPasswordVerificationMethod(verificationRecord: VerificationRecord) {
347+
private async guardForgotPasswordVerificationMethod(verificationRecord: VerificationRecord) {
346348
assertThat(
347349
verificationRecord.type === VerificationType.EmailVerificationCode ||
348350
verificationRecord.type === VerificationType.PhoneVerificationCode,
349351
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 422 })
350352
);
353+
354+
if (EnvSet.values.isDevFeaturesEnabled) {
355+
const { forgotPasswordMethods } = await this.getSignInExperienceData();
356+
357+
// If forgotPasswordMethods is null, fallback to connector-based validation (allow all)
358+
if (forgotPasswordMethods) {
359+
if (verificationRecord.type === VerificationType.EmailVerificationCode) {
360+
assertThat(
361+
forgotPasswordMethods.includes(ForgotPasswordMethod.EmailVerificationCode),
362+
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 422 })
363+
);
364+
}
365+
366+
if (verificationRecord.type === VerificationType.PhoneVerificationCode) {
367+
assertThat(
368+
forgotPasswordMethods.includes(ForgotPasswordMethod.PhoneVerificationCode),
369+
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 422 })
370+
);
371+
}
372+
}
373+
}
351374
}
352375
}

packages/core/src/routes/sign-in-experience/index.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,4 +261,18 @@ describe('PATCH /sign-in-exp', () => {
261261
},
262262
});
263263
});
264+
265+
it('should accept empty forgotPasswordMethods array', async () => {
266+
const response = await signInExperienceRequester.patch('/sign-in-exp').send({
267+
forgotPasswordMethods: [],
268+
});
269+
270+
expect(response).toMatchObject({
271+
status: 200,
272+
body: {
273+
...mockSignInExperience,
274+
forgotPasswordMethods: [],
275+
},
276+
});
277+
});
264278
});

packages/core/src/routes/sign-in-experience/index.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { DemoConnector } from '@logto/connector-kit';
22
import { PasswordPolicyChecker } from '@logto/core-kit';
3-
import { ConnectorType, SignInExperiences } from '@logto/schemas';
3+
import { ConnectorType, SignInExperiences, ForgotPasswordMethod } from '@logto/schemas';
44
import { conditional, tryThat } from '@silverhand/essentials';
55
import { literal, object, string, z } from 'zod';
66

7+
import { EnvSet } from '#src/env-set/index.js';
78
import {
89
validateSignUp,
910
validateSignIn,
@@ -82,7 +83,15 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
8283
query: { removeUnusedDemoSocialConnector },
8384
body: { socialSignInConnectorTargets, emailBlocklistPolicy, ...rest },
8485
} = ctx.guard;
85-
const { languageInfo, signUp, signIn, mfa, sentinelPolicy, captchaPolicy } = rest;
86+
const {
87+
languageInfo,
88+
signUp,
89+
signIn,
90+
mfa,
91+
sentinelPolicy,
92+
captchaPolicy,
93+
forgotPasswordMethods,
94+
} = rest;
8695

8796
if (languageInfo) {
8897
await validateLanguageInfo(languageInfo);
@@ -118,6 +127,26 @@ export default function signInExperiencesRoutes<T extends ManagementApiRouter>(
118127
validateMfa(mfa, currentSignIn);
119128
}
120129

130+
if (forgotPasswordMethods && EnvSet.values.isDevFeaturesEnabled) {
131+
const hasEmailConnector = connectors.some(({ type }) => type === ConnectorType.Email);
132+
const hasSmsConnector = connectors.some(({ type }) => type === ConnectorType.Sms);
133+
134+
for (const method of forgotPasswordMethods) {
135+
if (method === ForgotPasswordMethod.EmailVerificationCode && !hasEmailConnector) {
136+
throw new RequestError({
137+
code: 'sign_in_experiences.forgot_password_method_requires_connector',
138+
method: 'email',
139+
});
140+
}
141+
if (method === ForgotPasswordMethod.PhoneVerificationCode && !hasSmsConnector) {
142+
throw new RequestError({
143+
code: 'sign_in_experiences.forgot_password_method_requires_connector',
144+
method: 'sms',
145+
});
146+
}
147+
}
148+
}
149+
121150
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
122151
// Guard the quota for the security features enabled. Guarded properties are:
123152
// - sentinelPolicy: if sentinelPolicy is not empty object, security features are guarded

packages/core/src/routes/well-known/well-known.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,13 @@ describe('GET /.well-known/sign-in-exp', () => {
7272
expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1);
7373
expect(getLogtoConnectors).toHaveBeenCalledTimes(1);
7474
expect(response.status).toEqual(200);
75+
const { forgotPasswordMethods, ...expectedSignInExperience } = mockSignInExperience;
7576
expect(response.body).toMatchObject({
76-
...mockSignInExperience,
77+
...expectedSignInExperience,
78+
forgotPassword: {
79+
email: true,
80+
phone: true,
81+
},
7782
socialConnectors: [
7883
{
7984
...mockGithubConnector.metadata,

0 commit comments

Comments
 (0)