diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 0e0f50e5b3..1701c1fb24 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -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, },{ diff --git a/awx/ui_next/src/screens/Credential/Credential.jsx b/awx/ui_next/src/screens/Credential/Credential.jsx new file mode 100644 index 0000000000..84a8a1b0a9 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/Credential.jsx @@ -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 : ( + + + + + ); + + if (location.pathname.endsWith('edit') || location.pathname.endsWith('add')) { + cardHeader = null; + } + + if (!hasContentLoading && contentError) { + return ( + + + + {contentError.response && contentError.response.status === 404 && ( + + {i18n._(`Credential not found.`)}{' '} + {i18n._(`View all Credentials.`)} + + )} + + + + ); + } + + return ( + + + {cardHeader} + + + {credential && ( + + + + )} + + !hasContentLoading && ( + + {id && ( + + {i18n._(`View Credential Details`)} + + )} + + ) + } + /> + + + + ); +} + +export default withI18n()(Credential); diff --git a/awx/ui_next/src/screens/Credential/Credential.test.jsx b/awx/ui_next/src/screens/Credential/Credential.test.jsx new file mode 100644 index 0000000000..d46d970ff3 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/Credential.test.jsx @@ -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('', () => { + let wrapper; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( {}} />); + }); + }); + + 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( {}} />, { + 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( {}} />); + }); + await waitForElement(wrapper, 'ContentError', el => el.length === 1); + expect(wrapper.find('ContentError Title').text()).toEqual( + 'Something went wrong...' + ); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx new file mode 100644 index 0000000000..f20b34ce10 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx @@ -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 = ( + {inputs[id] && {label}}} + /> + ); + } else if (secret === true) { + detail = ; + } else { + detail = ; + } + + return detail; + }; + + if (hasContentLoading) { + return ; + } + + if (contentError) { + return ; + } + + return ( + + + + + {organization && ( + + {organization.name} + + } + /> + )} + + {credential_type.name} + + ) + } + /> + + {fields.map(field => renderDetail(field))} + + + + + + {user_capabilities.edit && ( + + )} + {user_capabilities.delete && ( + + {i18n._(t`Delete`)} + + )} + + {deletionError && ( + setDeletionError(null)} + > + {i18n._(t`Failed to delete credential.`)} + + + )} + + ); +} + +CredentialDetail.propTypes = { + credential: Credential.isRequired, + i18n: shape({}).isRequired, +}; + +export default withI18n()(CredentialDetail); diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx new file mode 100644 index 0000000000..c1f34c16a7 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx @@ -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('', () => { + let wrapper; + + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + + ); + }); + 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( + + ); + }); + 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')(); + }); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/index.js b/awx/ui_next/src/screens/Credential/CredentialDetail/index.js new file mode 100644 index 0000000000..3e5e8527fa --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/index.js @@ -0,0 +1 @@ +export { default } from './CredentialDetail'; diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx index fc45442a5d..8ec83f2f49 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialList.test.jsx @@ -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'); diff --git a/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.test.jsx b/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.test.jsx index bb6e62dc6c..0f23bf465d 100644 --- a/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialList/CredentialListItem.test.jsx @@ -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('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Credential/Credentials.jsx b/awx/ui_next/src/screens/Credential/Credentials.jsx index 3c2e196621..6b9db548ee 100644 --- a/awx/ui_next/src/screens/Credential/Credentials.jsx +++ b/awx/ui_next/src/screens/Credential/Credentials.jsx @@ -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 ( <> - } /> - } /> + + + + + + + + + ); diff --git a/awx/ui_next/src/screens/Credential/shared/data.credential_type.json b/awx/ui_next/src/screens/Credential/shared/data.credential_type.json new file mode 100644 index 0000000000..93adfcdf38 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/data.credential_type.json @@ -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": {} +} \ No newline at end of file diff --git a/awx/ui_next/src/screens/Credential/shared/data.credentials.json b/awx/ui_next/src/screens/Credential/shared/data.credentials.json index 60d4b71d05..45f82c3f8c 100644 --- a/awx/ui_next/src/screens/Credential/shared/data.credentials.json +++ b/awx/ui_next/src/screens/Credential/shared/data.credentials.json @@ -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 diff --git a/awx/ui_next/src/screens/Credential/shared/index.js b/awx/ui_next/src/screens/Credential/shared/index.js index 723f2ff91e..ad01a03c29 100644 --- a/awx/ui_next/src/screens/Credential/shared/index.js +++ b/awx/ui_next/src/screens/Credential/shared/index.js @@ -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';