diff --git a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
index 01fd154c47..ca5871c2cc 100644
--- a/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
+++ b/awx/ui_next/src/components/Lookup/ApplicationLookup.jsx
@@ -44,7 +44,7 @@ function ApplicationLookup({ i18n, onChange, value, label }) {
actionsResponse?.data?.related_search_fields || []
).map(val => val.slice(0, -8)),
searchableKeys: Object.keys(
- actionsResponse.data.actions?.GET || {}
+ actionsResponse?.data?.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable),
};
}, [location]),
diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx
index 95dcb487b4..d071cedd8f 100644
--- a/awx/ui_next/src/screens/User/User.jsx
+++ b/awx/ui_next/src/screens/User/User.jsx
@@ -80,7 +80,9 @@ function User({ i18n, setBreadcrumb, me }) {
}
let showCardHeader = true;
- if (['edit', 'add'].some(name => location.pathname.includes(name))) {
+ if (
+ ['edit', 'add', 'tokens'].some(name => location.pathname.includes(name))
+ ) {
showCardHeader = false;
}
@@ -131,7 +133,11 @@ function User({ i18n, setBreadcrumb, me }) {
)}
-
+
diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx
new file mode 100644
index 0000000000..af6f0f0b16
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserToken/UserToken.jsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useCallback } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import {
+ Link,
+ Redirect,
+ Route,
+ Switch,
+ useLocation,
+ useParams,
+} from 'react-router-dom';
+import { CaretLeftIcon } from '@patternfly/react-icons';
+import { Card, PageSection } from '@patternfly/react-core';
+import RoutedTabs from '../../../components/RoutedTabs';
+import ContentError from '../../../components/ContentError';
+import { TokensAPI } from '../../../api';
+import useRequest from '../../../util/useRequest';
+import UserTokenDetail from '../UserTokenDetail';
+
+function UserToken({ i18n, setBreadcrumb, user }) {
+ const location = useLocation();
+ const { id, tokenId } = useParams();
+ const {
+ isLoading,
+ error,
+ request: fetchToken,
+ result: { token, actions },
+ } = useRequest(
+ useCallback(async () => {
+ const [response, actionsResponse] = await Promise.all([
+ TokensAPI.readDetail(tokenId),
+ TokensAPI.readOptions(),
+ ]);
+ setBreadcrumb(user, response.data);
+ return {
+ token: response.data,
+ actions: actionsResponse.data.actions.POST,
+ };
+ }, [setBreadcrumb, user, tokenId]),
+ { token: null, actions: null }
+ );
+ useEffect(() => {
+ fetchToken();
+ }, [fetchToken]);
+
+ const tabsArray = [
+ {
+ name: (
+ <>
+
+ {i18n._(t`Back to Tokens`)}
+ >
+ ),
+ link: `/users/${id}/tokens`,
+ id: 99,
+ },
+ {
+ name: i18n._(t`Details`),
+ link: `/users/${id}/tokens/${tokenId}/details`,
+ id: 0,
+ },
+ ];
+
+ let showCardHeader = true;
+
+ if (location.pathname.endsWith('edit')) {
+ showCardHeader = false;
+ }
+
+ if (!isLoading && error) {
+ return (
+
+
+
+ {error.response.status === 404 && (
+
+ {i18n._(t`Token not found.`)}{' '}
+
+ {i18n._(t`View all tokens.`)}
+
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+ <>
+ {showCardHeader && }
+
+
+ {token && (
+
+
+
+ )}
+
+ {!isLoading && (
+
+ {id && (
+ {i18n._(t`View Tokens`)}
+ )}
+
+ )}
+
+
+ >
+ );
+}
+
+export default withI18n()(UserToken);
diff --git a/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx
new file mode 100644
index 0000000000..8e71f1b085
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { TokensAPI } from '../../../api';
+import UserToken from './UserToken';
+
+jest.mock('../../../api/models/Tokens');
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 1,
+ tokenId: 2,
+ }),
+}));
+describe('', () => {
+ let wrapper;
+ const user = {
+ id: 1,
+ type: 'user',
+ url: '/api/v2/users/1/',
+ summary_fields: {
+ user_capabilities: {
+ edit: true,
+ delete: false,
+ },
+ },
+ created: '2020-06-19T12:55:13.138692Z',
+ username: 'admin',
+ first_name: 'Alex',
+ last_name: 'Corey',
+ email: 'a@g.com',
+ };
+ test('should call api for token details and actions', async () => {
+ TokensAPI.readDetail.mockResolvedValue({
+ id: 2,
+ type: 'o_auth2_access_token',
+ url: '/api/v2/tokens/2/',
+
+ summary_fields: {
+ user: {
+ id: 1,
+ username: 'admin',
+ first_name: 'Alex',
+ last_name: 'Corey',
+ },
+ application: {
+ id: 3,
+ name: 'hg',
+ },
+ },
+ created: '2020-06-23T19:56:38.422053Z',
+ modified: '2020-06-23T19:56:38.441353Z',
+ description: 'cdfsg',
+ scope: 'read',
+ });
+ TokensAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: true } },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.find('UserToken').length).toBe(1);
+ });
+ test('should call api for token details and actions', async () => {
+ TokensAPI.readDetail.mockResolvedValue({
+ id: 2,
+ type: 'o_auth2_access_token',
+ url: '/api/v2/tokens/2/',
+
+ summary_fields: {
+ user: {
+ id: 1,
+ username: 'admin',
+ first_name: 'Alex',
+ last_name: 'Corey',
+ },
+ application: {
+ id: 3,
+ name: 'hg',
+ },
+ },
+ created: '2020-06-23T19:56:38.422053Z',
+ modified: '2020-06-23T19:56:38.441353Z',
+ description: 'cdfsg',
+ scope: 'read',
+ });
+ TokensAPI.readOptions.mockResolvedValue({
+ data: { actions: { POST: true } },
+ });
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(TokensAPI.readDetail).toBeCalledWith(2);
+ expect(TokensAPI.readOptions).toBeCalled();
+ });
+});
diff --git a/awx/ui_next/src/screens/User/UserToken/index.js b/awx/ui_next/src/screens/User/UserToken/index.js
new file mode 100644
index 0000000000..f899410e7d
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserToken/index.js
@@ -0,0 +1 @@
+export { default } from './UserToken';
diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx
new file mode 100644
index 0000000000..4e6891767d
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.jsx
@@ -0,0 +1,89 @@
+import React, { useCallback } from 'react';
+import { Link, useHistory, useParams } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Button } from '@patternfly/react-core';
+
+import AlertModal from '../../../components/AlertModal';
+import { CardBody, CardActionsRow } from '../../../components/Card';
+import DeleteButton from '../../../components/DeleteButton';
+import {
+ DetailList,
+ Detail,
+ UserDateDetail,
+} from '../../../components/DetailList';
+import ErrorDetail from '../../../components/ErrorDetail';
+import { TokensAPI } from '../../../api';
+import useRequest, { useDismissableError } from '../../../util/useRequest';
+import { toTitleCase } from '../../../util/strings';
+
+function UserTokenDetail({ token, canEditOrDelete, i18n }) {
+ const { scope, description, created, modified, summary_fields } = token;
+ const history = useHistory();
+ const { id, tokenId } = useParams();
+ const { request: deleteToken, isLoading, error: deleteError } = useRequest(
+ useCallback(async () => {
+ await TokensAPI.destroy(tokenId);
+ history.push(`/users/${id}/tokens`);
+ }, [tokenId, id, history])
+ );
+ const { error, dismissError } = useDismissableError(deleteError);
+
+ return (
+
+
+
+
+
+
+
+
+
+ {canEditOrDelete && (
+ <>
+
+
+ {i18n._(t`Delete`)}
+
+ >
+ )}
+
+ {error && (
+
+ {i18n._(t`Failed to user token.`)}
+
+
+ )}
+
+ );
+}
+
+export default withI18n()(UserTokenDetail);
diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx
new file mode 100644
index 0000000000..a3462f758f
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokenDetail/UserTokenDetail.test.jsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
+import { TokensAPI } from '../../../api';
+import UserTokenDetail from './UserTokenDetail';
+
+jest.mock('../../../api/models/Tokens');
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({
+ id: 1,
+ tokenId: 2,
+ }),
+}));
+describe('', () => {
+ let wrapper;
+ const token = {
+ id: 2,
+ type: 'o_auth2_access_token',
+ url: '/api/v2/tokens/2/',
+
+ summary_fields: {
+ user: {
+ id: 1,
+ username: 'admin',
+ first_name: 'Alex',
+ last_name: 'Corey',
+ },
+ application: {
+ id: 3,
+ name: 'hg',
+ },
+ },
+ created: '2020-06-23T19:56:38.422053Z',
+ modified: '2020-06-23T19:56:38.441353Z',
+ description: 'cdfsg',
+ scope: 'read',
+ };
+ test('should call api for token details and actions', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.find('UserTokenDetail').length).toBe(1);
+ });
+ test('should call api for token details and actions', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+
+ expect(wrapper.find('Detail[label="Application"]').prop('value')).toBe(
+ 'hg'
+ );
+ expect(wrapper.find('Detail[label="Description"]').prop('value')).toBe(
+ 'cdfsg'
+ );
+ expect(wrapper.find('Detail[label="Scope"]').prop('value')).toBe('Read');
+ expect(wrapper.find('UserDateDetail[label="Created"]').prop('date')).toBe(
+ '2020-06-23T19:56:38.422053Z'
+ );
+ expect(
+ wrapper.find('UserDateDetail[label="Last Modified"]').prop('date')
+ ).toBe('2020-06-23T19:56:38.441353Z');
+ expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(1);
+ expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(1);
+ });
+ test('should not render edit or delete buttons', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ expect(wrapper.find('Button[aria-label="Edit"]').length).toBe(0);
+ expect(wrapper.find('Button[aria-label="Delete"]').length).toBe(0);
+ });
+ test('should delete token properly', async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () =>
+ wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
+ );
+ wrapper.update();
+ await act(async () => wrapper.find('DeleteButton').prop('onConfirm')());
+ expect(TokensAPI.destroy).toBeCalledWith(2);
+ });
+ test('should throw deletion error', async () => {
+ TokensAPI.destroy.mockRejectedValue(
+ new Error({
+ response: {
+ config: {
+ method: 'delete',
+ url: '/api/v2/tokens',
+ },
+ data: 'An error occurred',
+ status: 400,
+ },
+ })
+ );
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ await act(async () =>
+ wrapper.find('Button[aria-label="Delete"]').prop('onClick')()
+ );
+ wrapper.update();
+ await act(async () => wrapper.find('DeleteButton').prop('onConfirm')());
+ expect(TokensAPI.destroy).toBeCalledWith(2);
+ wrapper.update();
+ expect(wrapper.find('ErrorDetail').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/User/UserTokenDetail/index.js b/awx/ui_next/src/screens/User/UserTokenDetail/index.js
new file mode 100644
index 0000000000..a6a9011996
--- /dev/null
+++ b/awx/ui_next/src/screens/User/UserTokenDetail/index.js
@@ -0,0 +1 @@
+export { default } from './UserTokenDetail';
diff --git a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx
index 4b1198c5a9..52eb44a7f1 100644
--- a/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx
+++ b/awx/ui_next/src/screens/User/UserTokenList/UserTokenListItem.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { Link, useParams } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import {
@@ -10,7 +11,7 @@ import {
import styled from 'styled-components';
import { toTitleCase } from '../../../util/strings';
-import { formatDateStringUTC } from '../../../util/dates';
+import { formatDateString } from '../../../util/dates';
import DataListCell from '../../../components/DataListCell';
const Label = styled.b`
@@ -22,6 +23,7 @@ const NameLabel = styled.b`
`;
function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
+ const { id } = useParams();
const labelId = `check-action-${token.id}`;
return (
@@ -41,10 +43,14 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
{token.summary_fields?.application?.name ? (
{i18n._(t`Application`)}
- {token.summary_fields.application.name}
+
+ {token.summary_fields.application.name}
+
) : (
- i18n._(t`Personal access token`)
+
+ {i18n._(t`Personal access token`)}
+
)}
,
@@ -53,7 +59,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
,
- {formatDateStringUTC(token.expires)}
+ {formatDateString(token.expires)}
,
]}
/>
diff --git a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
index dc072a6546..c73519d7f9 100644
--- a/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
+++ b/awx/ui_next/src/screens/User/UserTokens/UserTokens.jsx
@@ -3,17 +3,20 @@ import { withI18n } from '@lingui/react';
import { Switch, Route, useParams } from 'react-router-dom';
import UserTokenAdd from '../UserTokenAdd';
import UserTokenList from '../UserTokenList';
+import UserToken from '../UserToken';
-function UserTokens() {
- const { id: userId } = useParams();
-
+function UserTokens({ setBreadcrumb, user }) {
+ const { id } = useParams();
return (
-
+
+
+
+
-
+
);
diff --git a/awx/ui_next/src/screens/User/Users.jsx b/awx/ui_next/src/screens/User/Users.jsx
index 6f21f8be10..e9fe2d4ef2 100644
--- a/awx/ui_next/src/screens/User/Users.jsx
+++ b/awx/ui_next/src/screens/User/Users.jsx
@@ -18,7 +18,7 @@ function Users({ i18n }) {
const match = useRouteMatch();
const addUserBreadcrumb = useCallback(
- user => {
+ (user, token) => {
if (!user) {
return;
}
@@ -34,6 +34,10 @@ function Users({ i18n }) {
[`/users/${user.id}/organizations`]: i18n._(t`Organizations`),
[`/users/${user.id}/tokens`]: i18n._(t`Tokens`),
[`/users/${user.id}/tokens/add`]: i18n._(t`Create user token`),
+ [`/users/${user.id}/tokens/${token && token.id}`]: `Application Name`,
+ [`/users/${user.id}/tokens/${token && token.id}/details`]: i18n._(
+ t`Details`
+ ),
});
},
[i18n]