0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): apply account center field control (#6776)

This commit is contained in:
wangsijie 2024-11-11 15:03:29 +08:00 committed by GitHub
parent d804ee0507
commit 3d465f2c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 312 additions and 10 deletions

View file

@ -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<T extends UserRouter>(
...[router, { queries, libraries }]: RouterInitArgs<T>
@ -29,6 +36,8 @@ export default function profileRoutes<T extends UserRouter>(
users: { checkIdentifierCollision },
} = libraries;
router.use(koaAccountCenter(queries));
if (!EnvSet.values.isDevFeaturesEnabled) {
return;
}
@ -41,7 +50,8 @@ export default function profileRoutes<T extends UserRouter>(
}),
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<T extends UserRouter>(
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<T extends UserRouter>(
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<T extends UserRouter>(
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<T extends UserRouter>(
'/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<T extends UserRouter>(
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<T extends UserRouter>(
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<T extends UserRouter>(
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<T extends UserRouter>(
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<T extends UserRouter>(
}
);
}
/* eslint-enable max-lines */

View file

@ -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 extends IRouterParamContext = IRouterParamContext> =
ContextT & { accountCenter: AccountCenter };
/**
* Create a middleware that injects the account center configs and ensures
* the global config is enabled.
*/
export default function koaAccountCenter<StateT, ContextT extends IRouterParamContext, ResponseT>({
accountCenters: { findDefaultAccountCenter },
}: Queries): MiddlewareType<StateT, WithAccountCenterContext<ContextT>, ResponseT> {
return async (ctx, next) => {
const accountCenter = await findDefaultAccountCenter();
assertThat(accountCenter.enabled, 'account_center.not_enabled');
ctx.accountCenter = accountCenter;
return next();
};
}

View file

@ -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<UserProfileResponse>,
accountCenter: AccountCenter
): Partial<UserProfileResponse> => {
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 }),
};
};

View file

@ -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<unknown, WithLogContext & WithI18nContext>;
export type ManagementApiRouterContext = WithAuthContext &
@ -17,7 +19,10 @@ export type ManagementApiRouterContext = WithAuthContext &
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
export type UserRouter = Router<unknown, ManagementApiRouterContext & WithHookContext>;
export type UserRouter = Router<
unknown,
ManagementApiRouterContext & WithAccountCenterContext & WithHookContext
>;
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;

View file

@ -15,7 +15,7 @@ import { type AnonymousRouter } from '#src/routes/types.js';
type OpenApiRouters<R> = {
managementRouters: R[];
experienceRouters: R[];
userRouters: R[];
userRouters: UnknownRouter[];
};
export default function openapiRoutes<T extends AnonymousRouter, R extends UnknownRouter>(

View file

@ -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<AccountCenter>();
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
);
};

View file

@ -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();

View file

@ -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);
});
});

View file

@ -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', () => {

View file

@ -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 () => {

View file

@ -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();

View file

@ -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);

View file

@ -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);