diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js
index e5f1f34557..6a48a6ce21 100644
--- a/awx/ui_next/src/api/index.js
+++ b/awx/ui_next/src/api/index.js
@@ -1,5 +1,6 @@
import AdHocCommands from './models/AdHocCommands';
import Config from './models/Config';
+import CredentialInputSources from './models/CredentialInputSources';
import CredentialTypes from './models/CredentialTypes';
import Credentials from './models/Credentials';
import Groups from './models/Groups';
@@ -14,8 +15,8 @@ import Labels from './models/Labels';
import Me from './models/Me';
import NotificationTemplates from './models/NotificationTemplates';
import Organizations from './models/Organizations';
-import Projects from './models/Projects';
import ProjectUpdates from './models/ProjectUpdates';
+import Projects from './models/Projects';
import Root from './models/Root';
import Schedules from './models/Schedules';
import SystemJobs from './models/SystemJobs';
@@ -24,14 +25,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates';
import UnifiedJobs from './models/UnifiedJobs';
import Users from './models/Users';
import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates';
-import WorkflowJobs from './models/WorkflowJobs';
import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes';
import WorkflowJobTemplates from './models/WorkflowJobTemplates';
+import WorkflowJobs from './models/WorkflowJobs';
const AdHocCommandsAPI = new AdHocCommands();
const ConfigAPI = new Config();
-const CredentialsAPI = new Credentials();
+const CredentialInputSourcesAPI = new CredentialInputSources();
const CredentialTypesAPI = new CredentialTypes();
+const CredentialsAPI = new Credentials();
const GroupsAPI = new Groups();
const HostsAPI = new Hosts();
const InstanceGroupsAPI = new InstanceGroups();
@@ -44,8 +46,8 @@ const LabelsAPI = new Labels();
const MeAPI = new Me();
const NotificationTemplatesAPI = new NotificationTemplates();
const OrganizationsAPI = new Organizations();
-const ProjectsAPI = new Projects();
const ProjectUpdatesAPI = new ProjectUpdates();
+const ProjectsAPI = new Projects();
const RootAPI = new Root();
const SchedulesAPI = new Schedules();
const SystemJobsAPI = new SystemJobs();
@@ -54,15 +56,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates();
const UnifiedJobsAPI = new UnifiedJobs();
const UsersAPI = new Users();
const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates();
-const WorkflowJobsAPI = new WorkflowJobs();
const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes();
const WorkflowJobTemplatesAPI = new WorkflowJobTemplates();
+const WorkflowJobsAPI = new WorkflowJobs();
export {
AdHocCommandsAPI,
ConfigAPI,
- CredentialsAPI,
+ CredentialInputSourcesAPI,
CredentialTypesAPI,
+ CredentialsAPI,
GroupsAPI,
HostsAPI,
InstanceGroupsAPI,
@@ -75,8 +78,8 @@ export {
MeAPI,
NotificationTemplatesAPI,
OrganizationsAPI,
- ProjectsAPI,
ProjectUpdatesAPI,
+ ProjectsAPI,
RootAPI,
SchedulesAPI,
SystemJobsAPI,
@@ -85,7 +88,7 @@ export {
UnifiedJobsAPI,
UsersAPI,
WorkflowApprovalTemplatesAPI,
- WorkflowJobsAPI,
WorkflowJobTemplateNodesAPI,
WorkflowJobTemplatesAPI,
+ WorkflowJobsAPI,
};
diff --git a/awx/ui_next/src/api/models/CredentialInputSources.js b/awx/ui_next/src/api/models/CredentialInputSources.js
new file mode 100644
index 0000000000..ec09cba267
--- /dev/null
+++ b/awx/ui_next/src/api/models/CredentialInputSources.js
@@ -0,0 +1,10 @@
+import Base from '../Base';
+
+class CredentialInputSources extends Base {
+ constructor(http) {
+ super(http);
+ this.baseUrl = '/api/v2/credential_input_sources/';
+ }
+}
+
+export default CredentialInputSources;
diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js
index 9b31506956..ec7f97812d 100644
--- a/awx/ui_next/src/api/models/Credentials.js
+++ b/awx/ui_next/src/api/models/Credentials.js
@@ -6,6 +6,7 @@ class Credentials extends Base {
this.baseUrl = '/api/v2/credentials/';
this.readAccessList = this.readAccessList.bind(this);
+ this.readInputSources = this.readInputSources.bind(this);
}
readAccessList(id, params) {
@@ -13,6 +14,12 @@ class Credentials extends Base {
params,
});
}
+
+ readInputSources(id, params) {
+ return this.http.get(`${this.baseUrl}${id}/input_sources/`, {
+ params,
+ });
+ }
}
export default Credentials;
diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx
index d865a8b70c..f65aa6d3c4 100644
--- a/awx/ui_next/src/components/FormField/PasswordField.jsx
+++ b/awx/ui_next/src/components/FormField/PasswordField.jsx
@@ -1,29 +1,14 @@
-import React, { useState } from 'react';
+import React from 'react';
import PropTypes from 'prop-types';
-import { withI18n } from '@lingui/react';
-import { t } from '@lingui/macro';
import { useField } from 'formik';
-import {
- Button,
- ButtonVariant,
- FormGroup,
- InputGroup,
- TextInput,
- Tooltip,
-} from '@patternfly/react-core';
-import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
+import { FormGroup, InputGroup } from '@patternfly/react-core';
+import PasswordInput from './PasswordInput';
function PasswordField(props) {
- const { id, name, label, validate, isRequired, isDisabled, i18n } = props;
- const [inputType, setInputType] = useState('password');
- const [field, meta] = useField({ name, validate });
-
+ const { id, name, label, validate, isRequired } = props;
+ const [, meta] = useField({ name, validate });
const isValid = !(meta.touched && meta.error);
- const handlePasswordToggle = () => {
- setInputType(inputType === 'text' ? 'password' : 'text');
- };
-
return (
-
-
-
- {
- field.onChange(event);
- }}
- />
+
);
@@ -79,4 +39,4 @@ PasswordField.defaultProps = {
isDisabled: false,
};
-export default withI18n()(PasswordField);
+export default PasswordField;
diff --git a/awx/ui_next/src/components/FormField/PasswordField.test.jsx b/awx/ui_next/src/components/FormField/PasswordField.test.jsx
index 2f2ceab52a..e2b40dbd09 100644
--- a/awx/ui_next/src/components/FormField/PasswordField.test.jsx
+++ b/awx/ui_next/src/components/FormField/PasswordField.test.jsx
@@ -1,7 +1,6 @@
import React from 'react';
import { Formik } from 'formik';
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
-import { sleep } from '../../../testUtils/testUtils';
import PasswordField from './PasswordField';
describe('PasswordField', () => {
@@ -19,26 +18,4 @@ describe('PasswordField', () => {
);
expect(wrapper).toHaveLength(1);
});
-
- test('properly responds to show/hide toggles', async () => {
- const wrapper = mountWithContexts(
-
- {() => (
-
- )}
-
- );
- expect(wrapper.find('input').prop('type')).toBe('password');
- expect(wrapper.find('EyeSlashIcon').length).toBe(1);
- expect(wrapper.find('EyeIcon').length).toBe(0);
- wrapper.find('button').simulate('click');
- await sleep(1);
- expect(wrapper.find('input').prop('type')).toBe('text');
- expect(wrapper.find('EyeSlashIcon').length).toBe(0);
- expect(wrapper.find('EyeIcon').length).toBe(1);
- });
});
diff --git a/awx/ui_next/src/components/FormField/PasswordInput.jsx b/awx/ui_next/src/components/FormField/PasswordInput.jsx
new file mode 100644
index 0000000000..993ee9a523
--- /dev/null
+++ b/awx/ui_next/src/components/FormField/PasswordInput.jsx
@@ -0,0 +1,71 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { useField } from 'formik';
+import {
+ Button,
+ ButtonVariant,
+ TextInput,
+ Tooltip,
+} from '@patternfly/react-core';
+import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons';
+
+function PasswordInput(props) {
+ const { id, name, validate, isRequired, isDisabled, i18n } = props;
+ const [inputType, setInputType] = useState('password');
+ const [field, meta] = useField({ name, validate });
+
+ const isValid = !(meta.touched && meta.error);
+
+ const handlePasswordToggle = () => {
+ setInputType(inputType === 'text' ? 'password' : 'text');
+ };
+
+ return (
+ <>
+
+
+
+ {
+ field.onChange(event);
+ }}
+ />
+ >
+ );
+}
+
+PasswordInput.propTypes = {
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ validate: PropTypes.func,
+ isRequired: PropTypes.bool,
+ isDisabled: PropTypes.bool,
+};
+
+PasswordInput.defaultProps = {
+ validate: () => {},
+ isRequired: false,
+ isDisabled: false,
+};
+
+export default withI18n()(PasswordInput);
diff --git a/awx/ui_next/src/components/FormField/PasswordInput.test.jsx b/awx/ui_next/src/components/FormField/PasswordInput.test.jsx
new file mode 100644
index 0000000000..a506328f15
--- /dev/null
+++ b/awx/ui_next/src/components/FormField/PasswordInput.test.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { Formik } from 'formik';
+import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
+import PasswordInput from './PasswordInput';
+
+describe('PasswordInput', () => {
+ test('renders the expected content', () => {
+ const wrapper = mountWithContexts(
+
+ {() => (
+
+ )}
+
+ );
+ expect(wrapper).toHaveLength(1);
+ });
+
+ test('properly responds to show/hide toggles', async () => {
+ const wrapper = mountWithContexts(
+
+ {() => (
+
+ )}
+
+ );
+ expect(wrapper.find('input').prop('type')).toBe('password');
+ expect(wrapper.find('EyeSlashIcon').length).toBe(1);
+ expect(wrapper.find('EyeIcon').length).toBe(0);
+ wrapper.find('button').simulate('click');
+ expect(wrapper.find('input').prop('type')).toBe('text');
+ expect(wrapper.find('EyeSlashIcon').length).toBe(0);
+ expect(wrapper.find('EyeIcon').length).toBe(1);
+ });
+});
diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js
index 563f8519eb..fd0c95dafd 100644
--- a/awx/ui_next/src/components/FormField/index.js
+++ b/awx/ui_next/src/components/FormField/index.js
@@ -2,4 +2,5 @@ export { default } from './FormField';
export { default as CheckboxField } from './CheckboxField';
export { default as FieldTooltip } from './FieldTooltip';
export { default as PasswordField } from './PasswordField';
+export { default as PasswordInput } from './PasswordInput';
export { default as FormSubmitError } from './FormSubmitError';
diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx
index e42b3faec7..c721b56789 100644
--- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx
@@ -1,20 +1,74 @@
-import React, { useState, useEffect } from 'react';
+import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { PageSection, Card } from '@patternfly/react-core';
import { CardBody } from '../../../components/Card';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
-import { CredentialTypesAPI, CredentialsAPI } from '../../../api';
+import {
+ CredentialInputSourcesAPI,
+ CredentialTypesAPI,
+ CredentialsAPI,
+} from '../../../api';
import CredentialForm from '../shared/CredentialForm';
+import useRequest from '../../../util/useRequest';
function CredentialAdd({ me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
- const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory();
+ const {
+ error: submitError,
+ request: submitRequest,
+ result: credentialId,
+ } = useRequest(
+ useCallback(
+ async values => {
+ const { inputs, organization, ...remainingValues } = values;
+ const nonPluginInputs = {};
+ const pluginInputs = {};
+ Object.entries(inputs).forEach(([key, value]) => {
+ if (value.credential && value.inputs) {
+ pluginInputs[key] = value;
+ } else {
+ nonPluginInputs[key] = value;
+ }
+ });
+ const {
+ data: { id: newCredentialId },
+ } = await CredentialsAPI.create({
+ user: (me && me.id) || null,
+ organization: (organization && organization.id) || null,
+ inputs: nonPluginInputs,
+ ...remainingValues,
+ });
+ const inputSourceRequests = [];
+ Object.entries(pluginInputs).forEach(([key, value]) => {
+ inputSourceRequests.push(
+ CredentialInputSourcesAPI.create({
+ input_field_name: key,
+ metadata: value.inputs,
+ source_credential: value.credential.id,
+ target_credential: newCredentialId,
+ })
+ );
+ });
+ await Promise.all(inputSourceRequests);
+
+ return newCredentialId;
+ },
+ [me]
+ )
+ );
+
+ useEffect(() => {
+ if (credentialId) {
+ history.push(`/credentials/${credentialId}/details`);
+ }
+ }, [credentialId, history]);
+
useEffect(() => {
const loadData = async () => {
try {
@@ -38,21 +92,7 @@ function CredentialAdd({ me }) {
};
const handleSubmit = async values => {
- const { organization, ...remainingValues } = values;
- setFormSubmitError(null);
- try {
- const {
- data: { id: credentialId },
- } = await CredentialsAPI.create({
- user: (me && me.id) || null,
- organization: (organization && organization.id) || null,
- ...remainingValues,
- });
- const url = `/credentials/${credentialId}/details`;
- history.push(`${url}`);
- } catch (err) {
- setFormSubmitError(err);
- }
+ await submitRequest(values);
};
if (error) {
@@ -85,7 +125,7 @@ function CredentialAdd({ me }) {
onCancel={handleCancel}
onSubmit={handleSubmit}
credentialTypes={credentialTypes}
- submitError={formSubmitError}
+ submitError={submitError}
/>
diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx
index 71409638f6..aef18ea8e9 100644
--- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx
@@ -1,29 +1,117 @@
-import React, { useState, useEffect } from 'react';
+import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { object } from 'prop-types';
import { CardBody } from '../../../components/Card';
-import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
+import {
+ CredentialsAPI,
+ CredentialInputSourcesAPI,
+ CredentialTypesAPI,
+} from '../../../api';
import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading';
import CredentialForm from '../shared/CredentialForm';
+import useRequest from '../../../util/useRequest';
function CredentialEdit({ credential, me }) {
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [credentialTypes, setCredentialTypes] = useState(null);
- const [formSubmitError, setFormSubmitError] = useState(null);
+ const [inputSources, setInputSources] = useState({});
const history = useHistory();
+ const { error: submitError, request: submitRequest, result } = useRequest(
+ useCallback(
+ async (values, inputSourceMap) => {
+ const createAndUpdateInputSources = pluginInputs =>
+ Object.entries(pluginInputs).map(([fieldName, fieldValue]) => {
+ if (!inputSourceMap[fieldName]) {
+ return CredentialInputSourcesAPI.create({
+ input_field_name: fieldName,
+ metadata: fieldValue.inputs,
+ source_credential: fieldValue.credential.id,
+ target_credential: credential.id,
+ });
+ }
+ if (fieldValue.touched) {
+ return CredentialInputSourcesAPI.update(
+ inputSourceMap[fieldName].id,
+ {
+ metadata: fieldValue.inputs,
+ source_credential: fieldValue.credential.id,
+ }
+ );
+ }
+
+ return null;
+ });
+
+ const destroyInputSources = inputs => {
+ const destroyRequests = [];
+ Object.values(inputSourceMap).forEach(inputSource => {
+ const { id, input_field_name } = inputSource;
+ if (!inputs[input_field_name]?.credential) {
+ destroyRequests.push(CredentialInputSourcesAPI.destroy(id));
+ }
+ });
+ return destroyRequests;
+ };
+
+ const { inputs, organization, ...remainingValues } = values;
+ const nonPluginInputs = {};
+ const pluginInputs = {};
+ Object.entries(inputs).forEach(([key, value]) => {
+ if (value.credential && value.inputs) {
+ pluginInputs[key] = value;
+ } else {
+ nonPluginInputs[key] = value;
+ }
+ });
+ const [{ data }] = await Promise.all([
+ CredentialsAPI.update(credential.id, {
+ user: (me && me.id) || null,
+ organization: (organization && organization.id) || null,
+ inputs: nonPluginInputs,
+ ...remainingValues,
+ }),
+ ...destroyInputSources(inputs),
+ ]);
+ await Promise.all(createAndUpdateInputSources(pluginInputs));
+ return data;
+ },
+ [credential.id, me]
+ )
+ );
+
+ useEffect(() => {
+ if (result) {
+ history.push(`/credentials/${result.id}/details`);
+ }
+ }, [result, history]);
+
useEffect(() => {
const loadData = async () => {
try {
- const {
- data: { results: loadedCredentialTypes },
- } = await CredentialTypesAPI.read({
- or__namespace: ['gce', 'scm', 'ssh'],
- });
+ const [
+ {
+ data: { results: loadedCredentialTypes },
+ },
+ {
+ data: { results: loadedInputSources },
+ },
+ ] = await Promise.all([
+ CredentialTypesAPI.read({
+ or__namespace: ['gce', 'scm', 'ssh'],
+ }),
+ CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
+ ]);
setCredentialTypes(loadedCredentialTypes);
+ setInputSources(
+ loadedInputSources.reduce((inputSourcesMap, inputSource) => {
+ inputSourcesMap[inputSource.input_field_name] = inputSource;
+ return inputSourcesMap;
+ }, {})
+ );
} catch (err) {
setError(err);
} finally {
@@ -31,30 +119,15 @@ function CredentialEdit({ credential, me }) {
}
};
loadData();
- }, []);
+ }, [credential.id]);
const handleCancel = () => {
const url = `/credentials/${credential.id}/details`;
-
history.push(`${url}`);
};
const handleSubmit = async values => {
- const { organization, ...remainingValues } = values;
- setFormSubmitError(null);
- try {
- const {
- data: { id: credentialId },
- } = await CredentialsAPI.update(credential.id, {
- user: (me && me.id) || null,
- organization: (organization && organization.id) || null,
- ...remainingValues,
- });
- const url = `/credentials/${credentialId}/details`;
- history.push(`${url}`);
- } catch (err) {
- setFormSubmitError(err);
- }
+ await submitRequest(values, inputSources);
};
if (error) {
@@ -72,7 +145,8 @@ function CredentialEdit({ credential, me }) {
onSubmit={handleSubmit}
credential={credential}
credentialTypes={credentialTypes}
- submitError={formSubmitError}
+ inputSources={inputSources}
+ submitError={submitError}
/>
);
diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx
index 4b0ab68187..3d4ce756cb 100644
--- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx
@@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({
});
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
+CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } });
describe('', () => {
let wrapper;
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
index bccfa50583..ddd4ccaa5f 100644
--- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
@@ -2,7 +2,7 @@ import React from 'react';
import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import { func, shape } from 'prop-types';
+import { arrayOf, func, object, shape } from 'prop-types';
import { Form, FormGroup, Title } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
@@ -124,6 +124,7 @@ function CredentialFormFields({
function CredentialForm({
credential = {},
credentialTypes,
+ inputSources,
onSubmit,
onCancel,
submitError,
@@ -147,6 +148,13 @@ function CredentialForm({
},
};
+ Object.values(inputSources).forEach(inputSource => {
+ initialValues.inputs[inputSource.input_field_name] = {
+ credential: inputSource.summary_fields.source_credential,
+ inputs: inputSource.metadata,
+ };
+ });
+
const scmCredentialTypeId = Object.keys(credentialTypes)
.filter(key => credentialTypes[key].namespace === 'scm')
.map(key => credentialTypes[key].id)[0];
@@ -232,10 +240,12 @@ CredentialForm.proptype = {
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
credential: shape({}),
+ inputSources: arrayOf(object),
};
CredentialForm.defaultProps = {
credential: {},
+ inputSources: [],
};
export default withI18n()(CredentialForm);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx
new file mode 100644
index 0000000000..77cc11ceb9
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx
@@ -0,0 +1,103 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { useField } from 'formik';
+import {
+ Button,
+ ButtonVariant,
+ FormGroup,
+ InputGroup,
+ Tooltip,
+} from '@patternfly/react-core';
+import { KeyIcon } from '@patternfly/react-icons';
+import { CredentialPluginPrompt } from './CredentialPluginPrompt';
+import CredentialPluginSelected from './CredentialPluginSelected';
+
+function CredentialPluginField(props) {
+ const {
+ children,
+ id,
+ name,
+ label,
+ validate,
+ isRequired,
+ isDisabled,
+ i18n,
+ } = props;
+ const [showPluginWizard, setShowPluginWizard] = useState(false);
+ const [field, meta, helpers] = useField({ name, validate });
+ const isValid = !(meta.touched && meta.error);
+
+ return (
+
+ {field?.value?.credential ? (
+ helpers.setValue('')}
+ onEditPlugin={() => setShowPluginWizard(true)}
+ />
+ ) : (
+
+ {React.cloneElement(children, {
+ ...field,
+ isRequired,
+ onChange: (_, event) => {
+ field.onChange(event);
+ },
+ })}
+
+
+
+
+ )}
+ {showPluginWizard && (
+ setShowPluginWizard(false)}
+ onSubmit={val => {
+ val.touched = true;
+ helpers.setValue(val);
+ setShowPluginWizard(false);
+ }}
+ />
+ )}
+
+ );
+}
+
+CredentialPluginField.propTypes = {
+ id: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ validate: PropTypes.func,
+ isRequired: PropTypes.bool,
+ isDisabled: PropTypes.bool,
+};
+
+CredentialPluginField.defaultProps = {
+ validate: () => {},
+ isRequired: false,
+ isDisabled: false,
+};
+
+export default withI18n()(CredentialPluginField);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.test.jsx
new file mode 100644
index 0000000000..39f77b63b4
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.test.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { Formik } from 'formik';
+import { TextInput } from '@patternfly/react-core';
+import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import CredentialPluginField from './CredentialPluginField';
+
+describe('', () => {
+ let wrapper;
+ describe('No plugin configured', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('renders the expected content', () => {
+ expect(wrapper.find('input').length).toBe(1);
+ expect(wrapper.find('KeyIcon').length).toBe(1);
+ expect(wrapper.find('CredentialPluginSelected').length).toBe(0);
+ });
+ test('clicking plugin button shows plugin prompt', () => {
+ expect(wrapper.find('CredentialPluginPrompt').length).toBe(0);
+ wrapper.find('KeyIcon').simulate('click');
+ expect(wrapper.find('CredentialPluginPrompt').length).toBe(1);
+ });
+ });
+ describe('Plugin already configured', () => {
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ {() => (
+
+
+
+ )}
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('renders the expected content', () => {
+ expect(wrapper.find('CredentialPluginPrompt').length).toBe(0);
+ expect(wrapper.find('input').length).toBe(0);
+ expect(wrapper.find('KeyIcon').length).toBe(1);
+ expect(wrapper.find('CredentialPluginSelected').length).toBe(1);
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx
new file mode 100644
index 0000000000..831291871e
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { func, shape } from 'prop-types';
+import { Formik, useField } from 'formik';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { Wizard } from '@patternfly/react-core';
+import CredentialsStep from './CredentialsStep';
+import MetadataStep from './MetadataStep';
+
+function CredentialPluginWizard({ i18n, handleSubmit, onClose }) {
+ const [selectedCredential] = useField('credential');
+ const steps = [
+ {
+ id: 1,
+ name: i18n._(t`Credential`),
+ component: ,
+ enableNext: !!selectedCredential.value,
+ },
+ {
+ id: 2,
+ name: i18n._(t`Metadata`),
+ component: ,
+ canJumpTo: !!selectedCredential.value,
+ nextButtonText: i18n._(t`OK`),
+ },
+ ];
+
+ return (
+
+ );
+}
+
+function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) {
+ return (
+
+ {({ handleSubmit }) => (
+
+ )}
+
+ );
+}
+
+CredentialPluginPrompt.propTypes = {
+ onClose: func.isRequired,
+ onSubmit: func.isRequired,
+ initialValues: shape({}),
+};
+
+CredentialPluginPrompt.defaultProps = {
+ initialValues: {},
+};
+
+export default withI18n()(CredentialPluginPrompt);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx
new file mode 100644
index 0000000000..2634301e07
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.test.jsx
@@ -0,0 +1,228 @@
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import {
+ mountWithContexts,
+ waitForElement,
+} from '../../../../../../testUtils/enzymeHelpers';
+import { CredentialsAPI, CredentialTypesAPI } from '../../../../../api';
+import selectedCredential from '../../data.cyberArkCredential.json';
+import azureVaultCredential from '../../data.azureVaultCredential.json';
+import hashiCorpCredential from '../../data.hashiCorpCredential.json';
+import CredentialPluginPrompt from './CredentialPluginPrompt';
+
+jest.mock('../../../../../api/models/Credentials');
+jest.mock('../../../../../api/models/CredentialTypes');
+
+CredentialsAPI.read.mockResolvedValue({
+ data: {
+ count: 3,
+ results: [selectedCredential, azureVaultCredential, hashiCorpCredential],
+ },
+});
+
+CredentialTypesAPI.readDetail.mockResolvedValue({
+ data: {
+ id: 20,
+ type: 'credential_type',
+ url: '/api/v2/credential_types/20/',
+ related: {
+ named_url:
+ '/api/v2/credential_types/CyberArk Conjur Secret Lookup+external/',
+ credentials: '/api/v2/credential_types/20/credentials/',
+ activity_stream: '/api/v2/credential_types/20/activity_stream/',
+ },
+ summary_fields: { user_capabilities: { edit: false, delete: false } },
+ created: '2020-05-18T21:53:35.398260Z',
+ modified: '2020-05-18T21:54:05.451444Z',
+ name: 'CyberArk Conjur Secret Lookup',
+ description: '',
+ kind: 'external',
+ namespace: 'conjur',
+ managed_by_tower: true,
+ inputs: {
+ fields: [
+ { id: 'url', label: 'Conjur URL', type: 'string', format: 'url' },
+ { id: 'api_key', label: 'API Key', type: 'string', secret: true },
+ { id: 'account', label: 'Account', type: 'string' },
+ { id: 'username', label: 'Username', type: 'string' },
+ {
+ id: 'cacert',
+ label: 'Public Key Certificate',
+ type: 'string',
+ multiline: true,
+ },
+ ],
+ metadata: [
+ {
+ id: 'secret_path',
+ label: 'Secret Identifier',
+ type: 'string',
+ help_text: 'The identifier for the secret e.g., /some/identifier',
+ },
+ {
+ id: 'secret_version',
+ label: 'Secret Version',
+ type: 'string',
+ help_text:
+ 'Used to specify a specific secret version (if left empty, the latest version will be used).',
+ },
+ ],
+ required: ['url', 'api_key', 'account', 'username'],
+ },
+ injectors: {},
+ },
+});
+
+describe('', () => {
+ describe('Plugin not configured', () => {
+ let wrapper;
+ const onClose = jest.fn();
+ const onSubmit = jest.fn();
+ beforeAll(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('should render Wizard with all steps', async () => {
+ const wizard = await waitForElement(wrapper, 'Wizard');
+ const steps = wizard.prop('steps');
+
+ expect(steps).toHaveLength(2);
+ expect(steps[0].name).toEqual('Credential');
+ expect(steps[1].name).toEqual('Metadata');
+ });
+ test('credentials step renders correctly', () => {
+ expect(wrapper.find('CredentialsStep').length).toBe(1);
+ expect(wrapper.find('DataListItem').length).toBe(3);
+ expect(
+ wrapper.find('Radio').filterWhere(radio => radio.isChecked).length
+ ).toBe(0);
+ });
+ test('next button disabled until credential selected', () => {
+ expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
+ true
+ );
+ });
+ test('clicking cancel button calls correct function', () => {
+ wrapper.find('Button[children="Cancel"]').simulate('click');
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ test('clicking credential row enables next button', async () => {
+ await act(async () => {
+ wrapper
+ .find('Radio')
+ .at(0)
+ .invoke('onChange')(true);
+ });
+ wrapper.update();
+ expect(
+ wrapper
+ .find('Radio')
+ .at(0)
+ .prop('isChecked')
+ ).toBe(true);
+ expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
+ false
+ );
+ });
+ test('clicking next button shows metatdata step', async () => {
+ await act(async () => {
+ wrapper.find('Button[children="Next"]').simulate('click');
+ });
+ wrapper.update();
+ expect(wrapper.find('MetadataStep').length).toBe(1);
+ expect(wrapper.find('FormField').length).toBe(2);
+ });
+ test('submit button calls correct function with parameters', async () => {
+ await act(async () => {
+ wrapper.find('input#credential-secret_path').simulate('change', {
+ target: { value: '/foo/bar', name: 'secret_path' },
+ });
+ });
+ await act(async () => {
+ wrapper.find('input#credential-secret_version').simulate('change', {
+ target: { value: '9000', name: 'secret_version' },
+ });
+ });
+ await act(async () => {
+ wrapper.find('Button[children="OK"]').simulate('click');
+ });
+ // expect(wrapper.debug()).toBe(false);
+ // wrapper.find('Button[children="OK"]').simulate('click');
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ credential: selectedCredential,
+ secret_path: '/foo/bar',
+ secret_version: '9000',
+ }),
+ expect.anything()
+ );
+ });
+ });
+
+ describe('Plugin already configured', () => {
+ let wrapper;
+ const onClose = jest.fn();
+ const onSubmit = jest.fn();
+ beforeAll(async () => {
+ await act(async () => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('should render Wizard with all steps', async () => {
+ const wizard = await waitForElement(wrapper, 'Wizard');
+ const steps = wizard.prop('steps');
+
+ expect(steps).toHaveLength(2);
+ expect(steps[0].name).toEqual('Credential');
+ expect(steps[1].name).toEqual('Metadata');
+ });
+ test('credentials step renders correctly', () => {
+ expect(wrapper.find('CredentialsStep').length).toBe(1);
+ expect(wrapper.find('DataListItem').length).toBe(3);
+ expect(
+ wrapper
+ .find('Radio')
+ .at(0)
+ .prop('isChecked')
+ ).toBe(true);
+ expect(wrapper.find('Button[children="Next"]').prop('isDisabled')).toBe(
+ false
+ );
+ });
+ test('metadata step renders correctly', async () => {
+ await act(async () => {
+ wrapper.find('Button[children="Next"]').simulate('click');
+ });
+ wrapper.update();
+ expect(wrapper.find('MetadataStep').length).toBe(1);
+ expect(wrapper.find('FormField').length).toBe(2);
+ expect(wrapper.find('input#credential-secret_path').prop('value')).toBe(
+ '/foo/bar'
+ );
+ expect(
+ wrapper.find('input#credential-secret_version').prop('value')
+ ).toBe('9000');
+ });
+ });
+});
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx
new file mode 100644
index 0000000000..a59f894470
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx
@@ -0,0 +1,101 @@
+import React, { useCallback, useEffect } from 'react';
+import { useHistory } from 'react-router-dom';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { useField } from 'formik';
+import { CredentialsAPI } from '../../../../../api';
+import CheckboxListItem from '../../../../../components/CheckboxListItem';
+import ContentError from '../../../../../components/ContentError';
+import DataListToolbar from '../../../../../components/DataListToolbar';
+import PaginatedDataList from '../../../../../components/PaginatedDataList';
+import { getQSConfig, parseQueryString } from '../../../../../util/qs';
+import useRequest from '../../../../../util/useRequest';
+
+const QS_CONFIG = getQSConfig('credential', {
+ page: 1,
+ page_size: 5,
+ order_by: 'name',
+ credential_type__kind: 'external',
+});
+
+function CredentialsStep({ i18n }) {
+ const [selectedCredential, , selectedCredentialHelper] = useField(
+ 'credential'
+ );
+ const history = useHistory();
+
+ const {
+ result: { credentials, count },
+ error: credentialsError,
+ isLoading: isCredentialsLoading,
+ request: fetchCredentials,
+ } = useRequest(
+ useCallback(async () => {
+ const params = parseQueryString(QS_CONFIG, history.location.search);
+ const { data } = await CredentialsAPI.read({
+ ...params,
+ });
+ return {
+ credentials: data.results,
+ count: data.count,
+ };
+ }, [history.location.search]),
+ { credentials: [], count: 0 }
+ );
+
+ useEffect(() => {
+ fetchCredentials();
+ }, [fetchCredentials]);
+
+ if (credentialsError) {
+ return ;
+ }
+
+ return (
+ selectedCredentialHelper.setValue(row)}
+ qsConfig={QS_CONFIG}
+ renderItem={credential => (
+ selectedCredentialHelper.setValue(credential)}
+ onDeselect={() => selectedCredentialHelper.setValue(null)}
+ isRadio
+ />
+ )}
+ renderToolbar={props => }
+ showPageSizeOptions={false}
+ toolbarSearchColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ isDefault: true,
+ },
+ {
+ name: i18n._(t`Created By (Username)`),
+ key: 'created_by__username',
+ },
+ {
+ name: i18n._(t`Modified By (Username)`),
+ key: 'modified_by__username',
+ },
+ ]}
+ toolbarSortColumns={[
+ {
+ name: i18n._(t`Name`),
+ key: 'name',
+ },
+ ]}
+ />
+ );
+}
+
+export default withI18n()(CredentialsStep);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx
new file mode 100644
index 0000000000..c612fc4fdb
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx
@@ -0,0 +1,157 @@
+import React, { useCallback, useEffect } from 'react';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import { useField, useFormikContext } from 'formik';
+import styled from 'styled-components';
+import { Button, Form, FormGroup, Tooltip } from '@patternfly/react-core';
+import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons';
+import { CredentialTypesAPI } from '../../../../../api';
+import AnsibleSelect from '../../../../../components/AnsibleSelect';
+import ContentError from '../../../../../components/ContentError';
+import ContentLoading from '../../../../../components/ContentLoading';
+import FormField from '../../../../../components/FormField';
+import { FormFullWidthLayout } from '../../../../../components/FormLayout';
+import useRequest from '../../../../../util/useRequest';
+import { required } from '../../../../../util/validators';
+
+const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
+ margin-left: 10px;
+`;
+
+const TestButton = styled(Button)`
+ margin-top: 20px;
+`;
+
+function MetadataStep({ i18n }) {
+ const form = useFormikContext();
+ const [selectedCredential] = useField('credential');
+ const [inputValues] = useField('inputs');
+
+ const {
+ result: fields,
+ error,
+ isLoading,
+ request: fetchMetadataOptions,
+ } = useRequest(
+ useCallback(async () => {
+ const {
+ data: {
+ inputs: { required: requiredFields, metadata },
+ },
+ } = await CredentialTypesAPI.readDetail(
+ selectedCredential.value.credential_type ||
+ selectedCredential.value.credential_type_id
+ );
+ metadata.forEach(field => {
+ if (inputValues.value[field.id]) {
+ form.initialValues.inputs[field.id] = inputValues.value[field.id];
+ } else if (field.type === 'string' && field.choices) {
+ form.initialValues.inputs[field.id] =
+ field.default || field.choices[0];
+ } else {
+ form.initialValues.inputs[field.id] = '';
+ }
+ if (requiredFields && requiredFields.includes(field.id)) {
+ field.required = true;
+ }
+ });
+ return metadata;
+ /* eslint-disable-next-line react-hooks/exhaustive-deps */
+ }, []),
+ []
+ );
+
+ useEffect(() => {
+ fetchMetadataOptions();
+ }, [fetchMetadataOptions]);
+
+ const testMetadata = () => {
+ // https://github.com/ansible/awx/issues/7126
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ return (
+ <>
+ {fields.length > 0 && (
+
+ )}
+
+ testMetadata()}
+ >
+ {i18n._(t`Test`)}
+
+
+ >
+ );
+}
+
+export default withI18n()(MetadataStep);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js
new file mode 100644
index 0000000000..467b3f3936
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js
@@ -0,0 +1,3 @@
+export { default as CredentialPluginPrompt } from './CredentialPluginPrompt';
+export { default as CredentialsStep } from './CredentialsStep';
+export { default as MetadataStep } from './MetadataStep';
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx
new file mode 100644
index 0000000000..d97a7a39df
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { func } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t, Trans } from '@lingui/macro';
+import styled from 'styled-components';
+import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core';
+import { KeyIcon } from '@patternfly/react-icons';
+import CredentialChip from '../../../../components/CredentialChip';
+import { Credential } from '../../../../types';
+
+const SelectedCredential = styled.div`
+ display: flex;
+ justify-content: space-between;
+ background-color: white;
+ border-bottom-color: var(--pf-global--BorderColor--200);
+`;
+
+const SpacedCredentialChip = styled(CredentialChip)`
+ margin: 5px 8px;
+`;
+
+const PluginHelpText = styled.p`
+ margin-top: 5px;
+`;
+
+function CredentialPluginSelected({
+ i18n,
+ credential,
+ onEditPlugin,
+ onClearPlugin,
+}) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+ This field will be retrieved from an external secret management system
+ using the specified credential.
+
+
+ >
+ );
+}
+
+CredentialPluginSelected.propTypes = {
+ credential: Credential.isRequired,
+ onEditPlugin: func,
+ onClearPlugin: func,
+};
+
+CredentialPluginSelected.defaultProps = {
+ onEditPlugin: () => {},
+ onClearPlugin: () => {},
+};
+
+export default withI18n()(CredentialPluginSelected);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.test.jsx
new file mode 100644
index 0000000000..ce69724904
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.test.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { mountWithContexts } from '../../../../../testUtils/enzymeHelpers';
+import selectedCredential from '../data.cyberArkCredential.json';
+import CredentialPluginSelected from './CredentialPluginSelected';
+
+describe('', () => {
+ let wrapper;
+ const onClearPlugin = jest.fn();
+ const onEditPlugin = jest.fn();
+ beforeAll(() => {
+ wrapper = mountWithContexts(
+
+ );
+ });
+ afterAll(() => {
+ wrapper.unmount();
+ });
+ test('renders the expected content', () => {
+ expect(wrapper.find('CredentialChip').length).toBe(1);
+ expect(wrapper.find('KeyIcon').length).toBe(1);
+ });
+ test('clearing plugin calls expected function', () => {
+ wrapper.find('CredentialChip button').simulate('click');
+ expect(onClearPlugin).toBeCalledTimes(1);
+ });
+ test('editing plugin calls expected function', () => {
+ wrapper.find('KeyIcon').simulate('click');
+ expect(onEditPlugin).toBeCalledTimes(1);
+ });
+});
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js
new file mode 100644
index 0000000000..033586567f
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js
@@ -0,0 +1,2 @@
+export { default as CredentialPluginSelected } from './CredentialPluginSelected';
+export { default as CredentialPluginField } from './CredentialPluginField';
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx
index 89584b956c..2622106afb 100644
--- a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx
@@ -2,13 +2,18 @@ import React, { useState } from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { useField } from 'formik';
-import { FileUpload, FormGroup } from '@patternfly/react-core';
-import FormField from '../../../../components/FormField';
+import {
+ FileUpload,
+ FormGroup,
+ TextArea,
+ TextInput,
+} from '@patternfly/react-core';
import {
FormColumnLayout,
FormFullWidthLayout,
} from '../../../../components/FormLayout';
import { required } from '../../../../util/validators';
+import { CredentialPluginField } from '../CredentialPlugins';
const GoogleComputeEngineSubForm = ({ i18n }) => {
const [fileError, setFileError] = useState(null);
@@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => {
}}
/>
-
-
+
+
+
+ >
+
+
-
+ >
+
+
);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx
index 51a95292bd..d2d4ee20fc 100644
--- a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx
@@ -1,39 +1,50 @@
import React from 'react';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
-import FormField, { PasswordField } from '../../../../components/FormField';
+import { TextArea, TextInput } from '@patternfly/react-core';
+import { CredentialPluginField } from '../CredentialPlugins';
+import { PasswordInput } from '../../../../components/FormField';
export const UsernameFormField = withI18n()(({ i18n }) => (
-
+ >
+
+
));
export const PasswordFormField = withI18n()(({ i18n }) => (
-
+ >
+
+
));
export const SSHKeyDataField = withI18n()(({ i18n }) => (
-
+ >
+
+
));
export const SSHKeyUnlockField = withI18n()(({ i18n }) => (
-
+ >
+
+
));
diff --git a/awx/ui_next/src/screens/Credential/shared/data.azureVaultCredential.json b/awx/ui_next/src/screens/Credential/shared/data.azureVaultCredential.json
new file mode 100644
index 0000000000..efedfe045d
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/data.azureVaultCredential.json
@@ -0,0 +1,85 @@
+{
+ "id": 12,
+ "type": "credential",
+ "url": "/api/v2/credentials/12/",
+ "related": {
+ "created_by": "/api/v2/users/1/",
+ "modified_by": "/api/v2/users/1/",
+ "activity_stream": "/api/v2/credentials/12/activity_stream/",
+ "access_list": "/api/v2/credentials/12/access_list/",
+ "object_roles": "/api/v2/credentials/12/object_roles/",
+ "owner_users": "/api/v2/credentials/12/owner_users/",
+ "owner_teams": "/api/v2/credentials/12/owner_teams/",
+ "copy": "/api/v2/credentials/12/copy/",
+ "input_sources": "/api/v2/credentials/12/input_sources/",
+ "credential_type": "/api/v2/credential_types/19/",
+ "user": "/api/v2/users/1/"
+ },
+ "summary_fields": {
+ "credential_type": {
+ "id": 19,
+ "name": "Microsoft Azure Key Vault",
+ "description": ""
+ },
+ "created_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "modified_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "object_roles": {
+ "admin_role": {
+ "description": "Can manage all aspects of the credential",
+ "name": "Admin",
+ "id": 60
+ },
+ "use_role": {
+ "description": "Can use the credential in a job template",
+ "name": "Use",
+ "id": 61
+ },
+ "read_role": {
+ "description": "May view settings for the credential",
+ "name": "Read",
+ "id": 62
+ }
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true,
+ "copy": true,
+ "use": true
+ },
+ "owners": [
+ {
+ "id": 1,
+ "type": "user",
+ "name": "admin",
+ "description": " ",
+ "url": "/api/v2/users/1/"
+ }
+ ]
+ },
+ "created": "2020-05-26T14:54:45.612847Z",
+ "modified": "2020-05-26T14:54:45.612861Z",
+ "name": "Microsoft Azure Key Vault",
+ "description": "",
+ "organization": null,
+ "credential_type": 19,
+ "inputs": {
+ "url": "https://localhost",
+ "client": "foo",
+ "secret": "$encrypted$",
+ "tenant": "9000",
+ "cloud_name": "AzureCloud"
+ },
+ "kind": "azure_kv",
+ "cloud": false,
+ "kubernetes": false
+}
diff --git a/awx/ui_next/src/screens/Credential/shared/data.cyberArkCredential.json b/awx/ui_next/src/screens/Credential/shared/data.cyberArkCredential.json
new file mode 100644
index 0000000000..94b0bbd8fd
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/data.cyberArkCredential.json
@@ -0,0 +1,85 @@
+{
+ "id": 1,
+ "type": "credential",
+ "url": "/api/v2/credentials/1/",
+ "related": {
+ "named_url": "/api/v2/credentials/CyberArk Conjur Secret Lookup++CyberArk Conjur Secret Lookup+external++/",
+ "created_by": "/api/v2/users/1/",
+ "modified_by": "/api/v2/users/1/",
+ "activity_stream": "/api/v2/credentials/1/activity_stream/",
+ "access_list": "/api/v2/credentials/1/access_list/",
+ "object_roles": "/api/v2/credentials/1/object_roles/",
+ "owner_users": "/api/v2/credentials/1/owner_users/",
+ "owner_teams": "/api/v2/credentials/1/owner_teams/",
+ "copy": "/api/v2/credentials/1/copy/",
+ "input_sources": "/api/v2/credentials/1/input_sources/",
+ "credential_type": "/api/v2/credential_types/20/",
+ "user": "/api/v2/users/1/"
+ },
+ "summary_fields": {
+ "credential_type": {
+ "id": 20,
+ "name": "CyberArk Conjur Secret Lookup",
+ "description": ""
+ },
+ "created_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "modified_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "object_roles": {
+ "admin_role": {
+ "description": "Can manage all aspects of the credential",
+ "name": "Admin",
+ "id": 27
+ },
+ "use_role": {
+ "description": "Can use the credential in a job template",
+ "name": "Use",
+ "id": 28
+ },
+ "read_role": {
+ "description": "May view settings for the credential",
+ "name": "Read",
+ "id": 29
+ }
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true,
+ "copy": true,
+ "use": true
+ },
+ "owners": [
+ {
+ "id": 1,
+ "type": "user",
+ "name": "admin",
+ "description": " ",
+ "url": "/api/v2/users/1/"
+ }
+ ]
+ },
+ "created": "2020-05-19T12:51:36.956029Z",
+ "modified": "2020-05-19T12:51:36.956086Z",
+ "name": "CyberArk Conjur Secret Lookup",
+ "description": "",
+ "organization": null,
+ "credential_type": 20,
+ "inputs": {
+ "url": "https://localhost",
+ "account": "adsf",
+ "api_key": "$encrypted$",
+ "username": "adsf"
+ },
+ "kind": "conjur",
+ "cloud": false,
+ "kubernetes": false
+}
diff --git a/awx/ui_next/src/screens/Credential/shared/data.hashiCorpCredential.json b/awx/ui_next/src/screens/Credential/shared/data.hashiCorpCredential.json
new file mode 100644
index 0000000000..426b622c58
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/data.hashiCorpCredential.json
@@ -0,0 +1,82 @@
+{
+ "id": 11,
+ "type": "credential",
+ "url": "/api/v2/credentials/11/",
+ "related": {
+ "created_by": "/api/v2/users/1/",
+ "modified_by": "/api/v2/users/1/",
+ "activity_stream": "/api/v2/credentials/11/activity_stream/",
+ "access_list": "/api/v2/credentials/11/access_list/",
+ "object_roles": "/api/v2/credentials/11/object_roles/",
+ "owner_users": "/api/v2/credentials/11/owner_users/",
+ "owner_teams": "/api/v2/credentials/11/owner_teams/",
+ "copy": "/api/v2/credentials/11/copy/",
+ "input_sources": "/api/v2/credentials/11/input_sources/",
+ "credential_type": "/api/v2/credential_types/21/",
+ "user": "/api/v2/users/1/"
+ },
+ "summary_fields": {
+ "credential_type": {
+ "id": 21,
+ "name": "HashiCorp Vault Secret Lookup",
+ "description": ""
+ },
+ "created_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "modified_by": {
+ "id": 1,
+ "username": "admin",
+ "first_name": "",
+ "last_name": ""
+ },
+ "object_roles": {
+ "admin_role": {
+ "description": "Can manage all aspects of the credential",
+ "name": "Admin",
+ "id": 57
+ },
+ "use_role": {
+ "description": "Can use the credential in a job template",
+ "name": "Use",
+ "id": 58
+ },
+ "read_role": {
+ "description": "May view settings for the credential",
+ "name": "Read",
+ "id": 59
+ }
+ },
+ "user_capabilities": {
+ "edit": true,
+ "delete": true,
+ "copy": true,
+ "use": true
+ },
+ "owners": [
+ {
+ "id": 1,
+ "type": "user",
+ "name": "admin",
+ "description": " ",
+ "url": "/api/v2/users/1/"
+ }
+ ]
+ },
+ "created": "2020-05-26T14:54:00.674404Z",
+ "modified": "2020-05-26T14:54:00.674418Z",
+ "name": "HashiCorp Vault Secret Lookup",
+ "description": "",
+ "organization": null,
+ "credential_type": 21,
+ "inputs": {
+ "url": "https://localhost",
+ "api_version": "v1"
+ },
+ "kind": "hashivault_kv",
+ "cloud": false,
+ "kubernetes": false
+}