diff --git a/packages/core/src/lib/sign-in-experience.test.ts b/packages/core/src/lib/sign-in-experience.test.ts new file mode 100644 index 000000000..0fc506e1d --- /dev/null +++ b/packages/core/src/lib/sign-in-experience.test.ts @@ -0,0 +1,46 @@ +import { BrandingStyle } from '@logto/schemas'; + +import RequestError from '@/errors/RequestError'; +import { validateBranding, validateTermsOfUse } from '@/lib/sign-in-experience'; +import { mockBranding } from '@/utils/mock'; + +describe('validate branding', () => { + test('should throw when the UI style contains the slogan and slogan is empty', () => { + expect(() => { + validateBranding({ + ...mockBranding, + style: BrandingStyle.Logo_Slogan, + slogan: '', + }); + }).toMatchError(new RequestError('sign_in_experiences.empty_slogan')); + }); + + test('should throw when the UI style contains the slogan and slogan is blank', () => { + expect(() => { + validateBranding({ + ...mockBranding, + style: BrandingStyle.Logo_Slogan, + slogan: ' \t\n', + }); + }).toMatchError(new RequestError('sign_in_experiences.empty_slogan')); + }); + + test('should not throw when the UI style does not contain the slogan and slogan is empty', () => { + expect(() => { + validateBranding({ + ...mockBranding, + style: BrandingStyle.Logo, + }); + }).not.toThrow(); + }); +}); + +describe('validate terms of use', () => { + test('should throw when terms of use is enabled and content URL is empty', () => { + expect(() => { + validateTermsOfUse({ + enabled: true, + }); + }).toMatchError(new RequestError('sign_in_experiences.empty_content_url_of_terms_of_use')); + }); +}); diff --git a/packages/core/src/lib/sign-in-experience.ts b/packages/core/src/lib/sign-in-experience.ts new file mode 100644 index 000000000..b45898b39 --- /dev/null +++ b/packages/core/src/lib/sign-in-experience.ts @@ -0,0 +1,16 @@ +import { Branding, BrandingStyle, TermsOfUse } from '@logto/schemas'; + +import assertThat from '@/utils/assert-that'; + +export const validateBranding = (branding: Branding) => { + if (branding.style === BrandingStyle.Logo_Slogan) { + assertThat(branding.slogan?.trim(), 'sign_in_experiences.empty_slogan'); + } +}; + +export const validateTermsOfUse = (termsOfUse: TermsOfUse) => { + assertThat( + !termsOfUse.enabled || termsOfUse.contentUrl, + 'sign_in_experiences.empty_content_url_of_terms_of_use' + ); +}; diff --git a/packages/core/src/routes/sign-in-experience.test.ts b/packages/core/src/routes/sign-in-experience.test.ts index 0fe5872bc..27d5bc329 100644 --- a/packages/core/src/routes/sign-in-experience.test.ts +++ b/packages/core/src/routes/sign-in-experience.test.ts @@ -1,6 +1,7 @@ -import { SignInExperience, CreateSignInExperience, BrandingStyle, Branding } from '@logto/schemas'; +import { SignInExperience, CreateSignInExperience, TermsOfUse } from '@logto/schemas'; -import { mockSignInExperience } from '@/utils/mock'; +import * as signInExpLib from '@/lib/sign-in-experience'; +import { mockBranding, mockSignInExperience } from '@/utils/mock'; import { createRequester } from '@/utils/test-utils'; import signInExperiencesRoutes from './sign-in-experience'; @@ -25,34 +26,36 @@ describe('signInExperiences routes', () => { it('GET /sign-in-exp', async () => { const response = await signInExperienceRequester.get('/sign-in-exp'); - expect(response.status).toEqual(200); - expect(response.body).toEqual(mockSignInExperience); + expect(response).toMatchObject({ + status: 200, + body: mockSignInExperience, + }); }); it('PATCH /sign-in-exp', async () => { - const branding: Branding = { - primaryColor: '#000', - backgroundColor: '#fff', - darkMode: true, - darkBackgroundColor: '#000', - darkPrimaryColor: '#fff', - style: BrandingStyle.Logo, - logoUrl: 'http://silverhand.png', - slogan: 'silverhand', - }; - + const termsOfUse: TermsOfUse = { enabled: false }; const socialSignInConnectorIds = ['abc', 'def']; + const validateBranding = jest.spyOn(signInExpLib, 'validateBranding'); + const validateTermsOfUse = jest.spyOn(signInExpLib, 'validateTermsOfUse'); + const response = await signInExperienceRequester.patch('/sign-in-exp').send({ - branding, + branding: mockBranding, + termsOfUse, socialSignInConnectorIds, }); - expect(response.status).toEqual(200); - expect(response.body).toEqual({ - ...mockSignInExperience, - branding, - socialSignInConnectorIds, + expect(validateBranding).toHaveBeenCalledWith(mockBranding); + expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse); + + expect(response).toMatchObject({ + status: 200, + body: { + ...mockSignInExperience, + branding: mockBranding, + termsOfUse, + socialSignInConnectorIds, + }, }); }); diff --git a/packages/core/src/routes/sign-in-experience.ts b/packages/core/src/routes/sign-in-experience.ts index 09e08f9b2..85ea50df6 100644 --- a/packages/core/src/routes/sign-in-experience.ts +++ b/packages/core/src/routes/sign-in-experience.ts @@ -1,6 +1,7 @@ import { SignInExperiences } from '@logto/schemas'; import { getEnabledSocialConnectorIds } from '@/connectors'; +import { validateBranding, validateTermsOfUse } from '@/lib/sign-in-experience'; import koaGuard from '@/middleware/koa-guard'; import { findDefaultSignInExperience, @@ -41,10 +42,22 @@ export default function signInExperiencesRoutes(router: body: SignInExperiences.createGuard.omit({ id: true }).partial(), }), async (ctx, next) => { - const { body } = ctx.guard; + const { branding, termsOfUse } = ctx.guard.body; + if (branding) { + validateBranding(branding); + } + + if (termsOfUse) { + validateTermsOfUse(termsOfUse); + } + + // TODO: validate SignInMethods + // TODO: validate socialConnectorIds + + // TODO: Only update socialSignInConnectorIds when social sign-in is enabled. ctx.body = await updateDefaultSignInExperience({ - ...body, + ...ctx.guard.body, }); return next(); diff --git a/packages/core/src/utils/mock.ts b/packages/core/src/utils/mock.ts index 320cbf578..a210a60af 100644 --- a/packages/core/src/utils/mock.ts +++ b/packages/core/src/utils/mock.ts @@ -20,6 +20,7 @@ import { UserLogResult, ConnectorType, SignInMethodState, + Branding, } from '@logto/schemas'; import pick from 'lodash.pick'; @@ -370,4 +371,15 @@ export const mockUserLog: UserLog = { payload: {}, createdAt: 10, }; + +export const mockBranding: Branding = { + primaryColor: '#000', + backgroundColor: '#fff', + darkMode: true, + darkBackgroundColor: '#000', + darkPrimaryColor: '#fff', + style: BrandingStyle.Logo_Slogan, + logoUrl: 'http://silverhand.png', + slogan: 'Silverhand.', +}; /* eslint-enable max-lines */ diff --git a/packages/phrases/src/locales/en.ts b/packages/phrases/src/locales/en.ts index fb89ab7d8..b5b0173dd 100644 --- a/packages/phrases/src/locales/en.ts +++ b/packages/phrases/src/locales/en.ts @@ -340,6 +340,12 @@ const errors = { expired: 'Passcode has expired. Please request a new passcode.', exceed_max_try: 'Passcode verification limitation exceeded. Please request a new passcode.', }, + sign_in_experiences: { + empty_content_url_of_terms_of_use: + '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.', + }, 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 8da91ea26..72e258204 100644 --- a/packages/phrases/src/locales/zh-cn.ts +++ b/packages/phrases/src/locales/zh-cn.ts @@ -338,6 +338,11 @@ const errors = { expired: '验证码已过期. 请尝试请求新的验证码。', exceed_max_try: '超过最大验证次数. 请尝试请求新的验证码。', }, + sign_in_experiences: { + empty_content_url_of_terms_of_use: + '空的《用户协议》内容链接。当启用《用户协议》时,请添加其内容链接。', + empty_slogan: '空的标语。当使用包含标语的 UI 风格时,请添加标语。', + }, swagger: { invalid_zod_type: '无效的 Zod 类型,请检查路由 guard 配置。', },