From 7e507dbc2b8894b22a6cf5e07a6f1cb830fb2358 Mon Sep 17 00:00:00 2001 From: wangsijie Date: Sat, 7 Jan 2023 12:08:03 +0800 Subject: [PATCH] feat(core): user-role related api (#2827) --- packages/core/src/queries/user.ts | 12 +-- packages/core/src/queries/users-roles.ts | 11 +++ .../core/src/routes/admin-user-role.test.ts | 50 +++++++++++ packages/core/src/routes/admin-user-role.ts | 88 +++++++++++++++++++ packages/core/src/routes/init.ts | 2 + packages/core/src/routes/role.test.ts | 43 ++++++++- packages/core/src/routes/role.ts | 72 +++++++++++++++ packages/phrases/src/locales/de/errors.ts | 2 + packages/phrases/src/locales/en/errors.ts | 2 + packages/phrases/src/locales/fr/errors.ts | 2 + packages/phrases/src/locales/ko/errors.ts | 2 + packages/phrases/src/locales/pt-br/errors.ts | 2 + packages/phrases/src/locales/pt-pt/errors.ts | 2 + packages/phrases/src/locales/tr-tr/errors.ts | 2 + packages/phrases/src/locales/zh-cn/errors.ts | 2 + 15 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 packages/core/src/routes/admin-user-role.test.ts create mode 100644 packages/core/src/routes/admin-user-role.ts diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 484b751fe..9845d04ef 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -162,11 +162,13 @@ export const findUsers = async ( ); export const findUsersByIds = async (userIds: string[]) => - envSet.pool.any(sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - where ${fields.id} in (${sql.join(userIds, sql`, `)}) - `); + userIds.length > 0 + ? envSet.pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.id} in (${sql.join(userIds, sql`, `)}) + `) + : []; const updateUser = buildUpdateWhere(Users, true); diff --git a/packages/core/src/queries/users-roles.ts b/packages/core/src/queries/users-roles.ts index c5f8677f9..977209c5b 100644 --- a/packages/core/src/queries/users-roles.ts +++ b/packages/core/src/queries/users-roles.ts @@ -21,6 +21,17 @@ export const findUsersRolesByRoleId = async (roleId: string) => where ${fields.roleId}=${roleId} `); +export const findFirstUsersRolesByRoleIdAndUserIds = async (roleId: string, userIds: string[]) => + userIds.length > 0 + ? envSet.pool.maybeOne(sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where ${fields.roleId}=${roleId} + and ${fields.userId} in (${sql.join(userIds, sql`, `)}) + limit 1 + `) + : null; + export const insertUsersRoles = async (usersRoles: UsersRole[]) => envSet.pool.query(sql` insert into ${table} (${fields.userId}, ${fields.roleId}) values diff --git a/packages/core/src/routes/admin-user-role.test.ts b/packages/core/src/routes/admin-user-role.test.ts new file mode 100644 index 000000000..0233a3988 --- /dev/null +++ b/packages/core/src/routes/admin-user-role.test.ts @@ -0,0 +1,50 @@ +import { pickDefault, createMockUtils } from '@logto/shared/esm'; + +import { mockRole, mockUser } from '#src/__mocks__/index.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +const { mockEsmWithActual } = createMockUtils(jest); + +await mockEsmWithActual('#src/queries/user.js', () => ({ + findUserById: jest.fn(), +})); +const { findRolesByRoleIds } = await mockEsmWithActual('#src/queries/roles.js', () => ({ + findRolesByRoleIds: jest.fn(), + findRoleById: jest.fn(), +})); +const { findUsersRolesByUserId, insertUsersRoles, deleteUsersRolesByUserIdAndRoleId } = + await mockEsmWithActual('#src/queries/users-roles.js', () => ({ + findUsersRolesByUserId: jest.fn(), + insertUsersRoles: jest.fn(), + deleteUsersRolesByUserIdAndRoleId: jest.fn(), + })); +const roleRoutes = await pickDefault(import('./admin-user-role.js')); + +describe('user role routes', () => { + const roleRequester = createRequester({ authedRoutes: roleRoutes }); + + it('GET /users/:id/roles', async () => { + findUsersRolesByUserId.mockResolvedValueOnce([]); + findRolesByRoleIds.mockResolvedValueOnce([mockRole]); + const response = await roleRequester.get(`/users/${mockUser.id}/roles`); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockRole]); + }); + + it('POST /users/:id/roles', async () => { + findUsersRolesByUserId.mockResolvedValueOnce([]); + const response = await roleRequester.post(`/users/${mockUser.id}/roles`).send({ + roleIds: [mockRole.id], + }); + expect(response.status).toEqual(201); + expect(insertUsersRoles).toHaveBeenCalledWith([{ userId: mockUser.id, roleId: mockRole.id }]); + }); + + it('DELETE /users/:id/roles/:roleId', async () => { + const response = await roleRequester.delete(`/users/${mockUser.id}/roles/${mockRole.id}`); + expect(response.status).toEqual(204); + expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith(mockUser.id, mockRole.id); + }); +}); diff --git a/packages/core/src/routes/admin-user-role.ts b/packages/core/src/routes/admin-user-role.ts new file mode 100644 index 000000000..be841b1e6 --- /dev/null +++ b/packages/core/src/routes/admin-user-role.ts @@ -0,0 +1,88 @@ +import { object, string } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { findRolesByRoleIds, findRoleById } from '#src/queries/roles.js'; +import { findUserById } from '#src/queries/user.js'; +import { + deleteUsersRolesByUserIdAndRoleId, + findUsersRolesByUserId, + insertUsersRoles, +} from '#src/queries/users-roles.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { AuthedRouter } from './types.js'; + +export default function adminUserRoleRoutes(router: T) { + router.get( + '/users/:userId/roles', + koaGuard({ + params: object({ userId: string() }), + }), + async (ctx, next) => { + const { + params: { userId }, + } = ctx.guard; + + await findUserById(userId); + const usersRoles = await findUsersRolesByUserId(userId); + const roles = await findRolesByRoleIds(usersRoles.map(({ roleId }) => roleId)); + + ctx.body = roles; + + return next(); + } + ); + + router.post( + '/users/:userId/roles', + koaGuard({ + params: object({ userId: string() }), + body: object({ roleIds: string().min(1).array() }), + }), + async (ctx, next) => { + const { + params: { userId }, + body: { roleIds }, + } = ctx.guard; + + await findUserById(userId); + const usersRoles = await findUsersRolesByUserId(userId); + + for (const roleId of roleIds) { + assertThat( + !usersRoles.some(({ roleId: _roleId }) => _roleId === roleId), + new RequestError({ + code: 'user.role_exists', + status: 422, + roleId, + }) + ); + } + + await Promise.all(roleIds.map(async (roleId) => findRoleById(roleId))); + await insertUsersRoles(roleIds.map((roleId) => ({ userId, roleId }))); + ctx.status = 201; + + return next(); + } + ); + + router.delete( + '/users/:userId/roles/:roleId', + koaGuard({ + params: object({ userId: string(), roleId: string() }), + }), + async (ctx, next) => { + const { + params: { userId, roleId }, + } = ctx.guard; + + await deleteUsersRolesByUserIdAndRoleId(userId, roleId); + + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 34c3bdb76..52b331fad 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -8,6 +8,7 @@ import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js'; import koaAuth from '../middleware/koa-auth.js'; import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js'; +import adminUserRoleRoutes from './admin-user-role.js'; import adminUserRoutes from './admin-user.js'; import applicationRoutes from './application.js'; import authnRoutes from './authn.js'; @@ -45,6 +46,7 @@ const createRouters = (provider: Provider) => { resourceRoutes(managementRouter); signInExperiencesRoutes(managementRouter); adminUserRoutes(managementRouter); + adminUserRoleRoutes(managementRouter); logRoutes(managementRouter); roleRoutes(managementRouter); dashboardRoutes(managementRouter); diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index ad38c72e7..bf57ac3b9 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -1,7 +1,7 @@ import type { Role } from '@logto/schemas'; import { pickDefault, createMockUtils } from '@logto/shared/esm'; -import { mockRole, mockScope } from '#src/__mocks__/index.js'; +import { mockRole, mockScope, mockUser } from '#src/__mocks__/index.js'; import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; @@ -23,6 +23,7 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = mockEsm( ...mockRole, ...data, })), + findRolesByRoleIds: jest.fn(), }) ); const { findScopeById, findScopesByIds } = await mockEsmWithActual('#src/queries/scope.js', () => ({ @@ -37,6 +38,21 @@ const { insertRolesScopes, findRolesScopesByRoleId } = await mockEsmWithActual( deleteRolesScope: jest.fn(), }) ); +const { findUsersByIds } = await mockEsmWithActual('#src/queries/user.js', () => ({ + findUsersByIds: jest.fn(), + findUserById: jest.fn(), +})); +const { + insertUsersRoles, + findUsersRolesByRoleId, + deleteUsersRolesByUserIdAndRoleId, + findFirstUsersRolesByRoleIdAndUserIds, +} = await mockEsmWithActual('#src/queries/users-roles.js', () => ({ + insertUsersRoles: jest.fn(), + findUsersRolesByRoleId: jest.fn(), + findFirstUsersRolesByRoleIdAndUserIds: jest.fn(), + deleteUsersRolesByUserIdAndRoleId: jest.fn(), +})); const roleRoutes = await pickDefault(import('./role.js')); describe('role routes', () => { @@ -133,4 +149,29 @@ describe('role routes', () => { const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`); expect(response.status).toEqual(204); }); + + it('GET /roles/:id/users', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + findUsersRolesByRoleId.mockResolvedValueOnce([]); + findUsersByIds.mockResolvedValueOnce([mockUser]); + const response = await roleRequester.get(`/roles/${mockRole.id}/users`); + expect(response.status).toEqual(200); + expect(response.body).toEqual([mockUser]); + }); + + it('POST /roles/:id/users', async () => { + findRoleById.mockResolvedValueOnce(mockRole); + findFirstUsersRolesByRoleIdAndUserIds.mockResolvedValueOnce(null); + const response = await roleRequester.post(`/roles/${mockRole.id}/users`).send({ + userIds: [mockUser.id], + }); + expect(response.status).toEqual(201); + expect(insertUsersRoles).toHaveBeenCalledWith([{ userId: mockUser.id, roleId: mockRole.id }]); + }); + + it('DELETE /roles/:id/users/:userId', async () => { + const response = await roleRequester.delete(`/roles/${mockRole.id}/users/${mockUser.id}`); + expect(response.status).toEqual(204); + expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith(mockUser.id, mockRole.id); + }); }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index 772f1a476..4de52731c 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -18,6 +18,13 @@ import { updateRoleById, } from '#src/queries/roles.js'; import { findScopeById, findScopesByIds } from '#src/queries/scope.js'; +import { findUserById, findUsersByIds } from '#src/queries/user.js'; +import { + deleteUsersRolesByUserIdAndRoleId, + findFirstUsersRolesByRoleIdAndUserIds, + findUsersRolesByRoleId, + insertUsersRoles, +} from '#src/queries/users-roles.js'; import assertThat from '#src/utils/assert-that.js'; import type { AuthedRouter } from './types.js'; @@ -186,4 +193,69 @@ export default function roleRoutes(router: T) { return next(); } ); + + router.get( + '/roles/:id/users', + koaGuard({ + params: object({ id: string().min(1) }), + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + + await findRoleById(id); + const usersRoles = await findUsersRolesByRoleId(id); + ctx.body = await findUsersByIds(usersRoles.map(({ userId }) => userId)); + + return next(); + } + ); + + router.post( + '/roles/:id/users', + koaGuard({ + params: object({ id: string().min(1) }), + body: object({ userIds: string().min(1).array() }), + }), + async (ctx, next) => { + const { + params: { id }, + body: { userIds }, + } = ctx.guard; + + await findRoleById(id); + const existingRecord = await findFirstUsersRolesByRoleIdAndUserIds(id, userIds); + + if (existingRecord) { + throw new RequestError({ + code: 'role.user_exists', + status: 422, + userId: existingRecord.userId, + }); + } + + await Promise.all(userIds.map(async (userId) => findUserById(userId))); + await insertUsersRoles(userIds.map((userId) => ({ roleId: id, userId }))); + ctx.status = 201; + + return next(); + } + ); + + router.delete( + '/roles/:id/users/:userId', + koaGuard({ + params: object({ id: string().min(1), userId: string().min(1) }), + }), + async (ctx, next) => { + const { + params: { id, userId }, + } = ctx.guard; + await deleteUsersRolesByUserIdAndRoleId(userId, id); + ctx.status = 204; + + return next(); + } + ); } diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 9a52b5e9f..95eb3d6f8 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -64,6 +64,7 @@ const errors = { suspended: 'This account is suspended.', // UNTRANSLATED user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.', @@ -178,6 +179,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index df24c6ebb..1ae0ef94f 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -64,6 +64,7 @@ const errors = { suspended: 'This account is suspended.', user_not_exist: 'User with {{ identifier }} does not exist.', missing_profile: 'You need to provide additional info before signing-in.', + role_exists: 'The role id {{roleId}} is already been added to this user', }, password: { unsupported_encryption_method: 'The encryption method {{name}} is not supported.', @@ -177,6 +178,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', scope_exists: 'The scope id {{scopeId}} has already been added to this role', + user_exists: 'The user id {{userId}} is already been added to this role', }, }; diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 22f8fb869..52d5d6a41 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -65,6 +65,7 @@ const errors = { suspended: 'This account is suspended.', // UNTRANSLATED user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.", @@ -184,6 +185,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index be4903c1a..42501d230 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -62,6 +62,7 @@ const errors = { suspended: '이 계정은 일시 정시되었어요.', user_not_exist: '{{identifier}}의 사용자가 아직 등록되지 않았어요.', missing_profile: '로그인 전에 추가 정보를 제공해야해요.', + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.', @@ -171,6 +172,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/pt-br/errors.ts b/packages/phrases/src/locales/pt-br/errors.ts index 73d2c9510..91b3c1862 100644 --- a/packages/phrases/src/locales/pt-br/errors.ts +++ b/packages/phrases/src/locales/pt-br/errors.ts @@ -64,6 +64,7 @@ const errors = { suspended: 'Esta conta está suspensa.', user_not_exist: 'O usuário com {{ identifier }} não existe', missing_profile: 'Você precisa fornecer informações adicionais antes de fazer login.', + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: 'O método de criptografia {{name}} não é suportado.', @@ -185,6 +186,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index 17110ac89..a35af6acc 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -63,6 +63,7 @@ const errors = { suspended: 'This account is suspended.', // UNTRANSLATED user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.', @@ -179,6 +180,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 505b231fe..c863a01f8 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -64,6 +64,7 @@ const errors = { suspended: 'This account is suspended.', // UNTRANSLATED user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.', @@ -179,6 +180,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, }; diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index d9e530d1c..e3916b9fd 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -61,6 +61,7 @@ const errors = { suspended: '账号已被禁用。', user_not_exist: '未找到与 {{ identifier }} 相关联的用户。', missing_profile: '请于登录时提供必要的用户补充信息。', + role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED }, password: { unsupported_encryption_method: '不支持的加密方法 {{name}}', @@ -160,6 +161,7 @@ const errors = { role: { name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED scope_exists: 'The scope id {{scopeId}} has already been added to this role', // UNTRANSLATED + user_exists: 'The user id {{userId}} is already been added to this role', // UNTRANSLATED }, };