From 8d26d7861e01cf96a4c01e6927bd5e1558386553 Mon Sep 17 00:00:00 2001 From: John Mitchell Date: Tue, 18 Feb 2020 14:40:55 -0500 Subject: [PATCH] add credential form and add edit routes --- awx/ui_next/src/api/models/Credentials.js | 8 + .../src/screens/Credential/Credential.jsx | 8 +- .../CredentialAdd/CredentialAdd.jsx | 78 ++- .../CredentialAdd/CredentialAdd.test.jsx | 201 +++++++ .../CredentialDetail/CredentialDetail.jsx | 2 +- .../CredentialDetail.test.jsx | 4 - .../CredentialEdit/CredentialEdit.jsx | 81 +++ .../CredentialEdit/CredentialEdit.test.jsx | 298 ++++++++++ .../Credential/CredentialEdit/index.js | 1 + .../Credential/shared/CredentialForm.jsx | 183 +++++++ .../Credential/shared/CredentialForm.test.jsx | 516 ++++++++++++++++++ .../CredentialSubForms/ManualSubForm.jsx | 86 +++ .../CredentialSubForms/SharedFields.jsx | 40 ++ .../SourceControlSubForm.jsx | 24 + .../shared/CredentialSubForms/index.js | 2 + .../Credential/shared/data.credential.json | 84 +++ .../Credential/shared/data.orgCredential.json | 92 ++++ 17 files changed, 1698 insertions(+), 10 deletions(-) create mode 100644 awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx create mode 100644 awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx create mode 100644 awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx create mode 100644 awx/ui_next/src/screens/Credential/CredentialEdit/index.js create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialSubForms/ManualSubForm.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SourceControlSubForm.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialSubForms/index.js create mode 100644 awx/ui_next/src/screens/Credential/shared/data.credential.json create mode 100644 awx/ui_next/src/screens/Credential/shared/data.orgCredential.json diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 2e3634b4e5..9b31506956 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -4,6 +4,14 @@ class Credentials extends Base { constructor(http) { super(http); this.baseUrl = '/api/v2/credentials/'; + + this.readAccessList = this.readAccessList.bind(this); + } + + readAccessList(id, params) { + return this.http.get(`${this.baseUrl}${id}/access_list/`, { + params, + }); } } diff --git a/awx/ui_next/src/screens/Credential/Credential.jsx b/awx/ui_next/src/screens/Credential/Credential.jsx index 31ede881cc..d7f0754d97 100644 --- a/awx/ui_next/src/screens/Credential/Credential.jsx +++ b/awx/ui_next/src/screens/Credential/Credential.jsx @@ -17,6 +17,7 @@ import { ResourceAccessList } from '@components/ResourceAccessList'; import ContentError from '@components/ContentError'; import RoutedTabs from '@components/RoutedTabs'; import CredentialDetail from './CredentialDetail'; +import CredentialEdit from './CredentialEdit'; import { CredentialsAPI } from '@api'; function Credential({ i18n, setBreadcrumb }) { @@ -100,6 +101,11 @@ function Credential({ i18n, setBreadcrumb }) { path="/credentials/:id/details" render={() => } />, + } + />, credential.organization && ( {i18n._(`View Credential Details`)} - )} + )} ) } diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx index 964b301d8a..f72c33943d 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx @@ -1,14 +1,84 @@ -import React from 'react'; -import { Card, CardBody, PageSection } from '@patternfly/react-core'; +import React, { 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'; -function CredentialAdd() { +import { CredentialTypesAPI, CredentialsAPI } from '@api'; +import CredentialForm from '../shared/CredentialForm'; + +function CredentialAdd({ me }) { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [credentialTypes, setCredentialTypes] = useState(null); + const history = useHistory(); + + useEffect(() => { + const loadData = async () => { + try { + const { + data: { results: loadedCredentialTypes }, + } = await CredentialTypesAPI.read({ or__kind: ['scm', 'ssh'] }); + setCredentialTypes(loadedCredentialTypes); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, []); + + const handleCancel = () => { + history.push('/credentials'); + }; + + const handleSubmit = async values => { + const { organization, ...remainingValues } = values; + 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) { + setError(err); + } + }; + + if (error) { + return ( + + + + + + + + ); + } + if (isLoading) { + return ; + } return ( - Coming soon :) + + + ); } +export { CredentialAdd as _CredentialAdd }; export default CredentialAdd; diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx new file mode 100644 index 0000000000..4d1abe0672 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; + +import { CredentialsAPI, CredentialTypesAPI } from '@api'; +import CredentialAdd from './CredentialAdd'; + +jest.mock('@api'); + +CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 2, + type: 'credential_type', + url: '/api/v2/credential_types/2/', + related: { + credentials: '/api/v2/credential_types/2/credentials/', + activity_stream: '/api/v2/credential_types/2/activity_stream/', + }, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-02-12T19:42:43.551238Z', + modified: '2020-02-12T19:43:03.164800Z', + name: 'Source Control', + description: '', + kind: 'scm', + namespace: 'scm', + managed_by_tower: true, + inputs: { + fields: [ + { + id: 'username', + label: 'Username', + type: 'string', + }, + { + id: 'password', + label: 'Password', + type: 'string', + secret: true, + }, + { + id: 'ssh_key_data', + label: 'SCM Private Key', + type: 'string', + format: 'ssh_private_key', + secret: true, + multiline: true, + }, + { + id: 'ssh_key_unlock', + label: 'Private Key Passphrase', + type: 'string', + secret: true, + }, + ], + }, + injectors: {}, + }, + { + id: 1, + type: 'credential_type', + url: '/api/v2/credential_types/1/', + related: { + credentials: '/api/v2/credential_types/1/credentials/', + activity_stream: '/api/v2/credential_types/1/activity_stream/', + }, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-02-12T19:42:43.539626Z', + modified: '2020-02-12T19:43:03.159739Z', + name: 'Machine', + description: '', + kind: 'ssh', + namespace: 'ssh', + managed_by_tower: true, + inputs: { + fields: [ + { + id: 'username', + label: 'Username', + type: 'string', + }, + { + id: 'password', + label: 'Password', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + { + id: 'ssh_key_data', + label: 'SSH Private Key', + type: 'string', + format: 'ssh_private_key', + secret: true, + multiline: true, + }, + { + id: 'ssh_public_key_data', + label: 'Signed SSH Certificate', + type: 'string', + multiline: true, + secret: true, + }, + { + id: 'ssh_key_unlock', + label: 'Private Key Passphrase', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + { + id: 'become_method', + label: 'Privilege Escalation Method', + type: 'string', + help_text: + 'Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.', + }, + { + id: 'become_username', + label: 'Privilege Escalation Username', + type: 'string', + }, + { + id: 'become_password', + label: 'Privilege Escalation Password', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + ], + }, + injectors: {}, + }, + ], + }, +}); + +CredentialsAPI.create.mockResolvedValue({ data: { id: 13 } }); + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + history = createMemoryHistory({ initialEntries: ['/credentials'] }); + await act(async () => { + wrapper = mountWithContexts(, { + context: { router: { history } }, + }); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + test('handleSubmit should call the api and redirect to details page', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + + wrapper.find('CredentialForm').prop('onSubmit')({ + user: 1, + organization: null, + name: 'foo', + description: 'bar', + credential_type: '2', + inputs: {}, + }); + await sleep(1); + expect(CredentialsAPI.create).toHaveBeenCalledWith({ + user: 1, + organization: null, + name: 'foo', + description: 'bar', + credential_type: '2', + inputs: {}, + }); + expect(history.location.pathname).toBe('/credentials/13/details'); + }); + + test('handleCancel should return the user back to the inventories list', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('Button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/credentials'); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx index 290275fb15..1e35f7d36c 100644 --- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.jsx @@ -82,7 +82,7 @@ function CredentialDetail({ i18n, credential }) { /> ); } else if (secret === true) { - detail = ; + detail = null; } else { detail = ; } diff --git a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx index c1f34c16a7..1411eb70fa 100644 --- a/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialDetail/CredentialDetail.test.jsx @@ -49,10 +49,6 @@ describe('', () => { mockCredential.summary_fields.credential_type.name ); expectDetailToMatch(wrapper, 'Username', mockCredential.inputs.username); - expectDetailToMatch(wrapper, 'Password', 'Encrypted'); - expectDetailToMatch(wrapper, 'SSH Private Key', 'Encrypted'); - expectDetailToMatch(wrapper, 'Signed SSH Certificate', 'Encrypted'); - expectDetailToMatch(wrapper, 'Private Key Passphrase', 'Encrypted'); expectDetailToMatch( wrapper, 'Privilege Escalation Method', diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx new file mode 100644 index 0000000000..e6086f992e --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx @@ -0,0 +1,81 @@ +import React, { 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 ContentError from '@components/ContentError'; +import ContentLoading from '@components/ContentLoading'; +import CredentialForm from '../shared/CredentialForm'; + +function CredentialEdit({ credential, me }) { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [credentialTypes, setCredentialTypes] = useState(null); + const history = useHistory(); + + useEffect(() => { + const loadData = async () => { + try { + const { + data: { results: loadedCredentialTypes }, + } = await CredentialTypesAPI.read({ or__kind: ['scm', 'ssh'] }); + setCredentialTypes(loadedCredentialTypes); + } catch (err) { + setError(err); + } finally { + setIsLoading(false); + } + }; + loadData(); + }, []); + + const handleCancel = () => { + const url = `/credentials/${credential.id}/details`; + + history.push(`${url}`); + }; + + const handleSubmit = async values => { + const { organization, ...remainingValues } = values; + 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) { + setError(err); + } + }; + + if (error) { + return ; + } + + if (isLoading) { + return ; + } + + return ( + + + + ); +} + +CredentialEdit.proptype = { + inventory: object.isRequired, +}; + +export { CredentialEdit as _CredentialEdit }; +export default CredentialEdit; diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx new file mode 100644 index 0000000000..8a0cbe13d7 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx @@ -0,0 +1,298 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +import { sleep } from '@testUtils/testUtils'; + +import { CredentialsAPI, CredentialTypesAPI } from '@api'; +import CredentialEdit from './CredentialEdit'; + +jest.mock('@api'); + +const mockCredential = { + id: 3, + type: 'credential', + url: '/api/v2/credentials/3/', + related: { + named_url: '/api/v2/credentials/oersdgfasf++Machine+ssh++org/', + created_by: '/api/v2/users/1/', + modified_by: '/api/v2/users/1/', + organization: '/api/v2/organizations/1/', + activity_stream: '/api/v2/credentials/3/activity_stream/', + access_list: '/api/v2/credentials/3/access_list/', + object_roles: '/api/v2/credentials/3/object_roles/', + owner_users: '/api/v2/credentials/3/owner_users/', + owner_teams: '/api/v2/credentials/3/owner_teams/', + copy: '/api/v2/credentials/3/copy/', + input_sources: '/api/v2/credentials/3/input_sources/', + credential_type: '/api/v2/credential_types/1/', + }, + summary_fields: { + organization: { + id: 1, + name: 'org', + description: '', + }, + credential_type: { + id: 1, + name: 'Machine', + 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: 36, + }, + use_role: { + description: 'Can use the credential in a job template', + name: 'Use', + id: 37, + }, + read_role: { + description: 'May view settings for the credential', + name: 'Read', + id: 38, + }, + }, + user_capabilities: { + edit: true, + delete: true, + copy: true, + use: true, + }, + owners: [ + { + id: 1, + type: 'user', + name: 'admin', + description: ' ', + url: '/api/v2/users/1/', + }, + { + id: 1, + type: 'organization', + name: 'org', + description: '', + url: '/api/v2/organizations/1/', + }, + ], + }, + created: '2020-02-18T15:35:04.563928Z', + modified: '2020-02-18T15:35:04.563957Z', + name: 'oersdgfasf', + description: '', + organization: 1, + credential_type: 1, + inputs: {}, + kind: 'ssh', + cloud: false, + kubernetes: false, +}; + +CredentialTypesAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 2, + type: 'credential_type', + url: '/api/v2/credential_types/2/', + related: { + credentials: '/api/v2/credential_types/2/credentials/', + activity_stream: '/api/v2/credential_types/2/activity_stream/', + }, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-02-12T19:42:43.551238Z', + modified: '2020-02-12T19:43:03.164800Z', + name: 'Source Control', + description: '', + kind: 'scm', + namespace: 'scm', + managed_by_tower: true, + inputs: { + fields: [ + { + id: 'username', + label: 'Username', + type: 'string', + }, + { + id: 'password', + label: 'Password', + type: 'string', + secret: true, + }, + { + id: 'ssh_key_data', + label: 'SCM Private Key', + type: 'string', + format: 'ssh_private_key', + secret: true, + multiline: true, + }, + { + id: 'ssh_key_unlock', + label: 'Private Key Passphrase', + type: 'string', + secret: true, + }, + ], + }, + injectors: {}, + }, + { + id: 1, + type: 'credential_type', + url: '/api/v2/credential_types/1/', + related: { + credentials: '/api/v2/credential_types/1/credentials/', + activity_stream: '/api/v2/credential_types/1/activity_stream/', + }, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-02-12T19:42:43.539626Z', + modified: '2020-02-12T19:43:03.159739Z', + name: 'Machine', + description: '', + kind: 'ssh', + namespace: 'ssh', + managed_by_tower: true, + inputs: { + fields: [ + { + id: 'username', + label: 'Username', + type: 'string', + }, + { + id: 'password', + label: 'Password', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + { + id: 'ssh_key_data', + label: 'SSH Private Key', + type: 'string', + format: 'ssh_private_key', + secret: true, + multiline: true, + }, + { + id: 'ssh_public_key_data', + label: 'Signed SSH Certificate', + type: 'string', + multiline: true, + secret: true, + }, + { + id: 'ssh_key_unlock', + label: 'Private Key Passphrase', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + { + id: 'become_method', + label: 'Privilege Escalation Method', + type: 'string', + help_text: + 'Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.', + }, + { + id: 'become_username', + label: 'Privilege Escalation Username', + type: 'string', + }, + { + id: 'become_password', + label: 'Privilege Escalation Password', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + ], + }, + injectors: {}, + }, + ], + }, +}); + +CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } }); + +describe('', () => { + let wrapper; + let history; + + beforeEach(async () => { + history = createMemoryHistory({ initialEntries: ['/credentials'] }); + await act(async () => { + wrapper = mountWithContexts( + , + { + context: { router: { history } }, + } + ); + }); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('initially renders successfully', async () => { + expect(wrapper.find('CredentialEdit').length).toBe(1); + }); + + test('handleCancel returns the user to credential detail', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + wrapper.find('Button[aria-label="Cancel"]').simulate('click'); + expect(history.location.pathname).toEqual('/credentials/3/details'); + }); + + test('handleSubmit should post to the api', async () => { + await waitForElement(wrapper, 'isLoading', el => el.length === 0); + + wrapper.find('CredentialForm').prop('onSubmit')({ + user: 1, + organization: null, + name: 'foo', + description: 'bar', + credential_type: '2', + inputs: {}, + }); + await sleep(1); + expect(CredentialsAPI.update).toHaveBeenCalledWith(3, { + user: 1, + organization: null, + name: 'foo', + description: 'bar', + credential_type: '2', + inputs: {}, + }); + expect(history.location.pathname).toBe('/credentials/3/details'); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/index.js b/awx/ui_next/src/screens/Credential/CredentialEdit/index.js new file mode 100644 index 0000000000..9887874371 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/index.js @@ -0,0 +1 @@ +export { default } from './CredentialEdit'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx new file mode 100644 index 0000000000..89ffd6c228 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -0,0 +1,183 @@ +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 { Form, FormGroup, Title } from '@patternfly/react-core'; +import FormField from '@components/FormField'; +import FormActionGroup from '@components/FormActionGroup/FormActionGroup'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { required } from '@util/validators'; +import OrganizationLookup from '@components/Lookup/OrganizationLookup'; +import { FormColumnLayout, SubFormLayout } from '@components/FormLayout'; +import { ManualSubForm, SourceControlSubForm } from './CredentialSubForms'; + +function CredentialFormFields({ + i18n, + credentialTypes, + formik, + initialValues, +}) { + const [orgField, orgMeta, orgHelpers] = useField('organization'); + const [credTypeField, credTypeMeta, credTypeHelpers] = useField({ + name: 'credential_type', + validate: required(i18n._(t`Select a value for this field`), i18n), + }); + + const credentialTypeOptions = Object.keys(credentialTypes).map(key => { + return { + value: credentialTypes[key].id, + key: credentialTypes[key].kind, + label: credentialTypes[key].name, + }; + }); + const scmCredentialTypeId = Object.keys(credentialTypes) + .filter(key => credentialTypes[key].kind === 'scm') + .map(key => credentialTypes[key].id)[0]; + const sshCredentialTypeId = Object.keys(credentialTypes) + .filter(key => credentialTypes[key].kind === 'ssh') + .map(key => credentialTypes[key].id)[0]; + + const resetSubFormFields = (value, form) => { + Object.keys(form.initialValues.inputs).forEach(label => { + if (parseInt(value, 10) === form.initialValues.credential_type) { + form.setFieldValue(`inputs.${label}`, initialValues.inputs[label]); + } else { + form.setFieldValue(`inputs.${label}`, undefined); + } + form.setFieldTouched(`inputs.${label}`, false); + }); + }; + + return ( + <> + + + orgHelpers.setTouched()} + onChange={value => { + orgHelpers.setValue(value); + }} + value={orgField.value} + touched={orgMeta.touched} + error={orgMeta.error} + /> + + { + credTypeHelpers.setValue(value); + resetSubFormFields(value, formik); + }} + /> + + {formik.values.credential_type !== undefined && + formik.values.credential_type !== '' && ( + + {i18n._(t`Type Details`)} + { + { + [sshCredentialTypeId]: , + [scmCredentialTypeId]: , + }[formik.values.credential_type] + } + + )} + + ); +} + +function CredentialForm({ credential = {}, onSubmit, onCancel, ...rest }) { + const initialValues = { + name: credential.name || undefined, + description: credential.description || undefined, + organization: + (credential.summary_fields && credential.summary_fields.organization) || + null, + credential_type: credential.credential_type || undefined, + inputs: { + username: (credential.inputs && credential.inputs.username) || undefined, + password: (credential.inputs && credential.inputs.password) || undefined, + ssh_key_data: + (credential.inputs && credential.inputs.ssh_key_data) || undefined, + ssh_public_key_data: + (credential.inputs && credential.inputs.ssh_public_key_data) || + undefined, + ssh_key_unlock: + (credential.inputs && credential.inputs.ssh_key_unlock) || undefined, + become_method: + (credential.inputs && credential.inputs.become_method) || undefined, + become_username: + (credential.inputs && credential.inputs.become_username) || undefined, + become_password: + (credential.inputs && credential.inputs.become_password) || undefined, + }, + }; + + return ( + { + onSubmit(values); + }} + > + {formik => ( +
+ + + + +
+ )} +
+ ); +} + +CredentialForm.proptype = { + handleSubmit: func.isRequired, + handleCancel: func.isRequired, + credential: shape({}), +}; + +CredentialForm.defaultProps = { + credential: {}, +}; + +export default withI18n()(CredentialForm); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx new file mode 100644 index 0000000000..16fc3d3867 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx @@ -0,0 +1,516 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; + +import CredentialForm from './CredentialForm'; + +const machineCredential = { + id: 3, + type: 'credential', + url: '/api/v2/credentials/3/', + related: { + named_url: '/api/v2/credentials/oersdgfasf++Machine+ssh++org/', + created_by: '/api/v2/users/1/', + modified_by: '/api/v2/users/1/', + organization: '/api/v2/organizations/1/', + activity_stream: '/api/v2/credentials/3/activity_stream/', + access_list: '/api/v2/credentials/3/access_list/', + object_roles: '/api/v2/credentials/3/object_roles/', + owner_users: '/api/v2/credentials/3/owner_users/', + owner_teams: '/api/v2/credentials/3/owner_teams/', + copy: '/api/v2/credentials/3/copy/', + input_sources: '/api/v2/credentials/3/input_sources/', + credential_type: '/api/v2/credential_types/1/', + }, + summary_fields: { + organization: { + id: 1, + name: 'org', + description: '', + }, + credential_type: { + id: 1, + name: 'Machine', + 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: 36, + }, + use_role: { + description: 'Can use the credential in a job template', + name: 'Use', + id: 37, + }, + read_role: { + description: 'May view settings for the credential', + name: 'Read', + id: 38, + }, + }, + user_capabilities: { + edit: true, + delete: true, + copy: true, + use: true, + }, + owners: [ + { + id: 1, + type: 'user', + name: 'admin', + description: ' ', + url: '/api/v2/users/1/', + }, + { + id: 1, + type: 'organization', + name: 'org', + description: '', + url: '/api/v2/organizations/1/', + }, + ], + }, + created: '2020-02-18T15:35:04.563928Z', + modified: '2020-02-18T15:35:04.563957Z', + name: 'oersdgfasf', + description: '', + organization: 1, + credential_type: 1, + inputs: {}, + kind: 'ssh', + cloud: false, + kubernetes: false, +}; + +const sourceControlCredential = { + id: 4, + type: 'credential', + url: '/api/v2/credentials/4/', + related: { + named_url: '/api/v2/credentials/joijoij++Source Control+scm++/', + created_by: '/api/v2/users/1/', + modified_by: '/api/v2/users/1/', + activity_stream: '/api/v2/credentials/4/activity_stream/', + access_list: '/api/v2/credentials/4/access_list/', + object_roles: '/api/v2/credentials/4/object_roles/', + owner_users: '/api/v2/credentials/4/owner_users/', + owner_teams: '/api/v2/credentials/4/owner_teams/', + copy: '/api/v2/credentials/4/copy/', + input_sources: '/api/v2/credentials/4/input_sources/', + credential_type: '/api/v2/credential_types/2/', + user: '/api/v2/users/1/', + }, + summary_fields: { + credential_type: { + id: 2, + name: 'Source Control', + 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: 39, + }, + use_role: { + description: 'Can use the credential in a job template', + name: 'Use', + id: 40, + }, + read_role: { + description: 'May view settings for the credential', + name: 'Read', + id: 41, + }, + }, + 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-02-18T16:03:01.366287Z', + modified: '2020-02-18T16:03:01.366315Z', + name: 'joijoij', + description: 'ojiojojo', + organization: null, + credential_type: 2, + inputs: { + ssh_key_unlock: '$encrypted$', + }, + kind: 'scm', + cloud: false, + kubernetes: false, +}; + +const credentialTypes = [ + { + id: 2, + type: 'credential_type', + url: '/api/v2/credential_types/2/', + related: { + credentials: '/api/v2/credential_types/2/credentials/', + activity_stream: '/api/v2/credential_types/2/activity_stream/', + }, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-02-12T19:42:43.551238Z', + modified: '2020-02-12T19:43:03.164800Z', + name: 'Source Control', + description: '', + kind: 'scm', + namespace: 'scm', + managed_by_tower: true, + inputs: { + fields: [ + { + id: 'username', + label: 'Username', + type: 'string', + }, + { + id: 'password', + label: 'Password', + type: 'string', + secret: true, + }, + { + id: 'ssh_key_data', + label: 'SCM Private Key', + type: 'string', + format: 'ssh_private_key', + secret: true, + multiline: true, + }, + { + id: 'ssh_key_unlock', + label: 'Private Key Passphrase', + type: 'string', + secret: true, + }, + ], + }, + injectors: {}, + }, + { + id: 1, + type: 'credential_type', + url: '/api/v2/credential_types/1/', + related: { + credentials: '/api/v2/credential_types/1/credentials/', + activity_stream: '/api/v2/credential_types/1/activity_stream/', + }, + summary_fields: { + user_capabilities: { + edit: false, + delete: false, + }, + }, + created: '2020-02-12T19:42:43.539626Z', + modified: '2020-02-12T19:43:03.159739Z', + name: 'Machine', + description: '', + kind: 'ssh', + namespace: 'ssh', + managed_by_tower: true, + inputs: { + fields: [ + { + id: 'username', + label: 'Username', + type: 'string', + }, + { + id: 'password', + label: 'Password', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + { + id: 'ssh_key_data', + label: 'SSH Private Key', + type: 'string', + format: 'ssh_private_key', + secret: true, + multiline: true, + }, + { + id: 'ssh_public_key_data', + label: 'Signed SSH Certificate', + type: 'string', + multiline: true, + secret: true, + }, + { + id: 'ssh_key_unlock', + label: 'Private Key Passphrase', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + { + id: 'become_method', + label: 'Privilege Escalation Method', + type: 'string', + help_text: + 'Specify a method for "become" operations. This is equivalent to specifying the --become-method Ansible parameter.', + }, + { + id: 'become_username', + label: 'Privilege Escalation Username', + type: 'string', + }, + { + id: 'become_password', + label: 'Privilege Escalation Password', + type: 'string', + secret: true, + ask_at_runtime: true, + }, + ], + }, + injectors: {}, + }, +]; + +describe('', () => { + let wrapper; + let onCancel; + let onSubmit; + + const addFieldExpects = () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Username"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="Password"]').length).toBe(0); + expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(0); + expect( + wrapper.find('FormGroup[label="Signed SSH Certificate"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Private Key Passphrase"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Privelege Escalation Method"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Privilege Escalation Username"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Privilege Escalation Password"]').length + ).toBe(0); + }; + + const machineFieldExpects = () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="Signed SSH Certificate"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Private Key Passphrase"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Privelege Escalation Method"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Privilege Escalation Username"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Privilege Escalation Password"]').length + ).toBe(1); + }; + + const sourceFieldExpects = () => { + expect(wrapper.find('FormGroup[label="Name"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Description"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1); + expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(1); + expect( + wrapper.find('FormGroup[label="Signed SSH Certificate"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Private Key Passphrase"]').length + ).toBe(1); + expect( + wrapper.find('FormGroup[label="Privelege Escalation Method"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Privilege Escalation Username"]').length + ).toBe(0); + expect( + wrapper.find('FormGroup[label="Privilege Escalation Password"]').length + ).toBe(0); + }; + + beforeEach(() => { + onCancel = jest.fn(); + onSubmit = jest.fn(); + wrapper = mountWithContexts( + + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + test('Initially renders successfully', () => { + expect(wrapper.length).toBe(1); + }); + + test('should display form fields on add properly', () => { + wrapper = mountWithContexts( + + ); + addFieldExpects(); + }); + + test('should display form fields for machine credential properly', () => { + wrapper = mountWithContexts( + + ); + machineFieldExpects(); + }); + + test('should display form fields for source control credential properly', () => { + wrapper = mountWithContexts( + + ); + sourceFieldExpects(); + }); + + test('should update form values', async () => { + // name and description change + act(() => { + wrapper.find('input#credential-name').simulate('change', { + target: { value: 'new Foo', name: 'name' }, + }); + wrapper.find('input#credential-description').simulate('change', { + target: { value: 'new Bar', name: 'description' }, + }); + }); + wrapper.update(); + expect(wrapper.find('input#credential-name').prop('value')).toEqual( + 'new Foo' + ); + expect(wrapper.find('input#credential-description').prop('value')).toEqual( + 'new Bar' + ); + // organization change + act(() => { + wrapper.find('OrganizationLookup').invoke('onBlur')(); + wrapper.find('OrganizationLookup').invoke('onChange')({ + id: 3, + name: 'organization', + }); + }); + wrapper.update(); + expect(wrapper.find('OrganizationLookup').prop('value')).toEqual({ + id: 3, + name: 'organization', + }); + }); + + test('should display cred type subform when scm type select has a value', async () => { + wrapper = mountWithContexts( + + ); + addFieldExpects(); + await act(async () => { + await wrapper + .find('AnsibleSelect[id="credential_type"]') + .invoke('onChange')(null, 1); + }); + wrapper.update(); + machineFieldExpects(); + await act(async () => { + await wrapper + .find('AnsibleSelect[id="credential_type"]') + .invoke('onChange')(null, 2); + }); + wrapper.update(); + sourceFieldExpects(); + }); + + test('should call handleCancel when Cancel button is clicked', async () => { + expect(onCancel).not.toHaveBeenCalled(); + wrapper.find('button[aria-label="Cancel"]').invoke('onClick')(); + expect(onCancel).toBeCalled(); + }); +}); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/ManualSubForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/ManualSubForm.jsx new file mode 100644 index 0000000000..29f37547af --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/ManualSubForm.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import FormField, { PasswordField } from '@components/FormField'; +import { FormGroup } from '@patternfly/react-core'; +import AnsibleSelect from '@components/AnsibleSelect'; +import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout'; +import { + UsernameFormField, + PasswordFormField, + SSHKeyUnlockField, + SSHKeyDataField, +} from './SharedFields'; + +const ManualSubForm = ({ i18n }) => { + const becomeMethodOptions = [ + { + value: '', + key: '', + label: i18n._(t`Choose a Privelege Escalation Method`), + isDisabled: true, + }, + ...[ + 'sudo', + 'su', + 'pbrun', + 'pfexec', + 'dzdo', + 'pmrun', + 'runas', + 'enable', + 'doas', + 'ksu', + 'machinectl', + 'sesu', + ].map(val => ({ value: val, key: val, label: val })), + ]; + + const becomeMethodFieldArr = useField('inputs.become_method'); + const becomeMethodField = becomeMethodFieldArr[0]; + const becomeMethodHelpers = becomeMethodFieldArr[2]; + + return ( + + + + + + + + + + { + becomeMethodHelpers.setValue(value); + }} + /> + + + + + ); +}; + +export default withI18n()(ManualSubForm); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx new file mode 100644 index 0000000000..630adfc1ef --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SharedFields.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import FormField, { PasswordField } from '@components/FormField'; +import { Title } from '@patternfly/react-core'; +import styled from 'styled-components'; + +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/CredentialSubForms/SourceControlSubForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SourceControlSubForm.jsx new file mode 100644 index 0000000000..5a46465e65 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/SourceControlSubForm.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { FormColumnLayout, FormFullWidthLayout } from '@components/FormLayout'; +import { + UsernameFormField, + PasswordFormField, + SSHKeyUnlockField, + SSHKeyDataField, +} from './SharedFields'; + +const SourceControlSubForm = () => ( + <> + + + + + + + + + +); + +export default withI18n()(SourceControlSubForm); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/index.js new file mode 100644 index 0000000000..04cad7240e --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/index.js @@ -0,0 +1,2 @@ +export { default as ManualSubForm } from './ManualSubForm'; +export { default as SourceControlSubForm } from './SourceControlSubForm'; diff --git a/awx/ui_next/src/screens/Credential/shared/data.credential.json b/awx/ui_next/src/screens/Credential/shared/data.credential.json new file mode 100644 index 0000000000..c57ee690e5 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/data.credential.json @@ -0,0 +1,84 @@ +{ + "id": 2, + "type": "credential", + "url": "/api/v2/credentials/2/", + "related": { + "named_url": "/api/v2/credentials/jojoijoij++Source Control+scm++/", + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "activity_stream": "/api/v2/credentials/2/activity_stream/", + "access_list": "/api/v2/credentials/2/access_list/", + "object_roles": "/api/v2/credentials/2/object_roles/", + "owner_users": "/api/v2/credentials/2/owner_users/", + "owner_teams": "/api/v2/credentials/2/owner_teams/", + "copy": "/api/v2/credentials/2/copy/", + "input_sources": "/api/v2/credentials/2/input_sources/", + "credential_type": "/api/v2/credential_types/2/", + "user": "/api/v2/users/1/" + }, + "summary_fields": { + "credential_type": { + "id": 2, + "name": "Source Control", + "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": 6 + }, + "use_role": { + "description": "Can use the credential in a job template", + "name": "Use", + "id": 7 + }, + "read_role": { + "description": "May view settings for the credential", + "name": "Read", + "id": 8 + } + }, + "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-02-12T19:59:11.508933Z", + "modified": "2020-02-12T19:59:11.508958Z", + "name": "jojoijoij", + "description": "", + "organization": null, + "credential_type": 2, + "inputs": { + "password": "$encrypted$", + "username": "uujoij", + "ssh_key_unlock": "$encrypted$" + }, + "kind": "scm", + "cloud": false, + "kubernetes": false +} diff --git a/awx/ui_next/src/screens/Credential/shared/data.orgCredential.json b/awx/ui_next/src/screens/Credential/shared/data.orgCredential.json new file mode 100644 index 0000000000..621c3a43ad --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/data.orgCredential.json @@ -0,0 +1,92 @@ +{ + "id": 3, + "type": "credential", + "url": "/api/v2/credentials/3/", + "related": { + "named_url": "/api/v2/credentials/oersdgfasf++Machine+ssh++org/", + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "organization": "/api/v2/organizations/1/", + "activity_stream": "/api/v2/credentials/3/activity_stream/", + "access_list": "/api/v2/credentials/3/access_list/", + "object_roles": "/api/v2/credentials/3/object_roles/", + "owner_users": "/api/v2/credentials/3/owner_users/", + "owner_teams": "/api/v2/credentials/3/owner_teams/", + "copy": "/api/v2/credentials/3/copy/", + "input_sources": "/api/v2/credentials/3/input_sources/", + "credential_type": "/api/v2/credential_types/1/" + }, + "summary_fields": { + "organization": { + "id": 1, + "name": "org", + "description": "" + }, + "credential_type": { + "id": 1, + "name": "Machine", + "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": 36 + }, + "use_role": { + "description": "Can use the credential in a job template", + "name": "Use", + "id": 37 + }, + "read_role": { + "description": "May view settings for the credential", + "name": "Read", + "id": 38 + } + }, + "user_capabilities": { + "edit": true, + "delete": true, + "copy": true, + "use": true + }, + "owners": [ + { + "id": 1, + "type": "user", + "name": "admin", + "description": " ", + "url": "/api/v2/users/1/" + }, + { + "id": 1, + "type": "organization", + "name": "org", + "description": "", + "url": "/api/v2/organizations/1/" + } + ] + }, + "created": "2020-02-18T15:35:04.563928Z", + "modified": "2020-02-18T15:35:04.563957Z", + "name": "oersdgfasf", + "description": "", + "organization": 1, + "credential_type": 1, + "inputs": {}, + "kind": "ssh", + "cloud": false, + "kubernetes": false +}