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:
parent
d804ee0507
commit
3d465f2c6d
13 changed files with 312 additions and 10 deletions
|
@ -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 */
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
}
|
|
@ -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 }),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
6
packages/phrases/src/locales/en/errors/account-center.ts
Normal file
6
packages/phrases/src/locales/en/errors/account-center.ts
Normal 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);
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue