mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
fix(console,core,test): support filter by type for GET roles and apps APIs (#4727)
* fix(console,core): do not reuse advanced search for role/app type * chore(test): add integration test cases for using search component when assigning app/role * chore: apply suggestions from code review Co-authored-by: Gao Sun <gao@silverhand.io> * chore(test): add API test cases for filtering roles/applications and add changeset * chore: apply suggestions from code review Co-authored-by: Gao Sun <gao@silverhand.io> --------- Co-authored-by: Gao Sun <gao@silverhand.io>
This commit is contained in:
parent
d41b71a54a
commit
1ab39d19b6
19 changed files with 375 additions and 127 deletions
7
.changeset/sixty-ladybugs-exercise.md
Normal file
7
.changeset/sixty-ladybugs-exercise.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@logto/integration-tests": patch
|
||||
"@logto/console": patch
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
fix 500 error when using search component in console to filter both roles and applications.
|
|
@ -41,8 +41,7 @@ function SourceRolesBox({ entityId, type, selectedRoles, onChange }: Props) {
|
|||
const url = buildUrl('api/roles', {
|
||||
page: String(page),
|
||||
page_size: String(pageSize),
|
||||
'search.type': type,
|
||||
'mode.type': 'exact',
|
||||
type,
|
||||
[type === RoleType.User ? 'excludeUserId' : 'excludeApplicationId']: entityId,
|
||||
...conditional(keyword && { search: `%${keyword}%` }),
|
||||
});
|
||||
|
|
|
@ -98,9 +98,7 @@ function AssignRoleModal<T extends Application | User>({
|
|||
pathname: `api/${phraseKey}`,
|
||||
parameters: {
|
||||
excludeRoleId: roleId,
|
||||
...(roleType === RoleType.User
|
||||
? {}
|
||||
: { 'search.type': ApplicationType.MachineToMachine, 'mode.type': 'exact' }),
|
||||
...(roleType === RoleType.User ? {} : { types: ApplicationType.MachineToMachine }),
|
||||
},
|
||||
}}
|
||||
selectedEntities={entities}
|
||||
|
|
|
@ -4,7 +4,6 @@ import type { OmitAutoSetFields } from '@logto/shared';
|
|||
import { convertToIdentifiers, conditionalSql, conditionalArraySql } from '@logto/shared';
|
||||
import type { CommonQueryMethods, SqlSqlToken } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js';
|
||||
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
|
||||
|
@ -23,7 +22,6 @@ const buildApplicationConditions = (search: Search) => {
|
|||
Applications.fields.id,
|
||||
Applications.fields.name,
|
||||
Applications.fields.description,
|
||||
Applications.fields.type,
|
||||
];
|
||||
|
||||
return conditionalSql(
|
||||
|
@ -33,9 +31,7 @@ const buildApplicationConditions = (search: Search) => {
|
|||
* Avoid specifying the DB column type when calling the API (which is meaningless).
|
||||
* Should specify the DB column type of enum type.
|
||||
*/
|
||||
sql`${buildConditionsFromSearch(search, searchFields, {
|
||||
[Applications.fields.type]: snakeCase('ApplicationType'),
|
||||
})}`
|
||||
sql`${buildConditionsFromSearch(search, searchFields)}`
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -48,7 +44,19 @@ const buildConditionArray = (conditions: SqlSqlToken[]) => {
|
|||
};
|
||||
|
||||
export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
||||
const countApplications = async (search: Search, excludeApplicationIds: string[]) => {
|
||||
/**
|
||||
* Get the number of applications that match the search conditions, conditions are joined in `and` mode.
|
||||
*
|
||||
* @param search The search config object, can apply to `id`, `name` and `description` field for application.
|
||||
* @param excludeApplicationIds Exclude applications with these ids.
|
||||
* @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
|
||||
* @returns A Promise that resolves the number of applications that match the search conditions.
|
||||
*/
|
||||
const countApplications = async (
|
||||
search: Search,
|
||||
excludeApplicationIds: string[],
|
||||
types?: ApplicationType[]
|
||||
) => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
from ${table}
|
||||
|
@ -56,6 +64,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
excludeApplicationIds.length > 0
|
||||
? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`
|
||||
: sql``,
|
||||
types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``,
|
||||
buildApplicationConditions(search),
|
||||
])}
|
||||
`);
|
||||
|
@ -63,9 +72,20 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
return { count: Number(count) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the list of applications that match the search conditions, conditions are joined in `and` mode.
|
||||
*
|
||||
* @param search The search config object, can apply to `id`, `name` and `description` field for application
|
||||
* @param excludeApplicationIds Exclude applications with these ids.
|
||||
* @param types Optional array of {@link ApplicationType}, filter applications by types, if not provided, all types will be included.
|
||||
* @param limit Limit of the number of applications in each page.
|
||||
* @param offset Offset of the applications in the result.
|
||||
* @returns A Promise that resolves the list of applications that match the search conditions.
|
||||
*/
|
||||
const findApplications = async (
|
||||
search: Search,
|
||||
excludeApplicationIds: string[],
|
||||
types?: ApplicationType[],
|
||||
limit?: number,
|
||||
offset?: number
|
||||
) =>
|
||||
|
@ -76,6 +96,7 @@ export const createApplicationQueries = (pool: CommonQueryMethods) => {
|
|||
excludeApplicationIds.length > 0
|
||||
? sql`${fields.id} not in (${sql.join(excludeApplicationIds, sql`, `)})`
|
||||
: sql``,
|
||||
types && types.length > 0 ? sql`${fields.type} in (${sql.join(types, sql`, `)})` : sql``,
|
||||
buildApplicationConditions(search),
|
||||
])}
|
||||
order by ${fields.createdAt} desc
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import type { CreateRole, Role } from '@logto/schemas';
|
||||
import type { CreateRole, Role, RoleType } from '@logto/schemas';
|
||||
import { internalRolePrefix, SearchJointMode, Roles } from '@logto/schemas';
|
||||
import type { OmitAutoSetFields } from '@logto/shared';
|
||||
import { conditionalArraySql, conditionalSql, convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
|
||||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
|
@ -17,19 +16,11 @@ const { table, fields } = convertToIdentifiers(Roles);
|
|||
|
||||
const buildRoleConditions = (search: Search) => {
|
||||
const hasSearch = search.matches.length > 0;
|
||||
const searchFields = [
|
||||
Roles.fields.id,
|
||||
Roles.fields.name,
|
||||
Roles.fields.description,
|
||||
Roles.fields.type,
|
||||
];
|
||||
const searchFields = [Roles.fields.id, Roles.fields.name, Roles.fields.description];
|
||||
|
||||
return conditionalSql(
|
||||
hasSearch,
|
||||
() =>
|
||||
sql`and ${buildConditionsFromSearch(search, searchFields, {
|
||||
[Roles.fields.type]: snakeCase('RoleType'),
|
||||
})}`
|
||||
() => sql`and ${buildConditionsFromSearch(search, searchFields)}`
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -38,7 +29,11 @@ export const defaultSearch = { matches: [], isCaseSensitive: false, joint: Searc
|
|||
export const createRolesQueries = (pool: CommonQueryMethods) => {
|
||||
const countRoles = async (
|
||||
search: Search = defaultSearch,
|
||||
{ excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {}
|
||||
{
|
||||
excludeRoleIds = [],
|
||||
roleIds,
|
||||
type,
|
||||
}: { excludeRoleIds?: string[]; roleIds?: string[]; type?: RoleType } = {}
|
||||
) => {
|
||||
const { count } = await pool.one<{ count: string }>(sql`
|
||||
select count(*)
|
||||
|
@ -53,6 +48,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
(value) =>
|
||||
sql`and ${fields.id} in (${value.length > 0 ? sql.join(value, sql`, `) : sql`null`})`
|
||||
)}
|
||||
${conditionalSql(type, (type) => sql`and ${fields.type}=${type}`)}
|
||||
${buildRoleConditions(search)}
|
||||
`);
|
||||
|
||||
|
@ -63,7 +59,11 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
search: Search,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
{ excludeRoleIds = [], roleIds }: { excludeRoleIds?: string[]; roleIds?: string[] } = {}
|
||||
{
|
||||
excludeRoleIds = [],
|
||||
roleIds,
|
||||
type,
|
||||
}: { excludeRoleIds?: string[]; roleIds?: string[]; type?: RoleType } = {}
|
||||
) =>
|
||||
pool.any<Role>(
|
||||
sql`
|
||||
|
@ -79,6 +79,7 @@ export const createRolesQueries = (pool: CommonQueryMethods) => {
|
|||
(value) =>
|
||||
sql`and ${fields.id} in (${value.length > 0 ? sql.join(value, sql`, `) : sql`null`})`
|
||||
)}
|
||||
${conditionalSql(type, (type) => sql`and ${fields.type}=${type}`)}
|
||||
${buildRoleConditions(search)}
|
||||
${conditionalSql(limit, (value) => sql`limit ${value}`)}
|
||||
${conditionalSql(offset, (value) => sql`offset ${value}`)}
|
||||
|
|
|
@ -44,8 +44,8 @@ export default function adminUserRoleRoutes<T extends AuthedRouter>(
|
|||
const usersRoles = await findUsersRolesByUserId(userId);
|
||||
const roleIds = usersRoles.map(({ roleId }) => roleId);
|
||||
const [{ count }, roles] = await Promise.all([
|
||||
countRoles(search, { roleIds }),
|
||||
findRoles(search, limit, offset, { roleIds }),
|
||||
countRoles(search, { roleIds, type: RoleType.User }),
|
||||
findRoles(search, limit, offset, { roleIds, type: RoleType.User }),
|
||||
]);
|
||||
|
||||
// Return totalCount to pagination middleware
|
||||
|
|
|
@ -55,8 +55,8 @@ export default function applicationRoleRoutes<T extends AuthedRouter>(
|
|||
const applicationRoles = await findApplicationsRolesByApplicationId(applicationId);
|
||||
const roleIds = applicationRoles.map(({ roleId }) => roleId);
|
||||
const [{ count }, roles] = await Promise.all([
|
||||
countRoles(search, { roleIds }),
|
||||
findRoles(search, limit, offset, { roleIds }),
|
||||
countRoles(search, { roleIds, type: RoleType.MachineToMachine }),
|
||||
findRoles(search, limit, offset, { roleIds, type: RoleType.MachineToMachine }),
|
||||
]);
|
||||
|
||||
// Return totalCount to pagination middleware
|
||||
|
|
|
@ -21,6 +21,8 @@ import type { AuthedRouter, RouterInitArgs } from './types.js';
|
|||
const includesInternalAdminRole = (roles: Readonly<Array<{ role: Role }>>) =>
|
||||
roles.some(({ role: { name } }) => name === InternalRole.Admin);
|
||||
|
||||
const applicationTypeGuard = z.nativeEnum(ApplicationType);
|
||||
|
||||
export default function applicationRoutes<T extends AuthedRouter>(
|
||||
...[
|
||||
router,
|
||||
|
@ -51,12 +53,23 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
'/applications',
|
||||
koaPagination({ isOptional: true }),
|
||||
koaGuard({
|
||||
query: object({
|
||||
/**
|
||||
* We treat the `types` query param as an array, but it will be parsed as string-typed
|
||||
* value if only one type is specified, manually convert to ApplicationType array.
|
||||
*/
|
||||
types: applicationTypeGuard
|
||||
.array()
|
||||
.or(applicationTypeGuard.transform((type) => [type]))
|
||||
.optional(),
|
||||
}),
|
||||
response: z.array(Applications.guard),
|
||||
status: 200,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { limit, offset, disabled: paginationDisabled } = ctx.pagination;
|
||||
const { searchParams } = ctx.URL;
|
||||
const { types } = ctx.guard.query;
|
||||
|
||||
const search = parseSearchParamsForSearch(searchParams);
|
||||
|
||||
|
@ -69,14 +82,14 @@ export default function applicationRoutes<T extends AuthedRouter>(
|
|||
);
|
||||
|
||||
if (paginationDisabled) {
|
||||
ctx.body = await findApplications(search, excludeApplicationIds);
|
||||
ctx.body = await findApplications(search, excludeApplicationIds, types);
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
const [{ count }, applications] = await Promise.all([
|
||||
countApplications(search, excludeApplicationIds),
|
||||
findApplications(search, excludeApplicationIds, limit, offset),
|
||||
countApplications(search, excludeApplicationIds, types),
|
||||
findApplications(search, excludeApplicationIds, types, limit, offset),
|
||||
]);
|
||||
|
||||
// Return totalCount to pagination middleware
|
||||
|
|
|
@ -49,6 +49,10 @@ export default function roleRoutes<T extends AuthedRouter>(...[router, tenant]:
|
|||
'/roles',
|
||||
koaPagination(),
|
||||
koaGuard({
|
||||
query: object({
|
||||
excludeUserId: string().optional(),
|
||||
excludeApplicationId: string().optional(),
|
||||
}).merge(Roles.guard.pick({ type: true }).partial()),
|
||||
response: Roles.guard
|
||||
.merge(
|
||||
object({
|
||||
|
@ -64,15 +68,14 @@ export default function roleRoutes<T extends AuthedRouter>(...[router, tenant]:
|
|||
async (ctx, next) => {
|
||||
const { limit, offset } = ctx.pagination;
|
||||
const { searchParams } = ctx.request.URL;
|
||||
const { type, excludeUserId, excludeApplicationId } = ctx.guard.query;
|
||||
|
||||
return tryThat(
|
||||
async () => {
|
||||
const search = parseSearchParamsForSearch(searchParams);
|
||||
|
||||
const excludeUserId = searchParams.get('excludeUserId');
|
||||
const usersRoles = excludeUserId ? await findUsersRolesByUserId(excludeUserId) : [];
|
||||
|
||||
const excludeApplicationId = searchParams.get('excludeApplicationId');
|
||||
const applicationsRoles = excludeApplicationId
|
||||
? await findApplicationsRolesByApplicationId(excludeApplicationId)
|
||||
: [];
|
||||
|
@ -83,8 +86,8 @@ export default function roleRoutes<T extends AuthedRouter>(...[router, tenant]:
|
|||
];
|
||||
|
||||
const [{ count }, roles] = await Promise.all([
|
||||
countRoles(search, { excludeRoleIds }),
|
||||
findRoles(search, limit, offset, { excludeRoleIds }),
|
||||
countRoles(search, { excludeRoleIds, type }),
|
||||
findRoles(search, limit, offset, { excludeRoleIds, type }),
|
||||
]);
|
||||
|
||||
const rolesResponse: RoleResponse[] = await Promise.all(
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { SearchJointMode, SearchMatchMode } from '@logto/schemas';
|
||||
import type { Nullable, Optional } from '@silverhand/essentials';
|
||||
import { yes, conditionalString, conditional, cond } from '@silverhand/essentials';
|
||||
import { yes, conditionalString, cond } from '@silverhand/essentials';
|
||||
import { sql } from 'slonik';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
|
@ -195,13 +195,9 @@ const getMatchModeOperator = (match: SearchMatchMode, isCaseSensitive: boolean)
|
|||
const validateAndBuildValueExpression = (
|
||||
rawValues: string[],
|
||||
field: string,
|
||||
shouldLowercase: boolean,
|
||||
fieldsTypeMapping?: Record<string, string>
|
||||
shouldLowercase: boolean
|
||||
) => {
|
||||
const values =
|
||||
shouldLowercase && isLowercaseValid(field, fieldsTypeMapping)
|
||||
? rawValues.map((rawValue) => rawValue.toLowerCase())
|
||||
: rawValues;
|
||||
const values = shouldLowercase ? rawValues.map((rawValue) => rawValue.toLowerCase()) : rawValues;
|
||||
|
||||
// Type check for the first value
|
||||
assertThat(
|
||||
|
@ -210,25 +206,11 @@ const validateAndBuildValueExpression = (
|
|||
);
|
||||
|
||||
const valueExpression =
|
||||
values.length === 1
|
||||
? sql`${values[0]}`
|
||||
: sql`any(${sql.array(values, conditional(fieldsTypeMapping?.[field]) ?? 'varchar')})`;
|
||||
values.length === 1 ? sql`${values[0]}` : sql`any(${sql.array(values, 'varchar')})`;
|
||||
|
||||
return valueExpression;
|
||||
};
|
||||
|
||||
const isLowercaseValid = (field: string, fieldsTypeMapping?: Record<string, string>) => {
|
||||
return !conditional(fieldsTypeMapping?.[field]);
|
||||
};
|
||||
|
||||
const showLowercase = (
|
||||
shouldLowercase: boolean,
|
||||
field: string,
|
||||
fieldsTypeMapping?: Record<string, string>
|
||||
) => {
|
||||
return shouldLowercase && isLowercaseValid(field, fieldsTypeMapping);
|
||||
};
|
||||
|
||||
/**
|
||||
* Build search SQL token by parsing the search object and available search fields.
|
||||
* Note all `field`s will be normalized to snake case, so camel case fields are valid.
|
||||
|
@ -238,11 +220,7 @@ const showLowercase = (
|
|||
* @returns The SQL token that includes the all condition checks.
|
||||
* @throws TypeError error if fields in `search` do not match the `searchFields`, or invalid condition found (e.g. the value is empty).
|
||||
*/
|
||||
export const buildConditionsFromSearch = (
|
||||
search: Search,
|
||||
searchFields: readonly string[],
|
||||
fieldsTypeMapping?: Readonly<Record<string, string>>
|
||||
) => {
|
||||
export const buildConditionsFromSearch = (search: Search, searchFields: readonly string[]) => {
|
||||
assertThat(searchFields.length > 0, new TypeError('No search field found.'));
|
||||
|
||||
const { matches, joint, isCaseSensitive } = search;
|
||||
|
@ -259,15 +237,13 @@ export const buildConditionsFromSearch = (
|
|||
const fields = field ? [field] : searchFields;
|
||||
|
||||
const getValueExpressionFor = (fieldName: string, shouldLowercase: boolean) =>
|
||||
validateAndBuildValueExpression(values, fieldName, shouldLowercase, fieldsTypeMapping);
|
||||
validateAndBuildValueExpression(values, fieldName, shouldLowercase);
|
||||
|
||||
return sql`(${sql.join(
|
||||
fields.map(
|
||||
(field) =>
|
||||
sql`${
|
||||
showLowercase(shouldLowercase, field, fieldsTypeMapping)
|
||||
? sql`lower(${sql.identifier([field])})`
|
||||
: sql.identifier([field])
|
||||
shouldLowercase ? sql`lower(${sql.identifier([field])})` : sql.identifier([field])
|
||||
} ${getMatchModeOperator(mode, isCaseSensitive)} ${getValueExpressionFor(
|
||||
field,
|
||||
shouldLowercase
|
||||
|
|
|
@ -48,8 +48,17 @@ export const deleteUserIdentity = async (userId: string, connectorTarget: string
|
|||
export const assignRolesToUser = async (userId: string, roleIds: string[]) =>
|
||||
authedAdminApi.post(`users/${userId}/roles`, { json: { roleIds } });
|
||||
|
||||
export const getUserRoles = async (userId: string) =>
|
||||
authedAdminApi.get(`users/${userId}/roles`).json<Role[]>();
|
||||
/**
|
||||
* Get roles assigned to the user.
|
||||
*
|
||||
* @param userId Concerned user id
|
||||
* @param keyword Search among all roles (on `id`, `name` and `description` fields) assigned to the user with `keyword`
|
||||
* @returns All roles which contains the keyword assigned to the user
|
||||
*/
|
||||
export const getUserRoles = async (userId: string, keyword?: string) => {
|
||||
const searchParams = new URLSearchParams(keyword && [['search', `%${keyword}%`]]);
|
||||
return authedAdminApi.get(`users/${userId}/roles`, { searchParams }).json<Role[]>();
|
||||
};
|
||||
|
||||
export const deleteRoleFromUser = async (userId: string, roleId: string) =>
|
||||
authedAdminApi.delete(`users/${userId}/roles/${roleId}`);
|
||||
|
|
|
@ -24,13 +24,18 @@ export const createApplication = async (
|
|||
})
|
||||
.json<Application>();
|
||||
|
||||
export const getApplications = async (types?: ApplicationType[]) => {
|
||||
const searchParams = new URLSearchParams(
|
||||
conditional(
|
||||
types &&
|
||||
types.length > 0 && [...types.map((type) => ['search.type', type]), ['mode.type', 'exact']]
|
||||
)
|
||||
);
|
||||
export const getApplications = async (
|
||||
types?: ApplicationType[],
|
||||
searchParameters?: Record<string, string>
|
||||
) => {
|
||||
const searchParams = new URLSearchParams([
|
||||
...(conditional(types && types.length > 0 && types.map((type) => ['types', type])) ?? []),
|
||||
...(conditional(
|
||||
searchParameters &&
|
||||
Object.keys(searchParameters).length > 0 &&
|
||||
Object.entries(searchParameters).map(([key, value]) => [key, value])
|
||||
) ?? []),
|
||||
]);
|
||||
|
||||
return authedAdminApi.get('applications', { searchParams }).json<Application[]>();
|
||||
};
|
||||
|
@ -57,8 +62,17 @@ export const updateApplication = async (
|
|||
export const deleteApplication = async (applicationId: string) =>
|
||||
authedAdminApi.delete(`applications/${applicationId}`);
|
||||
|
||||
export const getApplicationRoles = async (applicationId: string) =>
|
||||
authedAdminApi.get(`applications/${applicationId}/roles`).json<Role[]>();
|
||||
/**
|
||||
* Get roles assigned to the m2m app.
|
||||
*
|
||||
* @param applicationId Concerned m2m app id
|
||||
* @param keyword Search among all roles (on `id`, `name` and `description` fields) assigned to the m2m app with `keyword`
|
||||
* @returns All roles which contains the keyword assigned to the m2m app
|
||||
*/
|
||||
export const getApplicationRoles = async (applicationId: string, keyword?: string) => {
|
||||
const searchParams = new URLSearchParams(conditional(keyword && [['search', `%${keyword}%`]]));
|
||||
return authedAdminApi.get(`applications/${applicationId}/roles`, { searchParams }).json<Role[]>();
|
||||
};
|
||||
|
||||
export const assignRolesToApplication = async (applicationId: string, roleIds: string[]) =>
|
||||
authedAdminApi.post(`applications/${applicationId}/roles`, {
|
||||
|
|
|
@ -5,7 +5,12 @@ import { generateRoleName } from '#src/utils.js';
|
|||
|
||||
import { authedAdminApi } from './api.js';
|
||||
|
||||
export type GetRoleOptions = { excludeUserId?: string; excludeApplicationId?: string };
|
||||
export type GetRoleOptions = {
|
||||
excludeUserId?: string;
|
||||
excludeApplicationId?: string;
|
||||
type?: RoleType;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export const createRole = async ({
|
||||
name,
|
||||
|
@ -58,8 +63,17 @@ export const assignScopesToRole = async (scopeIds: string[], roleId: string) =>
|
|||
export const deleteScopeFromRole = async (scopeId: string, roleId: string) =>
|
||||
authedAdminApi.delete(`roles/${roleId}/scopes/${scopeId}`);
|
||||
|
||||
export const getRoleUsers = async (roleId: string) =>
|
||||
authedAdminApi.get(`roles/${roleId}/users`).json<User[]>();
|
||||
/**
|
||||
* Get users assigned to the role.
|
||||
*
|
||||
* @param roleId Concerned role id.
|
||||
* @param keyword Search among all users (on `id`, `name` and `description` fields) assigned to the role with `keyword`.
|
||||
* @returns A Promise that resolves all users which contains the keyword assigned to the role.
|
||||
*/
|
||||
export const getRoleUsers = async (roleId: string, keyword?: string) => {
|
||||
const searchParams = new URLSearchParams(keyword && [['search', `%${keyword}%`]]);
|
||||
return authedAdminApi.get(`roles/${roleId}/users`, { searchParams }).json<User[]>();
|
||||
};
|
||||
|
||||
export const assignUsersToRole = async (userIds: string[], roleId: string) =>
|
||||
authedAdminApi.post(`roles/${roleId}/users`, {
|
||||
|
@ -69,8 +83,17 @@ 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<Application[]>();
|
||||
/**
|
||||
* Get apps assigned to the role.
|
||||
*
|
||||
* @param roleId Concerned role id.
|
||||
* @param keyword Search among all m2m apps (on `id`, `name` and `description` fields) assigned to the role with `keyword`.
|
||||
* @returns A Promise that resolves all m2m apps which contains the keyword assigned to the role.
|
||||
*/
|
||||
export const getRoleApplications = async (roleId: string, keyword?: string) => {
|
||||
const searchParams = new URLSearchParams(keyword && [['search', `%${keyword}%`]]);
|
||||
return authedAdminApi.get(`roles/${roleId}/applications`, { searchParams }).json<Application[]>();
|
||||
};
|
||||
|
||||
export const assignApplicationsToRole = async (applicationIds: string[], roleId: string) =>
|
||||
authedAdminApi.post(`roles/${roleId}/applications`, {
|
||||
|
|
|
@ -15,7 +15,8 @@ describe('admin console user management (roles)', () => {
|
|||
|
||||
it('should successfully assign user role to user and get list, but failed to assign m2m role to user', async () => {
|
||||
const user = await createUserByAdmin();
|
||||
const role = await createRole({});
|
||||
const role1 = await createRole({});
|
||||
const role2 = await createRole({});
|
||||
|
||||
const m2mRole = await createRole({ type: RoleType.MachineToMachine });
|
||||
await expectRejects(assignRolesToUser(user.id, [m2mRole.id]), {
|
||||
|
@ -23,10 +24,18 @@ describe('admin console user management (roles)', () => {
|
|||
statusCode: 422,
|
||||
});
|
||||
|
||||
await assignRolesToUser(user.id, [role.id]);
|
||||
await assignRolesToUser(user.id, [role1.id, role2.id]);
|
||||
const roles = await getUserRoles(user.id);
|
||||
expect(roles.length).toBe(1);
|
||||
expect(roles[0]).toHaveProperty('id', role.id);
|
||||
expect(roles.length).toBe(2);
|
||||
expect(roles.find(({ id }) => id === role1.id)).toBeDefined();
|
||||
|
||||
// Empty keyword should be ignored, all assigned roles should be returned
|
||||
await expect(getUserRoles(user.id, '')).resolves.toHaveLength(2);
|
||||
|
||||
// Get right assigned roles with search keyword
|
||||
const assignedRolesWithKeyword = await getUserRoles(user.id, role1.name);
|
||||
expect(assignedRolesWithKeyword).toHaveLength(1);
|
||||
expect(assignedRolesWithKeyword.find(({ id }) => id === role2.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail when assign duplicated role to user', async () => {
|
||||
|
|
|
@ -8,8 +8,9 @@ import {
|
|||
assignRolesToApplication,
|
||||
deleteRoleFromApplication,
|
||||
putRolesToApplication,
|
||||
getApplications,
|
||||
} from '#src/api/index.js';
|
||||
import { createRole } from '#src/api/role.js';
|
||||
import { createRole, assignApplicationsToRole } from '#src/api/role.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
|
||||
describe('admin console application management (roles)', () => {
|
||||
|
@ -40,6 +41,14 @@ describe('admin console application management (roles)', () => {
|
|||
expect(roles.length).toBe(2);
|
||||
expect(roles.find(({ id }) => id === role1.id)).toBeDefined();
|
||||
expect(roles.find(({ id }) => id === role2.id)).toBeDefined();
|
||||
|
||||
// Empty keyword should be ignored, all assigned roles should be returned
|
||||
await expect(getApplicationRoles(application.id, '')).resolves.toHaveLength(2);
|
||||
|
||||
// Get right assigned roles with search keyword
|
||||
const rolesWithSearchParams = await getApplicationRoles(application.id, role1.name);
|
||||
expect(rolesWithSearchParams).toHaveLength(1);
|
||||
expect(rolesWithSearchParams.find(({ id }) => id === role2.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fail when assign duplicated role to app', async () => {
|
||||
|
@ -107,4 +116,34 @@ describe('admin console application management (roles)', () => {
|
|||
);
|
||||
expect(response instanceof HTTPError && response.response.statusCode === 404).toBe(true);
|
||||
});
|
||||
|
||||
// This case tests GET operation on applications and filter by `types` parameter and `search` parameter.
|
||||
it('search applications with specified keyword, types and other parameters', async () => {
|
||||
await createApplication('test-m2m-app-001', ApplicationType.MachineToMachine);
|
||||
const m2mApp002 = await createApplication('test-m2m-app-002', ApplicationType.MachineToMachine);
|
||||
await createApplication('test-spa-app-001', ApplicationType.SPA);
|
||||
await createApplication('test-spa-app-002', ApplicationType.SPA);
|
||||
await createApplication('test-native-app-001', ApplicationType.Native);
|
||||
await createApplication('test-native-app-002', ApplicationType.Native);
|
||||
|
||||
// Search applications with `types` and `search` parameters
|
||||
const spaAndM2mAppsWithKeyword = await getApplications(
|
||||
[ApplicationType.SPA, ApplicationType.MachineToMachine],
|
||||
{ search: '%002%' }
|
||||
);
|
||||
expect(spaAndM2mAppsWithKeyword.length).toBe(2);
|
||||
expect(spaAndM2mAppsWithKeyword.find(({ name }) => name === 'test-m2m-app-002')).toBeTruthy();
|
||||
expect(spaAndM2mAppsWithKeyword.find(({ name }) => name === 'test-spa-app-002')).toBeTruthy();
|
||||
|
||||
// Search applications with `types`, `search` and `excludeRoleId` parameters
|
||||
const m2mRole = await createRole({ type: RoleType.MachineToMachine });
|
||||
await assignApplicationsToRole([m2mApp002.id], m2mRole.id);
|
||||
const applications = await getApplications(
|
||||
[ApplicationType.SPA, ApplicationType.MachineToMachine],
|
||||
{ search: '%002%', excludeRoleId: m2mRole.id }
|
||||
);
|
||||
expect(applications.length).toBe(1);
|
||||
expect(applications.find(({ name }) => name === 'test-m2m-app-002')).toBeFalsy();
|
||||
expect(applications.find(({ name }) => name === 'test-spa-app-002')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { ApplicationType, RoleType } from '@logto/schemas';
|
|||
import { generateStandardId } from '@logto/shared';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import { createApplication } from '#src/api/index.js';
|
||||
import { assignRolesToApplication, createApplication } from '#src/api/index.js';
|
||||
import {
|
||||
assignApplicationsToRole,
|
||||
createRole,
|
||||
|
@ -32,12 +32,21 @@ describe('roles applications', () => {
|
|||
|
||||
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);
|
||||
const m2mApp1 = await createApplication('m2m-app-001', ApplicationType.MachineToMachine);
|
||||
const m2mApp2 = await createApplication('m2m-app-002', ApplicationType.MachineToMachine);
|
||||
await assignApplicationsToRole([m2mApp1.id, m2mApp2.id], role.id);
|
||||
const applications = await getRoleApplications(role.id);
|
||||
|
||||
expect(applications.length).toBe(2);
|
||||
// No assigned m2m apps satisfy the search keyword
|
||||
await expect(getRoleApplications(role.id, 'not-found')).resolves.toHaveLength(0);
|
||||
|
||||
// Get right assigned m2m apps with search keyword
|
||||
await expect(getRoleApplications(role.id, 'm2m-app')).resolves.toHaveLength(2);
|
||||
|
||||
// Empty search keyword should be ignored, all assigned m2m apps should be returned
|
||||
await expect(getRoleApplications(role.id, '')).resolves.toHaveLength(2);
|
||||
|
||||
// Get all assigned m2m apps
|
||||
await expect(getRoleApplications(role.id)).resolves.toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should fail when try to assign empty applications', async () => {
|
||||
|
@ -96,4 +105,32 @@ describe('roles applications', () => {
|
|||
);
|
||||
expect(response instanceof HTTPError && response.response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
// This case tests GET operation on roles and filter by `type` parameter and `search` parameter.
|
||||
it('search roles with specified keyword, type and other parameters', async () => {
|
||||
const m2mRole1 = await createRole({
|
||||
type: RoleType.MachineToMachine,
|
||||
name: 'test-m2m-role-001',
|
||||
});
|
||||
await createRole({ type: RoleType.MachineToMachine, name: 'test-m2m-role-002' });
|
||||
await createRole({ type: RoleType.User, name: 'test-user-role-001' });
|
||||
await createRole({ type: RoleType.User, name: 'test-user-role-002' });
|
||||
|
||||
const m2mRoles = await getRoles({ type: RoleType.MachineToMachine, search: '%m2m-role-00%' });
|
||||
expect(m2mRoles.length).toBe(2);
|
||||
expect(m2mRoles.find(({ name }) => name === 'test-m2m-role-001')).toBeDefined();
|
||||
expect(m2mRoles.find(({ name }) => name === 'test-m2m-role-002')).toBeDefined();
|
||||
|
||||
const m2mApp = await createApplication(generateStandardId(), ApplicationType.MachineToMachine);
|
||||
await assignRolesToApplication(m2mApp.id, [m2mRole1.id]);
|
||||
const roles = await getRoles({
|
||||
excludeApplicationId: m2mApp.id,
|
||||
type: RoleType.MachineToMachine,
|
||||
search: '%m2m-role-00%',
|
||||
});
|
||||
|
||||
expect(roles.length).toBe(1);
|
||||
expect(roles.find(({ name }) => name === 'test-m2m-role-001')).toBeUndefined();
|
||||
expect(roles.find(({ name }) => name === 'test-m2m-role-002')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -33,12 +33,32 @@ describe('roles users', () => {
|
|||
|
||||
it('should assign users to role successfully', async () => {
|
||||
const role = await createRole({});
|
||||
const user1 = await createUser(generateNewUserProfile({}));
|
||||
const user2 = await createUser(generateNewUserProfile({}));
|
||||
await assignUsersToRole([user1.id, user2.id], role.id);
|
||||
const users = await getRoleUsers(role.id);
|
||||
const user1 = await createUser({
|
||||
username: 'username001',
|
||||
name: 'user001',
|
||||
primaryEmail: 'user001@logto.io',
|
||||
});
|
||||
const user2 = await createUser({ name: 'user002', primaryPhone: '123456789' });
|
||||
const user3 = await createUser({ username: 'username3', primaryEmail: 'user3@logto.io' });
|
||||
await assignUsersToRole([user1.id, user2.id, user3.id], role.id);
|
||||
|
||||
expect(users.length).toBe(2);
|
||||
// No assigned users satisfy the search keyword
|
||||
await expect(getRoleUsers(role.id, 'not-found')).resolves.toHaveLength(0);
|
||||
|
||||
// Get right assigned users with search keyword
|
||||
const assignedUsersWithEmailDomainSuffix = await getRoleUsers(role.id, '@logto.io');
|
||||
expect(assignedUsersWithEmailDomainSuffix).toHaveLength(2);
|
||||
expect(assignedUsersWithEmailDomainSuffix.find(({ id }) => id === user2.id)).toBeUndefined();
|
||||
|
||||
const assignedUsersWithAnotherKeyword = await getRoleUsers(role.id, 'user00');
|
||||
expect(assignedUsersWithAnotherKeyword).toHaveLength(2);
|
||||
expect(assignedUsersWithAnotherKeyword.find(({ id }) => id === user3.id)).toBeUndefined();
|
||||
|
||||
// Empty search keyword should be ignored, all assigned users should be returned
|
||||
await expect(getRoleUsers(role.id, '')).resolves.toHaveLength(3);
|
||||
|
||||
// Get all assigned users
|
||||
await expect(getRoleUsers(role.id)).resolves.toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should throw when assigning users to m2m role', async () => {
|
||||
|
|
|
@ -34,6 +34,8 @@ describe('M2M RBAC', () => {
|
|||
const permissionDescription = 'Dummy permission description';
|
||||
const roleName = generateRoleName();
|
||||
const roleDescription = 'Dummy role description';
|
||||
const anotherRoleName = generateRoleName();
|
||||
const anotherRoleDescription = 'Another dummy role description';
|
||||
|
||||
const rbacTestAppname = 'm2m-app-001';
|
||||
const m2mFramework = 'Machine-to-machine';
|
||||
|
@ -152,11 +154,37 @@ describe('M2M RBAC', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('create a m2m role and assign permissions to the role', async () => {
|
||||
await createM2mRoleAndAssignPermissions(page, { roleName, roleDescription }, [
|
||||
{ apiResourceName, permissionName },
|
||||
{ apiResourceName: managementApiResourceName, permissionName: managementApiPermission },
|
||||
]);
|
||||
it('create a m2m role and assign permissions to the role, then go back to role listing page', async () => {
|
||||
await createM2mRoleAndAssignPermissions(
|
||||
page,
|
||||
{ roleName, roleDescription },
|
||||
[
|
||||
{ apiResourceName, permissionName },
|
||||
{ apiResourceName: managementApiResourceName, permissionName: managementApiPermission },
|
||||
],
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('create another m2m role and assign permissions to the role, then go back to role listing page', async () => {
|
||||
await createM2mRoleAndAssignPermissions(
|
||||
page,
|
||||
{ roleName: anotherRoleName, roleDescription: anotherRoleDescription },
|
||||
[
|
||||
{ apiResourceName, permissionName },
|
||||
{ apiResourceName: managementApiResourceName, permissionName: managementApiPermission },
|
||||
],
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('search for a role and enter its details page', async () => {
|
||||
await expect(page).toFill('div[class$=filter] input', roleName);
|
||||
await expect(page).toClick('button', { text: 'Search' });
|
||||
|
||||
await expect(page).toClick('table tbody tr td div[class$=meta]:has(a[class$=title])', {
|
||||
text: roleName,
|
||||
});
|
||||
});
|
||||
|
||||
it('delete a permission from a role on the role details page', async () => {
|
||||
|
@ -229,6 +257,7 @@ describe('M2M RBAC', () => {
|
|||
|
||||
await expectModalWithTitle(page, 'Assign apps');
|
||||
|
||||
await expect(page).toFill('.ReactModalPortal input[type=text]', rbacTestAppname);
|
||||
await expect(page).toClick(
|
||||
'.ReactModalPortal div[class$=rolesTransfer] div[class$=item] div[class$=title]',
|
||||
{
|
||||
|
@ -248,30 +277,12 @@ describe('M2M RBAC', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assign/remove a role to/from a m2m app (on m2m app details page)', () => {
|
||||
it('remove a role form a m2m app on the app details page', async () => {
|
||||
// Navigate to app details page
|
||||
await expect(page).toClick('table tbody tr td a[class$=title]', {
|
||||
it('remove m2m app from the role on the role details page', async () => {
|
||||
const m2mAppRole = await expect(page).toMatchElement('table tbody tr:has(td div)', {
|
||||
text: rbacTestAppname,
|
||||
});
|
||||
|
||||
await expect(page).toMatchElement('div[class$=header] > div[class$=metadata] div', {
|
||||
text: rbacTestAppname,
|
||||
});
|
||||
|
||||
// Go to roles tab
|
||||
await expect(page).toClick('nav div[class$=item] div[class$=link] a', {
|
||||
text: 'Roles',
|
||||
});
|
||||
|
||||
const roleRow = await expect(page).toMatchElement('table tbody tr:has(td a[class$=title])', {
|
||||
text: roleName,
|
||||
});
|
||||
|
||||
// Click remove button
|
||||
await expect(roleRow).toClick('td:last-of-type button');
|
||||
await expect(m2mAppRole).toClick('td > div[class$=anchor] > button');
|
||||
|
||||
await expectConfirmModalAndAct(page, {
|
||||
title: 'Reminder',
|
||||
|
@ -279,11 +290,28 @@ describe('M2M RBAC', () => {
|
|||
});
|
||||
|
||||
await waitForToast(page, {
|
||||
text: `${roleName} was successfully removed from this user.`,
|
||||
text: `${rbacTestAppname} was successfully removed from this role`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assign/remove a role to/from a m2m app (on m2m app details page)', () => {
|
||||
it('navigate to application page and enter m2m app details page', async () => {
|
||||
await expectNavigation(
|
||||
page.goto(appendPathname('/console/applications', logtoConsoleUrl).href)
|
||||
);
|
||||
|
||||
await expect(page).toClick('table tbody tr td a[class$=title]', {
|
||||
text: rbacTestAppname,
|
||||
});
|
||||
});
|
||||
|
||||
it('add a role to m2m app on the application details page', async () => {
|
||||
// Go to roles tab
|
||||
await expect(page).toClick('nav div[class$=item] div[class$=link] a', {
|
||||
text: 'Roles',
|
||||
});
|
||||
|
||||
await expect(page).toClick('div[class$=filter] button span', {
|
||||
text: 'Assign roles',
|
||||
});
|
||||
|
@ -303,5 +331,30 @@ describe('M2M RBAC', () => {
|
|||
text: 'Successfully assigned role(s)',
|
||||
});
|
||||
});
|
||||
|
||||
it('remove a role form a m2m app on the app details page', async () => {
|
||||
await expect(page).toMatchElement(
|
||||
'div[class$=header] > div[class$=metadata] > div[class$=name]',
|
||||
{
|
||||
text: rbacTestAppname,
|
||||
}
|
||||
);
|
||||
|
||||
const roleRow = await expect(page).toMatchElement('table tbody tr:has(td a[class$=title])', {
|
||||
text: roleName,
|
||||
});
|
||||
|
||||
// Click remove button
|
||||
await expect(roleRow).toClick('td:last-of-type button');
|
||||
|
||||
await expectConfirmModalAndAct(page, {
|
||||
title: 'Reminder',
|
||||
actionText: 'Remove',
|
||||
});
|
||||
|
||||
await waitForToast(page, {
|
||||
text: `${roleName} was successfully removed from this user.`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,16 +1,29 @@
|
|||
import { type Page } from 'puppeteer';
|
||||
|
||||
import { logtoConsoleUrl } from '#src/constants.js';
|
||||
import {
|
||||
expectModalWithTitle,
|
||||
expectToClickModalAction,
|
||||
waitForToast,
|
||||
} from '#src/ui-helpers/index.js';
|
||||
import { expectNavigation, appendPathname } from '#src/utils.js';
|
||||
|
||||
/**
|
||||
* Create a machine-to-machine role and assign permissions to it by operating on the Web
|
||||
*
|
||||
* @param page The page to run the test on
|
||||
* @param createRolePayload The payload to create the role
|
||||
* @param apiResources The list of API resources which are going to be assigned to the role
|
||||
* @param backToListingPage Whether to go back to the roles listing page after creating the role
|
||||
*/
|
||||
export const createM2mRoleAndAssignPermissions = async (
|
||||
page: Page,
|
||||
{ roleName, roleDescription }: { roleName: string; roleDescription: string },
|
||||
apiResources: Array<{ apiResourceName: string; permissionName: string }>
|
||||
createRolePayload: { roleName: string; roleDescription: string },
|
||||
apiResources: Array<{ apiResourceName: string; permissionName: string }>,
|
||||
backToListingPage = false
|
||||
) => {
|
||||
const { roleName, roleDescription } = createRolePayload;
|
||||
|
||||
await expect(page).toClick('div[class$=headline] button span', {
|
||||
text: 'Create role',
|
||||
});
|
||||
|
@ -63,4 +76,17 @@ export const createM2mRoleAndAssignPermissions = async (
|
|||
await expect(page).toMatchElement('div[class$=header] div[class$=metadata] div[class$=name]', {
|
||||
text: roleName,
|
||||
});
|
||||
|
||||
if (backToListingPage) {
|
||||
await expectNavigation(
|
||||
page.goto(appendPathname('/console/roles', new URL(logtoConsoleUrl)).href)
|
||||
);
|
||||
|
||||
await expect(page).toMatchElement(
|
||||
'div[class$=main] div[class$=headline] div[class$=titleEllipsis]',
|
||||
{
|
||||
text: 'Roles',
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue