From 448e49ae43177b7b28b3cf7c96fe5922d8084682 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 6 Jan 2021 13:17:49 -0500 Subject: [PATCH 1/5] Add support for password prompting on job launch --- .../components/FormField/PasswordField.jsx | 2 +- .../components/FormField/PasswordInput.jsx | 13 +- .../components/LaunchButton/LaunchButton.jsx | 2 + .../components/LaunchPrompt/LaunchPrompt.jsx | 25 +- .../LaunchPrompt/LaunchPrompt.test.jsx | 29 +- .../steps/CredentialPasswordsStep.jsx | 129 ++++ .../steps/CredentialPasswordsStep.test.jsx | 603 ++++++++++++++++++ .../LaunchPrompt/steps/InventoryStep.jsx | 77 ++- .../steps/useCredentialPasswordsStep.jsx | 342 ++++++++++ .../LaunchPrompt/steps/useCredentialsStep.jsx | 10 +- .../LaunchPrompt/steps/useInventoryStep.jsx | 21 +- .../steps/useOtherPromptsStep.jsx | 23 +- .../LaunchPrompt/steps/usePreviewStep.jsx | 2 +- .../LaunchPrompt/steps/useSurveyStep.jsx | 159 ++--- .../components/LaunchPrompt/useLaunchSteps.js | 76 ++- .../Modals/NodeModals/NodeModal.jsx | 18 +- .../NodeTypeStep/useNodeTypeStep.jsx | 9 +- .../Modals/NodeModals/useRunTypeStep.jsx | 9 +- .../Modals/NodeModals/useWorkflowNodeSteps.js | 18 +- .../src/util/prompt/getCredentialPasswords.js | 29 + .../prompt/getCredentialPasswords.test.js | 66 ++ 21 files changed, 1481 insertions(+), 181 deletions(-) create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx create mode 100644 awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx create mode 100644 awx/ui_next/src/util/prompt/getCredentialPasswords.js create mode 100644 awx/ui_next/src/util/prompt/getCredentialPasswords.test.js diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index 44ebe5f889..fcba330c8c 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -8,7 +8,7 @@ import PasswordInput from './PasswordInput'; function PasswordField(props) { const { id, name, label, validate, isRequired, helperText } = props; const [, meta] = useField({ name, validate }); - const isValid = !(meta.touched && meta.error); + const isValid = !meta.touched || (meta.value && meta.value !== ''); return ( {}, isRequired: false, isDisabled: false, diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index d38cc7f2c2..06c1fce0b6 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -25,6 +25,8 @@ function canLaunchWithoutPrompt(launchData) { !launchData.ask_limit_on_launch && !launchData.ask_scm_branch_on_launch && !launchData.survey_enabled && + (!launchData.passwords_needed_to_start || + launchData.passwords_needed_to_start.length === 0) && (!launchData.variables_needed_to_start || launchData.variables_needed_to_start.length === 0) ); diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 00074a3e87..89b348d62b 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -7,6 +7,7 @@ import ContentError from '../ContentError'; import ContentLoading from '../ContentLoading'; import { useDismissableError } from '../../util/useRequest'; import mergeExtraVars from '../../util/prompt/mergeExtraVars'; +import getCredentialPasswords from '../../util/prompt/getCredentialPasswords'; import getSurveyValues from '../../util/prompt/getSurveyValues'; import useLaunchSteps from './useLaunchSteps'; import AlertModal from '../AlertModal'; @@ -19,17 +20,18 @@ function PromptModalForm({ resource, surveyConfig, }) { - const { values, setTouched, validateForm } = useFormikContext(); + const { setFieldTouched, values } = useFormikContext(); const { steps, isReady, + validateStep, visitStep, visitAllSteps, contentError, } = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); - const handleSave = () => { + const handleSubmit = () => { const postValues = {}; const setValue = (key, value) => { if (typeof value !== 'undefined' && value !== null) { @@ -37,6 +39,8 @@ function PromptModalForm({ } }; const surveyValues = getSurveyValues(values); + const credentialPasswords = getCredentialPasswords(values); + setValue('credential_passwords', credentialPasswords); setValue('inventory_id', values.inventory?.id); setValue( 'credentials', @@ -75,22 +79,25 @@ function PromptModalForm({ { + validateStep(nextStep.id); + }} onNext={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { - visitAllSteps(setTouched); + visitAllSteps(setFieldTouched); } else { - visitStep(prevStep.prevId); + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} onGoToStep={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { - visitAllSteps(setTouched); + visitAllSteps(setFieldTouched); } else { - visitStep(prevStep.prevId); + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} title={i18n._(t`Prompts`)} steps={ diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx index c320aac30b..08b777d12d 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.test.jsx @@ -82,8 +82,26 @@ describe('LaunchPrompt', () => { ask_credential_on_launch: true, ask_scm_branch_on_launch: true, survey_enabled: true, + passwords_needed_to_start: ['ssh_password'], + defaults: { + credentials: [ + { + id: 1, + passwords_needed: ['ssh_password'], + }, + ], + }, + }} + resource={{ + ...resource, + summary_fields: { + credentials: [ + { + id: 1, + }, + ], + }, }} - resource={resource} onLaunch={noop} onCancel={noop} surveyConfig={{ @@ -110,12 +128,13 @@ describe('LaunchPrompt', () => { const wizard = await waitForElement(wrapper, 'Wizard'); const steps = wizard.prop('steps'); - expect(steps).toHaveLength(5); + expect(steps).toHaveLength(6); expect(steps[0].name.props.children).toEqual('Inventory'); expect(steps[1].name.props.children).toEqual('Credentials'); - expect(steps[2].name.props.children).toEqual('Other prompts'); - expect(steps[3].name.props.children).toEqual('Survey'); - expect(steps[4].name.props.children).toEqual('Preview'); + expect(steps[2].name.props.children).toEqual('Credential passwords'); + expect(steps[3].name.props.children).toEqual('Other prompts'); + expect(steps[4].name.props.children).toEqual('Survey'); + expect(steps[5].name.props.children).toEqual('Preview'); }); test('should add inventory step', async () => { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx new file mode 100644 index 0000000000..ac17ce1071 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.jsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Form } from '@patternfly/react-core'; +import { useFormikContext } from 'formik'; +import { PasswordField } from '../../FormField'; + +function CredentialPasswordsStep({ launchConfig, i18n }) { + const { + values: { credentials }, + } = useFormikContext(); + + const vaultsThatPrompt = []; + let showcredentialPasswordSsh = false; + let showcredentialPasswordPrivilegeEscalation = false; + let showcredentialPasswordPrivateKeyPassphrase = false; + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if (password === 'ssh_password') { + showcredentialPasswordSsh = true; + } else if (password === 'become_password') { + showcredentialPasswordPrivilegeEscalation = true; + } else if (password === 'ssh_key_unlock') { + showcredentialPasswordPrivateKeyPassphrase = true; + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + vaultsThatPrompt.push(vaultId); + } + }); + } else if (credentials) { + credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if ( + launchConfigCredential.passwords_needed.includes('ssh_password') + ) { + showcredentialPasswordSsh = true; + } + if ( + launchConfigCredential.passwords_needed.includes('become_password') + ) { + showcredentialPasswordPrivilegeEscalation = true; + } + if ( + launchConfigCredential.passwords_needed.includes('ssh_key_unlock') + ) { + showcredentialPasswordPrivateKeyPassphrase = true; + } + + const vaultPasswordIds = launchConfigCredential.passwords_needed + .filter(passwordNeeded => + passwordNeeded.startsWith('vault_password') + ) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || ''); + + vaultsThatPrompt.push(...vaultPasswordIds); + } + } else { + if (credential?.inputs?.password === 'ASK') { + showcredentialPasswordSsh = true; + } + + if (credential?.inputs?.become_password === 'ASK') { + showcredentialPasswordPrivilegeEscalation = true; + } + + if (credential?.inputs?.ssh_key_unlock === 'ASK') { + showcredentialPasswordPrivateKeyPassphrase = true; + } + + if (credential?.inputs?.vault_password === 'ASK') { + vaultsThatPrompt.push(credential.inputs.vault_id); + } + } + }); + } + + return ( +
+ {showcredentialPasswordSsh && ( + + )} + {showcredentialPasswordPrivateKeyPassphrase && ( + + )} + {showcredentialPasswordPrivilegeEscalation && ( + + )} + {vaultsThatPrompt.map(credId => ( + + ))} + + ); +} + +export default withI18n()(CredentialPasswordsStep); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx new file mode 100644 index 0000000000..a9c63cce63 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/CredentialPasswordsStep.test.jsx @@ -0,0 +1,603 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { Formik } from 'formik'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import CredentialPasswordsStep from './CredentialPasswordsStep'; + +describe('CredentialPasswordsStep', () => { + describe('JT default credentials (no credential replacement) and creds are promptable', () => { + test('should render ssh password field when JT has default machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render become password field when JT has default machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render private key passphrase field when JT has default machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render vault password field when JT has default vault cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-1') + ).toHaveLength(1); + }); + test('should render all password field when JT has default vault cred and machine cred', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-1') + ).toHaveLength(1); + }); + }); + describe('Credentials have been replaced and creds are promptable', () => { + test('should render ssh password field when replacement machine cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render become password field when replacement machine cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render private key passphrase field when replacement machine cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render vault password field when replacement vault cred prompts for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + test('should render all password fields when replacement vault and machine creds prompt for it', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + }); + describe('Credentials have been replaced and creds are not promptable', () => { + test('should render ssh password field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render become password field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render private key passphrase field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(0); + }); + test('should render vault password field when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(0); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + test('should render all password fields when required', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + expect(wrapper.find('PasswordField#launch-ssh-password')).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-private-key-passphrase') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-privilege-escalation-password') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField[id^="launch-vault-password-"]') + ).toHaveLength(1); + expect( + wrapper.find('PasswordField#launch-vault-password-foobar') + ).toHaveLength(1); + }); + }); +}); diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx index 696809f547..06760f6809 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/InventoryStep.jsx @@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; +import { Alert } from '@patternfly/react-core'; import { InventoriesAPI } from '../../../api'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import useRequest from '../../../util/useRequest'; @@ -17,9 +18,10 @@ const QS_CONFIG = getQSConfig('inventory', { }); function InventoryStep({ i18n }) { - const [field, , helpers] = useField({ + const [field, meta, helpers] = useField({ name: 'inventory', }); + const history = useHistory(); const { @@ -65,40 +67,45 @@ function InventoryStep({ i18n }) { } return ( - field.onChange(null)} - /> + <> + field.onChange(null)} + /> + {meta.touched && meta.error && ( + + )} + ); } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx new file mode 100644 index 0000000000..10b2193722 --- /dev/null +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialPasswordsStep.jsx @@ -0,0 +1,342 @@ +import React from 'react'; +import { t } from '@lingui/macro'; +import { useFormikContext } from 'formik'; +import CredentialPasswordsStep from './CredentialPasswordsStep'; +import StepName from './StepName'; + +const STEP_ID = 'credentialPasswords'; + +const isValueMissing = val => { + return !val || val === ''; +}; + +export default function useCredentialPasswordsStep( + launchConfig, + i18n, + showStep, + visitedSteps +) { + const { values, setFieldError } = useFormikContext(); + const hasError = + Object.keys(visitedSteps).includes(STEP_ID) && + checkForError(launchConfig, values); + + return { + step: showStep + ? { + id: STEP_ID, + name: ( + + {i18n._(t`Credential passwords`)} + + ), + component: ( + + ), + enableNext: true, + } + : null, + initialValues: getInitialValues(launchConfig, values.credentials), + isReady: true, + contentError: null, + hasError, + setTouched: setFieldTouched => { + Object.keys(values) + .filter(valueKey => valueKey.startsWith('credentialPassword')) + .forEach(credentialValueKey => + setFieldTouched(credentialValueKey, true, false) + ); + }, + validate: () => { + const setPasswordFieldError = fieldName => { + setFieldError(fieldName, i18n._(t`This field may not be blank`)); + }; + + const { + credentialPasswordSsh, + credentialPasswordPrivilegeEscalation, + credentialPasswordPrivateKeyPassphrase, + } = values; + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if ( + password === 'ssh_password' && + isValueMissing(credentialPasswordSsh) + ) { + setPasswordFieldError('credentialPasswordSsh'); + } else if ( + password === 'become_password' && + isValueMissing(credentialPasswordPrivilegeEscalation) + ) { + setPasswordFieldError('credentialPasswordPrivilegeEscalation'); + } else if ( + password === 'ssh_key_unlock' && + isValueMissing(credentialPasswordPrivateKeyPassphrase) + ) { + setPasswordFieldError('credentialPasswordPrivateKeyPassphrase'); + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + if (isValueMissing(values[`credentialPasswordVault_${vaultId}`])) { + setPasswordFieldError(`credentialPasswordVault_${vaultId}`); + } + } + }); + } else if (values.credentials) { + values.credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if ( + launchConfigCredential.passwords_needed.includes( + 'ssh_password' + ) && + isValueMissing(credentialPasswordSsh) + ) { + setPasswordFieldError('credentialPasswordSsh'); + } + if ( + launchConfigCredential.passwords_needed.includes( + 'become_password' + ) && + isValueMissing(credentialPasswordPrivilegeEscalation) + ) { + setPasswordFieldError('credentialPasswordPrivilegeEscalation'); + } + if ( + launchConfigCredential.passwords_needed.includes( + 'ssh_key_unlock' + ) && + isValueMissing(credentialPasswordPrivateKeyPassphrase) + ) { + setPasswordFieldError('credentialPasswordPrivateKeyPassphrase'); + } + + launchConfigCredential.passwords_needed + .filter(passwordNeeded => + passwordNeeded.startsWith('vault_password') + ) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '') + .forEach(vaultId => { + if ( + isValueMissing(values[`credentialPasswordVault_${vaultId}`]) + ) { + setPasswordFieldError(`credentialPasswordVault_${vaultId}`); + } + }); + } + } else { + if ( + credential?.inputs?.password === 'ASK' && + isValueMissing(credentialPasswordSsh) + ) { + setPasswordFieldError('credentialPasswordSsh'); + } + + if ( + credential?.inputs?.become_password === 'ASK' && + isValueMissing(credentialPasswordPrivilegeEscalation) + ) { + setPasswordFieldError('credentialPasswordPrivilegeEscalation'); + } + + if ( + credential?.inputs?.ssh_key_unlock === 'ASK' && + isValueMissing(credentialPasswordPrivateKeyPassphrase) + ) { + setPasswordFieldError('credentialPasswordPrivateKeyPassphrase'); + } + + if ( + credential?.inputs?.vault_password === 'ASK' && + isValueMissing( + values[`credentialPasswordVault_${credential.inputs.vault_id}`] + ) + ) { + setPasswordFieldError( + `credentialPasswordVault_${credential.inputs.vault_id}` + ); + } + } + }); + } + }, + }; +} + +function getInitialValues(launchConfig, selectedCredentials = []) { + const initialValues = {}; + + if (!launchConfig) { + return initialValues; + } + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if (password === 'ssh_password') { + initialValues.credentialPasswordSsh = ''; + } else if (password === 'become_password') { + initialValues.credentialPasswordPrivilegeEscalation = ''; + } else if (password === 'ssh_key_unlock') { + initialValues.credentialPasswordPrivateKeyPassphrase = ''; + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + initialValues[`credentialPasswordVault_${vaultId}`] = ''; + } + }); + return initialValues; + } + + selectedCredentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if (launchConfigCredential.passwords_needed.includes('ssh_password')) { + initialValues.credentialPasswordSsh = ''; + } + if ( + launchConfigCredential.passwords_needed.includes('become_password') + ) { + initialValues.credentialPasswordPrivilegeEscalation = ''; + } + if ( + launchConfigCredential.passwords_needed.includes('ssh_key_unlock') + ) { + initialValues.credentialPasswordPrivateKeyPassphrase = ''; + } + + const vaultPasswordIds = launchConfigCredential.passwords_needed + .filter(passwordNeeded => passwordNeeded.startsWith('vault_password')) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || ''); + + vaultPasswordIds.forEach(vaultPasswordId => { + initialValues[`credentialPasswordVault_${vaultPasswordId}`] = ''; + }); + } + } else { + if (credential?.inputs?.password === 'ASK') { + initialValues.credentialPasswordSsh = ''; + } + + if (credential?.inputs?.become_password === 'ASK') { + initialValues.credentialPasswordPrivilegeEscalation = ''; + } + + if (credential?.inputs?.ssh_key_unlock === 'ASK') { + initialValues.credentialPasswordPrivateKeyPassphrase = ''; + } + + if (credential?.inputs?.vault_password === 'ASK') { + initialValues[`credentialPasswordVault_${credential.inputs.vault_id}`] = + ''; + } + } + }); + + return initialValues; +} + +function checkForError(launchConfig, values) { + const { + credentialPasswordSsh, + credentialPasswordPrivilegeEscalation, + credentialPasswordPrivateKeyPassphrase, + } = values; + + let hasError = false; + + if ( + !launchConfig.ask_credential_on_launch && + launchConfig.passwords_needed_to_start + ) { + launchConfig.passwords_needed_to_start.forEach(password => { + if ( + (password === 'ssh_password' && + isValueMissing(credentialPasswordSsh)) || + (password === 'become_password' && + isValueMissing(credentialPasswordPrivilegeEscalation)) || + (password === 'ssh_key_unlock' && + isValueMissing(credentialPasswordPrivateKeyPassphrase)) + ) { + hasError = true; + } else if (password.startsWith('vault_password')) { + const vaultId = password.split(/\.(.+)/)[1] || ''; + if (isValueMissing(values[`credentialPasswordVault_${vaultId}`])) { + hasError = true; + } + } + }); + } else if (values.credentials) { + values.credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + if ( + (launchConfigCredential.passwords_needed.includes('ssh_password') && + isValueMissing(credentialPasswordSsh)) || + (launchConfigCredential.passwords_needed.includes( + 'become_password' + ) && + isValueMissing(credentialPasswordPrivilegeEscalation)) || + (launchConfigCredential.passwords_needed.includes( + 'ssh_key_unlock' + ) && + isValueMissing(credentialPasswordPrivateKeyPassphrase)) + ) { + hasError = true; + } + + launchConfigCredential.passwords_needed + .filter(passwordNeeded => + passwordNeeded.startsWith('vault_password') + ) + .map(vaultPassword => vaultPassword.split(/\.(.+)/)[1] || '') + .forEach(vaultId => { + if ( + isValueMissing(values[`credentialPasswordVault_${vaultId}`]) + ) { + hasError = true; + } + }); + } + } else { + if ( + (credential?.inputs?.password === 'ASK' && + isValueMissing(credentialPasswordSsh)) || + (credential?.inputs?.become_password === 'ASK' && + isValueMissing(credentialPasswordPrivilegeEscalation)) || + (credential?.inputs?.ssh_key_unlock === 'ASK' && + isValueMissing(credentialPasswordPrivateKeyPassphrase)) + ) { + hasError = true; + } + + if ( + credential?.inputs?.vault_password === 'ASK' && + isValueMissing( + values[`credentialPasswordVault_${credential.inputs.vault_id}`] + ) + ) { + hasError = true; + } + } + }); + } + + return hasError; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx index 8151332078..eb3eab3eb7 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useCredentialsStep.jsx @@ -9,15 +9,13 @@ export default function useCredentialsStep(launchConfig, resource, i18n) { return { step: getStep(launchConfig, i18n), initialValues: getInitialValues(launchConfig, resource), - validate: () => ({}), isReady: true, contentError: null, - formError: null, - setTouched: setFieldsTouched => { - setFieldsTouched({ - credentials: true, - }); + hasError: false, + setTouched: setFieldTouched => { + setFieldTouched('credentials', true, false); }, + validate: () => {}, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx index 0d00a3b747..d81ee22e70 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useInventoryStep.jsx @@ -12,20 +12,27 @@ export default function useInventoryStep( i18n, visitedSteps ) { - const [, meta] = useField('inventory'); + const [, meta, helpers] = useField('inventory'); const formError = - Object.keys(visitedSteps).includes(STEP_ID) && (!meta.value || meta.error); + !resource || resource?.type === 'workflow_job_template' + ? false + : Object.keys(visitedSteps).includes(STEP_ID) && + meta.touched && + !meta.value; return { step: getStep(launchConfig, i18n, formError), initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - formError: launchConfig.ask_inventory_on_launch && formError, - setTouched: setFieldsTouched => { - setFieldsTouched({ - inventory: true, - }); + hasError: launchConfig.ask_inventory_on_launch && formError, + setTouched: setFieldTouched => { + setFieldTouched('inventory', true, false); + }, + validate: () => { + if (meta.touched && !meta.value && resource.type === 'job_template') { + helpers.setError(i18n._(t`An inventory must be selected`)); + } }, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx index 1f63397c17..c38f23f665 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useOtherPromptsStep.jsx @@ -22,18 +22,19 @@ export default function useOtherPromptsStep(launchConfig, resource, i18n) { initialValues: getInitialValues(launchConfig, resource), isReady: true, contentError: null, - formError: null, - setTouched: setFieldsTouched => { - setFieldsTouched({ - job_type: true, - limit: true, - verbosity: true, - diff_mode: true, - job_tags: true, - skip_tags: true, - extra_vars: true, - }); + hasError: false, + setTouched: setFieldTouched => { + [ + 'job_type', + 'limit', + 'verbosity', + 'diff_mode', + 'job_tags', + 'skip_tags', + 'extra_vars', + ].forEach(field => setFieldTouched(field, true, false)); }, + validate: () => {}, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx index 77570fab0b..8a4cc73dde 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/usePreviewStep.jsx @@ -35,9 +35,9 @@ export default function usePreviewStep( } : null, initialValues: {}, - validate: () => ({}), isReady: true, error: null, setTouched: () => {}, + validate: () => {}, }; } diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx index 37e5454d13..6069878dd6 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/useSurveyStep.jsx @@ -13,89 +13,51 @@ export default function useSurveyStep( i18n, visitedSteps ) { - const { values } = useFormikContext(); - const errors = {}; - const validate = () => { - if (!launchConfig.survey_enabled || !surveyConfig?.spec) { - return {}; - } - surveyConfig.spec.forEach(question => { - const errMessage = validateField( - question, - values[`survey_${question.variable}`], - i18n - ); - if (errMessage) { - errors[`survey_${question.variable}`] = errMessage; - } - }); - return errors; - }; - const formError = Object.keys(validate()).length > 0; + const { setFieldError, values } = useFormikContext(); + const hasError = + Object.keys(visitedSteps).includes(STEP_ID) && + checkForError(launchConfig, surveyConfig, values); + return { - step: getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps), + step: launchConfig.survey_enabled + ? { + id: STEP_ID, + name: ( + + {i18n._(t`Survey`)} + + ), + component: , + enableNext: true, + } + : null, initialValues: getInitialValues(launchConfig, surveyConfig, resource), - validate, surveyConfig, isReady: true, contentError: null, - formError, - setTouched: setFieldsTouched => { + hasError, + setTouched: setFieldTouched => { if (!surveyConfig?.spec) { return; } - const fields = {}; surveyConfig.spec.forEach(question => { - fields[`survey_${question.variable}`] = true; + setFieldTouched(`survey_${question.variable}`, true, false); }); - setFieldsTouched(fields); }, - }; -} - -function validateField(question, value, i18n) { - const isTextField = ['text', 'textarea'].includes(question.type); - const isNumeric = ['integer', 'float'].includes(question.type); - if (isTextField && (value || value === 0)) { - if (question.min && value.length < question.min) { - return i18n._(t`This field must be at least ${question.min} characters`); - } - if (question.max && value.length > question.max) { - return i18n._(t`This field must not exceed ${question.max} characters`); - } - } - if (isNumeric && (value || value === 0)) { - if (value < question.min || value > question.max) { - return i18n._( - t`This field must be a number and have a value between ${question.min} and ${question.max}` - ); - } - } - if (question.required && !value && value !== 0) { - return i18n._(t`This field must not be blank`); - } - return null; -} -function getStep(launchConfig, surveyConfig, validate, i18n, visitedSteps) { - if (!launchConfig.survey_enabled) { - return null; - } - - return { - id: STEP_ID, - name: ( - - {i18n._(t`Survey`)} - - ), - component: , - enableNext: true, + validate: () => { + if (launchConfig.survey_enabled && surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + const errMessage = validateSurveyField( + question, + values[`survey_${question.variable}`], + i18n + ); + if (errMessage) { + setFieldError(`survey_${question.variable}`, errMessage); + } + }); + } + }, }; } @@ -133,3 +95,56 @@ function getInitialValues(launchConfig, surveyConfig, resource) { return values; } + +function validateSurveyField(question, value, i18n) { + const isTextField = ['text', 'textarea'].includes(question.type); + const isNumeric = ['integer', 'float'].includes(question.type); + if (isTextField && (value || value === 0)) { + if (question.min && value.length < question.min) { + return i18n._(t`This field must be at least ${question.min} characters`); + } + if (question.max && value.length > question.max) { + return i18n._(t`This field must not exceed ${question.max} characters`); + } + } + if (isNumeric && (value || value === 0)) { + if (value < question.min || value > question.max) { + return i18n._( + t`This field must be a number and have a value between ${question.min} and ${question.max}` + ); + } + } + if (question.required && !value && value !== 0) { + return i18n._(t`This field must not be blank`); + } + return null; +} + +function checkForError(launchConfig, surveyConfig, values) { + let hasError = false; + if (launchConfig.survey_enabled && surveyConfig.spec) { + surveyConfig.spec.forEach(question => { + const value = values[`survey_${question.variable}`]; + const isTextField = ['text', 'textarea'].includes(question.type); + const isNumeric = ['integer', 'float'].includes(question.type); + if (isTextField && (value || value === 0)) { + if ( + (question.min && value.length < question.min) || + (question.max && value.length > question.max) + ) { + hasError = true; + } + } + if (isNumeric && (value || value === 0)) { + if (value < question.min || value > question.max) { + hasError = true; + } + } + if (question.required && !value && value !== 0) { + hasError = true; + } + }); + } + + return hasError; +} diff --git a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js index 3d6958a0ae..616aeb0a26 100644 --- a/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js +++ b/awx/ui_next/src/components/LaunchPrompt/useLaunchSteps.js @@ -2,10 +2,43 @@ import { useState, useEffect } from 'react'; import { useFormikContext } from 'formik'; import useInventoryStep from './steps/useInventoryStep'; import useCredentialsStep from './steps/useCredentialsStep'; +import useCredentialPasswordsStep from './steps/useCredentialPasswordsStep'; import useOtherPromptsStep from './steps/useOtherPromptsStep'; import useSurveyStep from './steps/useSurveyStep'; import usePreviewStep from './steps/usePreviewStep'; +function showCredentialPasswordsStep(credentials = [], launchConfig) { + if ( + !launchConfig?.ask_credential_on_launch && + launchConfig?.passwords_needed_to_start + ) { + return launchConfig.passwords_needed_to_start.length > 0; + } + + let credentialPasswordStepRequired = false; + + credentials.forEach(credential => { + if (!credential.inputs) { + const launchConfigCredential = launchConfig.defaults.credentials.find( + defaultCred => defaultCred.id === credential.id + ); + + if (launchConfigCredential?.passwords_needed.length > 0) { + credentialPasswordStepRequired = true; + } + } else if ( + credential?.inputs?.password === 'ASK' || + credential?.inputs?.become_password === 'ASK' || + credential?.inputs?.ssh_key_unlock === 'ASK' || + credential?.inputs?.vault_password === 'ASK' + ) { + credentialPasswordStepRequired = true; + } + }); + + return credentialPasswordStepRequired; +} + export default function useLaunchSteps( launchConfig, surveyConfig, @@ -14,14 +47,21 @@ export default function useLaunchSteps( ) { const [visited, setVisited] = useState({}); const [isReady, setIsReady] = useState(false); + const { touched, values: formikValues } = useFormikContext(); const steps = [ useInventoryStep(launchConfig, resource, i18n, visited), useCredentialsStep(launchConfig, resource, i18n), + useCredentialPasswordsStep( + launchConfig, + i18n, + showCredentialPasswordsStep(formikValues.credentials, launchConfig), + visited + ), useOtherPromptsStep(launchConfig, resource, i18n), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), ]; const { resetForm } = useFormikContext(); - const hasErrors = steps.some(step => step.formError); + const hasErrors = steps.some(step => step.hasError); steps.push( usePreviewStep(launchConfig, i18n, resource, surveyConfig, hasErrors, true) @@ -38,16 +78,26 @@ export default function useLaunchSteps( ...cur.initialValues, }; }, {}); + + const newFormValues = { ...initialValues }; + + Object.keys(formikValues).forEach(formikValueKey => { + if ( + Object.prototype.hasOwnProperty.call(newFormValues, formikValueKey) + ) { + newFormValues[formikValueKey] = formikValues[formikValueKey]; + } + }); + resetForm({ - values: { - ...initialValues, - }, + values: newFormValues, + touched, }); setIsReady(true); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stepsAreReady]); + }, [formikValues.credentials, stepsAreReady]); const stepWithError = steps.find(s => s.contentError); const contentError = stepWithError ? stepWithError.contentError : null; @@ -55,20 +105,26 @@ export default function useLaunchSteps( return { steps: pfSteps, isReady, - visitStep: stepId => + validateStep: stepId => { + steps.find(s => s?.step?.id === stepId).validate(); + }, + visitStep: (prevStepId, setFieldTouched) => { setVisited({ ...visited, - [stepId]: true, - }), - visitAllSteps: setFieldsTouched => { + [prevStepId]: true, + }); + steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched); + }, + visitAllSteps: setFieldTouched => { setVisited({ inventory: true, credentials: true, + credentialPasswords: true, other: true, survey: true, preview: true, }); - steps.forEach(s => s.setTouched(setFieldsTouched)); + steps.forEach(s => s.setTouched(setFieldTouched)); }, contentError, }; diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx index 2b57effb35..a0aeb1b184 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeModal.jsx @@ -44,7 +44,7 @@ function NodeModalForm({ }) { const history = useHistory(); const dispatch = useContext(WorkflowDispatchContext); - const { values, setTouched, validateForm } = useFormikContext(); + const { values, setFieldTouched } = useFormikContext(); const [triggerNext, setTriggerNext] = useState(0); @@ -60,6 +60,7 @@ function NodeModalForm({ const { steps: promptSteps, + validateStep, visitStep, visitAllSteps, contentError, @@ -192,24 +193,27 @@ function NodeModalForm({ onSave={() => { handleSaveNode(); }} + onBack={async nextStep => { + validateStep(nextStep.id); + }} onGoToStep={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { - visitAllSteps(setTouched); + visitAllSteps(setFieldTouched); } else { - visitStep(prevStep.prevId); + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} steps={promptSteps} css="overflow: scroll" title={title} onNext={async (nextStep, prevStep) => { if (nextStep.id === 'preview') { - visitAllSteps(setTouched); + visitAllSteps(setFieldTouched); } else { - visitStep(prevStep.prevId); + visitStep(prevStep.prevId, setFieldTouched); + validateStep(nextStep.id); } - await validateForm(); }} /> ); diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx index 2b0dcd888d..787c30674d 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/NodeTypeStep/useNodeTypeStep.jsx @@ -17,12 +17,11 @@ export default function useNodeTypeStep(i18n) { initialValues: getInitialValues(), isReady: true, contentError: null, - formError: meta.error, - setTouched: setFieldsTouched => { - setFieldsTouched({ - inventory: true, - }); + hasError: !!meta.error, + setTouched: setFieldTouched => { + setFieldTouched('nodeType', true, false); }, + validate: () => {}, }; } function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx index 2e117da77e..2ee6098fb7 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useRunTypeStep.jsx @@ -14,12 +14,11 @@ export default function useRunTypeStep(i18n, askLinkType) { initialValues: askLinkType ? { linkType: 'success' } : {}, isReady: true, contentError: null, - formError: meta.error, - setTouched: setFieldsTouched => { - setFieldsTouched({ - inventory: true, - }); + hasError: !!meta.error, + setTouched: setFieldTouched => { + setFieldTouched('linkType', true, false); }, + validate: () => {}, }; } function getStep(askLinkType, meta, i18n) { diff --git a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js index bd8a8c74e4..b6a80d72f2 100644 --- a/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js +++ b/awx/ui_next/src/screens/Template/WorkflowJobTemplateVisualizer/Modals/NodeModals/useWorkflowNodeSteps.js @@ -194,7 +194,8 @@ export default function useWorkflowNodeSteps( useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), ]; - const hasErrors = steps.some(step => step.formError); + const hasErrors = steps.some(step => step.hasError); + steps.push( usePreviewStep( launchConfig, @@ -250,12 +251,17 @@ export default function useWorkflowNodeSteps( return { steps: pfSteps, - visitStep: stepId => + validateStep: stepId => { + steps.find(s => s?.step?.id === stepId).validate(); + }, + visitStep: (prevStepId, setFieldTouched) => { setVisited({ ...visited, - [stepId]: true, - }), - visitAllSteps: setFieldsTouched => { + [prevStepId]: true, + }); + steps.find(s => s?.step?.id === prevStepId).setTouched(setFieldTouched); + }, + visitAllSteps: setFieldTouched => { setVisited({ inventory: true, credentials: true, @@ -263,7 +269,7 @@ export default function useWorkflowNodeSteps( survey: true, preview: true, }); - steps.forEach(s => s.setTouched(setFieldsTouched)); + steps.forEach(s => s.setTouched(setFieldTouched)); }, contentError, }; diff --git a/awx/ui_next/src/util/prompt/getCredentialPasswords.js b/awx/ui_next/src/util/prompt/getCredentialPasswords.js new file mode 100644 index 0000000000..d38ba858a3 --- /dev/null +++ b/awx/ui_next/src/util/prompt/getCredentialPasswords.js @@ -0,0 +1,29 @@ +export default function getCredentialPasswords(values) { + const credentialPasswords = {}; + Object.keys(values) + .filter(valueKey => valueKey.startsWith('credentialPassword')) + .forEach(credentialValueKey => { + if (credentialValueKey === 'credentialPasswordSsh') { + credentialPasswords.ssh_password = values[credentialValueKey]; + } + + if (credentialValueKey === 'credentialPasswordPrivilegeEscalation') { + credentialPasswords.become_password = values[credentialValueKey]; + } + + if (credentialValueKey === 'credentialPasswordPrivateKeyPassphrase') { + credentialPasswords.ssh_key_unlock = values[credentialValueKey]; + } + + if (credentialValueKey.startsWith('credentialPasswordVault_')) { + const vaultId = credentialValueKey.split('credentialPasswordVault_')[1]; + if (vaultId.length > 0) { + credentialPasswords[`vault_password.${vaultId}`] = + values[credentialValueKey]; + } else { + credentialPasswords.vault_password = values[credentialValueKey]; + } + } + }); + return credentialPasswords; +} diff --git a/awx/ui_next/src/util/prompt/getCredentialPasswords.test.js b/awx/ui_next/src/util/prompt/getCredentialPasswords.test.js new file mode 100644 index 0000000000..2959d42141 --- /dev/null +++ b/awx/ui_next/src/util/prompt/getCredentialPasswords.test.js @@ -0,0 +1,66 @@ +import getCredentialPasswords from './getCredentialPasswords'; + +describe('getCredentialPasswords', () => { + test('should handle ssh password', () => { + expect( + getCredentialPasswords({ + credentialPasswordSsh: 'foobar', + }) + ).toEqual({ + ssh_password: 'foobar', + }); + }); + test('should handle become password', () => { + expect( + getCredentialPasswords({ + credentialPasswordPrivilegeEscalation: 'foobar', + }) + ).toEqual({ + become_password: 'foobar', + }); + }); + test('should handle ssh key unlock', () => { + expect( + getCredentialPasswords({ + credentialPasswordPrivateKeyPassphrase: 'foobar', + }) + ).toEqual({ + ssh_key_unlock: 'foobar', + }); + }); + test('should handle vault password with identifier', () => { + expect( + getCredentialPasswords({ + credentialPasswordVault_1: 'foobar', + }) + ).toEqual({ + 'vault_password.1': 'foobar', + }); + }); + test('should handle vault password without identifier', () => { + expect( + getCredentialPasswords({ + credentialPasswordVault_: 'foobar', + }) + ).toEqual({ + vault_password: 'foobar', + }); + }); + test('should handle all password types', () => { + expect( + getCredentialPasswords({ + credentialPasswordSsh: '1', + credentialPasswordPrivilegeEscalation: '2', + credentialPasswordPrivateKeyPassphrase: '3', + credentialPasswordVault_: '4', + credentialPasswordVault_1: '5', + }) + ).toEqual({ + ssh_password: '1', + become_password: '2', + ssh_key_unlock: '3', + vault_password: '4', + 'vault_password.1': '5', + }); + }); +}); From fb62e0ec2caf55ac0d4e97e43e16abd8ebf279d5 Mon Sep 17 00:00:00 2001 From: mabashian Date: Wed, 6 Jan 2021 16:06:08 -0500 Subject: [PATCH 2/5] Revert changes to isValid --- awx/ui_next/src/components/FormField/PasswordField.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index fcba330c8c..44ebe5f889 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -8,7 +8,7 @@ import PasswordInput from './PasswordInput'; function PasswordField(props) { const { id, name, label, validate, isRequired, helperText } = props; const [, meta] = useField({ name, validate }); - const isValid = !meta.touched || (meta.value && meta.value !== ''); + const isValid = !(meta.touched && meta.error); return ( Date: Tue, 19 Jan 2021 11:34:19 -0500 Subject: [PATCH 3/5] Fix job relaunch where credentials are needed --- .../components/LaunchButton/LaunchButton.jsx | 41 ++-- .../LaunchButton/LaunchButton.test.jsx | 178 +++++++++++++++++- .../src/screens/Job/JobTypeRedirect.jsx | 10 +- 3 files changed, 202 insertions(+), 27 deletions(-) diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx index 06c1fce0b6..a832a929f8 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.jsx @@ -102,17 +102,20 @@ class LaunchButton extends React.Component { async launchWithParams(params) { try { const { history, resource } = this.props; - const jobPromise = - resource.type === 'workflow_job_template' - ? WorkflowJobTemplatesAPI.launch(resource.id, params || {}) - : JobTemplatesAPI.launch(resource.id, params || {}); + let jobPromise; + + if (resource.type === 'job_template') { + jobPromise = JobTemplatesAPI.launch(resource.id, params || {}); + } else if (resource.type === 'workflow_job_template') { + jobPromise = WorkflowJobTemplatesAPI.launch(resource.id, params || {}); + } else if (resource.type === 'job') { + jobPromise = JobsAPI.relaunch(resource.id, params || {}); + } else if (resource.type === 'workflow_job') { + jobPromise = WorkflowJobsAPI.relaunch(resource.id, params || {}); + } const { data: job } = await jobPromise; - history.push( - `/${ - resource.type === 'workflow_job_template' ? 'jobs/workflow' : 'jobs' - }/${job.id}/output` - ); + history.push(`/jobs/${job.id}/output`); } catch (launchError) { this.setState({ launchError }); } @@ -129,20 +132,15 @@ class LaunchButton extends React.Component { readRelaunch = InventorySourcesAPI.readLaunchUpdate( resource.inventory_source ); - relaunch = InventorySourcesAPI.launchUpdate(resource.inventory_source); } else if (resource.type === 'project_update') { // We'll need to handle the scenario where the project no longer exists readRelaunch = ProjectsAPI.readLaunchUpdate(resource.project); - relaunch = ProjectsAPI.launchUpdate(resource.project); } else if (resource.type === 'workflow_job') { readRelaunch = WorkflowJobsAPI.readRelaunch(resource.id); - relaunch = WorkflowJobsAPI.relaunch(resource.id); } else if (resource.type === 'ad_hoc_command') { readRelaunch = AdHocCommandsAPI.readRelaunch(resource.id); - relaunch = AdHocCommandsAPI.relaunch(resource.id); } else if (resource.type === 'job') { readRelaunch = JobsAPI.readRelaunch(resource.id); - relaunch = JobsAPI.relaunch(resource.id); } try { @@ -151,11 +149,22 @@ class LaunchButton extends React.Component { !relaunchConfig.passwords_needed_to_start || relaunchConfig.passwords_needed_to_start.length === 0 ) { + if (resource.type === 'inventory_update') { + relaunch = InventorySourcesAPI.launchUpdate( + resource.inventory_source + ); + } else if (resource.type === 'project_update') { + relaunch = ProjectsAPI.launchUpdate(resource.project); + } else if (resource.type === 'workflow_job') { + relaunch = WorkflowJobsAPI.relaunch(resource.id); + } else if (resource.type === 'ad_hoc_command') { + relaunch = AdHocCommandsAPI.relaunch(resource.id); + } else if (resource.type === 'job') { + relaunch = JobsAPI.relaunch(resource.id); + } const { data: job } = await relaunch; history.push(`/jobs/${job.id}/output`); } else { - // TODO: restructure (async?) to send launch command after prompts - // TODO: does relaunch need different prompt treatment than launch? this.setState({ showLaunchPrompt: true, launchConfig: relaunchConfig, diff --git a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx index 10fbbd1bf4..c84c7fde4d 100644 --- a/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx +++ b/awx/ui_next/src/components/LaunchButton/LaunchButton.test.jsx @@ -4,10 +4,16 @@ import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; import { sleep } from '../../../testUtils/testUtils'; import LaunchButton from './LaunchButton'; -import { JobTemplatesAPI, WorkflowJobTemplatesAPI } from '../../api'; +import { + InventorySourcesAPI, + JobsAPI, + JobTemplatesAPI, + ProjectsAPI, + WorkflowJobsAPI, + WorkflowJobTemplatesAPI, +} from '../../api'; -jest.mock('../../api/models/WorkflowJobTemplates'); -jest.mock('../../api/models/JobTemplates'); +jest.mock('../../api'); describe('LaunchButton', () => { JobTemplatesAPI.readLaunch.mockResolvedValue({ @@ -22,10 +28,14 @@ describe('LaunchButton', () => { }, }); - const children = ({ handleLaunch }) => ( + const launchButton = ({ handleLaunch }) => (