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:
softwarefactory-project-zuul[bot] 2020-08-12 21:00:38 +00:00 committed by GitHub
commit 06efba6f72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 461 additions and 13 deletions

View File

@ -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]),

View File

@ -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>

View 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);

View 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();
});
});

View File

@ -0,0 +1 @@
export { default } from './UserToken';

View File

@ -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);

View File

@ -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);
});
});

View File

@ -0,0 +1 @@
export { default } from './UserTokenDetail';

View File

@ -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>,
]}
/>

View File

@ -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>
);

View File

@ -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]