From 035a3b519fb73dcc6bfff531305e6e2c34ae8e1c Mon Sep 17 00:00:00 2001 From: AnuradhaSK Date: Thu, 29 Oct 2026 17:58:52 +0530 Subject: [PATCH] List role assignment candidates from an organization unit Signed-off-by: AnuradhaSK --- .../__tests__/rolesAndGroupsApi.test.ts | 6 ++ .../src/api/management/groups/getGroups.ts | 12 ++- .../admin/RoleAssignments/RoleAssignments.tsx | 55 ++++++++---- .../admin/__tests__/adminComponents.test.tsx | 88 ++++++++++++++++++- .../components/admin/admin-components.test.ts | 66 ++++++++++++++ .../admin/RoleAssignments/RoleAssignments.ts | 55 +++++++++--- .../react/src/pages/explorer/adminStories.tsx | 11 ++- .../vue/src/pages/explorer/adminStories.ts | 30 +++++-- 8 files changed, 282 insertions(+), 41 deletions(-) diff --git a/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts b/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts index 675e72aa..ce4ae040 100644 --- a/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts +++ b/packages/javascript/src/api/management/__tests__/rolesAndGroupsApi.test.ts @@ -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'}), diff --git a/packages/javascript/src/api/management/groups/getGroups.ts b/packages/javascript/src/api/management/groups/getGroups.ts index 0b22dabf..c5bb3505 100644 --- a/packages/javascript/src/api/management/groups/getGroups.ts +++ b/packages/javascript/src/api/management/groups/getGroups.ts @@ -8,7 +8,7 @@ import requestManagementResource from '../../../utils/requestManagementResource' /** * Configuration for the getGroups request. */ -export type GetGroupsConfig = ManagementRequestConfig & Pick; +export type GetGroupsConfig = ManagementRequestConfig & Pick; /** * Retrieves a page of groups. @@ -18,16 +18,20 @@ export type GetGroupsConfig = ManagementRequestConfig & Pick => +const getGroups = async ({filter, limit, offset, ...config}: GetGroupsConfig): Promise => requestManagementResource(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; diff --git a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx index cd39421c..046bec29 100644 --- a/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx +++ b/packages/react/src/components/admin/RoleAssignments/RoleAssignments.tsx @@ -25,6 +25,7 @@ import { getUsers, removeRoleAssignments, resolveManagedIdentityName, + toEqualityFilter, toManagementErrorMessage, } from '@thunderid/browser'; import {FC, ReactElement, useMemo, useState} from 'react'; @@ -40,6 +41,14 @@ interface RoleAssignmentsVariables { roleId: string; } +/** + * A candidate page with the filter it was listed with. + */ +interface FilteredPage { + filter: string | undefined; + page: T; +} + export type RoleAssignmentsProps = Omit< BaseRoleAssignmentsProps, | 'assignments' @@ -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; }; @@ -84,6 +97,7 @@ const RoleAssignments: FC = ({ administrationMode, editable = false, filterable = true, + ouId, preferences, roleId, ...rest @@ -95,24 +109,25 @@ const RoleAssignments: FC = ({ 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 = useResourceQuery( [RoleQueryKeys.ROLE_ASSIGNMENTS, roleId, {limit: 100, type}], (fetcher) => getRoleAssignments({...rolesEndpoint, fetcher, limit: 100, roleId, type}), {enabled: Boolean(roleId)}, ); - const users: ResourceQueryResult = useResourceQuery( - [UserQueryKeys.USERS, {limit: 100}], - (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + const users: ResourceQueryResult> = useResourceQuery( + [UserQueryKeys.USERS, {filter, limit: 100}], + async (fetcher) => ({filter, page: await getUsers({...usersEndpoint, fetcher, filter, limit: 100})}), {enabled: editable}, ); - const groups: ResourceQueryResult = useResourceQuery( - [GroupQueryKeys.GROUPS, {limit: 100}], - (fetcher) => getGroups({...groupsEndpoint, fetcher, limit: 100}), + const groups: ResourceQueryResult> = useResourceQuery( + [GroupQueryKeys.GROUPS, {filter, limit: 100}], + async (fetcher) => ({filter, page: await getGroups({...groupsEndpoint, fetcher, filter, limit: 100})}), {enabled: editable}, ); - const agents: ResourceQueryResult = useResourceQuery( - [AgentQueryKeys.AGENTS, {limit: 100}], - (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + const agents: ResourceQueryResult> = useResourceQuery( + [AgentQueryKeys.AGENTS, {filter, limit: 100}], + async (fetcher) => ({filter, page: await getAgents({...agentsEndpoint, fetcher, filter, limit: 100})}), {enabled: editable}, ); const assign: ResourceMutationResult = useResourceMutation( @@ -126,17 +141,23 @@ const RoleAssignments: FC = ({ () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], ); - const candidates: Partial> = 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> = useMemo(() => { + // A page listed for an earlier ouId stays in a query until the next one succeeds, so it is not offered. + const current = (data: FilteredPage | 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 ( { }); }); + it('lists the candidates from the organization unit it is given', async () => { + const fetcher: FetcherMock = vi + .fn() + .mockResolvedValue(respond({assignments: [], count: 0, startIndex: 1, totalResults: 0})); + + renderWithProviders(, {fetcher}); + + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(4)); + const filters: Record = 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]>([ + ['is pending', () => new Promise(() => 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().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 => ( + + {(props: BaseRoleAssignmentsRenderProps) => { + candidates = props.candidates; + + return null; + }} + + ); + + 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() + .mockResolvedValue(respond({assignments: [], count: 0, startIndex: 1, totalResults: 0})); + + renderWithProviders(, {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(); diff --git a/packages/vue/src/__tests__/components/admin/admin-components.test.ts b/packages/vue/src/__tests__/components/admin/admin-components.test.ts index 222ec6fc..b062287c 100644 --- a/packages/vue/src/__tests__/components/admin/admin-components.test.ts +++ b/packages/vue/src/__tests__/components/admin/admin-components.test.ts @@ -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]>([ + ['is pending', () => new Promise(() => 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); diff --git a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts index 793bd8e9..2f80f0e4 100644 --- a/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts +++ b/packages/vue/src/components/admin/RoleAssignments/RoleAssignments.ts @@ -14,6 +14,7 @@ import { getUsers, removeRoleAssignments, resolveManagedIdentityName, + toEqualityFilter, toManagementErrorMessage, } from '@thunderid/browser'; import type { @@ -44,6 +45,7 @@ type RoleAssignmentsProps = Readonly<{ description: string | undefined; editable: boolean; filterable: boolean; + ouId: string | undefined; preferences: Preferences | undefined; roleId: string; title: string | undefined; @@ -54,6 +56,14 @@ interface RoleAssignmentsVariables { roleId: string; } +/** + * A candidate page with the filter it was listed with. + */ +interface FilteredPage { + filter: string | undefined; + page: T; +} + /** * Lists who a role is assigned to and, unless `editable` is `false`, lets the signed-in user assign it * to users, groups and agents or remove assignments. @@ -81,6 +91,10 @@ const RoleAssignments: Component = defineComponent({ preferences: {default: undefined, type: Object as PropType}, /** Whether to show the assignee type filter, which narrows the list on the server. */ filterable: {default: true, type: Boolean}, + /** + * Organization unit the users, groups and agents offered for assignment are listed from. + */ + ouId: {default: undefined, type: String}, /** Identifier of the role. */ roleId: {required: true, type: String}, /** Heading of the section. Pass an empty string to hide it. */ @@ -106,19 +120,32 @@ const RoleAssignments: Component = defineComponent({ {enabled: () => Boolean(props.roleId)}, ); const editable = (): boolean => props.editable; - const users: ResourceQueryResult = useResourceQuery( - () => [UserQueryKeys.USERS, {limit: 100}], - (fetcher) => getUsers({...usersEndpoint, fetcher, limit: 100}), + const filter = (): string | undefined => toEqualityFilter('ouId', props.ouId); + const users: ResourceQueryResult> = useResourceQuery( + () => [UserQueryKeys.USERS, {filter: filter(), limit: 100}], + async (fetcher) => { + const listed: string | undefined = filter(); + + return {filter: listed, page: await getUsers({...usersEndpoint, fetcher, filter: listed, limit: 100})}; + }, {enabled: editable}, ); - const groups: ResourceQueryResult = useResourceQuery( - () => [GroupQueryKeys.GROUPS, {limit: 100}], - (fetcher) => getGroups({...groupsEndpoint, fetcher, limit: 100}), + const groups: ResourceQueryResult> = useResourceQuery( + () => [GroupQueryKeys.GROUPS, {filter: filter(), limit: 100}], + async (fetcher) => { + const listed: string | undefined = filter(); + + return {filter: listed, page: await getGroups({...groupsEndpoint, fetcher, filter: listed, limit: 100})}; + }, {enabled: editable}, ); - const agents: ResourceQueryResult = useResourceQuery( - () => [AgentQueryKeys.AGENTS, {limit: 100}], - (fetcher) => getAgents({...agentsEndpoint, fetcher, limit: 100}), + const agents: ResourceQueryResult> = useResourceQuery( + () => [AgentQueryKeys.AGENTS, {filter: filter(), limit: 100}], + async (fetcher) => { + const listed: string | undefined = filter(); + + return {filter: listed, page: await getAgents({...agentsEndpoint, fetcher, filter: listed, limit: 100})}; + }, {enabled: editable}, ); // Every mounted assignment list is refetched, including the roles listed for an agent. @@ -139,14 +166,18 @@ const RoleAssignments: Component = defineComponent({ () => [[RoleQueryKeys.ROLE_ASSIGNMENTS]], ); + // A page listed for an earlier ouId stays in a query until the next one succeeds, so it is not offered. + const current = (data: FilteredPage | undefined): T | undefined => + data && data.filter === filter() ? data.page : undefined; + return (): VNode => { const candidates: Partial> = { - agent: (agents.data.value?.agents ?? []).map((agent: BasicAgent) => ({ + agent: (current(agents.data.value)?.agents ?? []).map((agent: BasicAgent) => ({ id: agent.id, label: getAgentName(agent), })), - group: (groups.data.value?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})), - user: (users.data.value?.users ?? []).map((user: ManagedUser) => ({ + group: (current(groups.data.value)?.groups ?? []).map((group: Group) => ({id: group.id, label: group.name})), + user: (current(users.data.value)?.users ?? []).map((user: ManagedUser) => ({ id: user.id, label: resolveManagedIdentityName(user), })), diff --git a/playgrounds/react/src/pages/explorer/adminStories.tsx b/playgrounds/react/src/pages/explorer/adminStories.tsx index 2264a54a..2c34f81d 100644 --- a/playgrounds/react/src/pages/explorer/adminStories.tsx +++ b/playgrounds/react/src/pages/explorer/adminStories.tsx @@ -877,7 +877,7 @@ const STORIES: ComponentDef[] = [ { code: (args: Args) => isLive(args) - ? {imports: ['RoleAssignments'], jsx: jsx('RoleAssignments', pick(args, 'roleId', 'editable', 'title'))} + ? {imports: ['RoleAssignments'], jsx: jsx('RoleAssignments', pick(args, 'roleId', 'ouId', 'editable', 'title'))} : { imports: ['BaseRoleAssignments'], jsx: jsx('BaseRoleAssignments', pick(args, 'title', 'isLoading', 'error'), { @@ -896,12 +896,18 @@ const STORIES: ComponentDef[] = [ controls: [ SOURCE_CONTROL, idControl('roleId'), + { + control: 'text', + description: 'Live only: the organization unit the assignee picker lists from.', + name: 'ouId', + type: 'string', + }, TITLE, {control: 'bool', description: 'Adds the assignee picker and remove buttons.', name: 'editable', type: 'boolean'}, LOADING, ERROR, ], - defaults: {editable: true, error: '', isLoading: false, roleId: '', source: 'base', title: ''}, + defaults: {editable: true, error: '', isLoading: false, ouId: '', roleId: '', source: 'base', title: ''}, group: 'Admin: roles', id: 'roleassignments', live: true, @@ -914,6 +920,7 @@ const STORIES: ComponentDef[] = [ diff --git a/playgrounds/vue/src/pages/explorer/adminStories.ts b/playgrounds/vue/src/pages/explorer/adminStories.ts index 6bbc1e6c..dcbec124 100644 --- a/playgrounds/vue/src/pages/explorer/adminStories.ts +++ b/playgrounds/vue/src/pages/explorer/adminStories.ts @@ -939,9 +939,16 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ isLive(args) ? { imports: ['RoleAssignments'], - template: tpl('RoleAssignments', omit(args, 'source', 'isLoading', 'isMutating', 'error', 'roleId'), { - extra: [`role-id="${str(args, 'roleId') || SAMPLE_ROLES[0].id}"`], - }), + template: tpl( + 'RoleAssignments', + omit(args, 'source', 'isLoading', 'isMutating', 'error', 'roleId', 'ouId'), + { + extra: [ + `role-id="${str(args, 'roleId') || SAMPLE_ROLES[0].id}"`, + ...(str(args, 'ouId') ? [`ou-id="${str(args, 'ouId')}"`] : []), + ], + }, + ), } : { imports: ['BaseRoleAssignments', 'IdentityCandidate', 'RoleAssignment', 'RoleAssignmentInput'], @@ -955,7 +962,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ] : []), ], - template: tpl('BaseRoleAssignments', omit(args, 'source', 'editable', 'roleId'), { + template: tpl('BaseRoleAssignments', omit(args, 'source', 'editable', 'roleId', 'ouId'), { extra: [ ':assignments="assignments"', ...(bool(args, 'editable') @@ -969,6 +976,12 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ ...titleControls, {control: 'bool', description: 'Shows the assign form and remove buttons.', name: 'editable', type: 'boolean'}, idControl('roleId', 'role'), + { + control: 'text', + description: 'Live only: the organization unit the assign form lists from.', + name: 'ouId', + type: 'string', + }, { control: 'bool', description: 'Base only: an assign or unassign is in flight.', @@ -983,6 +996,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ error: '', isLoading: false, isMutating: false, + ouId: '', roleId: '', source: 'base', title: '', @@ -1001,7 +1015,13 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ pick: firstOf('roles'), query: () => useApiQuery(() => getRoles({...useManagementRequestConfig('roles'), limit: 1})), render: (roleId: string): VNode => - h(RoleAssignments, {...heading(args), editable: bool(args, 'editable'), key: roleId, roleId}), + h(RoleAssignments, { + ...heading(args), + editable: bool(args, 'editable'), + key: roleId, + ouId: str(args, 'ouId') || undefined, + roleId, + }), }) : h(BaseRoleAssignments, { ...heading(args),