From 6f9d4d89cde79e47e9716b27b3bb515041417598 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 9 Feb 2022 15:59:50 -0500 Subject: [PATCH] Adds credential password step to ad hoc commands wizard (#11598) --- .../components/AdHocCommands/AdHocCommands.js | 12 +- .../AdHocCommands/AdHocCommands.test.js | 3 + .../AdHocCommands/AdHocCommandsWizard.js | 3 +- .../AdHocCommands/AdHocCommandsWizard.test.js | 152 +++++++++++++++++- .../AdHocCommands/AdHocCredentialStep.js | 2 +- .../AdHocCommands/AdHocPreviewStep.js | 3 +- .../useAdHocCredentialPasswordStep.js | 82 ++++++++++ .../AdHocCommands/useAdHocCredentialStep.js | 8 +- .../AdHocCommands/useAdHocLaunchSteps.js | 56 ++++++- 9 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 awx/ui/src/components/AdHocCommands/useAdHocCredentialPasswordStep.js diff --git a/awx/ui/src/components/AdHocCommands/AdHocCommands.js b/awx/ui/src/components/AdHocCommands/AdHocCommands.js index d37106285f..7ddee926bf 100644 --- a/awx/ui/src/components/AdHocCommands/AdHocCommands.js +++ b/awx/ui/src/components/AdHocCommands/AdHocCommands.js @@ -79,11 +79,19 @@ function AdHocCommands({ ); const handleSubmit = async (values) => { - const { credential, execution_environment, ...remainingValues } = values; - const newCredential = credential[0].id; + const { + credentials, + credential_passwords: { become_password, ssh_password, ssh_key_unlock }, + execution_environment, + ...remainingValues + } = values; + const newCredential = credentials[0].id; const manipulatedValues = { credential: newCredential, + become_password, + ssh_password, + ssh_key_unlock, execution_environment: execution_environment[0]?.id, ...remainingValues, }; diff --git a/awx/ui/src/components/AdHocCommands/AdHocCommands.test.js b/awx/ui/src/components/AdHocCommands/AdHocCommands.test.js index 2dd22369a5..25eb74ffbc 100644 --- a/awx/ui/src/components/AdHocCommands/AdHocCommands.test.js +++ b/awx/ui/src/components/AdHocCommands/AdHocCommands.test.js @@ -234,12 +234,15 @@ describe('', () => { module_args: 'foo', diff_mode: false, credential: 4, + become_password: undefined, job_type: 'run', become_enabled: '', extra_vars: '---', forks: 0, limit: 'Inventory 1 Org 0, Inventory 2 Org 0', module_name: 'command', + ssh_key_unlock: undefined, + ssh_password: undefined, verbosity: 1, execution_environment: 2, }); diff --git a/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.js b/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.js index a7647c368d..c86890502c 100644 --- a/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.js +++ b/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.js @@ -61,7 +61,7 @@ const FormikApp = withFormik({ const adHocItemStrings = adHocItems.map((item) => item.name).join(', '); return { limit: adHocItemStrings || 'all', - credential: [], + credentials: [], module_args: '', verbosity: verbosityOptions[0].value, forks: 0, @@ -70,6 +70,7 @@ const FormikApp = withFormik({ module_name: '', extra_vars: '---', job_type: 'run', + credential_passwords: {}, execution_environment: '', }; }, diff --git a/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js b/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js index 7d19f3cadc..b9bb928a73 100644 --- a/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js +++ b/awx/ui/src/components/AdHocCommands/AdHocCommandsWizard.test.js @@ -184,7 +184,157 @@ describe('', () => { expect(onLaunch).toHaveBeenCalledWith({ become_enabled: '', - credential: [{ id: 1, name: 'Cred 1', url: '' }], + credentials: [{ id: 1, name: 'Cred 1', url: '' }], + credential_passwords: {}, + diff_mode: false, + execution_environment: [{ id: 1, name: 'EE 1', url: '' }], + extra_vars: '---', + forks: 0, + job_type: 'run', + limit: 'Inventory 1, Inventory 2, inventory 3', + module_args: 'foo', + module_name: 'command', + verbosity: 1, + }); + }); + + test('should render credential passwords step', async () => { + ExecutionEnvironmentsAPI.read.mockResolvedValue({ + data: { + results: [ + { id: 1, name: 'EE 1', url: '' }, + { id: 2, name: 'EE 2', url: '' }, + ], + count: 2, + }, + }); + ExecutionEnvironmentsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} } }, + }); + CredentialsAPI.read.mockResolvedValue({ + data: { + results: [ + { + id: 1, + name: 'Cred 1', + url: '', + inputs: { password: 'ASK' }, + }, + { id: 2, name: 'Cred2', url: '' }, + ], + count: 2, + }, + }); + CredentialsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {} } }, + }); + await waitForElement(wrapper, 'WizardNavItem', (el) => el.length > 0); + + await act(async () => { + wrapper.find('AnsibleSelect[name="module_name"]').prop('onChange')( + {}, + 'command' + ); + wrapper.find('input#module_args').simulate('change', { + target: { value: 'foo', name: 'module_args' }, + }); + wrapper.find('AnsibleSelect[name="verbosity"]').prop('onChange')({}, 1); + }); + wrapper.update(); + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( + false + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + + // step 2 + + await waitForElement(wrapper, 'OptionsList', (el) => el.length > 0); + expect(wrapper.find('CheckboxListItem').length).toBe(2); + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( + false + ); + + await act(async () => { + wrapper.find('td#check-action-item-1').find('input').simulate('click'); + }); + + wrapper.update(); + + expect( + wrapper.find('CheckboxListItem[label="EE 1"]').prop('isSelected') + ).toBe(true); + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( + false + ); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + // step 3 + + await waitForElement(wrapper, 'OptionsList', (el) => el.length > 0); + expect(wrapper.find('CheckboxListItem').length).toBe(2); + + await act(async () => { + wrapper.find('td#check-action-item-1').find('input').simulate('click'); + }); + + wrapper.update(); + + expect( + wrapper.find('CheckboxListItem[label="Cred 1"]').prop('isSelected') + ).toBe(true); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // step 4 + + expect(wrapper.find('PasswordInput')).toHaveLength(1); + await act(async () => + wrapper + .find('TextInputBase[name="credential_passwords.ssh_password"]') + .prop('onChange')('', { + target: { + value: 'password', + name: 'credential_passwords.ssh_password', + }, + }) + ); + wrapper.update(); + expect( + wrapper + .find('TextInput[name="credential_passwords.ssh_password"]') + .prop('value') + ).toBe('password'); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + + // step 5 + + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe( + false + ); + + expect(onLaunch).toHaveBeenCalledWith({ + become_enabled: '', + credentials: [ + { id: 1, name: 'Cred 1', url: '', inputs: { password: 'ASK' } }, + ], + credential_passwords: { ssh_password: 'password' }, diff_mode: false, execution_environment: [{ id: 1, name: 'EE 1', url: '' }], extra_vars: '---', diff --git a/awx/ui/src/components/AdHocCommands/AdHocCredentialStep.js b/awx/ui/src/components/AdHocCommands/AdHocCredentialStep.js index 1042f082c8..8001315cbf 100644 --- a/awx/ui/src/components/AdHocCommands/AdHocCredentialStep.js +++ b/awx/ui/src/components/AdHocCommands/AdHocCredentialStep.js @@ -76,7 +76,7 @@ function AdHocCredentialStep({ credentialTypeId }) { }, [fetchCredentials]); const [field, meta, helpers] = useField({ - name: 'credential', + name: 'credentials', validate: required(null), }); diff --git a/awx/ui/src/components/AdHocCommands/AdHocPreviewStep.js b/awx/ui/src/components/AdHocCommands/AdHocPreviewStep.js index b61c0261bc..595ab7676f 100644 --- a/awx/ui/src/components/AdHocCommands/AdHocPreviewStep.js +++ b/awx/ui/src/components/AdHocCommands/AdHocPreviewStep.js @@ -43,7 +43,8 @@ function AdHocPreviewStep({ hasErrors, values }) { ([key, value]) => key !== 'extra_vars' && key !== 'execution_environment' && - key !== 'credential' && ( + key !== 'credentials' && + !key.startsWith('credential_passwords') && ( ) )} diff --git a/awx/ui/src/components/AdHocCommands/useAdHocCredentialPasswordStep.js b/awx/ui/src/components/AdHocCommands/useAdHocCredentialPasswordStep.js new file mode 100644 index 0000000000..3f63a3db71 --- /dev/null +++ b/awx/ui/src/components/AdHocCommands/useAdHocCredentialPasswordStep.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { useFormikContext } from 'formik'; +import { t } from '@lingui/macro'; +import StepName from '../LaunchPrompt/steps/StepName'; +import CredentialPasswordsStep from '../LaunchPrompt/steps/CredentialPasswordsStep'; + +const STEP_ID = 'credentialPasswords'; + +const isValueMissing = (val) => !val || val === ''; + +export default function useCredentialPasswordsStep(showStep, visitedSteps) { + const { values, setFieldError } = useFormikContext(); + const hasError = + showStep && + Object.keys(visitedSteps).includes(STEP_ID) && + checkForError(values); + return { + step: showStep + ? { + id: STEP_ID, + name: ( + + {t`Credential passwords`} + + ), + component: , + enableNext: true, + } + : null, + isReady: true, + contentError: null, + hasError, + setTouched: (setFieldTouched) => { + Object.keys(values.credential_passwords).forEach((credentialValueKey) => + setFieldTouched( + `credential_passwords['${credentialValueKey}']`, + true, + false + ) + ); + }, + validate: () => { + const setPasswordFieldError = (fieldName) => { + setFieldError(fieldName, t`This field may not be blank`); + }; + + Object.entries(values.credentials[0].inputs).forEach(([key, value]) => { + if ( + value === 'ASK' && + isValueMissing( + key === 'password' + ? values.credential_passwords.ssh_password + : values.credential_passwords[key] + ) + ) { + setPasswordFieldError( + key === 'password' + ? `credential_passwords.ssh_password` + : `credential_passwords.${key}` + ); + } + }); + }, + }; +} + +function checkForError(values) { + let hasError = false; + Object.entries(values.credentials[0]?.inputs).forEach(([key, value]) => { + if ( + value === 'ASK' && + isValueMissing( + key === 'password' + ? values.credential_passwords.ssh_password + : values.credential_passwords[key] + ) + ) { + hasError = true; + } + }); + return hasError; +} diff --git a/awx/ui/src/components/AdHocCommands/useAdHocCredentialStep.js b/awx/ui/src/components/AdHocCommands/useAdHocCredentialStep.js index d4da6b1aa7..1b98ff94ed 100644 --- a/awx/ui/src/components/AdHocCommands/useAdHocCredentialStep.js +++ b/awx/ui/src/components/AdHocCommands/useAdHocCredentialStep.js @@ -4,14 +4,14 @@ import { t } from '@lingui/macro'; import StepName from '../LaunchPrompt/steps/StepName'; import AdHocCredentialStep from './AdHocCredentialStep'; -const STEP_ID = 'credential'; +const STEP_ID = 'credentials'; export default function useAdHocExecutionEnvironmentStep( visited, credentialTypeId ) { - const [field, meta, helpers] = useField('credential'); + const [field, meta, helpers] = useField('credentials'); const hasError = - Object.keys(visited).includes('credential') && + Object.keys(visited).includes('credentials') && !field.value.length && meta.touched; @@ -35,7 +35,7 @@ export default function useAdHocExecutionEnvironmentStep( } }, setTouched: (setFieldTouched) => { - setFieldTouched('credential', true, false); + setFieldTouched('credentials', true, false); }, }; } diff --git a/awx/ui/src/components/AdHocCommands/useAdHocLaunchSteps.js b/awx/ui/src/components/AdHocCommands/useAdHocLaunchSteps.js index 6fa2ad3404..caf7a91462 100644 --- a/awx/ui/src/components/AdHocCommands/useAdHocLaunchSteps.js +++ b/awx/ui/src/components/AdHocCommands/useAdHocLaunchSteps.js @@ -1,22 +1,73 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useFormikContext } from 'formik'; +import useCredentialPasswordsStep from './useAdHocCredentialPasswordStep'; import useAdHocDetailsStep from './useAdHocDetailsStep'; import useAdHocExecutionEnvironmentStep from './useAdHocExecutionEnvironmentStep'; import useAdHocCredentialStep from './useAdHocCredentialStep'; import useAdHocPreviewStep from './useAdHocPreviewStep'; +function showCredentialPasswordsStep(credential) { + if (!credential?.inputs) { + return false; + } + const { inputs } = credential; + if ( + inputs?.password === 'ASK' || + inputs?.become_password === 'ASK' || + inputs?.ssh_key_unlock === 'ASK' + ) { + return true; + } + + return false; +} + export default function useAdHocLaunchSteps( moduleOptions, verbosityOptions, organizationId, credentialTypeId ) { + const { values, resetForm, touched } = useFormikContext(); + const [visited, setVisited] = useState({}); const steps = [ useAdHocDetailsStep(visited, moduleOptions, verbosityOptions), useAdHocExecutionEnvironmentStep(organizationId), useAdHocCredentialStep(visited, credentialTypeId), + useCredentialPasswordsStep( + showCredentialPasswordsStep(values.credentials[0]), + visited + ), ]; + useEffect(() => { + const newFormValues = { ...values }; + + if (!values.credentials[0]?.inputs) { + return; + } + if ( + (values.credentials[0].inputs?.password || + values.credentials[0].inputs?.become_password || + values.credentials[0].inputs?.ssh_key_unlock) === 'ASK' + ) + newFormValues.credential_passwords = {}; + Object.keys(values.credentials[0].inputs).forEach((inputKey) => { + if (inputKey === 'become_password' || inputKey === 'ssh_key_unlock') { + newFormValues.credential_passwords[inputKey] = ''; + } + if (inputKey === 'password') { + newFormValues.credential_passwords.ssh_password = ''; + } + }); + resetForm({ + values: newFormValues, + touched, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [values.credentials.length]); + const hasErrors = steps.some((step) => step.hasError); steps.push(useAdHocPreviewStep(hasErrors)); @@ -35,7 +86,8 @@ export default function useAdHocLaunchSteps( setVisited({ details: true, executionEnvironment: true, - credential: true, + credentials: true, + credentialPasswords: true, preview: true, }); steps.forEach((s) => s.setTouched(setFieldTouched));