Adds credential password step to ad hoc commands wizard (#11598)

This commit is contained in:
Alex Corey 2022-02-09 15:59:50 -05:00 committed by GitHub
parent f8e680867b
commit 6f9d4d89cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 309 additions and 12 deletions

View File

@ -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,
};

View File

@ -234,12 +234,15 @@ describe('<AdHocCommands />', () => {
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,
});

View File

@ -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: '',
};
},

View File

@ -184,7 +184,157 @@ describe('<AdHocCommandsWizard/>', () => {
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: '---',

View File

@ -76,7 +76,7 @@ function AdHocCredentialStep({ credentialTypeId }) {
}, [fetchCredentials]);
const [field, meta, helpers] = useField({
name: 'credential',
name: 'credentials',
validate: required(null),
});

View File

@ -43,7 +43,8 @@ function AdHocPreviewStep({ hasErrors, values }) {
([key, value]) =>
key !== 'extra_vars' &&
key !== 'execution_environment' &&
key !== 'credential' && (
key !== 'credentials' &&
!key.startsWith('credential_passwords') && (
<Detail key={key} label={toTitleCase(key)} value={value} />
)
)}

View File

@ -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: (
<StepName hasErrors={hasError} id="credential-passwords-step">
{t`Credential passwords`}
</StepName>
),
component: <CredentialPasswordsStep launchConfig={{}} />,
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;
}

View File

@ -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);
},
};
}

View File

@ -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));