diff --git a/awx/api/serializers.py b/awx/api/serializers.py
index 571edf57a9..631d8afeca 100644
--- a/awx/api/serializers.py
+++ b/awx/api/serializers.py
@@ -2306,6 +2306,7 @@ class RoleSerializer(BaseSerializer):
content_model = obj.content_type.model_class()
ret['summary_fields']['resource_type'] = get_type_for_model(content_model)
ret['summary_fields']['resource_type_display_name'] = content_model._meta.verbose_name.title()
+ ret['summary_fields']['resource_id'] = obj.object_id
return ret
diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js
index e15908270b..d486e3ea81 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
+ });
+ }
+
+ roleOptions(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..27082dfd44
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx
@@ -0,0 +1,114 @@
+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 },
+ } = await UsersAPI.readRoles(id, params);
+ const {
+ data: { actions },
+ } = await UsersAPI.roleOptions(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..366d263926
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx
@@ -0,0 +1,76 @@
+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 } from '@testUtils/enzymeHelpers';
+import UserAccessList from './UserAccessList';
+
+jest.mock('@api/models/Users');
+describe('', () => {
+ test('should render properly', 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: 'Update',
+ type: 'role',
+ url: '/api/v2/roles/258/',
+ summary_fields: {
+ resource_name: 'Foo Bar',
+ resource_id: 75,
+ resource_type: 'credential',
+ resource_type_display_name: 'Credential',
+ user_capabilities: { unattach: true },
+ },
+ },
+ ],
+ count: 2,
+ },
+ });
+
+ UsersAPI.roleOptions.mockResolvedValue({
+ data: { actions: { POST: { id: 1, disassociate: true } } },
+ });
+
+ let wrapper;
+ const history = createMemoryHistory({
+ initialEntries: ['/users/18/access'],
+ });
+
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+
+ ,
+ {
+ context: {
+ router: {
+ history,
+ route: {
+ location: history.location,
+ match: { params: { id: 18 } },
+ },
+ },
+ },
+ }
+ );
+ });
+
+ expect(wrapper.find('UserAccessList').length).toBe(1);
+ });
+});
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..6d9bd1fd15
--- /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 = `check-action-${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..4837906f35
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx
@@ -0,0 +1,46 @@
+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');
+ console.log(wrapper.debug());
+ 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..df470abaa6
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserAccess/index.js
@@ -0,0 +1,8 @@
+export {
+ default as UserAccessListList
+}
+from './UserAccessList';
+export {
+ default as UserAccessListItem
+}
+from './UserAccessListItem';