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';