diff --git a/packages/core/src/lib/sign-in-experience.test.ts b/packages/core/src/lib/sign-in-experience.test.ts index 0fc506e1d..68370de97 100644 --- a/packages/core/src/lib/sign-in-experience.test.ts +++ b/packages/core/src/lib/sign-in-experience.test.ts @@ -1,8 +1,12 @@ -import { BrandingStyle } from '@logto/schemas'; +import { BrandingStyle, SignInMethodState } from '@logto/schemas'; import RequestError from '@/errors/RequestError'; -import { validateBranding, validateTermsOfUse } from '@/lib/sign-in-experience'; -import { mockBranding } from '@/utils/mock'; +import { + validateBranding, + validateSignInMethods, + validateTermsOfUse, +} from '@/lib/sign-in-experience'; +import { mockBranding, mockSignInMethods } from '@/utils/mock'; describe('validate branding', () => { test('should throw when the UI style contains the slogan and slogan is empty', () => { @@ -44,3 +48,29 @@ describe('validate terms of use', () => { }).toMatchError(new RequestError('sign_in_experiences.empty_content_url_of_terms_of_use')); }); }); + +describe('validate sign-in methods', () => { + describe('There must be one and only one primary sign-in method.', () => { + test('should throw when there is no primary sign-in method', async () => { + expect(() => { + validateSignInMethods({ + ...mockSignInMethods, + username: SignInMethodState.disabled, + }); + }).toMatchError( + new RequestError('sign_in_experiences.not_one_and_only_one_primary_sign_in_method') + ); + }); + + test('should throw when there are more than one primary sign-in methods', async () => { + expect(() => { + validateSignInMethods({ + ...mockSignInMethods, + social: SignInMethodState.primary, + }); + }).toMatchError( + new RequestError('sign_in_experiences.not_one_and_only_one_primary_sign_in_method') + ); + }); + }); +}); diff --git a/packages/core/src/lib/sign-in-experience.ts b/packages/core/src/lib/sign-in-experience.ts index b45898b39..362cd7e84 100644 --- a/packages/core/src/lib/sign-in-experience.ts +++ b/packages/core/src/lib/sign-in-experience.ts @@ -1,4 +1,10 @@ -import { Branding, BrandingStyle, TermsOfUse } from '@logto/schemas'; +import { + Branding, + BrandingStyle, + SignInMethods, + SignInMethodState, + TermsOfUse, +} from '@logto/schemas'; import assertThat from '@/utils/assert-that'; @@ -14,3 +20,13 @@ export const validateTermsOfUse = (termsOfUse: TermsOfUse) => { 'sign_in_experiences.empty_content_url_of_terms_of_use' ); }; + +export const validateSignInMethods = (signInMethods: SignInMethods) => { + const signInMethodStates = Object.values(signInMethods); + assertThat( + signInMethodStates.filter((state) => state === SignInMethodState.primary).length === 1, + 'sign_in_experiences.not_one_and_only_one_primary_sign_in_method' + ); + + // TODO: assert others next PR +}; diff --git a/packages/core/src/routes/sign-in-experience.test.ts b/packages/core/src/routes/sign-in-experience.test.ts index 27d5bc329..4b677d002 100644 --- a/packages/core/src/routes/sign-in-experience.test.ts +++ b/packages/core/src/routes/sign-in-experience.test.ts @@ -1,7 +1,7 @@ import { SignInExperience, CreateSignInExperience, TermsOfUse } from '@logto/schemas'; import * as signInExpLib from '@/lib/sign-in-experience'; -import { mockBranding, mockSignInExperience } from '@/utils/mock'; +import { mockBranding, mockSignInExperience, mockSignInMethods } from '@/utils/mock'; import { createRequester } from '@/utils/test-utils'; import signInExperiencesRoutes from './sign-in-experience'; @@ -38,15 +38,19 @@ describe('signInExperiences routes', () => { const validateBranding = jest.spyOn(signInExpLib, 'validateBranding'); const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse'); + const validateSignInMethods = jest.spyOn(signInExpLib, 'validateSignInMethods'); const response = await signInExperienceRequester.patch('/sign-in-exp').send({ branding: mockBranding, termsOfUse, + signInMethods: mockSignInMethods, socialSignInConnectorIds, }); expect(validateBranding).toHaveBeenCalledWith(mockBranding); expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse); + expect(validateSignInMethods).toHaveBeenCalledWith(mockSignInMethods); + // TODO: only update socialSignInConnectorIds when social sign-in is enabled. expect(response).toMatchObject({ status: 200, @@ -54,6 +58,7 @@ describe('signInExperiences routes', () => { ...mockSignInExperience, branding: mockBranding, termsOfUse, + signInMethods: mockSignInMethods, socialSignInConnectorIds, }, }); diff --git a/packages/core/src/routes/sign-in-experience.ts b/packages/core/src/routes/sign-in-experience.ts index 85ea50df6..3c18d2038 100644 --- a/packages/core/src/routes/sign-in-experience.ts +++ b/packages/core/src/routes/sign-in-experience.ts @@ -1,7 +1,11 @@ import { SignInExperiences } from '@logto/schemas'; import { getEnabledSocialConnectorIds } from '@/connectors'; -import { validateBranding, validateTermsOfUse } from '@/lib/sign-in-experience'; +import { + validateBranding, + validateTermsOfUse, + validateSignInMethods, +} from '@/lib/sign-in-experience'; import koaGuard from '@/middleware/koa-guard'; import { findDefaultSignInExperience, @@ -42,7 +46,7 @@ export default function signInExperiencesRoutes(router: body: SignInExperiences.createGuard.omit({ id: true }).partial(), }), async (ctx, next) => { - const { branding, termsOfUse } = ctx.guard.body; + const { branding, termsOfUse, signInMethods } = ctx.guard.body; if (branding) { validateBranding(branding); @@ -52,7 +56,10 @@ export default function signInExperiencesRoutes(router: validateTermsOfUse(termsOfUse); } - // TODO: validate SignInMethods + if (signInMethods) { + validateSignInMethods(signInMethods); + } + // TODO: validate socialConnectorIds // TODO: Only update socialSignInConnectorIds when social sign-in is enabled. diff --git a/packages/core/src/utils/mock.ts b/packages/core/src/utils/mock.ts index a210a60af..48ee24ed0 100644 --- a/packages/core/src/utils/mock.ts +++ b/packages/core/src/utils/mock.ts @@ -21,6 +21,7 @@ import { ConnectorType, SignInMethodState, Branding, + SignInMethods, } from '@logto/schemas'; import pick from 'lodash.pick'; @@ -382,4 +383,11 @@ export const mockBranding: Branding = { logoUrl: 'http://silverhand.png', slogan: 'Silverhand.', }; + +export const mockSignInMethods: SignInMethods = { + username: SignInMethodState.primary, + email: SignInMethodState.disabled, + sms: SignInMethodState.disabled, + social: SignInMethodState.disabled, +}; /* eslint-enable max-lines */ diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index 0383fb68f..179c0673e 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -347,6 +347,8 @@ const errors = { 'Empty "Terms of use" content URL. Please add the content URL if "Terms of use" is enabled.', empty_slogan: 'Empty branding slogan. Please add a branding slogan if a UI style containing the slogan is selected.', + not_one_and_only_one_primary_sign_in_method: + 'There must be one and only one primary sign-in method. Please check your input.', }, swagger: { invalid_zod_type: 'Invalid Zod type, please check route guard config.', diff --git a/packages/phrases/src/locales/zh-cn.ts b/packages/phrases/src/locales/zh-cn.ts index b041893d5..3d8de5093 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -344,6 +344,7 @@ const errors = { empty_content_url_of_terms_of_use: '空的《用户协议》内容链接。当启用《用户协议》时,请添加其内容链接。', empty_slogan: '空的标语。当使用包含标语的 UI 风格时,请添加标语。', + not_one_and_only_one_primary_sign_in_method: '主要的登录方式必须有且仅有一个。请检查你的输入。', }, swagger: { invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。',