0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): user-role related api (#2827)

This commit is contained in:
wangsijie 2023-01-07 12:08:03 +08:00 committed by GitHub
parent 6e8e3003f8
commit 7e507dbc2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 288 additions and 6 deletions

View file

@ -162,11 +162,13 @@ export const findUsers = async (
); );
export const findUsersByIds = async (userIds: string[]) => export const findUsersByIds = async (userIds: string[]) =>
envSet.pool.any<User>(sql` userIds.length > 0
? envSet.pool.any<User>(sql`
select ${sql.join(Object.values(fields), sql`, `)} select ${sql.join(Object.values(fields), sql`, `)}
from ${table} from ${table}
where ${fields.id} in (${sql.join(userIds, sql`, `)}) where ${fields.id} in (${sql.join(userIds, sql`, `)})
`); `)
: [];
const updateUser = buildUpdateWhere<CreateUser, User>(Users, true); const updateUser = buildUpdateWhere<CreateUser, User>(Users, true);

View file

@ -21,6 +21,17 @@ export const findUsersRolesByRoleId = async (roleId: string) =>
where ${fields.roleId}=${roleId} where ${fields.roleId}=${roleId}
`); `);
export const findFirstUsersRolesByRoleIdAndUserIds = async (roleId: string, userIds: string[]) =>
userIds.length > 0
? envSet.pool.maybeOne<UsersRole>(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[]) => export const insertUsersRoles = async (usersRoles: UsersRole[]) =>
envSet.pool.query(sql` envSet.pool.query(sql`
insert into ${table} (${fields.userId}, ${fields.roleId}) values insert into ${table} (${fields.userId}, ${fields.roleId}) values

View file

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

View file

@ -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<T extends AuthedRouter>(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();
}
);
}

View file

@ -8,6 +8,7 @@ import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js';
import koaAuth from '../middleware/koa-auth.js'; import koaAuth from '../middleware/koa-auth.js';
import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js'; import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js';
import adminUserRoleRoutes from './admin-user-role.js';
import adminUserRoutes from './admin-user.js'; import adminUserRoutes from './admin-user.js';
import applicationRoutes from './application.js'; import applicationRoutes from './application.js';
import authnRoutes from './authn.js'; import authnRoutes from './authn.js';
@ -45,6 +46,7 @@ const createRouters = (provider: Provider) => {
resourceRoutes(managementRouter); resourceRoutes(managementRouter);
signInExperiencesRoutes(managementRouter); signInExperiencesRoutes(managementRouter);
adminUserRoutes(managementRouter); adminUserRoutes(managementRouter);
adminUserRoleRoutes(managementRouter);
logRoutes(managementRouter); logRoutes(managementRouter);
roleRoutes(managementRouter); roleRoutes(managementRouter);
dashboardRoutes(managementRouter); dashboardRoutes(managementRouter);

View file

@ -1,7 +1,7 @@
import type { Role } from '@logto/schemas'; import type { Role } from '@logto/schemas';
import { pickDefault, createMockUtils } from '@logto/shared/esm'; 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'; import { createRequester } from '#src/utils/test-utils.js';
const { jest } = import.meta; const { jest } = import.meta;
@ -23,6 +23,7 @@ const { findRoleByRoleName, findRoleById, deleteRoleById } = mockEsm(
...mockRole, ...mockRole,
...data, ...data,
})), })),
findRolesByRoleIds: jest.fn(),
}) })
); );
const { findScopeById, findScopesByIds } = await mockEsmWithActual('#src/queries/scope.js', () => ({ const { findScopeById, findScopesByIds } = await mockEsmWithActual('#src/queries/scope.js', () => ({
@ -37,6 +38,21 @@ const { insertRolesScopes, findRolesScopesByRoleId } = await mockEsmWithActual(
deleteRolesScope: jest.fn(), 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')); const roleRoutes = await pickDefault(import('./role.js'));
describe('role routes', () => { describe('role routes', () => {
@ -133,4 +149,29 @@ describe('role routes', () => {
const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`); const response = await roleRequester.delete(`/roles/${mockRole.id}/scopes/${mockScope.id}`);
expect(response.status).toEqual(204); 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);
});
}); });

View file

@ -18,6 +18,13 @@ import {
updateRoleById, updateRoleById,
} from '#src/queries/roles.js'; } from '#src/queries/roles.js';
import { findScopeById, findScopesByIds } from '#src/queries/scope.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 assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter } from './types.js'; import type { AuthedRouter } from './types.js';
@ -186,4 +193,69 @@ export default function roleRoutes<T extends AuthedRouter>(router: T) {
return next(); 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();
}
);
} }

View file

@ -64,6 +64,7 @@ const errors = {
suspended: 'This account is suspended.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED
user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED,
missing_profile: 'You need to provide additional info before signing-in.', // 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: { password: {
unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.', unsupported_encryption_method: 'Die Verschlüsselungsmethode {{name}} wird nicht unterstützt.',
@ -178,6 +179,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };

View file

@ -64,6 +64,7 @@ const errors = {
suspended: 'This account is suspended.', suspended: 'This account is suspended.',
user_not_exist: 'User with {{ identifier }} does not exist.', user_not_exist: 'User with {{ identifier }} does not exist.',
missing_profile: 'You need to provide additional info before signing-in.', 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: { password: {
unsupported_encryption_method: 'The encryption method {{name}} is not supported.', unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
@ -177,6 +178,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', name_in_use: 'This role name {{name}} is already in use',
scope_exists: 'The scope id {{scopeId}} has already been added to this role', 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',
}, },
}; };

View file

@ -65,6 +65,7 @@ const errors = {
suspended: 'This account is suspended.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED
user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED,
missing_profile: 'You need to provide additional info before signing-in.', // 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: { password: {
unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.", unsupported_encryption_method: "La méthode de cryptage {{name}} n'est pas prise en charge.",
@ -184,6 +185,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };

View file

@ -62,6 +62,7 @@ const errors = {
suspended: '이 계정은 일시 정시되었어요.', suspended: '이 계정은 일시 정시되었어요.',
user_not_exist: '{{identifier}}의 사용자가 아직 등록되지 않았어요.', user_not_exist: '{{identifier}}의 사용자가 아직 등록되지 않았어요.',
missing_profile: '로그인 전에 추가 정보를 제공해야해요.', missing_profile: '로그인 전에 추가 정보를 제공해야해요.',
role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED
}, },
password: { password: {
unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.', unsupported_encryption_method: '{{name}} 암호화 방법을 지원하지 않아요.',
@ -171,6 +172,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };

View file

@ -64,6 +64,7 @@ const errors = {
suspended: 'Esta conta está suspensa.', suspended: 'Esta conta está suspensa.',
user_not_exist: 'O usuário com {{ identifier }} não existe', user_not_exist: 'O usuário com {{ identifier }} não existe',
missing_profile: 'Você precisa fornecer informações adicionais antes de fazer login.', 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: { password: {
unsupported_encryption_method: 'O método de criptografia {{name}} não é suportado.', unsupported_encryption_method: 'O método de criptografia {{name}} não é suportado.',
@ -185,6 +186,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };

View file

@ -63,6 +63,7 @@ const errors = {
suspended: 'This account is suspended.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED
user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED,
missing_profile: 'You need to provide additional info before signing-in.', // 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: { password: {
unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.', unsupported_encryption_method: 'O método de enncriptação {{name}} não é suportado.',
@ -179,6 +180,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };

View file

@ -64,6 +64,7 @@ const errors = {
suspended: 'This account is suspended.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED
user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED, user_not_exist: 'User with {{ identifier }} does not exist.', // UNTRANSLATED,
missing_profile: 'You need to provide additional info before signing-in.', // 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: { password: {
unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.', unsupported_encryption_method: '{{name}} şifreleme metodu desteklenmiyor.',
@ -179,6 +180,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };

View file

@ -61,6 +61,7 @@ const errors = {
suspended: '账号已被禁用。', suspended: '账号已被禁用。',
user_not_exist: '未找到与 {{ identifier }} 相关联的用户。', user_not_exist: '未找到与 {{ identifier }} 相关联的用户。',
missing_profile: '请于登录时提供必要的用户补充信息。', missing_profile: '请于登录时提供必要的用户补充信息。',
role_exists: 'The role id {{roleId}} is already been added to this user', // UNTRANSLATED
}, },
password: { password: {
unsupported_encryption_method: '不支持的加密方法 {{name}}', unsupported_encryption_method: '不支持的加密方法 {{name}}',
@ -160,6 +161,7 @@ const errors = {
role: { role: {
name_in_use: 'This role name {{name}} is already in use', // UNTRANSLATED 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 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
}, },
}; };