mirror of
https://github.com/ansible/awx.git
synced 2026-01-12 02:19:58 -03:30
Merge pull request #7800 from AlexSCorey/7789-UserTokenDetails
Adds User Token Details page
Reviewed-by: Daniel Sami
https://github.com/dsesami
This commit is contained in:
commit
06efba6f72
@ -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]),
|
||||
|
||||
@ -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 }) {
|
||||
</Route>
|
||||
)}
|
||||
<Route path="/users/:id/tokens">
|
||||
<UserTokens id={Number(match.params.id)} />
|
||||
<UserTokens
|
||||
user={user}
|
||||
setBreadcrumb={setBreadcrumb}
|
||||
id={Number(match.params.id)}
|
||||
/>
|
||||
</Route>
|
||||
<Route key="not-found" path="*">
|
||||
<ContentError isNotFound>
|
||||
|
||||
117
awx/ui_next/src/screens/User/UserToken/UserToken.jsx
Normal file
117
awx/ui_next/src/screens/User/UserToken/UserToken.jsx
Normal file
@ -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: (
|
||||
<>
|
||||
<CaretLeftIcon />
|
||||
{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 (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentError error={error}>
|
||||
{error.response.status === 404 && (
|
||||
<span>
|
||||
{i18n._(t`Token not found.`)}{' '}
|
||||
<Link to="/users/:id/tokens">
|
||||
{i18n._(t`View all tokens.`)}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
</ContentError>
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showCardHeader && <RoutedTabs tabsArray={tabsArray} />}
|
||||
<Switch>
|
||||
<Redirect
|
||||
from="/users/:id/tokens/:tokenId"
|
||||
to="/users/:id/tokens/:tokenId/details"
|
||||
exact
|
||||
/>
|
||||
{token && (
|
||||
<Route path="/users/:id/tokens/:tokenId/details">
|
||||
<UserTokenDetail canEditOrDelete={actions} token={token} />
|
||||
</Route>
|
||||
)}
|
||||
<Route key="not-found" path="*">
|
||||
{!isLoading && (
|
||||
<ContentError isNotFound>
|
||||
{id && (
|
||||
<Link to={`/users/${id}/tokens`}>{i18n._(t`View Tokens`)}</Link>
|
||||
)}
|
||||
</ContentError>
|
||||
)}
|
||||
</Route>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(UserToken);
|
||||
101
awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx
Normal file
101
awx/ui_next/src/screens/User/UserToken/UserToken.test.jsx
Normal file
@ -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('<UserToken/>', () => {
|
||||
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(
|
||||
<UserToken setBreadcrumb={jest.fn()} user={user} />
|
||||
);
|
||||
});
|
||||
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(
|
||||
<UserToken setBreadcrumb={jest.fn()} user={user} />
|
||||
);
|
||||
});
|
||||
expect(TokensAPI.readDetail).toBeCalledWith(2);
|
||||
expect(TokensAPI.readOptions).toBeCalled();
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/User/UserToken/index.js
Normal file
1
awx/ui_next/src/screens/User/UserToken/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserToken';
|
||||
@ -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 (
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
<Detail
|
||||
label={i18n._(t`Application`)}
|
||||
value={summary_fields?.application?.name}
|
||||
dataCy="application-token-detail-name"
|
||||
/>
|
||||
<Detail label={i18n._(t`Description`)} value={description} />
|
||||
<Detail label={i18n._(t`Scope`)} value={toTitleCase(scope)} />
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Created`)}
|
||||
date={created}
|
||||
user={summary_fields.user}
|
||||
/>
|
||||
<UserDateDetail
|
||||
label={i18n._(t`Last Modified`)}
|
||||
date={modified}
|
||||
user={summary_fields.user}
|
||||
/>
|
||||
</DetailList>
|
||||
<CardActionsRow>
|
||||
{canEditOrDelete && (
|
||||
<>
|
||||
<Button
|
||||
aria-label={i18n._(t`Edit`)}
|
||||
component={Link}
|
||||
to={`/users/${id}/tokens/${tokenId}/details`}
|
||||
>
|
||||
{i18n._(t`Edit`)}
|
||||
</Button>
|
||||
<DeleteButton
|
||||
name={summary_fields?.application?.name}
|
||||
modalTitle={i18n._(t`Delete User Token`)}
|
||||
onConfirm={deleteToken}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{i18n._(t`Delete`)}
|
||||
</DeleteButton>
|
||||
</>
|
||||
)}
|
||||
</CardActionsRow>
|
||||
{error && (
|
||||
<AlertModal
|
||||
isOpen={error}
|
||||
variant="error"
|
||||
title={i18n._(t`Error!`)}
|
||||
onClose={dismissError}
|
||||
>
|
||||
{i18n._(t`Failed to user token.`)}
|
||||
<ErrorDetail error={error} />
|
||||
</AlertModal>
|
||||
)}
|
||||
</CardBody>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n()(UserTokenDetail);
|
||||
@ -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('<UserTokenDetail/>', () => {
|
||||
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(
|
||||
<UserTokenDetail canEditOrDelete token={token} />
|
||||
);
|
||||
});
|
||||
expect(wrapper.find('UserTokenDetail').length).toBe(1);
|
||||
});
|
||||
test('should call api for token details and actions', async () => {
|
||||
await act(async () => {
|
||||
wrapper = mountWithContexts(
|
||||
<UserTokenDetail canEditOrDelete token={token} />
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<UserTokenDetail canEditOrDelete={false} token={token} />
|
||||
);
|
||||
});
|
||||
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(
|
||||
<UserTokenDetail canEditOrDelete token={token} />
|
||||
);
|
||||
});
|
||||
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(
|
||||
<UserTokenDetail canEditOrDelete token={token} />
|
||||
);
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
1
awx/ui_next/src/screens/User/UserTokenDetail/index.js
Normal file
1
awx/ui_next/src/screens/User/UserTokenDetail/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './UserTokenDetail';
|
||||
@ -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 (
|
||||
<DataListItem key={token.id} aria-labelledby={labelId} id={`${token.id}`}>
|
||||
@ -41,10 +43,14 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
|
||||
{token.summary_fields?.application?.name ? (
|
||||
<span>
|
||||
<NameLabel>{i18n._(t`Application`)}</NameLabel>
|
||||
{token.summary_fields.application.name}
|
||||
<Link to={`/users/${id}/tokens/${token.id}/details`}>
|
||||
{token.summary_fields.application.name}
|
||||
</Link>
|
||||
</span>
|
||||
) : (
|
||||
i18n._(t`Personal access token`)
|
||||
<Link to={`/users/${id}/tokens/${token.id}/details`}>
|
||||
{i18n._(t`Personal access token`)}
|
||||
</Link>
|
||||
)}
|
||||
</DataListCell>,
|
||||
<DataListCell aria-label={i18n._(t`scope`)} key={token.scope}>
|
||||
@ -53,7 +59,7 @@ function UserTokenListItem({ i18n, token, isSelected, onSelect }) {
|
||||
</DataListCell>,
|
||||
<DataListCell aria-label={i18n._(t`expiration`)} key="expiration">
|
||||
<Label>{i18n._(t`Expires`)}</Label>
|
||||
{formatDateStringUTC(token.expires)}
|
||||
{formatDateString(token.expires)}
|
||||
</DataListCell>,
|
||||
]}
|
||||
/>
|
||||
|
||||
@ -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 (
|
||||
<Switch>
|
||||
<Route key="add" path="/users/:id/tokens/add">
|
||||
<UserTokenAdd id={Number(userId)} />
|
||||
<UserTokenAdd id={Number(id)} />
|
||||
</Route>
|
||||
<Route key="token" path="/users/:id/tokens/:tokenId">
|
||||
<UserToken user={user} setBreadcrumb={setBreadcrumb} id={Number(id)} />
|
||||
</Route>
|
||||
<Route key="list" path="/users/:id/tokens">
|
||||
<UserTokenList id={Number(userId)} />
|
||||
<UserTokenList id={Number(id)} />
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@ -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]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user