diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index 2de6a235e0..c3cfc1167f 100644
--- a/awx/ui_next/src/api/index.js
+++ b/awx/ui_next/src/api/index.js
@@ -24,6 +24,7 @@ import Roles from './models/Roles';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
import Teams from './models/Teams';
+import Tokens from './models/Tokens';
import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
@@ -58,6 +59,7 @@ const RolesAPI = new Roles();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
const TeamsAPI = new Teams();
+const TokensAPI = new Tokens();
const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
@@ -93,6 +95,7 @@ export {
SchedulesAPI,
SystemJobsAPI,
TeamsAPI,
+ TokensAPI,
UnifiedJobTemplatesAPI,
UnifiedJobsAPI,
UsersAPI,
diff --git a/awx/ui_next/src/api/models/Tokens.js b/awx/ui_next/src/api/models/Tokens.js
new file mode 100644
index 0000000000..5dd490808d
--- /dev/null
+++ b/awx/ui_next/src/api/models/Tokens.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class Tokens extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/tokens/';
+ }
+}
+
+export default Tokens;
diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js
index 3d4ec4aac9..97c7a6976c 100644
--- a/awx/ui_next/src/api/models/Users.js
+++ b/awx/ui_next/src/api/models/Users.js
@@ -12,6 +12,10 @@ class Users extends Base {
});
}
+ createToken(userId, data) {
+ return this.http.post(`${this.baseUrl}${userId}/authorized_tokens/`, data);
+ }
+
disassociateRole(userId, roleId) {
return this.http.post(`${this.baseUrl}${userId}/roles/`, {
id: roleId,
diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
new file mode 100644
index 0000000000..2d43c0491b
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
@@ -0,0 +1,106 @@
+import React, { useCallback, useEffect } from 'react';
+import { func, node } from 'prop-types';
+import { withRouter, useLocation } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { FormGroup } from '@patternfly/react-core';
+import { ApplicationsAPI } from '../../api';
+import { Application } from '../../types';
+import { getQSConfig, parseQueryString } from '../../util/qs';
+import Lookup from './Lookup';
+import OptionsList from '../OptionsList';
+import useRequest from '../../util/useRequest';
+import LookupErrorMessage from './shared/LookupErrorMessage';
+
+const QS_CONFIG = getQSConfig('applications', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+});
+
+function ApplicationLookup({ i18n, onChange, value, label }) {
+ const location = useLocation();
+ const {
+ error,
+ result: { applications, itemCount },
+ request: fetchApplications,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, location.search);
+
+ const {
+ data: { results, count },
+ } = await ApplicationsAPI.read(params);
+ return { applications: results, itemCount: count };
+ }, [location]),
+ { applications: [], itemCount: 0 }
+ );
+ useEffect(() => {
+ fetchApplications();
+ }, [fetchApplications]);
+ return (
+
+ (
+ dispatch({ type: 'SELECT_ITEM', item })}
+ deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
+ />
+ )}
+ />
+
+
+ );
+}
+ApplicationLookup.propTypes = {
+ label: node.isRequired,
+ onChange: func.isRequired,
+ value: Application,
+};
+
+ApplicationLookup.defaultProps = {
+ value: null,
+};
+
+export default withI18n()(withRouter(ApplicationLookup));
diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx
new file mode 100644
index 0000000000..5d2e2e33a0
--- /dev/null
+++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.test.jsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import ApplicationLookup from './ApplicationLookup';
+import { ApplicationsAPI } from '../../api';
+
+jest.mock('../../api');
+const application = {
+ id: 1,
+ name: 'app',
+ description: '',
+};
+
+const fetchedApplications = {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'app',
+ description: '',
+ },
+ {
+ id: 4,
+ name: 'application that should not crach',
+ description: '',
+ },
+ ],
+};
+describe('ApplicationLookup', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ ApplicationsAPI.read.mockResolvedValueOnce(fetchedApplications);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ wrapper.unmount();
+ });
+
+ test('should render successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ expect(wrapper.find('ApplicationLookup')).toHaveLength(1);
+ });
+
+ test('should fetch applications', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ expect(ApplicationsAPI.read).toHaveBeenCalledTimes(1);
+ });
+
+ test('should display label', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+ {}}
+ />
+ );
+ });
+ const title = wrapper.find('FormGroup .pf-c-form__label-text');
+ expect(title.text()).toEqual('Application');
+ });
+});
diff --git a/awx/ui_next/src/components/Lookup/index.js b/awx/ui_next/src/components/Lookup/index.js
index 9321fb08e9..fb99cd5681 100644
--- a/awx/ui_next/src/components/Lookup/index.js
+++ b/awx/ui_next/src/components/Lookup/index.js
@@ -4,3 +4,4 @@ export { default as InventoryLookup } from './InventoryLookup';
export { default as ProjectLookup } from './ProjectLookup';
export { default as MultiCredentialsLookup } from './MultiCredentialsLookup';
export { default as CredentialLookup } from './CredentialLookup';
+export { default as ApplicationLookup } from './ApplicationLookup';
diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx
index af60199e69..282a193ab8 100644
--- a/awx/ui_next/src/screens/User/User.jsx
+++ b/awx/ui_next/src/screens/User/User.jsx
@@ -20,7 +20,7 @@ import UserDetail from './UserDetail';
import UserEdit from './UserEdit';
import UserOrganizations from './UserOrganizations';
import UserTeams from './UserTeams';
-import UserTokenList from './UserTokenList';
+import UserTokens from './UserTokens';
import UserAccessList from './UserAccess/UserAccessList';
function User({ i18n, setBreadcrumb, me }) {
@@ -80,7 +80,7 @@ function User({ i18n, setBreadcrumb, me }) {
}
let showCardHeader = true;
- if (['edit'].some(name => location.pathname.includes(name))) {
+ if (['edit', 'add'].some(name => location.pathname.includes(name))) {
showCardHeader = false;
}
@@ -131,7 +131,7 @@ function User({ i18n, setBreadcrumb, me }) {
)}
-
+
diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx
new file mode 100644
index 0000000000..606171c028
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.jsx
@@ -0,0 +1,42 @@
+import React, { useCallback } from 'react';
+import { useHistory, useParams } from 'react-router-dom';
+
+import { CardBody } from '../../../components/Card';
+import { TokensAPI, UsersAPI } from '../../../api';
+import useRequest from '../../../util/useRequest';
+import UserTokenFrom from '../shared/UserTokenForm';
+
+function UserTokenAdd() {
+ const history = useHistory();
+ const { id: userId } = useParams();
+ const { error: submitError, request: handleSubmit } = useRequest(
+ useCallback(
+ async formData => {
+ if (formData.application) {
+ formData.application = formData.application?.id || null;
+ await UsersAPI.createToken(userId, formData);
+ } else {
+ await TokensAPI.create(formData);
+ }
+
+ history.push(`/users/${userId}/tokens`);
+ },
+ [history, userId]
+ )
+ );
+
+ const handleCancel = () => {
+ history.push(`/users/${userId}/tokens`);
+ };
+
+ return (
+
+
+
+ );
+}
+export default UserTokenAdd;
diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx
new file mode 100644
index 0000000000..4323663c45
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokenAdd/UserTokenAdd.test.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { createMemoryHistory } from 'history';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import UserTokenAdd from './UserTokenAdd';
+import { UsersAPI, TokensAPI } from '../../../api';
+
+jest.mock('../../../api');
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ history: () => ({
+ location: '/user',
+ }),
+ useParams: () => ({ id: 1 }),
+}));
+let wrapper;
+
+describe('', () => {
+ test('handleSubmit should post to api', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts();
+ });
+ UsersAPI.createToken.mockResolvedValueOnce({ data: { id: 1 } });
+ const tokenData = {
+ application: 1,
+ description: 'foo',
+ scope: 'read',
+ };
+ await act(async () => {
+ wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
+ });
+ expect(UsersAPI.createToken).toHaveBeenCalledWith(1, tokenData);
+ });
+
+ test('should navigate to tokens list when cancel is clicked', async () => {
+ const history = createMemoryHistory({});
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ await act(async () => {
+ wrapper.find('button[aria-label="Cancel"]').prop('onClick')();
+ });
+ expect(history.location.pathname).toEqual('/users/1/tokens');
+ });
+
+ test('successful form submission should trigger redirect', async () => {
+ const history = createMemoryHistory({});
+ const tokenData = {
+ application: 1,
+ description: 'foo',
+ scope: 'read',
+ };
+ UsersAPI.createToken.mockResolvedValueOnce({
+ data: {
+ id: 2,
+ ...tokenData,
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ await waitForElement(wrapper, 'button[aria-label="Save"]');
+ await act(async () => {
+ wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
+ });
+ expect(history.location.pathname).toEqual('/users/1/tokens');
+ });
+
+ test('should successful submit form with application', async () => {
+ const history = createMemoryHistory({});
+ const tokenData = {
+ scope: 'read',
+ };
+ TokensAPI.create.mockResolvedValueOnce({
+ data: {
+ id: 2,
+ ...tokenData,
+ },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ await waitForElement(wrapper, 'button[aria-label="Save"]');
+ await act(async () => {
+ wrapper.find('UserTokenForm').prop('handleSubmit')(tokenData);
+ });
+ expect(history.location.pathname).toEqual('/users/1/tokens');
+ });
+});
diff --git a/awx/ui_next/src/screens/User/UserTokenAdd/index.js b/awx/ui_next/src/screens/User/UserTokenAdd/index.js
new file mode 100644
index 0000000000..d8a9b4a1f7
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokenAdd/index.js
@@ -0,0 +1 @@
+export { default } from './UserTokenAdd';
diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx
index cb5e4057c5..4b1198c5a9 100644
--- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx
+++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx
@@ -40,7 +40,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
>
{token.summary_fields?.application?.name ? (
- {i18n._(t`Application:`)}
+ {i18n._(t`Application`)}
{token.summary_fields.application.name}
) : (
diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx
index fe009e4b8a..a91e2d1632 100644
--- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx
+++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.test.jsx
@@ -53,7 +53,7 @@ describe('', () => {
expect(wrapper.find('DataListCheck').prop('checked')).toBe(false);
expect(
wrapper.find('PFDataListCell[aria-label="application name"]').text()
- ).toBe('Application:app');
+ ).toBe('Applicationapp');
expect(wrapper.find('PFDataListCell[aria-label="scope"]').text()).toBe(
'ScopeRead'
);
diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
new file mode 100644
index 0000000000..dc072a6546
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { Switch, Route, useParams } from 'react-router-dom';
+import UserTokenAdd from '../UserTokenAdd';
+import UserTokenList from '../UserTokenList';
+
+function UserTokens() {
+ const { id: userId } = useParams();
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
+
+export default withI18n()(UserTokens);
diff --git a/awx/ui_next/src/screens/User/UserTokens/index.js b/awx/ui_next/src/screens/User/UserTokens/index.js
new file mode 100644
index 0000000000..8ea0743daa
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokens/index.js
@@ -0,0 +1 @@
+export { default } from './UserTokens';
diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx
index 575b997f48..6f21f8be10 100644
--- a/awx/ui_next/src/screens/User/Users.jsx
+++ b/awx/ui_next/src/screens/User/Users.jsx
@@ -33,6 +33,7 @@ function Users({ i18n }) {
[`/users/${user.id}/teams`]: i18n._(t`Teams`),
[`/users/${user.id}/organizations`]: i18n._(t`Organizations`),
[`/users/${user.id}/tokens`]: i18n._(t`Tokens`),
+ [`/users/${user.id}/tokens/add`]: i18n._(t`Create user token`),
});
},
[i18n]
diff --git a/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx b/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx
new file mode 100644
index 0000000000..bfc5f0f08f
--- /dev/null
+++ b/awx/ui_next/src/screens/User/shared/UserTokenForm.jsx
@@ -0,0 +1,127 @@
+import React from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Formik, useField } from 'formik';
+import { Form, FormGroup } from '@patternfly/react-core';
+import AnsibleSelect from '../../../components/AnsibleSelect';
+import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
+import FormField, {
+ FormSubmitError,
+ FieldTooltip,
+} from '../../../components/FormField';
+import ApplicationLookup from '../../../components/Lookup/ApplicationLookup';
+import { required } from '../../../util/validators';
+
+import { FormColumnLayout } from '../../../components/FormLayout';
+
+function UserTokenFormFields({ i18n }) {
+ const [applicationField, applicationMeta, applicationHelpers] = useField(
+ 'application'
+ );
+
+ const [scopeField, scopeMeta, scopeHelpers] = useField({
+ name: 'scope',
+ validate: required(i18n._(t`Please enter a value.`), i18n),
+ });
+
+ return (
+ <>
+
+ {
+ applicationHelpers.setValue(value);
+ }}
+ label={
+
+ {i18n._(t`Application`)}
+
+
+ }
+ touched={applicationMeta.touched}
+ />
+
+
+
+
+ }
+ >
+ {
+ scopeHelpers.setValue(value);
+ }}
+ />
+
+ >
+ );
+}
+
+function UserTokenForm({
+ handleCancel,
+ handleSubmit,
+ submitError,
+ i18n,
+ token = {},
+}) {
+ return (
+
+ {formik => (
+
+ )}
+
+ );
+}
+export default withI18n()(UserTokenForm);
diff --git a/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx b/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx
new file mode 100644
index 0000000000..ddfcbd6cb4
--- /dev/null
+++ b/awx/ui_next/src/screens/User/shared/UserTokenForm.test.jsx
@@ -0,0 +1,144 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../testUtils/enzymeHelpers';
+import UserTokenForm from './UserTokenForm';
+import { sleep } from '../../../../testUtils/testUtils';
+import { ApplicationsAPI } from '../../../api';
+
+jest.mock('../../../api');
+const applications = {
+ data: {
+ count: 2,
+ results: [
+ {
+ id: 1,
+ name: 'app',
+ description: '',
+ },
+ {
+ id: 4,
+ name: 'application that should not crach',
+ description: '',
+ },
+ ],
+ },
+};
+describe('', () => {
+ let wrapper;
+ beforeEach(() => {});
+ afterEach(() => {
+ wrapper.unmount();
+ jest.clearAllMocks();
+ });
+
+ test('initially renders successfully', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ expect(wrapper.find('UserTokenForm').length).toBe(1);
+ });
+
+ test('add form displays all form fields', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(wrapper.find('FormGroup[name="application"]').length).toBe(1);
+ expect(wrapper.find('FormField[name="description"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[name="scope"]').length).toBe(1);
+ });
+
+ test('inputs should update form value on change', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('ApplicationLookup').invoke('onChange')({
+ id: 1,
+ name: 'application',
+ });
+ wrapper.find('input[name="description"]').simulate('change', {
+ target: { value: 'new Bar', name: 'description' },
+ });
+ wrapper.find('AnsibleSelect[name="scope"]').prop('onChange')({}, 'read');
+ });
+ wrapper.update();
+ expect(wrapper.find('ApplicationLookup').prop('value')).toEqual({
+ id: 1,
+ name: 'application',
+ });
+ expect(wrapper.find('input[name="description"]').prop('value')).toBe(
+ 'new Bar'
+ );
+ expect(wrapper.find('AnsibleSelect#token-scope').prop('value')).toBe(
+ 'read'
+ );
+ });
+
+ test('should call handleSubmit when Submit button is clicked', async () => {
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ const handleSubmit = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+
+ await act(async () => {
+ wrapper.find('AnsibleSelect[name="scope"]').prop('onChange')({}, 'read');
+ });
+ wrapper.update();
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').prop('onClick')();
+ });
+ await sleep(1);
+
+ expect(handleSubmit).toBeCalled();
+ });
+
+ test('should call handleCancel when Cancel button is clicked', async () => {
+ const handleCancel = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+ expect(handleCancel).not.toHaveBeenCalled();
+ wrapper.find('button[aria-label="Cancel"]').invoke('onClick')();
+ expect(handleCancel).toBeCalled();
+ });
+ test('should throw error on submit without scope value', async () => {
+ ApplicationsAPI.read.mockResolvedValue(applications);
+ const handleSubmit = jest.fn();
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
+
+ await act(async () => {
+ wrapper.find('button[aria-label="Save"]').prop('onClick')();
+ });
+ await sleep(1);
+ wrapper.update();
+ expect(
+ wrapper.find('FormGroup[name="scope"]').prop('helperTextInvalid')
+ ).toBe('Please enter a value.');
+ expect(handleSubmit).not.toBeCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/User/shared/index.js b/awx/ui_next/src/screens/User/shared/index.js
index ee4362b5c2..4a93f427bd 100644
--- a/awx/ui_next/src/screens/User/shared/index.js
+++ b/awx/ui_next/src/screens/User/shared/index.js
@@ -1,2 +1,3 @@
/* eslint-disable-next-line import/prefer-default-export */
export { default as UserForm } from './UserForm';
+export { default as UserTokenForm } from './UserTokenForm';