diff --git a/packages/core/src/__mocks__/index.ts b/packages/core/src/__mocks__/index.ts index 2052dd9f2..34e62fb4b 100644 --- a/packages/core/src/__mocks__/index.ts +++ b/packages/core/src/__mocks__/index.ts @@ -58,6 +58,14 @@ export const mockScopeWithResource = { resource: mockResource, }; +export const mockAdminApplicationRole: Role = { + tenantId: 'fake_tenant', + id: 'role_id', + name: 'admin', + description: 'admin application', + type: RoleType.MachineToMachine, +}; + export const mockAdminUserRole: Role = { tenantId: 'fake_tenant', id: 'role_id', diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index 194e9c8f0..8664eb184 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -1,7 +1,7 @@ -import type { CreateApplication } from '@logto/schemas'; +import type { Application, CreateApplication } from '@logto/schemas'; import { ApplicationType, Applications } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; -import { convertToIdentifiers } from '@logto/shared'; +import { convertToIdentifiers, conditionalSql } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; @@ -11,23 +11,45 @@ import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { getTotalRowCountWithPool } from '#src/database/row-count.js'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; import { DeletionError } from '#src/errors/SlonikError/index.js'; +import { buildConditionsFromSearch } from '#src/utils/search.js'; +import type { Search } from '#src/utils/search.js'; const { table, fields } = convertToIdentifiers(Applications); +const buildApplicationConditions = (search: Search) => { + const hasSearch = search.matches.length > 0; + const searchFields = [ + Applications.fields.id, + Applications.fields.name, + Applications.fields.description, + ]; + + return conditionalSql( + hasSearch, + () => sql`and ${buildConditionsFromSearch(search, searchFields)}` + ); +}; + export const createApplicationQueries = (pool: CommonQueryMethods) => { const findTotalNumberOfApplications = async () => getTotalRowCountWithPool(pool)(table); + const findAllApplications = buildFindAllEntitiesWithPool(pool)(Applications, [ { field: 'createdAt', order: 'desc' }, ]); + const findApplicationById = buildFindEntityByIdWithPool(pool)(Applications); + const insertApplication = buildInsertIntoWithPool(pool)(Applications, { returning: true, }); + const updateApplication = buildUpdateWhereWithPool(pool)(Applications, true); + const updateApplicationById = async ( id: string, set: Partial> ) => updateApplication({ set, where: { id }, jsonbMode: 'merge' }); + const countNonM2mApplications = async () => { const { count } = await pool.one<{ count: string }>(sql` select count(*) @@ -37,6 +59,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { return { count: Number(count) }; }; + const countM2mApplications = async () => { const { count } = await pool.one<{ count: string }>(sql` select count(*) @@ -47,6 +70,43 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { return { count: Number(count) }; }; + const countM2mApplicationsByIds = async (search: Search, applicationIds: string[]) => { + if (applicationIds.length === 0) { + return { count: 0 }; + } + + const { count } = await pool.one<{ count: string }>(sql` + select count(*) + from ${table} + where ${fields.type} = ${ApplicationType.MachineToMachine} + and ${fields.id} in (${sql.join(applicationIds, sql`, `)}) + ${buildApplicationConditions(search)} + `); + + return { count: Number(count) }; + }; + + const findM2mApplicationsByIds = async ( + search: Search, + limit: number, + offset: number, + applicationIds: string[] + ) => { + if (applicationIds.length === 0) { + return []; + } + + return pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.type} = ${ApplicationType.MachineToMachine} + and ${fields.id} in (${sql.join(applicationIds, sql`, `)}) + ${buildApplicationConditions(search)} + limit ${limit} + offset ${offset} + `); + }; + const deleteApplicationById = async (id: string) => { const { rowCount } = await pool.query(sql` delete from ${table} @@ -67,6 +127,8 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => { updateApplicationById, countNonM2mApplications, countM2mApplications, + countM2mApplicationsByIds, + findM2mApplicationsByIds, deleteApplicationById, }; }; diff --git a/packages/core/src/queries/applications-roles.ts b/packages/core/src/queries/applications-roles.ts index 93de894ce..ba56b9eb7 100644 --- a/packages/core/src/queries/applications-roles.ts +++ b/packages/core/src/queries/applications-roles.ts @@ -1,6 +1,7 @@ import type { ApplicationsRole, CreateApplicationsRole, Role } from '@logto/schemas'; import { Roles, ApplicationsRoles, RolesScopes } from '@logto/schemas'; import { convertToIdentifiers } from '@logto/shared'; +import { type Nullable } from '@silverhand/essentials'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; @@ -10,6 +11,27 @@ const { table, fields } = convertToIdentifiers(ApplicationsRoles, true); const { fields: insertFields } = convertToIdentifiers(ApplicationsRoles); export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => { + const findFirstApplicationsRolesByRoleIdAndApplicationIds = async ( + roleId: string, + applicationIds: string[] + ): Promise> => + applicationIds.length > 0 + ? pool.maybeOne(sql` + select ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where ${fields.roleId}=${roleId} + and ${fields.applicationId} in (${sql.join(applicationIds, sql`, `)}) + limit 1 + `) + : null; + + const countApplicationsRolesByRoleId = async (roleId: string) => + pool.one<{ count: number }>(sql` + select count(*) + from ${table} + where ${fields.roleId}=${roleId} + `); + const findApplicationsRolesByApplicationId = async (applicationId: string) => pool.any(sql` select @@ -20,6 +42,14 @@ export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => { where ${fields.applicationId}=${applicationId} `); + const findApplicationsRolesByRoleId = async (roleId: string) => + pool.any(sql` + select + ${sql.join(Object.values(fields), sql`,`)} + from ${table} + where ${fields.roleId}=${roleId} + `); + const insertApplicationsRoles = async (applicationsRoles: CreateApplicationsRole[]) => pool.query(sql` insert into ${table} (${insertFields.id}, ${insertFields.applicationId}, ${ @@ -45,7 +75,10 @@ export const createApplicationsRolesQueries = (pool: CommonQueryMethods) => { }; return { + findFirstApplicationsRolesByRoleIdAndApplicationIds, + countApplicationsRolesByRoleId, findApplicationsRolesByApplicationId, + findApplicationsRolesByRoleId, insertApplicationsRoles, deleteApplicationRole, }; diff --git a/packages/core/src/queries/roles.ts b/packages/core/src/queries/roles.ts index 4209a4606..894eec210 100644 --- a/packages/core/src/queries/roles.ts +++ b/packages/core/src/queries/roles.ts @@ -24,11 +24,11 @@ const buildRoleConditions = (search: Search) => { ); }; -export const defaultUserSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or }; +export const defaultSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or }; export const createRolesQueries = (pool: CommonQueryMethods) => { const countRoles = async ( - search: Search = defaultUserSearch, + search: Search = defaultSearch, { excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {} ) => { const { count } = await pool.one<{ count: string }>(sql` diff --git a/packages/core/src/routes/admin-user-role.test.ts b/packages/core/src/routes/admin-user-role.test.ts index abb7a1c84..47920b5fa 100644 --- a/packages/core/src/routes/admin-user-role.test.ts +++ b/packages/core/src/routes/admin-user-role.test.ts @@ -103,16 +103,42 @@ describe('user role routes', () => { expect(deleteUsersRolesByUserIdAndRoleId).not.toHaveBeenCalled(); expect(insertUsersRoles).toHaveBeenCalledWith([]); }); - }); - it('DELETE /users/:id/roles/:roleId', async () => { - const response = await roleRequester.delete( - `/users/${mockUser.id}/roles/${mockAdminUserRole.id}` - ); - expect(response.status).toEqual(204); - expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith( - mockUser.id, - mockAdminUserRole.id - ); + it('POST /users/:id/roles', async () => { + findUsersRolesByUserId.mockResolvedValueOnce([]); + const response = await roleRequester.post(`/users/${mockUser.id}/roles`).send({ + roleIds: [mockAdminUserRole.id], + }); + expect(response.status).toEqual(201); + expect(insertUsersRoles).toHaveBeenCalledWith([ + { id: mockId, userId: mockUser.id, roleId: mockAdminUserRole.id }, + ]); + }); + + it('PUT /users/:id/roles', async () => { + findUsersRolesByUserId.mockResolvedValueOnce([mockUserRole]); + const response = await roleRequester.put(`/users/${mockUser.id}/roles`).send({ + roleIds: [mockAdminUserRole2.id], + }); + expect(response.status).toEqual(200); + expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith( + mockUser.id, + mockAdminUserRole.id + ); + expect(insertUsersRoles).toHaveBeenCalledWith([ + { id: mockId, userId: mockUser.id, roleId: mockAdminUserRole2.id }, + ]); + }); + + it('DELETE /users/:id/roles/:roleId', async () => { + const response = await roleRequester.delete( + `/users/${mockUser.id}/roles/${mockAdminUserRole.id}` + ); + expect(response.status).toEqual(204); + expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith( + mockUser.id, + mockAdminUserRole.id + ); + }); }); }); diff --git a/packages/core/src/routes/role.application.test.ts b/packages/core/src/routes/role.application.test.ts new file mode 100644 index 000000000..47c3b00ce --- /dev/null +++ b/packages/core/src/routes/role.application.test.ts @@ -0,0 +1,87 @@ +import { pickDefault } from '@logto/shared/esm'; + +import { mockAdminApplicationRole, mockApplication } from '#src/__mocks__/index.js'; +import { mockId, mockStandardId } from '#src/test-utils/nanoid.js'; +import { createMockQuotaLibrary } from '#src/test-utils/quota.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +await mockStandardId(); + +const roles = { + findRoleById: jest.fn(), +}; +const { findRoleById } = roles; + +const applications = { + findApplicationById: jest.fn(), + countM2mApplicationsByIds: jest.fn(async () => ({ count: 1 })), + findM2mApplicationsByIds: jest.fn(async () => [mockApplication]), +}; + +const applicationsRoles = { + findFirstApplicationsRolesByRoleIdAndApplicationIds: jest.fn(), + findApplicationsRolesByRoleId: jest.fn(), + insertApplicationsRoles: jest.fn(), + deleteApplicationRole: jest.fn(), +}; +const { + findFirstApplicationsRolesByRoleIdAndApplicationIds, + findApplicationsRolesByRoleId, + insertApplicationsRoles, + deleteApplicationRole, +} = applicationsRoles; + +const roleUserRoutes = await pickDefault(import('./role.application.js')); + +const tenantContext = new MockTenant( + undefined, + { + applicationsRoles, + applications, + roles, + }, + undefined, + { quota: createMockQuotaLibrary() } +); + +describe('role application routes', () => { + const roleUserRequester = createRequester({ authedRoutes: roleUserRoutes, tenantContext }); + + it('GET /roles/:id/applications', async () => { + findRoleById.mockResolvedValueOnce(mockAdminApplicationRole); + findApplicationsRolesByRoleId.mockResolvedValueOnce([]); + const response = await roleUserRequester.get( + `/roles/${mockAdminApplicationRole.id}/applications` + ); + expect(response.status).toEqual(200); + expect(response.body[0]).toHaveProperty('id', mockApplication.id); + }); + + it('POST /roles/:id/applications', async () => { + findRoleById.mockResolvedValueOnce(mockAdminApplicationRole); + findFirstApplicationsRolesByRoleIdAndApplicationIds.mockResolvedValueOnce(null); + const response = await roleUserRequester + .post(`/roles/${mockAdminApplicationRole.id}/applications`) + .send({ + applicationIds: [mockApplication.id], + }); + expect(response.status).toEqual(201); + expect(insertApplicationsRoles).toHaveBeenCalledWith([ + { id: mockId, applicationId: mockApplication.id, roleId: mockAdminApplicationRole.id }, + ]); + }); + + it('DELETE /roles/:id/applications/:applicationId', async () => { + const response = await roleUserRequester.delete( + `/roles/${mockAdminApplicationRole.id}/applications/${mockApplication.id}` + ); + expect(response.status).toEqual(204); + expect(deleteApplicationRole).toHaveBeenCalledWith( + mockApplication.id, + mockAdminApplicationRole.id + ); + }); +}); diff --git a/packages/core/src/routes/role.application.ts b/packages/core/src/routes/role.application.ts new file mode 100644 index 000000000..7e78b08d7 --- /dev/null +++ b/packages/core/src/routes/role.application.ts @@ -0,0 +1,132 @@ +import { Applications } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { tryThat } from '@silverhand/essentials'; +import { object, string } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { parseSearchParamsForSearch } from '#src/utils/search.js'; + +import type { AuthedRouter, RouterInitArgs } from './types.js'; + +export default function roleApplicationRoutes( + ...[router, { queries }]: RouterInitArgs +) { + const { + roles: { findRoleById }, + applications: { countM2mApplicationsByIds, findM2mApplicationsByIds, findApplicationById }, + applicationsRoles: { + findFirstApplicationsRolesByRoleIdAndApplicationIds, + findApplicationsRolesByRoleId, + insertApplicationsRoles, + deleteApplicationRole, + }, + } = queries; + + router.get( + '/roles/:id/applications', + koaPagination(), + koaGuard({ + params: object({ id: string().min(1) }), + response: Applications.guard.array(), + status: [200, 204, 400, 404], + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + const { limit, offset } = ctx.pagination; + const { searchParams } = ctx.request.URL; + + await findRoleById(id); + + return tryThat( + async () => { + const search = parseSearchParamsForSearch(searchParams); + const applicationRoles = await findApplicationsRolesByRoleId(id); + const applicationIds = applicationRoles.map(({ applicationId }) => applicationId); + + const [{ count }, applications] = await Promise.all([ + countM2mApplicationsByIds(search, applicationIds), + findM2mApplicationsByIds(search, limit, offset, applicationIds), + ]); + + ctx.pagination.totalCount = count; + ctx.body = applications; + + return next(); + }, + (error) => { + if (error instanceof TypeError) { + throw new RequestError( + { code: 'request.invalid_input', details: error.message }, + error + ); + } + throw error; + } + ); + } + ); + + router.post( + '/roles/:id/applications', + koaGuard({ + params: object({ id: string().min(1) }), + body: object({ applicationIds: string().min(1).array().nonempty() }), + status: [201, 404, 422], + }), + async (ctx, next) => { + const { + params: { id }, + body: { applicationIds }, + } = ctx.guard; + + await findRoleById(id); + const existingRecord = await findFirstApplicationsRolesByRoleIdAndApplicationIds( + id, + applicationIds + ); + + if (existingRecord) { + throw new RequestError({ + code: 'role.application_exists', + status: 422, + userId: existingRecord.applicationId, + }); + } + + await Promise.all( + applicationIds.map(async (applicationId) => findApplicationById(applicationId)) + ); + await insertApplicationsRoles( + applicationIds.map((applicationId) => ({ + id: generateStandardId(), + roleId: id, + applicationId, + })) + ); + ctx.status = 201; + + return next(); + } + ); + + router.delete( + '/roles/:id/applications/:applicationId', + koaGuard({ + params: object({ id: string().min(1), applicationId: string().min(1) }), + status: [204, 404], + }), + async (ctx, next) => { + const { + params: { id, applicationId }, + } = ctx.guard; + await deleteApplicationRole(applicationId, id); + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/role.test.ts b/packages/core/src/routes/role.test.ts index 869d98636..d6c4137d1 100644 --- a/packages/core/src/routes/role.test.ts +++ b/packages/core/src/routes/role.test.ts @@ -1,9 +1,8 @@ import type { Role } from '@logto/schemas'; -import { RoleType } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; -import { mockAdminUserRole, mockScope, mockUser, mockResource } from '#src/__mocks__/index.js'; -import { mockId, mockStandardId } from '#src/test-utils/nanoid.js'; +import { mockAdminUserRole, mockScope, mockUser } from '#src/__mocks__/index.js'; +import { mockStandardId } from '#src/test-utils/nanoid.js'; import { createMockQuotaLibrary } from '#src/test-utils/quota.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createRequester } from '#src/utils/test-utils.js'; @@ -30,7 +29,6 @@ const roles = { ...data, tenantId: 'fake_tenant', })), - findRolesByRoleIds: jest.fn(), }; const { findRoleByRoleName, findRoleById, deleteRoleById } = roles; @@ -39,36 +37,22 @@ const scopes = { }; const { findScopeById } = scopes; -const resources = { - findResourcesByIds: jest.fn(async () => [mockResource]), -}; - const rolesScopes = { insertRolesScopes: jest.fn(), - deleteRolesScope: jest.fn(), }; const { insertRolesScopes } = rolesScopes; const users = { findUsersByIds: jest.fn(), - findUserById: jest.fn(), - countUsers: jest.fn(async () => ({ count: 1 })), - findUsers: jest.fn(async () => [mockUser]), }; const { findUsersByIds } = users; const usersRoles = { - insertUsersRoles: jest.fn(), countUsersRolesByRoleId: jest.fn(), findUsersRolesByRoleId: jest.fn(), deleteUsersRolesByUserIdAndRoleId: jest.fn(), }; -const { - insertUsersRoles, - findUsersRolesByRoleId, - deleteUsersRolesByUserIdAndRoleId, - countUsersRolesByRoleId, -} = usersRoles; +const { findUsersRolesByRoleId, countUsersRolesByRoleId } = usersRoles; const roleRoutes = await pickDefault(import('./role.js')); @@ -78,7 +62,6 @@ const tenantContext = new MockTenant( usersRoles, users, rolesScopes, - resources, scopes, roles, }, @@ -167,49 +150,4 @@ describe('role routes', () => { expect(response.status).toEqual(204); expect(deleteRoleById).toHaveBeenCalledWith(mockAdminUserRole.id); }); - - it('GET /roles/:id/users', async () => { - findRoleById.mockResolvedValueOnce(mockAdminUserRole); - findUsersRolesByRoleId.mockResolvedValueOnce([]); - findUsersByIds.mockResolvedValueOnce([mockUser]); - const response = await roleRequester.get(`/roles/${mockAdminUserRole.id}/users`); - expect(response.status).toEqual(200); - expect(response.body[0]).toHaveProperty('id', mockUser.id); - }); - - describe('POST /roles/:id/users', () => { - it('Succeed', async () => { - findRoleById.mockResolvedValueOnce(mockAdminUserRole); - findUsersRolesByRoleId.mockResolvedValueOnce([]); - const response = await roleRequester.post(`/roles/${mockAdminUserRole.id}/users`).send({ - userIds: [mockUser.id], - }); - expect(response.status).toEqual(201); - expect(insertUsersRoles).toHaveBeenCalledWith([ - { id: mockId, userId: mockUser.id, roleId: mockAdminUserRole.id }, - ]); - }); - - it('Should throw error when trying to assign machine to machine role to users', async () => { - const mockMachineToMachineRole = { ...mockAdminUserRole, type: RoleType.MachineToMachine }; - findRoleById.mockResolvedValueOnce(mockMachineToMachineRole); - const response = await roleRequester - .post(`/roles/${mockMachineToMachineRole.id}/users`) - .send({ - userIds: [mockUser.id], - }); - expect(response.status).toEqual(422); - }); - }); - - it('DELETE /roles/:id/users/:userId', async () => { - const response = await roleRequester.delete( - `/roles/${mockAdminUserRole.id}/users/${mockUser.id}` - ); - expect(response.status).toEqual(204); - expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith( - mockUser.id, - mockAdminUserRole.id - ); - }); }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index e7a6df7ca..e88917940 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -1,13 +1,7 @@ import type { RoleResponse } from '@logto/schemas'; -import { - userInfoSelectFields, - userProfileResponseGuard, - Roles, - RoleType, - Users, -} from '@logto/schemas'; +import { Roles, Users } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; -import { pick, tryThat } from '@silverhand/essentials'; +import { tryThat } from '@silverhand/essentials'; import { object, string, z, number } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; @@ -18,17 +12,15 @@ import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.j import assertThat from '#src/utils/assert-that.js'; import { parseSearchParamsForSearch } from '#src/utils/search.js'; +import roleApplicationRoutes from './role.application.js'; +import roleUserRoutes from './role.user.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; -export default function roleRoutes( - ...[ - router, - { - queries, - libraries: { quota }, - }, - ]: RouterInitArgs -) { +export default function roleRoutes(...[router, tenant]: RouterInitArgs) { + const { + queries, + libraries: { quota }, + } = tenant; const { rolesScopes: { insertRolesScopes }, roles: { @@ -41,14 +33,8 @@ export default function roleRoutes( updateRoleById, }, scopes: { findScopeById }, - users: { findUserById, findUsersByIds, countUsers, findUsers }, - usersRoles: { - countUsersRolesByRoleId, - deleteUsersRolesByUserIdAndRoleId, - findUsersRolesByRoleId, - findUsersRolesByUserId, - insertUsersRoles, - }, + users: { findUsersByIds }, + usersRoles: { countUsersRolesByRoleId, findUsersRolesByRoleId, findUsersRolesByUserId }, } = queries; router.use('/roles(/.*)?', koaRoleRlsErrorHandler()); @@ -228,101 +214,6 @@ export default function roleRoutes( } ); - router.get( - '/roles/:id/users', - koaPagination(), - koaGuard({ - params: object({ id: string().min(1) }), - response: userProfileResponseGuard.array(), - status: [200, 400, 404], - }), - async (ctx, next) => { - const { - params: { id }, - } = ctx.guard; - const { limit, offset } = ctx.pagination; - const { searchParams } = ctx.request.URL; - - await findRoleById(id); - - return tryThat( - async () => { - const search = parseSearchParamsForSearch(searchParams); - const usersRoles = await findUsersRolesByRoleId(id); - const userIds = usersRoles.map(({ userId }) => userId); - - const [{ count }, users] = await Promise.all([ - countUsers(search, undefined, userIds), - findUsers(limit, offset, search, undefined, userIds), - ]); - - ctx.pagination.totalCount = count; - ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); - - return next(); - }, - (error) => { - if (error instanceof TypeError) { - throw new RequestError( - { code: 'request.invalid_input', details: error.message }, - error - ); - } - throw error; - } - ); - } - ); - - router.post( - '/roles/:id/users', - koaGuard({ - params: object({ id: string().min(1) }), - body: object({ userIds: string().min(1).array().nonempty() }), - status: [201, 404, 422], - }), - async (ctx, next) => { - const { - params: { id }, - body: { userIds }, - } = ctx.guard; - - const role = await findRoleById(id); - assertThat( - role.type === RoleType.User, - new RequestError({ code: 'user.invalid_role_type', status: 422, roleId: role.id }) - ); - - const usersRoles = await findUsersRolesByRoleId(id); - const existingUserIds = new Set(usersRoles.map(({ userId }) => userId)); - const userIdsToAdd = userIds.filter((id) => !existingUserIds.has(id)); // Skip existing user ids. - - if (userIdsToAdd.length > 0) { - await Promise.all(userIdsToAdd.map(async (userId) => findUserById(userId))); - await insertUsersRoles( - userIdsToAdd.map((userId) => ({ id: generateStandardId(), 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) }), - status: [204, 404], - }), - async (ctx, next) => { - const { - params: { id, userId }, - } = ctx.guard; - await deleteUsersRolesByUserIdAndRoleId(userId, id); - ctx.status = 204; - - return next(); - } - ); + roleUserRoutes(router, tenant); + roleApplicationRoutes(router, tenant); } diff --git a/packages/core/src/routes/role.user.test.ts b/packages/core/src/routes/role.user.test.ts new file mode 100644 index 000000000..e40aa8163 --- /dev/null +++ b/packages/core/src/routes/role.user.test.ts @@ -0,0 +1,83 @@ +import { pickDefault } from '@logto/shared/esm'; + +import { mockAdminUserRole, mockUser } from '#src/__mocks__/index.js'; +import { mockId, mockStandardId } from '#src/test-utils/nanoid.js'; +import { createMockQuotaLibrary } from '#src/test-utils/quota.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; + +await mockStandardId(); + +const roles = { + findRoleById: jest.fn(), +}; +const { findRoleById } = roles; + +const users = { + findUserById: jest.fn(), + countUsers: jest.fn(async () => ({ count: 1 })), + findUsers: jest.fn(async () => [mockUser]), +}; + +const usersRoles = { + insertUsersRoles: jest.fn(), + findUsersRolesByRoleId: jest.fn(), + findFirstUsersRolesByRoleIdAndUserIds: jest.fn(), + deleteUsersRolesByUserIdAndRoleId: jest.fn(), +}; +const { + insertUsersRoles, + findUsersRolesByRoleId, + deleteUsersRolesByUserIdAndRoleId, + findFirstUsersRolesByRoleIdAndUserIds, +} = usersRoles; + +const roleUserRoutes = await pickDefault(import('./role.user.js')); + +const tenantContext = new MockTenant( + undefined, + { + usersRoles, + users, + roles, + }, + undefined, + { quota: createMockQuotaLibrary() } +); + +describe('role user routes', () => { + const roleUserRequester = createRequester({ authedRoutes: roleUserRoutes, tenantContext }); + + it('GET /roles/:id/users', async () => { + findRoleById.mockResolvedValueOnce(mockAdminUserRole); + findUsersRolesByRoleId.mockResolvedValueOnce([]); + const response = await roleUserRequester.get(`/roles/${mockAdminUserRole.id}/users`); + expect(response.status).toEqual(200); + expect(response.body[0]).toHaveProperty('id', mockUser.id); + }); + + it('POST /roles/:id/users', async () => { + findRoleById.mockResolvedValueOnce(mockAdminUserRole); + findFirstUsersRolesByRoleIdAndUserIds.mockResolvedValueOnce(null); + const response = await roleUserRequester.post(`/roles/${mockAdminUserRole.id}/users`).send({ + userIds: [mockUser.id], + }); + expect(response.status).toEqual(201); + expect(insertUsersRoles).toHaveBeenCalledWith([ + { id: mockId, userId: mockUser.id, roleId: mockAdminUserRole.id }, + ]); + }); + + it('DELETE /roles/:id/users/:userId', async () => { + const response = await roleUserRequester.delete( + `/roles/${mockAdminUserRole.id}/users/${mockUser.id}` + ); + expect(response.status).toEqual(204); + expect(deleteUsersRolesByUserIdAndRoleId).toHaveBeenCalledWith( + mockUser.id, + mockAdminUserRole.id + ); + }); +}); diff --git a/packages/core/src/routes/role.user.ts b/packages/core/src/routes/role.user.ts new file mode 100644 index 000000000..698f0f362 --- /dev/null +++ b/packages/core/src/routes/role.user.ts @@ -0,0 +1,123 @@ +import { userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { pick, tryThat } from '@silverhand/essentials'; +import { object, string } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { parseSearchParamsForSearch } from '#src/utils/search.js'; + +import type { AuthedRouter, RouterInitArgs } from './types.js'; + +export default function roleUserRoutes( + ...[router, { queries }]: RouterInitArgs +) { + const { + roles: { findRoleById }, + users: { findUserById, countUsers, findUsers }, + usersRoles: { + deleteUsersRolesByUserIdAndRoleId, + findFirstUsersRolesByRoleIdAndUserIds, + findUsersRolesByRoleId, + insertUsersRoles, + }, + } = queries; + + router.get( + '/roles/:id/users', + koaPagination(), + koaGuard({ + params: object({ id: string().min(1) }), + response: userProfileResponseGuard.array(), + status: [200, 400, 404], + }), + async (ctx, next) => { + const { + params: { id }, + } = ctx.guard; + const { limit, offset } = ctx.pagination; + const { searchParams } = ctx.request.URL; + + await findRoleById(id); + + return tryThat( + async () => { + const search = parseSearchParamsForSearch(searchParams); + const usersRoles = await findUsersRolesByRoleId(id); + const userIds = usersRoles.map(({ userId }) => userId); + + const [{ count }, users] = await Promise.all([ + countUsers(search, undefined, userIds), + findUsers(limit, offset, search, undefined, userIds), + ]); + + ctx.pagination.totalCount = count; + ctx.body = users.map((user) => pick(user, ...userInfoSelectFields)); + + return next(); + }, + (error) => { + if (error instanceof TypeError) { + throw new RequestError( + { code: 'request.invalid_input', details: error.message }, + error + ); + } + throw error; + } + ); + } + ); + + router.post( + '/roles/:id/users', + koaGuard({ + params: object({ id: string().min(1) }), + body: object({ userIds: string().min(1).array().nonempty() }), + status: [201, 404, 422], + }), + 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) => ({ id: generateStandardId(), 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) }), + status: [204, 404], + }), + async (ctx, next) => { + const { + params: { id, userId }, + } = ctx.guard; + await deleteUsersRolesByUserIdAndRoleId(userId, id); + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/integration-tests/src/api/role.ts b/packages/integration-tests/src/api/role.ts index 0f0089734..72d754b92 100644 --- a/packages/integration-tests/src/api/role.ts +++ b/packages/integration-tests/src/api/role.ts @@ -1,4 +1,4 @@ -import type { CreateRole, Role, Scope, User } from '@logto/schemas'; +import type { CreateRole, Role, Scope, User, Application } from '@logto/schemas'; import { RoleType } from '@logto/schemas'; import { generateRoleName } from '#src/utils.js'; @@ -65,3 +65,14 @@ export const assignUsersToRole = async (userIds: string[], roleId: string) => export const deleteUserFromRole = async (userId: string, roleId: string) => authedAdminApi.delete(`roles/${roleId}/users/${userId}`); + +export const getRoleApplications = async (roleId: string) => + authedAdminApi.get(`roles/${roleId}/applications`).json(); + +export const assignApplicationsToRole = async (applicationIds: string[], roleId: string) => + authedAdminApi.post(`roles/${roleId}/applications`, { + json: { applicationIds }, + }); + +export const deleteApplicationFromRole = async (applicationId: string, roleId: string) => + authedAdminApi.delete(`roles/${roleId}/applications/${applicationId}`); diff --git a/packages/integration-tests/src/tests/api/role.application.test.ts b/packages/integration-tests/src/tests/api/role.application.test.ts new file mode 100644 index 000000000..2d99a4dc7 --- /dev/null +++ b/packages/integration-tests/src/tests/api/role.application.test.ts @@ -0,0 +1,95 @@ +import { ApplicationType, RoleType } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { HTTPError } from 'got'; + +import { createApplication } from '#src/api/index.js'; +import { + assignApplicationsToRole, + createRole, + deleteApplicationFromRole, + getRoleApplications, +} from '#src/api/role.js'; + +describe('roles applications', () => { + it('should get role applications successfully', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + await assignApplicationsToRole([m2mApp.id], role.id); + const applications = await getRoleApplications(role.id); + + expect(applications.length).toBe(1); + expect(applications[0]).toHaveProperty('id', m2mApp.id); + }); + + it('should return 404 if role not found', async () => { + const response = await getRoleApplications('not-found').catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should assign applications to role successfully', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const m2mApp1 = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + const m2mApp2 = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + await assignApplicationsToRole([m2mApp1.id, m2mApp2.id], role.id); + const applications = await getRoleApplications(role.id); + + expect(applications.length).toBe(2); + }); + + it('should fail when try to assign empty applications', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const response = await assignApplicationsToRole([], role.id).catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(400); + }); + + it('should fail with invalid application input', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const response = await assignApplicationsToRole([''], role.id).catch((error: unknown) => error); + expect(response instanceof HTTPError && response.response.statusCode).toBe(400); + }); + + it('should fail if role not found', async () => { + const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + const response = await assignApplicationsToRole([m2mApp.id], 'not-found').catch( + (error: unknown) => error + ); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should fail if application not found', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const response = await assignApplicationsToRole(['not-found'], role.id).catch( + (error: unknown) => error + ); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should remove application from role successfully', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + await assignApplicationsToRole([m2mApp.id], role.id); + const applications = await getRoleApplications(role.id); + expect(applications.length).toBe(1); + + await deleteApplicationFromRole(m2mApp.id, role.id); + + const newApplications = await getRoleApplications(role.id); + expect(newApplications.length).toBe(0); + }); + + it('should fail if role not found when trying to remove application from role', async () => { + const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine); + const response = await deleteApplicationFromRole(m2mApp.id, 'not-found').catch( + (error: unknown) => error + ); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); + + it('should fail if application not found when trying to remove application from role', async () => { + const role = await createRole({ type: RoleType.MachineToMachine }); + const response = await deleteApplicationFromRole('not-found', role.id).catch( + (error: unknown) => error + ); + expect(response instanceof HTTPError && response.response.statusCode).toBe(404); + }); +}); diff --git a/packages/integration-tests/src/tests/api/role.user.test.ts b/packages/integration-tests/src/tests/api/role.user.test.ts index 846fca076..3addd6936 100644 --- a/packages/integration-tests/src/tests/api/role.user.test.ts +++ b/packages/integration-tests/src/tests/api/role.user.test.ts @@ -36,7 +36,7 @@ describe('roles users', () => { const m2mRole = await createRole({ type: RoleType.MachineToMachine }); const user = await createUser(generateNewUserProfile({})); await expectRejects(assignUsersToRole([user.id], m2mRole.id), { - code: 'user.invalid_role_type', + code: 'entity.db_constraint_violated', statusCode: 422, }); const users = await getRoleUsers(m2mRole.id); diff --git a/packages/phrases/src/locales/de/errors/role.ts b/packages/phrases/src/locales/de/errors/role.ts index 05e3fdd30..2755aedb5 100644 --- a/packages/phrases/src/locales/de/errors/role.ts +++ b/packages/phrases/src/locales/de/errors/role.ts @@ -2,6 +2,8 @@ const role = { name_in_use: 'Dieser Rollenname {{name}} wird bereits verwendet.', scope_exists: 'Die Scope-ID {{scopeId}} wurde bereits zu dieser Rolle hinzugefügt.', user_exists: 'Die Benutzer-ID {{userId}} wurde bereits zu dieser Rolle hinzugefügt.', + application_exists: + 'Die Anwendungs-ID {{applicationId}} wurde bereits zu dieser Rolle hinzugefügt.', default_role_missing: 'Einige der Standardrollennamen sind in der Datenbank nicht vorhanden. Bitte stellen Sie sicher, dass Sie zuerst Rollen erstellen.', internal_role_violation: diff --git a/packages/phrases/src/locales/en/errors/role.ts b/packages/phrases/src/locales/en/errors/role.ts index 3fe148147..b3a9da651 100644 --- a/packages/phrases/src/locales/en/errors/role.ts +++ b/packages/phrases/src/locales/en/errors/role.ts @@ -2,6 +2,7 @@ const 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', + application_exists: 'The application id {{applicationId}} is already been added to this role', default_role_missing: 'Some of the default roleNames does not exist in database, please ensure to create roles first', internal_role_violation: diff --git a/packages/phrases/src/locales/es/errors/role.ts b/packages/phrases/src/locales/es/errors/role.ts index c60e3d3b4..8426be374 100644 --- a/packages/phrases/src/locales/es/errors/role.ts +++ b/packages/phrases/src/locales/es/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'Este nombre de rol {{name}} ya está en uso', scope_exists: 'El id de alcance {{scopeId}} ya ha sido agregado a este rol', user_exists: 'El id de usuario {{userId}} ya ha sido agregado a este rol', + application_exists: 'El id de aplicación {{applicationId}} ya ha sido agregado a este rol', default_role_missing: 'Algunos de los nombres de roles predeterminados no existen en la base de datos, por favor asegúrese de crear los roles primero', internal_role_violation: diff --git a/packages/phrases/src/locales/fr/errors/role.ts b/packages/phrases/src/locales/fr/errors/role.ts index 37ee770b3..7def0d02a 100644 --- a/packages/phrases/src/locales/fr/errors/role.ts +++ b/packages/phrases/src/locales/fr/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'Ce nom de rôle {{name}} est déjà utilisé', scope_exists: "L'identifiant de portée {{scopeId}} a déjà été ajouté à ce rôle", user_exists: "L'identifiant d'utilisateur {{userId}} a déjà été ajouté à ce rôle", + application_exists: "L'identifiant d'application {{applicationId}} a déjà été ajouté à ce rôle", default_role_missing: "Certains noms de rôles par défaut n'existent pas dans la base de données, veuillez vous assurer de créer d'abord des rôles", internal_role_violation: diff --git a/packages/phrases/src/locales/it/errors/role.ts b/packages/phrases/src/locales/it/errors/role.ts index 584038006..e5c0bcbb5 100644 --- a/packages/phrases/src/locales/it/errors/role.ts +++ b/packages/phrases/src/locales/it/errors/role.ts @@ -2,6 +2,8 @@ const role = { name_in_use: 'Il nome di ruolo {{name}} è già in uso', scope_exists: "L'identificatore di ambito {{scopeId}} è già stato aggiunto a questo ruolo", user_exists: "L'identificatore di utente {{userId}} è già stato aggiunto a questo ruolo", + application_exists: + "L'ID dell'applicazione {{applicationId}} è già stato aggiunto a questo ruolo", default_role_missing: 'Alcuni dei nomi di ruolo predefiniti non esistono nel database, assicurati di creare prima i ruoli', internal_role_violation: diff --git a/packages/phrases/src/locales/ja/errors/role.ts b/packages/phrases/src/locales/ja/errors/role.ts index 4e0e6fbee..582cc68a0 100644 --- a/packages/phrases/src/locales/ja/errors/role.ts +++ b/packages/phrases/src/locales/ja/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'このロール名{{name}}はすでに使用されています', scope_exists: 'スコープID {{scopeId}}はすでにこのロールに追加されています', user_exists: 'ユーザーID{{userId}}はすでにこのロールに追加されています', + application_exists: 'アプリケーション ID {{applicationId}} はすでにこのロールに追加されています', default_role_missing: 'データベースにデフォルトロール名が存在しないものがあります。ロールを作成してください', internal_role_violation: diff --git a/packages/phrases/src/locales/ko/errors/role.ts b/packages/phrases/src/locales/ko/errors/role.ts index 559ceaff6..f83d50df7 100644 --- a/packages/phrases/src/locales/ko/errors/role.ts +++ b/packages/phrases/src/locales/ko/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: '역할 이름 {{name}}이/가 이미 사용 중이에요.', scope_exists: '범위 ID {{scopeId}}이/가 이미 이 역할에 추가되어 있어요.', user_exists: '사용자 ID {{userId}}이/가 이미 이 역할에 추가되어 있어요.', + application_exists: '애플리케이션 ID {{applicationId}} 가 이미 이 역할에 추가되어 있어요.', default_role_missing: '기본 역할 이름의 일부가 데이터베이스에 존재하지 않아요. 먼저 역할을 생성해 주세요.', internal_role_violation: diff --git a/packages/phrases/src/locales/pl-pl/errors/role.ts b/packages/phrases/src/locales/pl-pl/errors/role.ts index cb6c869b0..8179b2de2 100644 --- a/packages/phrases/src/locales/pl-pl/errors/role.ts +++ b/packages/phrases/src/locales/pl-pl/errors/role.ts @@ -2,10 +2,11 @@ const role = { name_in_use: 'Ta nazwa roli {{name}} jest już w użyciu', scope_exists: 'Identyfikator zakresu {{scopeId}} został już dodany do tej roli', user_exists: 'Identyfikator użytkownika {{userId}} został już dodany do tej roli', + application_exists: 'Identyfikator aplikacji {{applicationId}} został już dodany do tej roli', default_role_missing: 'Niektóre z domyślnych nazw ról nie istnieją w bazie danych, upewnij się, że najpierw utworzysz role', internal_role_violation: - 'Możesz próbować zaktualizować lub usunąć rolę wewnętrzną, co jest zabronione przez Logto. Jeśli tworzysz nową rolę, spróbuj innej nazwy, która nie zaczyna się od "#internal:".', + 'Możesz próbować zaktualizować lub usunąć rolę wewnętrzną, co jest zabronione przez Logto. Jeśli tworzysz nową rolę, spróbuj innej nazwy, która nie zaczyna się od "#internal:".', }; export default Object.freeze(role); diff --git a/packages/phrases/src/locales/pt-br/errors/role.ts b/packages/phrases/src/locales/pt-br/errors/role.ts index 6ec646582..48009dbb0 100644 --- a/packages/phrases/src/locales/pt-br/errors/role.ts +++ b/packages/phrases/src/locales/pt-br/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'Este nome de papel {{name}} já está em uso', scope_exists: 'O id de escopo {{scopeId}} já foi adicionado a este papel', user_exists: 'O id de usuário {{userId}} já foi adicionado a este papel', + application_exists: 'O id do aplicativo {{applicationId}} já foi adicionado a este papel', default_role_missing: 'Alguns dos nomes de função padrão não existem no banco de dados, certifique-se de criar funções primeiro', internal_role_violation: diff --git a/packages/phrases/src/locales/pt-pt/errors/role.ts b/packages/phrases/src/locales/pt-pt/errors/role.ts index 9e1a6bd24..2c5511aee 100644 --- a/packages/phrases/src/locales/pt-pt/errors/role.ts +++ b/packages/phrases/src/locales/pt-pt/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'Este nome de função {{name}} já está em uso', scope_exists: 'O id do escopo {{scopeId}} já foi adicionado a esta função', user_exists: 'O id do usuário {{userId}} já foi adicionado a esta função', + application_exists: 'O id de aplicação {{applicationId}} já foi adicionado a esta função', default_role_missing: 'Alguns dos nomes de função padrão não existem no banco de dados, por favor, certifique-se de criar as funções primeiro', internal_role_violation: diff --git a/packages/phrases/src/locales/ru/errors/role.ts b/packages/phrases/src/locales/ru/errors/role.ts index d604669e1..e91c92cc9 100644 --- a/packages/phrases/src/locales/ru/errors/role.ts +++ b/packages/phrases/src/locales/ru/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'Это имя роли {{name}} уже используется', scope_exists: 'Идентификатор области действия {{scopeId}} уже был добавлен в эту роль', user_exists: 'Идентификатор пользователя {{userId}} уже был добавлен в эту роль', + application_exists: 'Идентификатор приложения {{applicationId}} уже был добавлен в эту роль', default_role_missing: 'Некоторые имена ролей по умолчанию отсутствуют в базе данных, пожалуйста, убедитесь в том, что сначала создали роли', internal_role_violation: diff --git a/packages/phrases/src/locales/tr-tr/errors/role.ts b/packages/phrases/src/locales/tr-tr/errors/role.ts index 1335c3ada..16c628d16 100644 --- a/packages/phrases/src/locales/tr-tr/errors/role.ts +++ b/packages/phrases/src/locales/tr-tr/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: 'Bu rol adı {{name}} zaten kullanımda', scope_exists: 'Bu kapsam kimliği {{scopeId}} zaten bu role eklendi', user_exists: 'Bu kullanıcı kimliği {{userId}} zaten bu role eklendi', + application_exists: 'Bu uygulama kimliği {{applicationId}} zaten bu role eklendi', default_role_missing: 'Varsayılan rol adlarından bazıları veritabanında mevcut değil, lütfen önce rolleri oluşturduğunuzdan emin olun', internal_role_violation: diff --git a/packages/phrases/src/locales/zh-cn/errors/role.ts b/packages/phrases/src/locales/zh-cn/errors/role.ts index 80dde06eb..4d7007e9e 100644 --- a/packages/phrases/src/locales/zh-cn/errors/role.ts +++ b/packages/phrases/src/locales/zh-cn/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: '此角色名称 {{name}} 已被使用', scope_exists: '作用域 ID {{scopeId}} 已添加到此角色', user_exists: '用户 ID {{userId}} 已添加到此角色', + application_exists: '应用程序 ID {{applicationId}} 已添加到此角色', default_role_missing: '某些默认角色名称在数据库中不存在,请确保先创建角色', internal_role_violation: '你可能正在尝试更新或删除 Logto 禁止的内部角色。如果你要创建新角色,请尝试使用不以“#internal:”开头的名称。', diff --git a/packages/phrases/src/locales/zh-hk/errors/role.ts b/packages/phrases/src/locales/zh-hk/errors/role.ts index e5b14abd7..f048b9db7 100644 --- a/packages/phrases/src/locales/zh-hk/errors/role.ts +++ b/packages/phrases/src/locales/zh-hk/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: '此角色名稱 {{name}} 已被使用', scope_exists: '作用域 ID {{scopeId}} 已添加到此角色', user_exists: '用戶 ID {{userId}} 已添加到此角色', + application_exists: '應用程式 ID {{applicationId}} 已添加到此角色', default_role_missing: '某些默認角色名稱在數據庫中不存在,請確保先創建角色', internal_role_violation: '你可能正在嘗試更新或刪除 Logto 禁止的內部角色。如果你要創建新角色,請嘗試使用不以“#internal:”開頭的名稱。', diff --git a/packages/phrases/src/locales/zh-tw/errors/role.ts b/packages/phrases/src/locales/zh-tw/errors/role.ts index 3b26ceb24..b96236408 100644 --- a/packages/phrases/src/locales/zh-tw/errors/role.ts +++ b/packages/phrases/src/locales/zh-tw/errors/role.ts @@ -2,6 +2,7 @@ const role = { name_in_use: '此角色名稱 {{name}} 已被使用', scope_exists: '作用域 ID {{scopeId}} 已添加到此角色', user_exists: '用戶 ID {{userId}} 已添加到此角色', + application_exists: '已經將應用程式 ID {{applicationId}} 添加到此角色', default_role_missing: '某些預設角色名稱在資料庫中不存在,請確保先創建角色', internal_role_violation: '你可能正在嘗試更新或刪除 Logto 禁止的內部角色。如果你要創建新角色,請嘗試使用不以“#internal:”開頭的名稱。',