From 3d465f2c6dd36528fefa202f20e10fe4bb0bcc9a Mon Sep 17 00:00:00 2001 From: wangsijie Date: Mon, 11 Nov 2024 15:03:29 +0800 Subject: [PATCH] feat(core): apply account center field control (#6776) --- packages/core/src/routes/profile/index.ts | 66 +++++++++- .../profile/middlewares/koa-account-center.ts | 29 +++++ .../profile/utils/get-scoped-profile.ts | 43 ++++++- packages/core/src/routes/types.ts | 7 +- .../routes/well-known/well-known.openapi.ts | 2 +- .../src/api/account-center.ts | 32 ++++- .../src/tests/api/account-center.test.ts | 10 +- .../api/profile/account-center-reject.test.ts | 119 ++++++++++++++++++ .../tests/api/profile/email-and-phone.test.ts | 2 + .../src/tests/api/profile/index.test.ts | 2 + .../src/tests/api/profile/social.test.ts | 2 + .../src/locales/en/errors/account-center.ts | 6 + .../phrases/src/locales/en/errors/index.ts | 2 + 13 files changed, 312 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/routes/profile/middlewares/koa-account-center.ts create mode 100644 packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts create mode 100644 packages/phrases/src/locales/en/errors/account-center.ts diff --git a/packages/core/src/routes/profile/index.ts b/packages/core/src/routes/profile/index.ts index 5f5542b1e..c0e0cc1c0 100644 --- a/packages/core/src/routes/profile/index.ts +++ b/packages/core/src/routes/profile/index.ts @@ -1,5 +1,11 @@ +/* eslint-disable max-lines */ import { emailRegEx, phoneRegEx, usernameRegEx, UserScope } from '@logto/core-kit'; -import { VerificationType, userProfileResponseGuard, userProfileGuard } from '@logto/schemas'; +import { + VerificationType, + userProfileResponseGuard, + userProfileGuard, + AccountCenterControlValue, +} from '@logto/schemas'; import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; @@ -15,7 +21,8 @@ import assertThat from '../../utils/assert-that.js'; import { PasswordValidator } from '../experience/classes/libraries/password-validator.js'; import type { UserRouter, RouterInitArgs } from '../types.js'; -import { getScopedProfile } from './utils/get-scoped-profile.js'; +import koaAccountCenter from './middlewares/koa-account-center.js'; +import { getAccountCenterFilteredProfile, getScopedProfile } from './utils/get-scoped-profile.js'; export default function profileRoutes( ...[router, { queries, libraries }]: RouterInitArgs @@ -29,6 +36,8 @@ export default function profileRoutes( users: { checkIdentifierCollision }, } = libraries; + router.use(koaAccountCenter(queries)); + if (!EnvSet.values.isDevFeaturesEnabled) { return; } @@ -41,7 +50,8 @@ export default function profileRoutes( }), async (ctx, next) => { const { id: userId, scopes } = ctx.auth; - ctx.body = await getScopedProfile(queries, libraries, scopes, userId); + const profile = await getScopedProfile(queries, libraries, scopes, userId); + ctx.body = getAccountCenterFilteredProfile(profile, ctx.accountCenter); return next(); } ); @@ -61,7 +71,20 @@ export default function profileRoutes( const { id: userId, scopes } = ctx.auth; const { body } = ctx.guard; const { name, avatar, username } = body; + const { fields } = ctx.accountCenter; + assertThat( + name === undefined || fields.name === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); + assertThat( + avatar === undefined || fields.avatar === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); + assertThat( + username === undefined || fields.username === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized'); if (username !== undefined) { @@ -76,7 +99,8 @@ export default function profileRoutes( ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); - ctx.body = await getScopedProfile(queries, libraries, scopes, userId); + const profile = await getScopedProfile(queries, libraries, scopes, userId); + ctx.body = getAccountCenterFilteredProfile(profile, ctx.accountCenter); return next(); } @@ -92,7 +116,12 @@ export default function profileRoutes( async (ctx, next) => { const { id: userId, scopes } = ctx.auth; const { body } = ctx.guard; + const { fields } = ctx.accountCenter; + assertThat( + fields.profile === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); assertThat(scopes.has(UserScope.Profile), 'auth.unauthorized'); if (body.address !== undefined) { @@ -116,11 +145,16 @@ export default function profileRoutes( '/profile/password', koaGuard({ body: z.object({ password: z.string().min(1), verificationRecordId: z.string() }), - status: [204, 401, 422], + status: [204, 400, 401, 422], }), async (ctx, next) => { const { id: userId } = ctx.auth; const { password, verificationRecordId } = ctx.guard.body; + const { fields } = ctx.accountCenter; + assertThat( + fields.password === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); const user = await findUserById(userId); const signInExperience = await findDefaultSignInExperience(); @@ -161,6 +195,11 @@ export default function profileRoutes( async (ctx, next) => { const { id: userId, scopes } = ctx.auth; const { email, verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body; + const { fields } = ctx.accountCenter; + assertThat( + fields.email === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); assertThat(scopes.has(UserScope.Email), 'auth.unauthorized'); @@ -206,6 +245,11 @@ export default function profileRoutes( async (ctx, next) => { const { id: userId, scopes } = ctx.auth; const { phone, verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body; + const { fields } = ctx.accountCenter; + assertThat( + fields.phone === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); assertThat(scopes.has(UserScope.Phone), 'auth.unauthorized'); @@ -250,6 +294,11 @@ export default function profileRoutes( async (ctx, next) => { const { id: userId, scopes } = ctx.auth; const { verificationRecordId, newIdentifierVerificationRecordId } = ctx.guard.body; + const { fields } = ctx.accountCenter; + assertThat( + fields.social === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized'); @@ -311,6 +360,12 @@ export default function profileRoutes( const { id: userId, scopes } = ctx.auth; const { verificationRecordId } = ctx.guard.query; const { target } = ctx.guard.params; + const { fields } = ctx.accountCenter; + assertThat( + fields.social === AccountCenterControlValue.Edit, + 'account_center.filed_not_editable' + ); + assertThat(scopes.has(UserScope.Identities), 'auth.unauthorized'); await verifyUserSensitivePermission({ @@ -340,3 +395,4 @@ export default function profileRoutes( } ); } +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/profile/middlewares/koa-account-center.ts b/packages/core/src/routes/profile/middlewares/koa-account-center.ts new file mode 100644 index 000000000..47ec573fe --- /dev/null +++ b/packages/core/src/routes/profile/middlewares/koa-account-center.ts @@ -0,0 +1,29 @@ +import type { AccountCenter } from '@logto/schemas'; +import type { MiddlewareType } from 'koa'; +import { type IRouterParamContext } from 'koa-router'; + +import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; + +/** + * Extend the context with the account center configs. + */ +export type WithAccountCenterContext = + ContextT & { accountCenter: AccountCenter }; + +/** + * Create a middleware that injects the account center configs and ensures + * the global config is enabled. + */ +export default function koaAccountCenter({ + accountCenters: { findDefaultAccountCenter }, +}: Queries): MiddlewareType, ResponseT> { + return async (ctx, next) => { + const accountCenter = await findDefaultAccountCenter(); + assertThat(accountCenter.enabled, 'account_center.not_enabled'); + + ctx.accountCenter = accountCenter; + + return next(); + }; +} diff --git a/packages/core/src/routes/profile/utils/get-scoped-profile.ts b/packages/core/src/routes/profile/utils/get-scoped-profile.ts index d4d481dd8..95ab702f7 100644 --- a/packages/core/src/routes/profile/utils/get-scoped-profile.ts +++ b/packages/core/src/routes/profile/utils/get-scoped-profile.ts @@ -1,5 +1,9 @@ import { UserScope } from '@logto/core-kit'; -import { type UserProfileResponse } from '@logto/schemas'; +import { + type AccountCenter, + AccountCenterControlValue, + type UserProfileResponse, +} from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import type Libraries from '../../../tenants/Libraries.js'; @@ -67,3 +71,40 @@ export const getScopedProfile = async ( ), }; }; + +const isFieldReadable = (field?: AccountCenterControlValue): boolean => { + return field === AccountCenterControlValue.ReadOnly || field === AccountCenterControlValue.Edit; +}; + +export const getAccountCenterFilteredProfile = ( + user: Partial, + accountCenter: AccountCenter +): Partial => { + const { + username, + primaryEmail, + primaryPhone, + name, + avatar, + customData, + identities, + profile, + hasPassword, + ...rest + } = user; + + const { fields } = accountCenter; + + return { + ...rest, + ...conditional(isFieldReadable(fields.name) && { name }), + ...conditional(isFieldReadable(fields.avatar) && { avatar }), + ...conditional(isFieldReadable(fields.username) && { username }), + ...conditional(isFieldReadable(fields.email) && { primaryEmail }), + ...conditional(isFieldReadable(fields.phone) && { primaryPhone }), + ...conditional(isFieldReadable(fields.profile) && { profile }), + ...conditional(isFieldReadable(fields.customData) && { customData }), + ...conditional(isFieldReadable(fields.social) && { identities }), + ...conditional(isFieldReadable(fields.password) && { hasPassword }), + }; +}; diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index e86584717..942bcdefc 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -7,6 +7,8 @@ import type { WithI18nContext } from '#src/middleware/koa-i18next.js'; import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js'; import type TenantContext from '#src/tenants/TenantContext.js'; +import { type WithAccountCenterContext } from './profile/middlewares/koa-account-center.js'; + export type AnonymousRouter = Router; export type ManagementApiRouterContext = WithAuthContext & @@ -17,7 +19,10 @@ export type ManagementApiRouterContext = WithAuthContext & export type ManagementApiRouter = Router; -export type UserRouter = Router; +export type UserRouter = Router< + unknown, + ManagementApiRouterContext & WithAccountCenterContext & WithHookContext +>; type RouterInit = (router: T, tenant: TenantContext) => void; export type RouterInitArgs = Parameters>; diff --git a/packages/core/src/routes/well-known/well-known.openapi.ts b/packages/core/src/routes/well-known/well-known.openapi.ts index 605daea67..5f1f6b7b2 100644 --- a/packages/core/src/routes/well-known/well-known.openapi.ts +++ b/packages/core/src/routes/well-known/well-known.openapi.ts @@ -15,7 +15,7 @@ import { type AnonymousRouter } from '#src/routes/types.js'; type OpenApiRouters = { managementRouters: R[]; experienceRouters: R[]; - userRouters: R[]; + userRouters: UnknownRouter[]; }; export default function openapiRoutes( diff --git a/packages/integration-tests/src/api/account-center.ts b/packages/integration-tests/src/api/account-center.ts index 401c5114b..666a82f8f 100644 --- a/packages/integration-tests/src/api/account-center.ts +++ b/packages/integration-tests/src/api/account-center.ts @@ -1,4 +1,4 @@ -import type { AccountCenter } from '@logto/schemas'; +import { AccountCenterControlValue, type AccountCenter } from '@logto/schemas'; import { type KyInstance } from 'ky'; import { authedAdminApi } from './api.js'; @@ -15,3 +15,33 @@ export const updateAccountCenter = async ( json: accountCenter, }) .json(); + +export const disableAccountCenter = async (api: KyInstance = authedAdminApi) => { + await updateAccountCenter( + { + enabled: false, + fields: {}, + }, + api + ); +}; + +export const enableAllAccountCenterFields = async (api: KyInstance = authedAdminApi) => { + await updateAccountCenter( + { + enabled: true, + fields: { + name: AccountCenterControlValue.Edit, + username: AccountCenterControlValue.Edit, + email: AccountCenterControlValue.Edit, + phone: AccountCenterControlValue.Edit, + password: AccountCenterControlValue.Edit, + avatar: AccountCenterControlValue.Edit, + profile: AccountCenterControlValue.Edit, + social: AccountCenterControlValue.Edit, + customData: AccountCenterControlValue.Edit, + }, + }, + api + ); +}; diff --git a/packages/integration-tests/src/tests/api/account-center.test.ts b/packages/integration-tests/src/tests/api/account-center.test.ts index ec565db84..23b9ac8a7 100644 --- a/packages/integration-tests/src/tests/api/account-center.test.ts +++ b/packages/integration-tests/src/tests/api/account-center.test.ts @@ -1,11 +1,19 @@ import { AccountCenterControlValue } from '@logto/schemas'; -import { getAccountCenter, updateAccountCenter } from '#src/api/account-center.js'; +import { + disableAccountCenter, + getAccountCenter, + updateAccountCenter, +} from '#src/api/account-center.js'; import { devFeatureTest } from '#src/utils.js'; const { describe, it } = devFeatureTest; describe('account center', () => { + beforeAll(async () => { + await disableAccountCenter(); + }); + it('should get account center successfully', async () => { const accountCenter = await getAccountCenter(); diff --git a/packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts b/packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts new file mode 100644 index 000000000..0b031f972 --- /dev/null +++ b/packages/integration-tests/src/tests/api/profile/account-center-reject.test.ts @@ -0,0 +1,119 @@ +import { UserScope } from '@logto/core-kit'; +import { AccountCenterControlValue } from '@logto/schemas'; + +import { mockSocialConnectorTarget } from '#src/__mocks__/connectors-mock.js'; +import { updateAccountCenter } from '#src/api/account-center.js'; +import { + deleteIdentity, + getUserInfo, + updateIdentities, + updateOtherProfile, + updatePassword, + updatePrimaryEmail, + updatePrimaryPhone, + updateUser, +} from '#src/api/profile.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { + createDefaultTenantUserWithPassword, + deleteDefaultTenantUser, + signInAndGetUserApi, +} from '#src/helpers/profile.js'; +import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js'; + +const { describe, it } = devFeatureTest; + +const expectedError = { + code: 'account_center.filed_not_editable', + status: 400, +}; + +describe('profile, account center fields disabled', () => { + beforeAll(async () => { + await updateAccountCenter({ + enabled: true, + fields: { + name: AccountCenterControlValue.ReadOnly, + // Unexisted filed should not be readable + }, + }); + }); + + it('should return only name in GET /profile', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password, { + scopes: [UserScope.Email], + }); + + const response = await getUserInfo(api); + expect(response).toMatchObject({ name: null }); + expect(response).not.toHaveProperty('avatar'); + expect(response).not.toHaveProperty('username'); + expect(response).not.toHaveProperty('primaryEmail'); + expect(response).not.toHaveProperty('primaryPhone'); + expect(response).not.toHaveProperty('identities'); + expect(response).not.toHaveProperty('profile'); + + await deleteDefaultTenantUser(user.id); + }); + + it('should fail for each API', async () => { + const { user, username, password } = await createDefaultTenantUserWithPassword(); + const api = await signInAndGetUserApi(username, password); + + await expectRejects(updateUser(api, { name: 'name' }), { + code: 'account_center.filed_not_editable', + status: 400, + }); + await expectRejects(updateUser(api, { avatar: 'https://example.com/avatar.png' }), { + code: 'account_center.filed_not_editable', + status: 400, + }); + await expectRejects(updateUser(api, { username: 'username' }), { + code: 'account_center.filed_not_editable', + status: 400, + }); + + await expectRejects(updateOtherProfile(api, { profile: 'profile' }), { + code: 'account_center.filed_not_editable', + status: 400, + }); + + await expectRejects(updatePassword(api, 'verification-record-id', 'new-password'), { + code: 'account_center.filed_not_editable', + status: 400, + }); + + await expectRejects( + updatePrimaryEmail( + api, + generateEmail(), + 'verification-record-id', + 'new-verification-record-id' + ), + expectedError + ); + + await expectRejects( + updatePrimaryPhone( + api, + generatePhone(), + 'verification-record-id', + 'new-verification-record-id' + ), + expectedError + ); + + await expectRejects( + updateIdentities(api, 'verification-record-id', 'new-verification-record-id'), + expectedError + ); + + await expectRejects( + deleteIdentity(api, mockSocialConnectorTarget, 'verification-record-id'), + expectedError + ); + + await deleteDefaultTenantUser(user.id); + }); +}); diff --git a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts b/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts index 301ad8df9..b21a371b9 100644 --- a/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts +++ b/packages/integration-tests/src/tests/api/profile/email-and-phone.test.ts @@ -1,6 +1,7 @@ import { UserScope } from '@logto/core-kit'; import { SignInIdentifier } from '@logto/schemas'; +import { enableAllAccountCenterFields } from '#src/api/account-center.js'; import { authedAdminApi } from '#src/api/api.js'; import { getUserInfo, updatePrimaryEmail, updatePrimaryPhone } from '#src/api/profile.js'; import { @@ -24,6 +25,7 @@ describe('profile (email and phone)', () => { await enableAllPasswordSignInMethods(); await setEmailConnector(authedAdminApi); await setSmsConnector(authedAdminApi); + await enableAllAccountCenterFields(authedAdminApi); }); describe('POST /profile/primary-email', () => { diff --git a/packages/integration-tests/src/tests/api/profile/index.test.ts b/packages/integration-tests/src/tests/api/profile/index.test.ts index 71a0e4fd8..1ae981a9f 100644 --- a/packages/integration-tests/src/tests/api/profile/index.test.ts +++ b/packages/integration-tests/src/tests/api/profile/index.test.ts @@ -1,6 +1,7 @@ import { UserScope } from '@logto/core-kit'; import { hookEvents } from '@logto/schemas'; +import { enableAllAccountCenterFields } from '#src/api/account-center.js'; import { getUserInfo, updateOtherProfile, updatePassword, updateUser } from '#src/api/profile.js'; import { createVerificationRecordByPassword } from '#src/api/verification-record.js'; import { WebHookApiTest } from '#src/helpers/hook.js'; @@ -27,6 +28,7 @@ describe('profile', () => { beforeAll(async () => { await webHookMockServer.listen(); await enableAllPasswordSignInMethods(); + await enableAllAccountCenterFields(); }); afterAll(async () => { diff --git a/packages/integration-tests/src/tests/api/profile/social.test.ts b/packages/integration-tests/src/tests/api/profile/social.test.ts index e053fcfaf..5b55bd20f 100644 --- a/packages/integration-tests/src/tests/api/profile/social.test.ts +++ b/packages/integration-tests/src/tests/api/profile/social.test.ts @@ -6,6 +6,7 @@ import { mockSocialConnectorId, mockSocialConnectorTarget, } from '#src/__mocks__/connectors-mock.js'; +import { enableAllAccountCenterFields } from '#src/api/account-center.js'; import { deleteIdentity, getUserInfo, updateIdentities } from '#src/api/profile.js'; import { createSocialVerificationRecord, @@ -36,6 +37,7 @@ describe('profile (social)', () => { beforeAll(async () => { await enableAllPasswordSignInMethods(); + await enableAllAccountCenterFields(); await clearConnectorsByTypes([ConnectorType.Social]); const { id: socialConnectorId } = await setSocialConnector(); diff --git a/packages/phrases/src/locales/en/errors/account-center.ts b/packages/phrases/src/locales/en/errors/account-center.ts new file mode 100644 index 000000000..a882114a1 --- /dev/null +++ b/packages/phrases/src/locales/en/errors/account-center.ts @@ -0,0 +1,6 @@ +const account_center = { + not_enabled: 'Account center is not enabled.', + filed_not_editable: 'Field is not editable.', +}; + +export default Object.freeze(account_center); diff --git a/packages/phrases/src/locales/en/errors/index.ts b/packages/phrases/src/locales/en/errors/index.ts index 58f416285..100bc7038 100644 --- a/packages/phrases/src/locales/en/errors/index.ts +++ b/packages/phrases/src/locales/en/errors/index.ts @@ -1,3 +1,4 @@ +import account_center from './account-center.js'; import application from './application.js'; import auth from './auth.js'; import connector from './connector.js'; @@ -52,6 +53,7 @@ const errors = { organization, single_sign_on, verification_record, + account_center, }; export default Object.freeze(errors);