diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index e15908270b..b98cf45cae 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -24,6 +24,16 @@ class Users extends Base { params, }); } + + readRoles(userId, params) { + return this.http.get(`${this.baseUrl}${userId}/roles/`, { + params, + }); + } + + readRoleOptions(userId) { + return this.http.options(`${this.baseUrl}${userId}/roles/`); + } } export default Users; diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index f7a15c48f9..cee493bc77 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -22,6 +22,7 @@ import UserEdit from './UserEdit'; import UserOrganizations from './UserOrganizations'; import UserTeams from './UserTeams'; import UserTokens from './UserTokens'; +import UserAccessList from './UserAccess/UserAccessList'; function User({ i18n, setBreadcrumb }) { const location = useLocation(); @@ -111,10 +112,7 @@ function User({ i18n, setBreadcrumb }) { {user && ( - - this needs a different access list from regular resources like - proj, inv, jt - + )} diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx new file mode 100644 index 0000000000..808ec3530a --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -0,0 +1,119 @@ +import React, { useCallback, useEffect } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { UsersAPI } from '@api'; +import useRequest from '@util/useRequest'; +import PaginatedDataList, { + ToolbarAddButton, +} from '@components/PaginatedDataList'; +import DatalistToolbar from '@components/DataListToolbar'; +import UserAccessListItem from './UserAccessListItem'; + +const QS_CONFIG = getQSConfig('roles', { + page: 1, + page_size: 20, + order_by: 'id', +}); +// TODO Figure out how to best conduct a search of this list. +// Since we only have a role ID in the top level of each role object +// we can't really search using the normal search parameters. +function UserAccessList({ i18n }) { + const { id } = useParams(); + const { search } = useLocation(); + + const { + isLoading, + request: fetchRoles, + error, + result: { roleCount, roles, options }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, search); + const [ + { + data: { results, count }, + }, + { + data: { actions }, + }, + ] = await Promise.all([ + UsersAPI.readRoles(id, params), + UsersAPI.readRoleOptions(id), + ]); + return { roleCount: count, roles: results, options: actions }; + }, [id, search]), + { + roles: [], + roleCount: 0, + } + ); + useEffect(() => { + fetchRoles(); + }, [fetchRoles]); + const canAdd = + options && Object.prototype.hasOwnProperty.call(options, 'POST'); + + const detailUrl = role => { + const { resource_id, resource_type } = role.summary_fields; + + if (!role || !resource_type) { + return null; + } + + if (resource_type?.includes('template')) { + return `/templates/${resource_type}/${resource_id}/details`; + } + if (resource_type?.includes('inventory')) { + return `/inventories/${resource_type}/${resource_id}/details`; + } + return `/${resource_type}s/${resource_id}/details`; + }; + + return ( + { + return ( + {}} + isSelected={false} + /> + ); + }} + renderToolbar={props => ( + ] : []), + ]} + /> + )} + /> + ); +} +export default withI18n()(UserAccessList); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx new file mode 100644 index 0000000000..85cdf25f4f --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { UsersAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import UserAccessList from './UserAccessList'; + +jest.mock('@api/models/Users'); +describe('', () => { + let wrapper; + let history; + beforeEach(async () => { + UsersAPI.readRoles.mockResolvedValue({ + data: { + results: [ + { + id: 2, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + { + id: 3, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 16, + resource_type: 'workflow_job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + { + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }, + { + id: 5, + name: 'Update', + type: 'role', + url: '/api/v2/roles/259/', + summary_fields: { + resource_name: 'Inventory Foo', + resource_id: 76, + resource_type: 'inventory', + resource_type_display_name: 'Inventory', + user_capabilities: { unattach: true }, + }, + }, + { + id: 6, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/260/', + summary_fields: { + resource_name: 'Smart Inventory Foo', + resource_id: 77, + resource_type: 'smart_inventory', + resource_type_display_name: 'Inventory', + user_capabilities: { unattach: true }, + }, + }, + ], + count: 4, + }, + }); + + UsersAPI.readRoleOptions.mockResolvedValue({ + data: { actions: { POST: { id: 1, disassociate: true } } }, + }); + + history = createMemoryHistory({ + initialEntries: ['/users/18/access'], + }); + + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 18 } }, + }, + }, + }, + } + ); + }); + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + test('should render properly', async () => { + expect(wrapper.find('UserAccessList').length).toBe(1); + }); + + test('should create proper detailUrl', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe( + '/templates/job_template/15/details' + ); + expect(wrapper.find(`Link#userRole-3`).prop('to')).toBe( + '/templates/workflow_job_template/16/details' + ); + expect(wrapper.find('Link#userRole-4').prop('to')).toBe( + '/credentials/75/details' + ); + expect(wrapper.find('Link#userRole-5').prop('to')).toBe( + '/inventories/inventory/76/details' + ); + expect(wrapper.find('Link#userRole-6').prop('to')).toBe( + '/inventories/smart_inventory/77/details' + ); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx new file mode 100644 index 0000000000..032c981da6 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core'; +import DataListCell from '@components/DataListCell'; + +import { Link } from 'react-router-dom'; + +function UserAccessListItem({ role, i18n, detailUrl }) { + const labelId = `userRole-${role.id}`; + return ( + + + + + {role.summary_fields.resource_name} + + , + + {role.summary_fields && ( + <> + {i18n._(t`Type`)} + {role.summary_fields.resource_type_display_name} + + )} + , + + {role.name && ( + <> + {i18n._(t`Role`)} + {role.name} + + )} + , + ]} + /> + + + ); +} + +export default withI18n()(UserAccessListItem); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx new file mode 100644 index 0000000000..3b6df96c73 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import UserAccessListItem from './UserAccessListItem'; + +describe('', () => { + let wrapper; + const role = { + id: 1, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }; + + beforeEach(() => { + wrapper = mountWithContexts( + + ); + }); + + test('should mount properly', () => { + expect(wrapper.length).toBe(1); + }); + + test('should render proper list item data', () => { + expect( + wrapper.find('PFDataListCell[aria-label="resource name"]').text() + ).toBe('template delete project'); + expect( + wrapper.find('PFDataListCell[aria-label="resource type"]').text() + ).toContain('Job Template'); + expect( + wrapper.find('PFDataListCell[aria-label="resource role"]').text() + ).toContain('Admin'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserAccess/index.js b/awx/ui_next/src/screens/User/UserAccess/index.js new file mode 100644 index 0000000000..754ba1ae99 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/index.js @@ -0,0 +1,2 @@ +export { default as UserAccessListList } from './UserAccessList'; +export { default as UserAccessListItem } from './UserAccessListItem';