Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ describe('roles and groups management API', () => {
'GET',
`${baseUrl}/groups?include=display&offset=10`,
],
[
'getGroups with a filter',
(fetcher: ApiFetcher) => getGroups({baseUrl, fetcher, filter: 'ouId eq "ou-1"', limit: 10}),
'GET',
`${baseUrl}/groups?filter=ouId+eq+%22ou-1%22&include=display&limit=10`,
],
[
'getGroup',
(fetcher: ApiFetcher) => getGroup({baseUrl, fetcher, groupId: 'g-1'}),
Expand Down
12 changes: 8 additions & 4 deletions packages/javascript/src/api/management/groups/getGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import requestManagementResource from '../../../utils/requestManagementResource'
/**
* Configuration for the getGroups request.
*/
export type GetGroupsConfig = ManagementRequestConfig & Pick<ApiFilteringParams, 'limit' | 'offset'>;
export type GetGroupsConfig = ManagementRequestConfig & Pick<ApiFilteringParams, 'filter' | 'limit' | 'offset'>;

/**
* Retrieves a page of groups.
Expand All @@ -18,16 +18,20 @@ export type GetGroupsConfig = ManagementRequestConfig & Pick<ApiFilteringParams,
*
* @example
* ```typescript
* const {groups} = await getGroups({baseUrl: 'https://localhost:8090', limit: 20});
* const {groups} = await getGroups({
* baseUrl: 'https://localhost:8090',
* filter: 'ouId eq "c4f2a5e0-0a54-4b8e-9d1a-2f6c1e7b8a90"',
* limit: 20,
* });
* ```
*/
const getGroups = async ({limit, offset, ...config}: GetGroupsConfig): Promise<GroupListResponse> =>
const getGroups = async ({filter, limit, offset, ...config}: GetGroupsConfig): Promise<GroupListResponse> =>
requestManagementResource<GroupListResponse>(config, {
collection: 'groups',
failureMessage: 'Failed to fetch groups',
method: 'GET',
operation: 'getGroups',
query: {include: 'display', limit, offset},
query: {filter, include: 'display', limit, offset},
});

export default getGroups;
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getUsers,
removeRoleAssignments,
resolveManagedIdentityName,
toEqualityFilter,
toManagementErrorMessage,
} from '@thunderid/browser';
import {FC, ReactElement, useMemo, useState} from 'react';
Expand All @@ -40,6 +41,14 @@ interface RoleAssignmentsVariables {
roleId: string;
}

/**
* A candidate page with the filter it was listed with.
*/
interface FilteredPage<T> {
filter: string | undefined;
page: T;
}

export type RoleAssignmentsProps = Omit<
BaseRoleAssignmentsProps,
| 'assignments'
Expand Down Expand Up @@ -68,6 +77,10 @@ export type RoleAssignmentsProps = Omit<
* @default true
*/
filterable?: boolean;
/**
* Organization unit the users, groups and agents offered for assignment are listed from.
*/
ouId?: string;
roleId: string;
};

Expand All @@ -84,6 +97,7 @@ const RoleAssignments: FC<RoleAssignmentsProps> = ({
administrationMode,
editable = false,
filterable = true,
ouId,
preferences,
roleId,
...rest
Expand All @@ -95,24 +109,25 @@ const RoleAssignments: FC<RoleAssignmentsProps> = ({
const groupsEndpoint: ManagementEndpoint = useManagementEndpoint('groups');
const agentsEndpoint: ManagementEndpoint = useManagementEndpoint('agents');
const type: RoleAssigneeType | undefined = typeFilter ?? undefined;
const filter: string | undefined = toEqualityFilter('ouId', ouId);
const assignments: ResourceQueryResult<RoleAssignmentListResponse> = useResourceQuery(
[RoleQueryKeys.ROLE_ASSIGNMENTS, roleId, {limit: 100, type}],
(fetcher) => getRoleAssignments({...rolesEndpoint, fetcher, limit: 100, roleId, type}),
{enabled: Boolean(roleId)},
);
const users: ResourceQueryResult<ManagedUserListResponse> = useResourceQuery(
[UserQueryKeys.USERS, {limit: 100}],
(fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}),
const users: ResourceQueryResult<FilteredPage<ManagedUserListResponse>> = useResourceQuery(
[UserQueryKeys.USERS, {filter, limit: 100}],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
async (fetcher) => ({filter, page: await getUsers({...usersEndpoint, fetcher, filter, limit: 100})}),
{enabled: editable},
);
const groups: ResourceQueryResult<GroupListResponse> = useResourceQuery(
[GroupQueryKeys.GROUPS, {limit: 100}],
(fetcher) => getGroups({...groupsEndpoint, fetcher, limit: 100}),
const groups: ResourceQueryResult<FilteredPage<GroupListResponse>> = useResourceQuery(
[GroupQueryKeys.GROUPS, {filter, limit: 100}],
async (fetcher) => ({filter, page: await getGroups({...groupsEndpoint, fetcher, filter, limit: 100})}),
{enabled: editable},
);
const agents: ResourceQueryResult<AgentListResponse> = useResourceQuery(
[AgentQueryKeys.AGENTS, {limit: 100}],
(fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}),
const agents: ResourceQueryResult<FilteredPage<AgentListResponse>> = useResourceQuery(
[AgentQueryKeys.AGENTS, {filter, limit: 100}],
async (fetcher) => ({filter, page: await getAgents({...agentsEndpoint, fetcher, filter, limit: 100})}),
{enabled: editable},
);
const assign: ResourceMutationResult<void, RoleAssignmentsVariables> = useResourceMutation(
Expand All @@ -126,17 +141,23 @@ const RoleAssignments: FC<RoleAssignmentsProps> = ({
() => [[RoleQueryKeys.ROLE_ASSIGNMENTS]],
);

const candidates: Partial<Record<'user' | 'group' | 'agent', IdentityCandidate[]>> = useMemo(
() => ({
agent: (agents.data?.agents ?? []).map((agent: BasicAgent) => ({id: agent.id, label: getAgentName(agent)})),
group: (groups.data?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})),
user: (users.data?.users ?? []).map((user: ManagedUser) => ({
const candidates: Partial<Record<'user' | 'group' | 'agent', IdentityCandidate[]>> = useMemo(() => {
// A page listed for an earlier ouId stays in a query until the next one succeeds, so it is not offered.
const current = <T,>(data: FilteredPage<T> | undefined): T | undefined =>
data && data.filter === filter ? data.page : undefined;

return {
agent: (current(agents.data)?.agents ?? []).map((agent: BasicAgent) => ({
id: agent.id,
label: getAgentName(agent),
})),
group: (current(groups.data)?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})),
user: (current(users.data)?.users ?? []).map((user: ManagedUser) => ({
id: user.id,
label: resolveManagedIdentityName(user),
})),
}),
[agents.data, groups.data, users.data],
);
};
}, [agents.data, filter, groups.data, users.data]);

return (
<BaseRoleAssignments
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import GroupCreate from '../GroupCreate/GroupCreate';
import GroupList from '../GroupList/GroupList';
import BaseGroupMembers from '../GroupMembers/BaseGroupMembers';
import GroupMembers from '../GroupMembers/GroupMembers';
import BaseRoleAssignments from '../RoleAssignments/BaseRoleAssignments';
import BaseRoleAssignments, {BaseRoleAssignmentsRenderProps} from '../RoleAssignments/BaseRoleAssignments';
import RoleAssignments from '../RoleAssignments/RoleAssignments';
import RoleList from '../RoleList/RoleList';
import BaseRolePermissions from '../RolePermissions/BaseRolePermissions';
Expand Down Expand Up @@ -652,6 +652,92 @@ describe('search and filters', () => {
});
});

it('lists the candidates from the organization unit it is given', async () => {
const fetcher: FetcherMock = vi
.fn<ApiFetcher>()
.mockResolvedValue(respond({assignments: [], count: 0, startIndex: 1, totalResults: 0}));

renderWithProviders(<RoleAssignments roleId="r-1" ouId="ou-1" editable />, {fetcher});

await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(4));
const filters: Record<string, string | null> = Object.fromEntries(
fetcher.mock.calls.map(([url]: [string, RequestInit]) => {
const {pathname, searchParams} = new URL(url);

return [pathname, searchParams.get('filter')];
}),
);
expect(filters).toEqual({
'/agents': 'ouId eq "ou-1"',
'/groups': 'ouId eq "ou-1"',
'/roles/r-1/assignments': null,
'/users': 'ouId eq "ou-1"',
});
});

it.each<[string, () => Promise<Response>]>([
['is pending', () => new Promise<Response>(() => undefined)],
['has failed', () => Promise.resolve(respond({}, 500))],
])('offers no candidates from the previous organization unit while the new list %s', async (_state, listNext) => {
const fetcher: FetcherMock = vi.fn<ApiFetcher>().mockImplementation((url: string) =>
new URL(url).searchParams.get('filter') === 'ouId eq "ou-2"'
? listNext()
: Promise.resolve(
respond({
agents: [{attributes: {name: 'Deploy bot'}, id: 'ag-1'}],
assignments: [],
count: 1,
groups: [{id: 'g-1', name: 'Ops'}],
startIndex: 1,
totalResults: 1,
users: [{display: 'Aiko Tanaka', id: 'u-1'}],
}),
),
);
let candidates: BaseRoleAssignmentsRenderProps['candidates'];
const view = (ouId: string): ReactElement => (
<RoleAssignments roleId="r-1" ouId={ouId} editable>
{(props: BaseRoleAssignmentsRenderProps) => {
candidates = props.candidates;

return null;
}}
</RoleAssignments>
);

const {rerender} = renderWithProviders(view('ou-1'), {fetcher});
await waitFor(() => expect(candidates?.user).toHaveLength(1));
expect(candidates?.group).toHaveLength(1);
expect(candidates?.agent).toHaveLength(1);

rerender(view('ou-2'));

await waitFor(() =>
expect(
fetcher.mock.calls
.filter(([url]: [string, RequestInit]) => url.includes('ou-2'))
.map(([url]) => new URL(url).pathname),
).toEqual(expect.arrayContaining(['/users', '/groups', '/agents'])),
);
await act(async () => {
await Promise.resolve();
});
expect(candidates).toEqual({agent: [], group: [], user: []});
});

it('lists the candidates without a filter when no organization unit is given', async () => {
const fetcher: FetcherMock = vi
.fn<ApiFetcher>()
.mockResolvedValue(respond({assignments: [], count: 0, startIndex: 1, totalResults: 0}));

renderWithProviders(<RoleAssignments roleId="r-1" editable />, {fetcher});

await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(4));
fetcher.mock.calls.forEach(([url]: [string, RequestInit]) => {
expect(new URL(url).searchParams.has('filter')).toBe(false);
});
});

it('hands the search to a list render function', () => {
const onSearch: Mock = vi.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,72 @@ describe('admin components (vue)', () => {
expect(invalidatedKeys(invalidate)).toEqual([[RoleQueryKeys.ROLE_ASSIGNMENTS], [RoleQueryKeys.ROLE_ASSIGNMENTS]]);
});

it('RoleAssignments lists the candidates from the organization unit it is given', async () => {
const context = createManagementContext();
const keys = subscribedKeys(context);
context.fetcher.mockImplementation(respondWith());
mountWith(RoleAssignments, {editable: true, ouId: 'ou-1', roleId: 'r1'}, context);

await vi.waitFor(() => expect(context.fetcher).toHaveBeenCalledTimes(4));
const filter = 'ouId eq "ou-1"';
const query: string = new URLSearchParams({filter, include: 'display', limit: '100'}).toString();
expect(requests(context)).toEqual(
expect.arrayContaining([
'GET /roles/r1/assignments?include=display&limit=100',
`GET /users?${query}`,
`GET /groups?${query}`,
`GET /agents?${query}`,
]),
);
expect(keys()).toEqual([
[RoleQueryKeys.ROLE_ASSIGNMENTS, 'r1', {limit: 100}],
[UserQueryKeys.USERS, {filter, limit: 100}],
[GroupQueryKeys.GROUPS, {filter, limit: 100}],
[AgentQueryKeys.AGENTS, {filter, limit: 100}],
]);
});

it.each<[string, () => Promise<Response>]>([
['is pending', () => new Promise<Response>(() => undefined)],
['has failed', () => Promise.resolve(jsonResponse({}, 500))],
])(
'RoleAssignments offers no candidates from the previous organization unit while the new list %s',
async (_state, listNext) => {
const context = createManagementContext();
context.fetcher.mockImplementation((url: string, init: RequestInit) =>
new URL(url).searchParams.get('filter') === 'ouId eq "ou-2"'
? listNext()
: respondWith(() => ({
...page,
agents: [{attributes: {name: 'Deploy bot'}, id: 'ag-1'}],
assignments: [],
groups: [{id: 'g-1', name: 'Ops'}],
users: [{display: 'Aiko Tanaka', id: 'u-1'}],
}))(url, init),
);
let renderProps: BaseRoleAssignmentsRenderProps | undefined;
const {wrapper} = mountWith(RoleAssignments, {editable: true, ouId: 'ou-1', roleId: 'r1'}, context, {
default: (props: BaseRoleAssignmentsRenderProps) => {
renderProps = props;

return h('span');
},
});

await vi.waitFor(() => expect(renderProps?.candidates?.user).toHaveLength(1));
expect(renderProps?.candidates?.group).toHaveLength(1);
expect(renderProps?.candidates?.agent).toHaveLength(1);

await wrapper.setProps({ouId: 'ou-2'});

await vi.waitFor(() =>
expect(requests(context).filter((request: string) => request.includes('ou-2'))).toHaveLength(3),
);
await nextTick();
expect(renderProps?.candidates).toEqual({agent: [], group: [], user: []});
},
);

it('RolePermissions loads the role under its key', async () => {
const context = createManagementContext();
const keys = subscribedKeys(context);
Expand Down
Loading
Loading