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 && ( +
+ + {fields.map(field => { + if (field.type === 'string') { + if (field.choices) { + return ( + + {field.help_text && ( + + + + )} + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + form.setFieldValue(`inputs.${field.id}`, value); + }} + validate={field.required ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
+ )} + + 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 }) => { }} /> - - + +
+ + > + + - + > +