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';