Merge pull request #5664 from marshmalien/5276-credential-details

Add Credential Detail view

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-01-16 16:37:51 +00:00 committed by GitHub
commit 6fa4d6462d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 604 additions and 12 deletions

View File

@ -1136,7 +1136,7 @@ ManagedCredentialType(
'help_text': ugettext_noop('The OpenShift or Kubernetes API Endpoint to authenticate with.')
},{
'id': 'bearer_token',
'label': ugettext_noop('API authentication bearer token.'),
'label': ugettext_noop('API authentication bearer token'),
'type': 'string',
'secret': True,
},{

View File

@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { withI18n } from '@lingui/react';
import { Card, PageSection } from '@patternfly/react-core';
import {
Switch,
useParams,
useHistory,
useLocation,
Route,
Redirect,
Link,
} from 'react-router-dom';
import { TabbedCardHeader } from '@components/Card';
import CardCloseButton from '@components/CardCloseButton';
import ContentError from '@components/ContentError';
import RoutedTabs from '@components/RoutedTabs';
import CredentialDetail from './CredentialDetail';
import { CredentialsAPI } from '@api';
function Credential({ i18n, setBreadcrumb }) {
const [credential, setCredential] = useState(null);
const [contentError, setContentError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const history = useHistory();
const location = useLocation();
const { id } = useParams();
useEffect(() => {
async function fetchData() {
try {
const { data } = await CredentialsAPI.readDetail(id);
setBreadcrumb(data);
setCredential(data);
} catch (error) {
setContentError(error);
} finally {
setHasContentLoading(false);
}
}
fetchData();
}, [id, setBreadcrumb]);
const tabsArray = [
{ name: i18n._(t`Details`), link: `/credentials/${id}/details`, id: 0 },
{ name: i18n._(t`Access`), link: `/credentials/${id}/access`, id: 1 },
{
name: i18n._(t`Notifications`),
link: `/credentials/${id}/notifications`,
id: 2,
},
];
let cardHeader = hasContentLoading ? null : (
<TabbedCardHeader>
<RoutedTabs history={history} tabsArray={tabsArray} />
<CardCloseButton linkTo="/credentials" />
</TabbedCardHeader>
);
if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) {
cardHeader = null;
}
if (!hasContentLoading && contentError) {
return (
<PageSection>
<Card className="awx-c-card">
<ContentError error={contentError}>
{contentError.response && contentError.response.status === 404 && (
<span>
{i18n._(`Credential not found.`)}{' '}
<Link to="/credentials">{i18n._(`View all Credentials.`)}</Link>
</span>
)}
</ContentError>
</Card>
</PageSection>
);
}
return (
<PageSection>
<Card className="awx-c-card">
{cardHeader}
<Switch>
<Redirect
from="/credentials/:id"
to="/credentials/:id/details"
exact
/>
{credential && (
<Route path="/credentials/:id/details">
<CredentialDetail credential={credential} />
</Route>
)}
<Route
key="not-found"
path="*"
render={() =>
!hasContentLoading && (
<ContentError isNotFound>
{id && (
<Link to={`/credentials/${id}/details`}>
{i18n._(`View Credential Details`)}
</Link>
)}
</ContentError>
)
}
/>
</Switch>
</Card>
</PageSection>
);
}
export default withI18n()(Credential);

View File

@ -0,0 +1,65 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { createMemoryHistory } from 'history';
import { CredentialsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { mockCredentials } from './shared';
import Credential from './Credential';
jest.mock('@api');
CredentialsAPI.readDetail.mockResolvedValue({
data: mockCredentials.results[0],
});
describe('<Credential />', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
});
test('initially renders succesfully', async () => {
expect(wrapper.find('Credential').length).toBe(1);
});
test('should show content error when user attempts to navigate to erroneous route', async () => {
const history = createMemoryHistory({
initialEntries: ['/credentials/1/foobar'],
});
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />, {
context: {
router: {
history,
route: {
location: history.location,
match: {
params: { id: 1 },
url: '/credentials/1/foobar',
path: '/credentials/1/foobar',
},
},
},
},
});
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual('Not Found');
});
test('should show content error if api throws an error', async () => {
CredentialsAPI.readDetail.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(<Credential setBreadcrumb={() => {}} />);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
expect(wrapper.find('ContentError Title').text()).toEqual(
'Something went wrong...'
);
});
});

View File

@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { shape } from 'prop-types';
import { Button, List, ListItem } from '@patternfly/react-core';
import AlertModal from '@components/AlertModal';
import { CardBody, CardActionsRow } from '@components/Card';
import ContentError from '@components/ContentError';
import ContentLoading from '@components/ContentLoading';
import DeleteButton from '@components/DeleteButton';
import { DetailList, Detail, UserDateDetail } from '@components/DetailList';
import ErrorDetail from '@components/ErrorDetail';
import { CredentialsAPI, CredentialTypesAPI } from '@api';
import { Credential } from '@types';
function CredentialDetail({ i18n, credential }) {
const {
id: credentialId,
name,
description,
inputs,
created,
modified,
summary_fields: {
credential_type,
organization,
created_by,
modified_by,
user_capabilities,
},
} = credential;
const [fields, setFields] = useState([]);
const [managedByTower, setManagedByTower] = useState([]);
const [contentError, setContentError] = useState(null);
const [deletionError, setDeletionError] = useState(null);
const [hasContentLoading, setHasContentLoading] = useState(true);
const history = useHistory();
useEffect(() => {
(async () => {
setContentError(null);
setHasContentLoading(true);
try {
const {
data: { inputs: credentialTypeInputs, managed_by_tower },
} = await CredentialTypesAPI.readDetail(credential_type.id);
setFields(credentialTypeInputs.fields || []);
setManagedByTower(managed_by_tower);
} catch (error) {
setContentError(error);
} finally {
setHasContentLoading(false);
}
})();
}, [credential_type]);
const handleDelete = async () => {
setHasContentLoading(true);
try {
await CredentialsAPI.destroy(credentialId);
history.push('/credentials');
} catch (error) {
setDeletionError(error);
}
setHasContentLoading(false);
};
const renderDetail = ({ id, label, type, secret }) => {
let detail;
if (type === 'boolean') {
detail = (
<Detail
key={id}
label={i18n._(t`Options`)}
value={<List>{inputs[id] && <ListItem>{label}</ListItem>}</List>}
/>
);
} else if (secret === true) {
detail = <Detail key={id} label={label} value={i18n._(t`Encrypted`)} />;
} else {
detail = <Detail key={id} label={label} value={inputs[id]} />;
}
return detail;
};
if (hasContentLoading) {
return <ContentLoading />;
}
if (contentError) {
return <ContentError error={contentError} />;
}
return (
<CardBody>
<DetailList>
<Detail label={i18n._(t`Name`)} value={name} />
<Detail label={i18n._(t`Description`)} value={description} />
{organization && (
<Detail
label={i18n._(t`Organization`)}
value={
<Link to={`/organizations/${organization.id}/details`}>
{organization.name}
</Link>
}
/>
)}
<Detail
label={i18n._(t`Credential Type`)}
value={
managedByTower ? (
credential_type.name
) : (
<Link to={`/credential_types/${credential_type.id}/details`}>
{credential_type.name}
</Link>
)
}
/>
{fields.map(field => renderDetail(field))}
<UserDateDetail
label={i18n._(t`Created`)}
date={created}
user={created_by}
/>
<UserDateDetail
label={i18n._(t`Last Modified`)}
date={modified}
user={modified_by}
/>
</DetailList>
<CardActionsRow>
{user_capabilities.edit && (
<Button component={Link} to={`/credentials/${credentialId}/edit`}>
{i18n._(t`Edit`)}
</Button>
)}
{user_capabilities.delete && (
<DeleteButton
name={name}
modalTitle={i18n._(t`Delete Credential`)}
onConfirm={handleDelete}
>
{i18n._(t`Delete`)}
</DeleteButton>
)}
</CardActionsRow>
{deletionError && (
<AlertModal
isOpen={deletionError}
variant="danger"
title={i18n._(t`Error!`)}
onClose={() => setDeletionError(null)}
>
{i18n._(t`Failed to delete credential.`)}
<ErrorDetail error={deletionError} />
</AlertModal>
)}
</CardBody>
);
}
CredentialDetail.propTypes = {
credential: Credential.isRequired,
i18n: shape({}).isRequired,
};
export default withI18n()(CredentialDetail);

View File

@ -0,0 +1,104 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import { CredentialsAPI, CredentialTypesAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import CredentialDetail from './CredentialDetail';
import { mockCredentials, mockCredentialType } from '../shared';
jest.mock('@api');
const mockCredential = mockCredentials.results[0];
CredentialTypesAPI.readDetail.mockResolvedValue({
data: mockCredentialType,
});
function expectDetailToMatch(wrapper, label, value) {
const detail = wrapper.find(`Detail[label="${label}"]`);
expect(detail).toHaveLength(1);
expect(detail.find('dd').text()).toEqual(value);
}
describe('<CredentialDetail />', () => {
let wrapper;
beforeEach(async () => {
await act(async () => {
wrapper = mountWithContexts(
<CredentialDetail credential={mockCredential} />
);
});
await waitForElement(wrapper, 'ContentLoading', el => el.length === 0);
});
test('should render successfully', () => {
expect(wrapper.find('CredentialDetail').length).toBe(1);
});
test('should render details', () => {
expectDetailToMatch(wrapper, 'Name', mockCredential.name);
expectDetailToMatch(wrapper, 'Description', mockCredential.description);
expectDetailToMatch(
wrapper,
'Organization',
mockCredential.summary_fields.organization.name
);
expectDetailToMatch(
wrapper,
'Credential Type',
mockCredential.summary_fields.credential_type.name
);
expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username);
expectDetailToMatch(wrapper, 'Password', 'Encrypted');
expectDetailToMatch(wrapper, 'SSH Private Key', 'Encrypted');
expectDetailToMatch(wrapper, 'Signed SSH Certificate', 'Encrypted');
expectDetailToMatch(wrapper, 'Private Key Passphrase', 'Encrypted');
expectDetailToMatch(
wrapper,
'Privilege Escalation Method',
mockCredential.inputs.become_method
);
expectDetailToMatch(
wrapper,
'Privilege Escalation Username',
mockCredential.inputs.become_username
);
expect(wrapper.find(`Detail[label="Options"] ListItem`).text()).toEqual(
'Authorize'
);
});
test('should show content error on throw', async () => {
CredentialTypesAPI.readDetail.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper = mountWithContexts(
<CredentialDetail credential={mockCredential} />
);
});
await waitForElement(wrapper, 'ContentError', el => el.length === 1);
});
test('handleDelete should call api', async () => {
CredentialsAPI.destroy = jest.fn();
await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();
});
wrapper.update();
expect(CredentialsAPI.destroy).toHaveBeenCalledTimes(1);
});
test('should show error modal when credential is not successfully deleted from api', async () => {
CredentialsAPI.destroy.mockImplementationOnce(() =>
Promise.reject(new Error())
);
await act(async () => {
wrapper.find('DeleteButton').invoke('onConfirm')();
});
await waitForElement(wrapper, 'ErrorDetail', el => el.length === 1);
await act(async () => {
wrapper.find('ModalBoxCloseButton').invoke('onClose')();
});
});
});

View File

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

View File

@ -3,7 +3,7 @@ import { act } from 'react-dom/test-utils';
import { CredentialsAPI } from '@api';
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
import { CredentialList } from '.';
import mockCredentials from '../shared';
import { mockCredentials } from '../shared';
jest.mock('@api');

View File

@ -1,7 +1,7 @@
import React from 'react';
import { mountWithContexts } from '@testUtils/enzymeHelpers';
import { CredentialListItem } from '.';
import mockCredentials from '../shared';
import { mockCredentials } from '../shared';
describe('<CredentialListItem />', () => {
let wrapper;

View File

@ -1,24 +1,48 @@
import React from 'react';
import React, { useState, useCallback } from 'react';
import { Route, Switch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
import { CredentialList } from './CredentialList';
import Breadcrumbs from '@components/Breadcrumbs';
import Credential from './Credential';
import CredentialAdd from './CredentialAdd';
import { CredentialList } from './CredentialList';
function Credentials({ i18n }) {
const breadcrumbConfig = {
const [breadcrumbConfig, setBreadcrumbConfig] = useState({
'/credentials': i18n._(t`Credentials`),
'/credentials/add': i18n._(t`Create New Credential`),
};
});
const buildBreadcrumbConfig = useCallback(
credential => {
if (!credential) {
return;
}
setBreadcrumbConfig({
'/credentials': i18n._(t`Credentials`),
'/credentials/add': i18n._(t`Create New Credential`),
[`/credentials/${credential.id}`]: `${credential.name}`,
[`/credentials/${credential.id}/details`]: i18n._(t`Details`),
});
},
[i18n]
);
return (
<>
<Breadcrumbs breadcrumbConfig={breadcrumbConfig} />
<Switch>
<Route path="/credentials/add" render={() => <CredentialAdd />} />
<Route path="/credentials" render={() => <CredentialList />} />
<Route path="/credentials/add">
<CredentialAdd />
</Route>
<Route path="/credentials/:id">
<Credential setBreadcrumb={buildBreadcrumbConfig} />
</Route>
<Route path="/credentials">
<CredentialList />
</Route>
</Switch>
</>
);

View File

@ -0,0 +1,85 @@
{
"id": 1,
"type": "credential_type",
"url": "/api/v2/credential_types/1/",
"related": {
"named_url": "/api/v2/credential_types/Machine+ssh/",
"credentials": "/api/v2/credential_types/1/credentials/",
"activity_stream": "/api/v2/credential_types/1/activity_stream/"
},
"summary_fields": {
"user_capabilities": {
"edit": false,
"delete": false
}
},
"created": "2020-01-08T20:18:23.663144Z",
"modified": "2020-01-08T20:18:46.056120Z",
"name": "Machine",
"description": "",
"kind": "ssh",
"namespace": "ssh",
"managed_by_tower": true,
"inputs": {
"fields": [
{
"id": "username",
"label": "Username",
"type": "string"
},
{
"id": "password",
"label": "Password",
"type": "string",
"secret": true,
"ask_at_runtime": true
},
{
"id": "ssh_key_data",
"label": "SSH Private Key",
"type": "string",
"format": "ssh_private_key",
"secret": true,
"multiline": true
},
{
"id": "ssh_public_key_data",
"label": "Signed SSH Certificate",
"type": "string",
"multiline": true,
"secret": true
},
{
"id": "ssh_key_unlock",
"label": "Private Key Passphrase",
"type": "string",
"secret": true,
"ask_at_runtime": true
},
{
"id": "become_method",
"label": "Privilege Escalation Method",
"type": "string",
"help_text": "Specify a method for \"become\" operations. This is equivalent to specifying the --become-method Ansible parameter."
},
{
"id": "become_username",
"label": "Privilege Escalation Username",
"type": "string"
},
{
"id": "become_password",
"label": "Privilege Escalation Password",
"type": "string",
"secret": true,
"ask_at_runtime": true
},
{
"id": "authorize",
"label": "Authorize",
"type": "boolean"
}
]
},
"injectors": {}
}

View File

@ -21,6 +21,11 @@
"user": "/api/v2/users/7/"
},
"summary_fields": {
"organization": {
"id": 1,
"name": "Org",
"description": ""
},
"credential_type": {
"id": 1,
"name": "Machine",
@ -65,8 +70,19 @@
"created": "2019-12-17T16:12:25.258897Z",
"modified": "2019-12-17T16:12:25.258920Z",
"name": "Foo",
"organization": null,
"description": "Foo Description",
"organization": 1,
"credential_type": 1,
"inputs": {
"password": "$encrypted$",
"username": "foo",
"ssh_key_data": "$encrypted$",
"become_method": "sudo",
"become_password": "$encrypted$",
"become_username": "bar",
"ssh_public_key_data": "$encrypted$",
"authorize": true
},
"kind": null,
"cloud": true,
"kubernetes": false

View File

@ -1 +1,2 @@
export { default } from './data.credentials.json';
export { default as mockCredentials } from './data.credentials.json';
export { default as mockCredentialType } from './data.credential_type.json';