Prevent users from selecting credentials that prompt for passwords on workflow nodes and schedules

This commit is contained in:
mabashian
2021-03-10 10:42:59 -05:00
parent 3878d8f7d8
commit 467536f93f
26 changed files with 864 additions and 289 deletions

View File

@@ -36,6 +36,7 @@ function LaunchButton({ resource, i18n, children, history }) {
const [showLaunchPrompt, setShowLaunchPrompt] = useState(false); const [showLaunchPrompt, setShowLaunchPrompt] = useState(false);
const [launchConfig, setLaunchConfig] = useState(null); const [launchConfig, setLaunchConfig] = useState(null);
const [surveyConfig, setSurveyConfig] = useState(null); const [surveyConfig, setSurveyConfig] = useState(null);
const [resourceCredentials, setResourceCredentials] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const handleLaunch = async () => { const handleLaunch = async () => {
const readLaunch = const readLaunch =
@@ -56,6 +57,17 @@ function LaunchButton({ resource, i18n, children, history }) {
setSurveyConfig(data); setSurveyConfig(data);
} }
if (
launch.ask_credential_on_launch &&
resource.type === 'workflow_job_template'
) {
const {
data: { results: jobTemplateCredentials },
} = await JobTemplatesAPI.readCredentials(resource.id);
setResourceCredentials(jobTemplateCredentials);
}
if (canLaunchWithoutPrompt(launch)) { if (canLaunchWithoutPrompt(launch)) {
launchWithParams({}); launchWithParams({});
} else { } else {
@@ -161,6 +173,7 @@ function LaunchButton({ resource, i18n, children, history }) {
resource={resource} resource={resource}
onLaunch={launchWithParams} onLaunch={launchWithParams}
onCancel={() => setShowLaunchPrompt(false)} onCancel={() => setShowLaunchPrompt(false)}
resourceDefaultCredentials={resourceCredentials}
/> />
)} )}
</Fragment> </Fragment>

View File

@@ -18,6 +18,7 @@ function PromptModalForm({
onSubmit, onSubmit,
resource, resource,
surveyConfig, surveyConfig,
resourceDefaultCredentials,
}) { }) {
const { setFieldTouched, values } = useFormikContext(); const { setFieldTouched, values } = useFormikContext();
@@ -28,7 +29,13 @@ function PromptModalForm({
visitStep, visitStep,
visitAllSteps, visitAllSteps,
contentError, contentError,
} = useLaunchSteps(launchConfig, surveyConfig, resource, i18n); } = useLaunchSteps(
launchConfig,
surveyConfig,
resource,
i18n,
resourceDefaultCredentials
);
const handleSubmit = () => { const handleSubmit = () => {
const postValues = {}; const postValues = {};
@@ -122,6 +129,7 @@ function LaunchPrompt({
onLaunch, onLaunch,
resource = {}, resource = {},
surveyConfig, surveyConfig,
resourceDefaultCredentials = [],
}) { }) {
return ( return (
<Formik initialValues={{}} onSubmit={values => onLaunch(values)}> <Formik initialValues={{}} onSubmit={values => onLaunch(values)}>
@@ -132,6 +140,7 @@ function LaunchPrompt({
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resource={resource} resource={resource}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</Formik> </Formik>
); );

View File

@@ -87,7 +87,9 @@ describe('LaunchPrompt', () => {
credentials: [ credentials: [
{ {
id: 1, id: 1,
name: 'cred that prompts',
passwords_needed: ['ssh_password'], passwords_needed: ['ssh_password'],
credential_type: 1,
}, },
], ],
}, },
@@ -122,6 +124,16 @@ describe('LaunchPrompt', () => {
}, },
], ],
}} }}
resourceDefaultCredentials={[
{
id: 5,
name: 'cred that prompts',
credential_type: 1,
inputs: {
password: 'ASK',
},
},
]}
/> />
); );
}); });

View File

@@ -4,7 +4,8 @@ import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField } from 'formik'; import { useField } from 'formik';
import { ToolbarItem } from '@patternfly/react-core'; import styled from 'styled-components';
import { Alert, ToolbarItem } from '@patternfly/react-core';
import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
import AnsibleSelect from '../../AnsibleSelect'; import AnsibleSelect from '../../AnsibleSelect';
import OptionsList from '../../OptionsList'; import OptionsList from '../../OptionsList';
@@ -13,7 +14,11 @@ import CredentialChip from '../../CredentialChip';
import ContentError from '../../ContentError'; import ContentError from '../../ContentError';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
import { required } from '../../../util/validators'; import credentialsValidator from './credentialsValidator';
const CredentialErrorAlert = styled(Alert)`
margin-bottom: 20px;
`;
const QS_CONFIG = getQSConfig('credential', { const QS_CONFIG = getQSConfig('credential', {
page: 1, page: 1,
@@ -21,10 +26,21 @@ const QS_CONFIG = getQSConfig('credential', {
order_by: 'name', order_by: 'name',
}); });
function CredentialsStep({ i18n }) { function CredentialsStep({
const [field, , helpers] = useField({ i18n,
allowCredentialsWithPasswords,
defaultCredentials = [],
}) {
const [field, meta, helpers] = useField({
name: 'credentials', name: 'credentials',
validate: required(null, i18n), validate: val => {
return credentialsValidator(
i18n,
defaultCredentials,
allowCredentialsWithPasswords,
val
);
},
}); });
const [selectedType, setSelectedType] = useState(null); const [selectedType, setSelectedType] = useState(null);
const history = useHistory(); const history = useHistory();
@@ -87,6 +103,18 @@ function CredentialsStep({ i18n }) {
fetchCredentials(); fetchCredentials();
}, [fetchCredentials]); }, [fetchCredentials]);
useEffect(() => {
helpers.setError(
credentialsValidator(
i18n,
defaultCredentials,
allowCredentialsWithPasswords,
field.value
)
);
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, []);
if (isTypesLoading) { if (isTypesLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
@@ -97,17 +125,23 @@ function CredentialsStep({ i18n }) {
const isVault = selectedType?.kind === 'vault'; const isVault = selectedType?.kind === 'vault';
const renderChip = ({ item, removeItem, canDelete }) => ( const renderChip = ({ item, removeItem, canDelete }) => {
<CredentialChip return (
key={item.id} <CredentialChip
onClick={() => removeItem(item)} id={`credential-chip-${item.id}`}
isReadOnly={!canDelete} key={item.id}
credential={item} onClick={() => removeItem(item)}
/> isReadOnly={!canDelete}
); credential={item}
/>
);
};
return ( return (
<> <>
{meta.error && (
<CredentialErrorAlert variant="danger" isInline title={meta.error} />
)}
{types && types.length > 0 && ( {types && types.length > 0 && (
<ToolbarItem css=" display: flex; align-items: center;"> <ToolbarItem css=" display: flex; align-items: center;">
<div css="flex: 0 0 25%; margin-right: 32px"> <div css="flex: 0 0 25%; margin-right: 32px">
@@ -130,57 +164,56 @@ function CredentialsStep({ i18n }) {
/> />
</ToolbarItem> </ToolbarItem>
)} )}
{!isCredentialsLoading && ( <OptionsList
<OptionsList isLoading={isCredentialsLoading}
value={field.value || []} value={field.value || []}
options={credentials} options={credentials}
optionCount={count} optionCount={count}
searchColumns={[ searchColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
key: 'name__icontains', key: 'name__icontains',
isDefault: true, isDefault: true,
}, },
{ {
name: i18n._(t`Created By (Username)`), name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains', key: 'created_by__username__icontains',
}, },
{ {
name: i18n._(t`Modified By (Username)`), name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains', key: 'modified_by__username__icontains',
}, },
]} ]}
sortColumns={[ sortColumns={[
{ {
name: i18n._(t`Name`), name: i18n._(t`Name`),
key: 'name', key: 'name',
}, },
]} ]}
searchableKeys={searchableKeys} searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys} relatedSearchableKeys={relatedSearchableKeys}
multiple={isVault} multiple={isVault}
header={i18n._(t`Credentials`)} header={i18n._(t`Credentials`)}
name="credentials" name="credentials"
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
readOnly={false} readOnly={false}
selectItem={item => { selectItem={item => {
const hasSameVaultID = val => const hasSameVaultID = val =>
val?.inputs?.vault_id !== undefined && val?.inputs?.vault_id !== undefined &&
val?.inputs?.vault_id === item?.inputs?.vault_id; val?.inputs?.vault_id === item?.inputs?.vault_id;
const hasSameCredentialType = val => const hasSameCredentialType = val =>
val.credential_type === item.credential_type; val.credential_type === item.credential_type;
const newItems = field.value.filter(i => const newItems = field.value.filter(i =>
isVault ? !hasSameVaultID(i) : !hasSameCredentialType(i) isVault ? !hasSameVaultID(i) : !hasSameCredentialType(i)
); );
newItems.push(item); newItems.push(item);
helpers.setValue(newItems); helpers.setValue(newItems);
}} }}
deselectItem={item => { deselectItem={item => {
helpers.setValue(field.value.filter(i => i.id !== item.id)); helpers.setValue(field.value.filter(i => i.id !== item.id));
}} }}
renderItemChip={renderChip} renderItemChip={renderChip}
/> />
)}
</> </>
); );
} }

View File

@@ -9,17 +9,95 @@ jest.mock('../../../api/models/CredentialTypes');
jest.mock('../../../api/models/Credentials'); jest.mock('../../../api/models/Credentials');
const types = [ const types = [
{ id: 1, kind: 'ssh', name: 'SSH' }, { id: 1, kind: 'ssh', name: 'SSH', url: '/api/v2/credential_types/1/' },
{ id: 2, kind: 'cloud', name: 'Ansible Tower' }, { id: 3, kind: 'vault', name: 'Vault', url: '/api/v2/credential_types/3/' },
{ id: 3, kind: 'vault', name: 'Vault' }, {
id: 5,
name: 'Amazon Web Services',
kind: 'cloud',
url: '/api/v2/credential_types/5/',
},
{
id: 9,
name: 'Google Compute Engine',
kind: 'cloud',
url: '/api/v2/credential_types/9/',
},
]; ];
const credentials = [ const credentials = [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' }, {
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' }, id: 1,
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' }, kind: 'aws',
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' }, name: 'Cred 1',
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' }, credential_type: 5,
url: '/api/v2/credentials/1/',
inputs: {},
},
{
id: 2,
kind: 'ssh',
name: 'Cred 2',
credential_type: 1,
url: '/api/v2/credentials/2/',
inputs: {
password: 'ASK',
},
},
{
id: 3,
kind: 'gce',
name: 'Cred 3',
credential_type: 9,
url: '/api/v2/credentials/3/',
inputs: {},
},
{
id: 4,
kind: 'ssh',
name: 'Cred 4',
credential_type: 1,
url: '/api/v2/credentials/4/',
inputs: {},
},
{
id: 5,
kind: 'ssh',
name: 'Cred 5',
credential_type: 1,
url: '/api/v2/credentials/5/',
inputs: {},
},
{
id: 33,
kind: 'vault',
name: 'Cred 33',
credential_type: 3,
url: '/api/v2/credentials/33/',
inputs: {
vault_id: 'foo',
},
summary_fields: {
credential_type: {
name: 'Vault',
},
},
},
{
id: 34,
kind: 'vault',
name: 'Cred 34',
credential_type: 3,
url: '/api/v2/credentials/34/',
inputs: {
vault_id: 'bar',
},
summary_fields: {
credential_type: {
name: 'Vault',
},
},
},
]; ];
describe('CredentialsStep', () => { describe('CredentialsStep', () => {
@@ -47,7 +125,7 @@ describe('CredentialsStep', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik> <Formik>
<CredentialsStep /> <CredentialsStep allowCredentialsWithPasswords />
</Formik> </Formik>
); );
}); });
@@ -62,7 +140,7 @@ describe('CredentialsStep', () => {
await act(async () => { await act(async () => {
wrapper = mountWithContexts( wrapper = mountWithContexts(
<Formik> <Formik>
<CredentialsStep /> <CredentialsStep allowCredentialsWithPasswords />
</Formik> </Formik>
); );
}); });
@@ -76,13 +154,173 @@ describe('CredentialsStep', () => {
}); });
await act(async () => { await act(async () => {
wrapper.find('AnsibleSelect').invoke('onChange')({}, 2); wrapper.find('AnsibleSelect').invoke('onChange')({}, 3);
}); });
expect(CredentialsAPI.read).toHaveBeenCalledWith({ expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type: 2, credential_type: 3,
order_by: 'name', order_by: 'name',
page: 1, page: 1,
page_size: 5, page_size: 5,
}); });
}); });
test("error should be shown when a credential that prompts for passwords is selected on a step that doesn't allow it", async () => {
let wrapper;
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: [],
}}
>
<CredentialsStep allowCredentialsWithPasswords={false} />
</Formik>
);
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(0);
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-2"]')
.simulate('change', { target: { checked: true } });
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
expect(
wrapper
.find('Alert')
.text()
.includes('Cred 2')
).toBe(true);
});
test('error should be toggled when default machine credential is removed and then replaced', async () => {
let wrapper;
const selectedCredentials = [
{
id: 5,
kind: 'ssh',
name: 'Cred 5',
credential_type: 1,
url: '/api/v2/credentials/5/',
inputs: {},
summary_fields: {
credential_type: {
name: 'Machine',
},
},
},
];
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: selectedCredentials,
}}
>
<CredentialsStep
allowCredentialsWithPasswords={false}
defaultCredentials={selectedCredentials}
/>
</Formik>
);
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(0);
expect(wrapper.find('CredentialChip').length).toBe(1);
await act(async () => {
wrapper.find('button#remove_credential-chip-5').simulate('click');
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(1);
expect(
wrapper
.find('Alert')
.text()
.includes('Machine')
).toBe(true);
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-5"]')
.simulate('change', { target: { checked: true } });
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(0);
});
test('error should be toggled when default vault credential is removed and then replaced', async () => {
let wrapper;
const selectedCredentials = [
{
id: 33,
kind: 'vault',
name: 'Cred 33',
credential_type: 3,
url: '/api/v2/credentials/33/',
inputs: {
vault_id: 'foo',
},
summary_fields: {
credential_type: {
name: 'Vault',
},
},
},
{
id: 34,
kind: 'vault',
name: 'Cred 34',
credential_type: 3,
url: '/api/v2/credentials/34/',
inputs: {
vault_id: 'bar',
},
summary_fields: {
credential_type: {
name: 'Vault',
},
},
},
];
await act(async () => {
wrapper = mountWithContexts(
<Formik
initialValues={{
credentials: selectedCredentials,
}}
>
<CredentialsStep
allowCredentialsWithPasswords={false}
defaultCredentials={selectedCredentials}
/>
</Formik>
);
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(0);
expect(wrapper.find('CredentialChip').length).toBe(2);
await act(async () => {
wrapper.find('button#remove_credential-chip-33').simulate('click');
});
wrapper.update();
expect(wrapper.find('CredentialChip').length).toBe(1);
expect(wrapper.find('Alert').length).toBe(1);
expect(
wrapper
.find('Alert')
.text()
.includes('Vault | foo')
).toBe(true);
await act(async () => {
wrapper.find('AnsibleSelect').invoke('onChange')({}, 3);
});
wrapper.update();
await act(async () => {
wrapper
.find('input[aria-labelledby="check-action-item-33"]')
.simulate('change', { target: { checked: true } });
});
wrapper.update();
expect(wrapper.find('Alert').length).toBe(0);
});
}); });

View File

@@ -3,6 +3,7 @@ import { useHistory } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField } from 'formik'; import { useField } from 'formik';
import styled from 'styled-components';
import { Alert } from '@patternfly/react-core'; import { Alert } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api'; import { InventoriesAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../util/qs';
@@ -11,6 +12,10 @@ import OptionsList from '../../OptionsList';
import ContentLoading from '../../ContentLoading'; import ContentLoading from '../../ContentLoading';
import ContentError from '../../ContentError'; import ContentError from '../../ContentError';
const InventoryErrorAlert = styled(Alert)`
margin-bottom: 20px;
`;
const QS_CONFIG = getQSConfig('inventory', { const QS_CONFIG = getQSConfig('inventory', {
page: 1, page: 1,
page_size: 5, page_size: 5,
@@ -68,6 +73,9 @@ function InventoryStep({ i18n, warningMessage = null }) {
return ( return (
<> <>
{meta.touched && meta.error && (
<InventoryErrorAlert variant="danger" isInline title={meta.error} />
)}
{warningMessage} {warningMessage}
<OptionsList <OptionsList
value={field.value ? [field.value] : []} value={field.value ? [field.value] : []}
@@ -103,9 +111,6 @@ function InventoryStep({ i18n, warningMessage = null }) {
selectItem={helpers.setValue} selectItem={helpers.setValue}
deselectItem={() => field.onChange(null)} deselectItem={() => field.onChange(null)}
/> />
{meta.touched && meta.error && (
<Alert variant="danger" isInline title={meta.error} />
)}
</> </>
); );
} }

View File

@@ -0,0 +1,66 @@
import { t } from '@lingui/macro';
const credentialPromptsForPassword = credential =>
credential?.inputs?.password === 'ASK' ||
credential?.inputs?.ssh_key_unlock === 'ASK' ||
credential?.inputs?.become_password === 'ASK' ||
credential?.inputs?.vault_password === 'ASK';
export default function credentialsValidator(
i18n,
defaultCredentials = [],
allowCredentialsWithPasswords,
selectedCredentials
) {
if (defaultCredentials.length > 0 && selectedCredentials) {
const missingCredentialTypes = [];
defaultCredentials.forEach(defaultCredential => {
if (
!selectedCredentials.find(selectedCredential => {
return (
(selectedCredential.credential_type ===
defaultCredential.credential_type &&
!selectedCredential.inputs.vault_id &&
!defaultCredential.inputs.vault_id) ||
(selectedCredential.inputs.vault_id &&
defaultCredential.inputs.vault_id &&
selectedCredential.inputs.vault_id ===
defaultCredential.inputs.vault_id)
);
})
) {
missingCredentialTypes.push(
defaultCredential.inputs.vault_id
? `${defaultCredential.summary_fields.credential_type.name} | ${defaultCredential.inputs.vault_id}`
: defaultCredential.summary_fields.credential_type.name
);
}
});
if (missingCredentialTypes.length > 0) {
return i18n._(
t`Job Template default credentials must be replaced with one of the same type. Please select a credential for the following types in order to proceed: ${missingCredentialTypes.join(
', '
)}`
);
}
}
if (!allowCredentialsWithPasswords && selectedCredentials) {
const credentialsThatPrompt = [];
selectedCredentials.forEach(selectedCredential => {
if (credentialPromptsForPassword(selectedCredential)) {
credentialsThatPrompt.push(selectedCredential.name);
}
});
if (credentialsThatPrompt.length > 0) {
return i18n._(
t`Credentials that require passwords on launch are not permitted. Please remove or replace the following credentials with a credential of the same type in order to proceed: ${credentialsThatPrompt.join(
', '
)}`
);
}
}
return undefined;
}

View File

@@ -1,25 +1,59 @@
import React from 'react'; import React from 'react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { useField } from 'formik';
import CredentialsStep from './CredentialsStep'; import CredentialsStep from './CredentialsStep';
import StepName from './StepName'; import StepName from './StepName';
import credentialsValidator from './credentialsValidator';
const STEP_ID = 'credentials'; const STEP_ID = 'credentials';
export default function useCredentialsStep(launchConfig, resource, i18n) { export default function useCredentialsStep(
launchConfig,
resource,
resourceDefaultCredentials,
i18n,
allowCredentialsWithPasswords = false
) {
const [field, meta, helpers] = useField('credentials');
const formError =
!resource || resource?.type === 'workflow_job_template'
? false
: meta.error;
return { return {
step: getStep(launchConfig, i18n), step: getStep(
initialValues: getInitialValues(launchConfig, resource), launchConfig,
i18n,
allowCredentialsWithPasswords,
formError,
resourceDefaultCredentials
),
initialValues: getInitialValues(launchConfig, resourceDefaultCredentials),
isReady: true, isReady: true,
contentError: null, contentError: null,
hasError: false, hasError: launchConfig.ask_credential_on_launch && formError,
setTouched: setFieldTouched => { setTouched: setFieldTouched => {
setFieldTouched('credentials', true, false); setFieldTouched('credentials', true, false);
}, },
validate: () => {}, validate: () => {
helpers.setError(
credentialsValidator(
i18n,
resourceDefaultCredentials,
allowCredentialsWithPasswords,
field.value
)
);
},
}; };
} }
function getStep(launchConfig, i18n) { function getStep(
launchConfig,
i18n,
allowCredentialsWithPasswords,
formError,
resourceDefaultCredentials
) {
if (!launchConfig.ask_credential_on_launch) { if (!launchConfig.ask_credential_on_launch) {
return null; return null;
} }
@@ -27,21 +61,27 @@ function getStep(launchConfig, i18n) {
id: STEP_ID, id: STEP_ID,
key: 4, key: 4,
name: ( name: (
<StepName hasErrors={false} id="credentials-step"> <StepName hasErrors={formError} id="credentials-step">
{i18n._(t`Credentials`)} {i18n._(t`Credentials`)}
</StepName> </StepName>
), ),
component: <CredentialsStep i18n={i18n} />, component: (
<CredentialsStep
i18n={i18n}
allowCredentialsWithPasswords={allowCredentialsWithPasswords}
defaultCredentials={resourceDefaultCredentials}
/>
),
enableNext: true, enableNext: true,
}; };
} }
function getInitialValues(launchConfig, resource) { function getInitialValues(launchConfig, resourceDefaultCredentials) {
if (!launchConfig.ask_credential_on_launch) { if (!launchConfig.ask_credential_on_launch) {
return {}; return {};
} }
return { return {
credentials: resource?.summary_fields?.credentials || [], credentials: resourceDefaultCredentials || [],
}; };
} }

View File

@@ -43,14 +43,21 @@ export default function useLaunchSteps(
launchConfig, launchConfig,
surveyConfig, surveyConfig,
resource, resource,
i18n i18n,
resourceDefaultCredentials
) { ) {
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const { touched, values: formikValues } = useFormikContext(); const { touched, values: formikValues } = useFormikContext();
const steps = [ const steps = [
useInventoryStep(launchConfig, resource, i18n, visited), useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n), useCredentialsStep(
launchConfig,
resource,
resourceDefaultCredentials,
i18n,
true
),
useCredentialPasswordsStep( useCredentialPasswordsStep(
launchConfig, launchConfig,
i18n, i18n,

View File

@@ -26,6 +26,7 @@ function Schedule({
launchConfig, launchConfig,
surveyConfig, surveyConfig,
hasDaysToKeepField, hasDaysToKeepField,
resourceDefaultCredentials,
}) { }) {
const { scheduleId } = useParams(); const { scheduleId } = useParams();
@@ -114,6 +115,7 @@ function Schedule({
resource={resource} resource={resource}
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</Route>, </Route>,
<Route <Route

View File

@@ -22,6 +22,7 @@ function ScheduleAdd({
launchConfig, launchConfig,
surveyConfig, surveyConfig,
hasDaysToKeepField, hasDaysToKeepField,
resourceDefaultCredentials,
}) { }) {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();
@@ -117,6 +118,7 @@ function ScheduleAdd({
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resource={resource} resource={resource}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -22,6 +22,7 @@ function ScheduleEdit({
resource, resource,
launchConfig, launchConfig,
surveyConfig, surveyConfig,
resourceDefaultCredentials,
}) { }) {
const [formSubmitError, setFormSubmitError] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null);
const history = useHistory(); const history = useHistory();
@@ -131,6 +132,7 @@ function ScheduleEdit({
resource={resource} resource={resource}
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</CardBody> </CardBody>
</Card> </Card>

View File

@@ -30,8 +30,20 @@ SchedulesAPI.readZoneInfo.mockResolvedValue({
SchedulesAPI.readCredentials.mockResolvedValue({ SchedulesAPI.readCredentials.mockResolvedValue({
data: { data: {
results: [ results: [
{ name: 'schedule credential 1', id: 1, kind: 'vault' }, {
{ name: 'schedule credential 2', id: 2, kind: 'aws' }, name: 'schedule credential 1',
id: 1,
kind: 'vault',
credential_type: 3,
inputs: {},
},
{
name: 'schedule credential 2',
id: 2,
kind: 'aws',
credential_type: 4,
inputs: {},
},
], ],
count: 2, count: 2,
}, },
@@ -45,9 +57,9 @@ CredentialsAPI.read.mockResolvedValue({
data: { data: {
count: 3, count: 3,
results: [ results: [
{ id: 1, name: 'Credential 1', kind: 'ssh', url: '' }, { id: 1, name: 'Credential 1', kind: 'ssh', url: '', credential_type: 1 },
{ id: 2, name: 'Credential 2', kind: 'ssh', url: '' }, { id: 2, name: 'Credential 2', kind: 'ssh', url: '', credential_type: 1 },
{ id: 3, name: 'Credential 3', kind: 'ssh', url: '' }, { id: 3, name: 'Credential 3', kind: 'ssh', url: '', credential_type: 1 },
], ],
}, },
}); });
@@ -115,6 +127,7 @@ describe('<ScheduleEdit />', () => {
], ],
}, },
}} }}
resourceDefaultCredentials={[]}
launchConfig={{ launchConfig={{
can_start_without_user_input: false, can_start_without_user_input: false,
passwords_needed_to_start: [], passwords_needed_to_start: [],
@@ -150,6 +163,7 @@ describe('<ScheduleEdit />', () => {
id: null, id: null,
}, },
scm_branch: '', scm_branch: '',
credentials: [],
}, },
}} }}
surveyConfig={{}} surveyConfig={{}}
@@ -466,7 +480,7 @@ describe('<ScheduleEdit />', () => {
.prop('isCurrent') .prop('isCurrent')
).toBe(true); ).toBe(true);
expect(wrapper.find('CredentialChip').length).toBe(3); expect(wrapper.find('CredentialChip').length).toBe(2);
wrapper.update(); wrapper.update();

View File

@@ -13,6 +13,7 @@ function Schedules({
launchConfig, launchConfig,
surveyConfig, surveyConfig,
resource, resource,
resourceDefaultCredentials,
}) { }) {
const match = useRouteMatch(); const match = useRouteMatch();
@@ -32,6 +33,7 @@ function Schedules({
resource={resource} resource={resource}
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</Route> </Route>
<Route key="details" path={`${match.path}/:scheduleId`}> <Route key="details" path={`${match.path}/:scheduleId`}>
@@ -41,6 +43,7 @@ function Schedules({
resource={resource} resource={resource}
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</Route> </Route>
<Route key="list" path={`${match.path}`}> <Route key="list" path={`${match.path}`}>

View File

@@ -204,6 +204,7 @@ function ScheduleForm({
resource, resource,
launchConfig, launchConfig,
surveyConfig, surveyConfig,
resourceDefaultCredentials,
...rest ...rest
}) { }) {
const [isWizardOpen, setIsWizardOpen] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false);
@@ -297,11 +298,84 @@ function ScheduleForm({
return missingValues; return missingValues;
}, [launchConfig, schedule, surveyConfig]); }, [launchConfig, schedule, surveyConfig]);
const hasCredentialsThatPrompt = useCallback(() => {
if (launchConfig?.ask_credential_on_launch) {
if (Object.keys(schedule).length > 0) {
const defaultCredsWithoutOverrides = [];
const credentialHasOverride = templateDefaultCred => {
let hasOverride = false;
credentials.forEach(nodeCredential => {
if (
templateDefaultCred.credential_type ===
nodeCredential.credential_type
) {
if (
(!templateDefaultCred.vault_id &&
!nodeCredential.inputs.vault_id) ||
(templateDefaultCred.vault_id &&
nodeCredential.inputs.vault_id &&
templateDefaultCred.vault_id ===
nodeCredential.inputs.vault_id)
) {
hasOverride = true;
}
}
});
return hasOverride;
};
if (resourceDefaultCredentials) {
resourceDefaultCredentials.forEach(defaultCred => {
if (!credentialHasOverride(defaultCred)) {
defaultCredsWithoutOverrides.push(defaultCred);
}
});
}
return (
credentials
.concat(defaultCredsWithoutOverrides)
.filter(credential => {
let credentialRequiresPass = false;
Object.entries(credential.inputs).forEach(([key, value]) => {
if (key !== 'vault_id' && value === 'ASK') {
credentialRequiresPass = true;
}
});
return credentialRequiresPass;
}).length > 0
);
}
return launchConfig?.defaults?.credentials
? launchConfig.defaults.credentials.filter(
credential => credential?.passwords_needed.length > 0
).length > 0
: false;
}
return false;
}, [launchConfig, schedule, credentials, resourceDefaultCredentials]);
useEffect(() => { useEffect(() => {
if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) { if (
isTemplate &&
(missingRequiredInventory() ||
hasMissingSurveyValue() ||
hasCredentialsThatPrompt())
) {
setIsSaveDisabled(true); setIsSaveDisabled(true);
} }
}, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]); }, [
isTemplate,
hasMissingSurveyValue,
missingRequiredInventory,
hasCredentialsThatPrompt,
]);
useEffect(() => { useEffect(() => {
loadScheduleData(); loadScheduleData();
@@ -527,14 +601,14 @@ function ScheduleForm({
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
launchConfig={launchConfig} launchConfig={launchConfig}
resource={resource} resource={resource}
onCloseWizard={hasErrors => { onCloseWizard={() => {
setIsWizardOpen(false); setIsWizardOpen(false);
setIsSaveDisabled(hasErrors);
}} }}
onSave={() => { onSave={() => {
setIsWizardOpen(false); setIsWizardOpen(false);
setIsSaveDisabled(false); setIsSaveDisabled(false);
}} }}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
)} )}
<FormSubmitError error={submitError} /> <FormSubmitError error={submitError} />

View File

@@ -17,10 +17,10 @@ function SchedulePromptableFields({
onSave, onSave,
credentials, credentials,
resource, resource,
resourceDefaultCredentials,
i18n, i18n,
}) { }) {
const { const {
validateForm,
setFieldTouched, setFieldTouched,
values, values,
initialValues, initialValues,
@@ -39,12 +39,12 @@ function SchedulePromptableFields({
schedule, schedule,
resource, resource,
i18n, i18n,
credentials credentials,
resourceDefaultCredentials
); );
const { error, dismissError } = useDismissableError(contentError); const { error, dismissError } = useDismissableError(contentError);
const cancelPromptableValues = async () => { const cancelPromptableValues = async () => {
const hasErrors = await validateForm();
resetForm({ resetForm({
values: { values: {
...initialValues, ...initialValues,
@@ -66,7 +66,7 @@ function SchedulePromptableFields({
timezone: values.timezone, timezone: values.timezone,
}, },
}); });
onCloseWizard(Object.keys(hasErrors).length > 0); onCloseWizard();
}; };
if (error) { if (error) {
@@ -89,13 +89,16 @@ function SchedulePromptableFields({
isOpen isOpen
onClose={cancelPromptableValues} onClose={cancelPromptableValues}
onSave={onSave} onSave={onSave}
onBack={async nextStep => {
validateStep(nextStep.id);
}}
onNext={async (nextStep, prevStep) => { onNext={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') { if (nextStep.id === 'preview') {
visitAllSteps(setFieldTouched); visitAllSteps(setFieldTouched);
} else { } else {
visitStep(prevStep.prevId, setFieldTouched); visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id);
} }
await validateForm();
}} }}
onGoToStep={async (nextStep, prevStep) => { onGoToStep={async (nextStep, prevStep) => {
if (nextStep.id === 'preview') { if (nextStep.id === 'preview') {
@@ -104,7 +107,6 @@ function SchedulePromptableFields({
visitStep(prevStep.prevId, setFieldTouched); visitStep(prevStep.prevId, setFieldTouched);
validateStep(nextStep.id); validateStep(nextStep.id);
} }
await validateForm();
}} }}
title={i18n._(t`Prompts`)} title={i18n._(t`Prompts`)}
steps={ steps={

View File

@@ -13,29 +13,28 @@ export default function useSchedulePromptSteps(
schedule, schedule,
resource, resource,
i18n, i18n,
scheduleCredentials scheduleCredentials,
resourceDefaultCredentials
) { ) {
const {
summary_fields: { credentials: resourceCredentials },
} = resource;
const sourceOfValues = const sourceOfValues =
(Object.keys(schedule).length > 0 && schedule) || resource; (Object.keys(schedule).length > 0 && schedule) || resource;
sourceOfValues.summary_fields = {
credentials: [...(resourceCredentials || []), ...scheduleCredentials],
...sourceOfValues.summary_fields,
};
const { resetForm, values } = useFormikContext(); const { resetForm, values } = useFormikContext();
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const steps = [ const steps = [
useInventoryStep(launchConfig, sourceOfValues, i18n, visited), useInventoryStep(launchConfig, sourceOfValues, i18n, visited),
useCredentialsStep(launchConfig, sourceOfValues, i18n), useCredentialsStep(
launchConfig,
sourceOfValues,
resourceDefaultCredentials,
i18n
),
useOtherPromptsStep(launchConfig, sourceOfValues, i18n), useOtherPromptsStep(launchConfig, sourceOfValues, i18n),
useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited), useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited),
]; ];
const hasErrors = steps.some(step => step.hasError); const hasErrors = steps.some(step => step.hasError);
steps.push( steps.push(
usePreviewStep( usePreviewStep(
launchConfig, launchConfig,
@@ -52,21 +51,61 @@ export default function useSchedulePromptSteps(
const isReady = !steps.some(s => !s.isReady); const isReady = !steps.some(s => !s.isReady);
useEffect(() => { useEffect(() => {
let initialValues = {};
if (launchConfig && surveyConfig && isReady) { if (launchConfig && surveyConfig && isReady) {
let initialValues = {};
initialValues = steps.reduce((acc, cur) => { initialValues = steps.reduce((acc, cur) => {
return { return {
...acc, ...acc,
...cur.initialValues, ...cur.initialValues,
}; };
}, {}); }, {});
if (launchConfig.ask_credential_on_launch) {
const defaultCredsWithoutOverrides = [];
const credentialHasOverride = templateDefaultCred => {
let hasOverride = false;
scheduleCredentials.forEach(scheduleCredential => {
if (
templateDefaultCred.credential_type ===
scheduleCredential.credential_type
) {
if (
(!templateDefaultCred.inputs.vault_id &&
!scheduleCredential.inputs.vault_id) ||
(templateDefaultCred.inputs.vault_id &&
scheduleCredential.inputs.vault_id &&
templateDefaultCred.inputs.vault_id ===
scheduleCredential.inputs.vault_id)
) {
hasOverride = true;
}
}
});
return hasOverride;
};
if (resourceDefaultCredentials) {
resourceDefaultCredentials.forEach(defaultCred => {
if (!credentialHasOverride(defaultCred)) {
defaultCredsWithoutOverrides.push(defaultCred);
}
});
}
initialValues.credentials = scheduleCredentials.concat(
defaultCredsWithoutOverrides
);
}
resetForm({
values: {
...initialValues,
...values,
},
});
} }
resetForm({
values: {
...initialValues,
...values,
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [launchConfig, surveyConfig, isReady]); }, [launchConfig, surveyConfig, isReady]);

View File

@@ -6,7 +6,7 @@ import styled from 'styled-components';
import ChipGroup from '../ChipGroup'; import ChipGroup from '../ChipGroup';
const Split = styled(PFSplit)` const Split = styled(PFSplit)`
margin: 20px 0 5px 0; margin: 20px 0 5px 0 !important;
align-items: baseline; align-items: baseline;
`; `;

View File

@@ -32,7 +32,13 @@ function Template({ i18n, setBreadcrumb }) {
const { me = {} } = useConfig(); const { me = {} } = useConfig();
const { const {
result: { isNotifAdmin, template, surveyConfig, launchConfig }, result: {
isNotifAdmin,
template,
surveyConfig,
launchConfig,
resourceDefaultCredentials,
},
isLoading, isLoading,
error: contentError, error: contentError,
request: loadTemplateAndRoles, request: loadTemplateAndRoles,
@@ -40,11 +46,17 @@ function Template({ i18n, setBreadcrumb }) {
useCallback(async () => { useCallback(async () => {
const [ const [
{ data }, { data },
{
data: { results: defaultCredentials },
},
actions, actions,
notifAdminRes, notifAdminRes,
{ data: launchConfiguration }, { data: launchConfiguration },
] = await Promise.all([ ] = await Promise.all([
JobTemplatesAPI.readDetail(templateId), JobTemplatesAPI.readDetail(templateId),
JobTemplatesAPI.readCredentials(templateId, {
page_size: 200,
}),
JobTemplatesAPI.readTemplateOptions(templateId), JobTemplatesAPI.readTemplateOptions(templateId),
OrganizationsAPI.read({ OrganizationsAPI.read({
page_size: 1, page_size: 1,
@@ -52,7 +64,7 @@ function Template({ i18n, setBreadcrumb }) {
}), }),
JobTemplatesAPI.readLaunch(templateId), JobTemplatesAPI.readLaunch(templateId),
]); ]);
let surveyConfiguration = null; let surveyConfiguration = {};
if (data.survey_enabled) { if (data.survey_enabled) {
const { data: survey } = await JobTemplatesAPI.readSurvey(templateId); const { data: survey } = await JobTemplatesAPI.readSurvey(templateId);
@@ -86,9 +98,10 @@ function Template({ i18n, setBreadcrumb }) {
isNotifAdmin: notifAdminRes.data.results.length > 0, isNotifAdmin: notifAdminRes.data.results.length > 0,
surveyConfig: surveyConfiguration, surveyConfig: surveyConfiguration,
launchConfig: launchConfiguration, launchConfig: launchConfiguration,
resourceDefaultCredentials: defaultCredentials,
}; };
}, [templateId]), }, [templateId]),
{ isNotifAdmin: false, template: null } { isNotifAdmin: false, template: null, resourceDefaultCredentials: [] }
); );
useEffect(() => { useEffect(() => {
@@ -221,6 +234,7 @@ function Template({ i18n, setBreadcrumb }) {
loadScheduleOptions={loadScheduleOptions} loadScheduleOptions={loadScheduleOptions}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
launchConfig={launchConfig} launchConfig={launchConfig}
resourceDefaultCredentials={resourceDefaultCredentials}
/> />
</Route> </Route>
{canSeeNotificationsTab && ( {canSeeNotificationsTab && (

View File

@@ -41,6 +41,7 @@ function NodeModalForm({
launchConfig, launchConfig,
surveyConfig, surveyConfig,
isLaunchLoading, isLaunchLoading,
resourceDefaultCredentials,
}) { }) {
const history = useHistory(); const history = useHistory();
const dispatch = useContext(WorkflowDispatchContext); const dispatch = useContext(WorkflowDispatchContext);
@@ -69,7 +70,8 @@ function NodeModalForm({
surveyConfig, surveyConfig,
i18n, i18n,
values.nodeResource, values.nodeResource,
askLinkType askLinkType,
resourceDefaultCredentials
); );
const handleSaveNode = () => { const handleSaveNode = () => {
@@ -229,7 +231,7 @@ const NodeModalInner = ({ i18n, title, ...rest }) => {
const { const {
request: readLaunchConfigs, request: readLaunchConfigs,
error: launchConfigError, error: launchConfigError,
result: { launchConfig, surveyConfig }, result: { launchConfig, surveyConfig, resourceDefaultCredentials },
isLoading, isLoading,
} = useRequest( } = useRequest(
useCallback(async () => { useCallback(async () => {
@@ -247,6 +249,7 @@ const NodeModalInner = ({ i18n, title, ...rest }) => {
return { return {
launchConfig: {}, launchConfig: {},
surveyConfig: {}, surveyConfig: {},
resourceDefaultCredentials: [],
}; };
} }
@@ -267,9 +270,21 @@ const NodeModalInner = ({ i18n, title, ...rest }) => {
survey = data; survey = data;
} }
let defaultCredentials = [];
if (launch.ask_credential_on_launch) {
const {
data: { results },
} = await JobTemplatesAPI.readCredentials(values?.nodeResource?.id, {
page_size: 200,
});
defaultCredentials = results;
}
return { return {
launchConfig: launch, launchConfig: launch,
surveyConfig: survey, surveyConfig: survey,
resourceDefaultCredentials: defaultCredentials,
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -319,6 +334,7 @@ const NodeModalInner = ({ i18n, title, ...rest }) => {
{...rest} {...rest}
launchConfig={launchConfig} launchConfig={launchConfig}
surveyConfig={surveyConfig} surveyConfig={surveyConfig}
resourceDefaultCredentials={resourceDefaultCredentials}
isLaunchLoading={isLoading} isLaunchLoading={isLoading}
title={wizardTitle} title={wizardTitle}
i18n={i18n} i18n={i18n}

View File

@@ -115,6 +115,11 @@ describe('NodeModal', () => {
}, },
}); });
JobTemplatesAPI.readLaunch.mockResolvedValue({ data: jtLaunchConfig }); JobTemplatesAPI.readLaunch.mockResolvedValue({ data: jtLaunchConfig });
JobTemplatesAPI.readCredentials.mockResolvedValue({
data: {
results: [],
},
});
JobTemplatesAPI.readSurvey.mockResolvedValue({ JobTemplatesAPI.readSurvey.mockResolvedValue({
data: { data: {
name: '', name: '',
@@ -239,7 +244,12 @@ describe('NodeModal', () => {
nodeToEdit: null, nodeToEdit: null,
}} }}
> >
<NodeModal askLinkType onSave={onSave} title="Add Node" /> <NodeModal
askLinkType
onSave={onSave}
title="Add Node"
resourceDefaultCredentials={[]}
/>
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
); );
@@ -254,8 +264,9 @@ describe('NodeModal', () => {
test('Can successfully create a new job template node', async () => { test('Can successfully create a new job template node', async () => {
act(() => { act(() => {
wrapper.find('#link-type-always').simulate('click'); wrapper.find('SelectableCard#link-type-always').simulate('click');
}); });
wrapper.update();
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
@@ -271,6 +282,9 @@ describe('NodeModal', () => {
wrapper.update(); wrapper.update();
expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1); expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1);
expect(JobTemplatesAPI.readCredentials).toBeCalledWith(1, {
page_size: 200,
});
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25); expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25);
wrapper.update(); wrapper.update();
expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next'); expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Next');
@@ -281,11 +295,6 @@ describe('NodeModal', () => {
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
}); });
wrapper.update();
expect(JobTemplatesAPI.readLaunch).toBeCalledWith(1);
expect(JobTemplatesAPI.readSurvey).toBeCalledWith(25);
wrapper.update(); wrapper.update();
expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Save'); expect(wrapper.find('NodeNextButton').prop('buttonText')).toBe('Save');
act(() => { act(() => {
@@ -317,7 +326,7 @@ describe('NodeModal', () => {
test('Can successfully create a new project sync node', async () => { test('Can successfully create a new project sync node', async () => {
act(() => { act(() => {
wrapper.find('#link-type-failure').simulate('click'); wrapper.find('SelectableCard#link-type-failure').simulate('click');
}); });
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
@@ -352,7 +361,7 @@ describe('NodeModal', () => {
test('Can successfully create a new inventory source sync node', async () => { test('Can successfully create a new inventory source sync node', async () => {
act(() => { act(() => {
wrapper.find('#link-type-failure').simulate('click'); wrapper.find('SelectableCard#link-type-failure').simulate('click');
}); });
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
@@ -446,7 +455,7 @@ describe('NodeModal', () => {
test('Can successfully create a new approval template node', async () => { test('Can successfully create a new approval template node', async () => {
act(() => { act(() => {
wrapper.find('#link-type-always').simulate('click'); wrapper.find('SelectableCard#link-type-always').simulate('click');
}); });
await act(async () => { await act(async () => {
wrapper.find('button#next-node-modal').simulate('click'); wrapper.find('button#next-node-modal').simulate('click');
@@ -543,6 +552,7 @@ describe('NodeModal', () => {
askLinkType={false} askLinkType={false}
onSave={onSave} onSave={onSave}
title="Edit Node" title="Edit Node"
resourceDefaultCredentials={[]}
/> />
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>
@@ -629,6 +639,7 @@ describe('NodeModal', () => {
askLinkType={false} askLinkType={false}
onSave={onSave} onSave={onSave}
title="Edit Node" title="Edit Node"
resourceDefaultCredentials={[]}
/> />
</WorkflowStateContext.Provider> </WorkflowStateContext.Provider>
</WorkflowDispatchContext.Provider> </WorkflowDispatchContext.Provider>

View File

@@ -3,7 +3,6 @@ import { useLocation } from 'react-router-dom';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { func, shape } from 'prop-types'; import { func, shape } from 'prop-types';
import { Tooltip } from '@patternfly/react-core';
import { JobTemplatesAPI } from '../../../../../../api'; import { JobTemplatesAPI } from '../../../../../../api';
import { getQSConfig, parseQueryString } from '../../../../../../util/qs'; import { getQSConfig, parseQueryString } from '../../../../../../util/qs';
import useRequest from '../../../../../../util/useRequest'; import useRequest from '../../../../../../util/useRequest';
@@ -57,56 +56,26 @@ function JobTemplatesList({ i18n, nodeResource, onUpdateNodeResource }) {
fetchJobTemplates(); fetchJobTemplates();
}, [fetchJobTemplates]); }, [fetchJobTemplates]);
const onSelectRow = row => {
if (
row.project &&
row.project !== null &&
((row.inventory && row.inventory !== null) || row.ask_inventory_on_launch)
) {
onUpdateNodeResource(row);
}
};
return ( return (
<PaginatedDataList <PaginatedDataList
contentError={error} contentError={error}
hasContentLoading={isLoading} hasContentLoading={isLoading}
itemCount={count} itemCount={count}
items={jobTemplates} items={jobTemplates}
onRowClick={row => onSelectRow(row)} onRowClick={row => onUpdateNodeResource(row)}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderItem={item => { renderItem={item => (
const isDisabled = <CheckboxListItem
!item.project || isSelected={!!(nodeResource && nodeResource.id === item.id)}
item.project === null || itemId={item.id}
((!item.inventory || item.inventory === null) && key={`${item.id}-listItem`}
!item.ask_inventory_on_launch); name={item.name}
const listItem = ( label={item.name}
<CheckboxListItem onSelect={() => onUpdateNodeResource(item)}
isDisabled={isDisabled} onDeselect={() => onUpdateNodeResource(null)}
isSelected={!!(nodeResource && nodeResource.id === item.id)} isRadio
itemId={item.id} />
key={`${item.id}-listItem`} )}
name={item.name}
label={item.name}
onSelect={() => onSelectRow(item)}
onDeselect={() => onUpdateNodeResource(null)}
isRadio
/>
);
return isDisabled ? (
<Tooltip
key={`${item.id}-tooltip`}
content={i18n._(
t`Job Templates with a missing inventory or project cannot be selected when creating or editing nodes`
)}
>
{listItem}
</Tooltip>
) : (
listItem
);
}}
renderToolbar={props => <DataListToolbar {...props} fillWidth />} renderToolbar={props => <DataListToolbar {...props} fillWidth />}
showPageSizeOptions={false} showPageSizeOptions={false}
toolbarSearchColumns={[ toolbarSearchColumns={[

View File

@@ -61,22 +61,15 @@ describe('JobTemplatesList', () => {
); );
}); });
wrapper.update(); wrapper.update();
// expect(wrapper.debug()).toBe(false);
expect( expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props() wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isSelected .isSelected
).toBe(true); ).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isDisabled
).toBe(false);
expect( expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props() wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isSelected .isSelected
).toBe(false); ).toBe(false);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isDisabled
).toBe(false);
wrapper wrapper
.find('CheckboxListItem[name="Test Job Template 2"]') .find('CheckboxListItem[name="Test Job Template 2"]')
.simulate('click'); .simulate('click');
@@ -89,71 +82,6 @@ describe('JobTemplatesList', () => {
project: 2, project: 2,
}); });
}); });
test('Row disabled when job template missing inventory or project', async () => {
JobTemplatesAPI.read.mockResolvedValueOnce({
data: {
count: 2,
results: [
{
id: 1,
name: 'Test Job Template',
type: 'job_template',
url: '/api/v2/job_templates/1',
inventory: 1,
project: null,
ask_inventory_on_launch: false,
},
{
id: 2,
name: 'Test Job Template 2',
type: 'job_template',
url: '/api/v2/job_templates/2',
inventory: null,
project: 2,
ask_inventory_on_launch: false,
},
],
},
});
JobTemplatesAPI.readOptions.mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<JobTemplatesList
nodeResource={nodeResource}
onUpdateNodeResource={onUpdateNodeResource}
/>
);
});
wrapper.update();
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isSelected
).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isDisabled
).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isSelected
).toBe(false);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template 2"]').props()
.isDisabled
).toBe(true);
wrapper
.find('CheckboxListItem[name="Test Job Template 2"]')
.simulate('click');
expect(onUpdateNodeResource).not.toHaveBeenCalled();
});
test('Error shown when read() request errors', async () => { test('Error shown when read() request errors', async () => {
JobTemplatesAPI.read.mockRejectedValue(new Error()); JobTemplatesAPI.read.mockRejectedValue(new Error());
JobTemplatesAPI.readOptions.mockResolvedValue({ JobTemplatesAPI.readOptions.mockResolvedValue({

View File

@@ -4,7 +4,7 @@ import { withI18n } from '@lingui/react';
import { t, Trans } from '@lingui/macro'; import { t, Trans } from '@lingui/macro';
import styled from 'styled-components'; import styled from 'styled-components';
import { useField } from 'formik'; import { useField } from 'formik';
import { Form, FormGroup, TextInput } from '@patternfly/react-core'; import { Alert, Form, FormGroup, TextInput } from '@patternfly/react-core';
import { required } from '../../../../../../util/validators'; import { required } from '../../../../../../util/validators';
import { FormFullWidthLayout } from '../../../../../../components/FormLayout'; import { FormFullWidthLayout } from '../../../../../../components/FormLayout';
@@ -15,6 +15,10 @@ import ProjectsList from './ProjectsList';
import WorkflowJobTemplatesList from './WorkflowJobTemplatesList'; import WorkflowJobTemplatesList from './WorkflowJobTemplatesList';
import FormField from '../../../../../../components/FormField'; import FormField from '../../../../../../components/FormField';
const NodeTypeErrorAlert = styled(Alert)`
margin-bottom: 20px;
`;
const TimeoutInput = styled(TextInput)` const TimeoutInput = styled(TextInput)`
width: 200px; width: 200px;
:not(:first-of-type) { :not(:first-of-type) {
@@ -29,7 +33,9 @@ const TimeoutLabel = styled.p`
function NodeTypeStep({ i18n }) { function NodeTypeStep({ i18n }) {
const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType'); const [nodeTypeField, , nodeTypeHelpers] = useField('nodeType');
const [nodeResourceField, , nodeResourceHelpers] = useField('nodeResource'); const [nodeResourceField, nodeResourceMeta, nodeResourceHelpers] = useField(
'nodeResource'
);
const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName'); const [, approvalNameMeta, approvalNameHelpers] = useField('approvalName');
const [, , approvalDescriptionHelpers] = useField('approvalDescription'); const [, , approvalDescriptionHelpers] = useField('approvalDescription');
const [timeoutMinutesField, , timeoutMinutesHelpers] = useField( const [timeoutMinutesField, , timeoutMinutesHelpers] = useField(
@@ -42,6 +48,13 @@ function NodeTypeStep({ i18n }) {
const isValid = !approvalNameMeta.touched || !approvalNameMeta.error; const isValid = !approvalNameMeta.touched || !approvalNameMeta.error;
return ( return (
<> <>
{nodeResourceMeta.error && (
<NodeTypeErrorAlert
variant="danger"
isInline
title={nodeResourceMeta.error}
/>
)}
<div css="display: flex; align-items: center; margin-bottom: 20px;"> <div css="display: flex; align-items: center; margin-bottom: 20px;">
<b css="margin-right: 24px">{i18n._(t`Node Type`)}</b> <b css="margin-right: 24px">{i18n._(t`Node Type`)}</b>
<div> <div>

View File

@@ -6,31 +6,62 @@ import StepName from '../../../../../../components/LaunchPrompt/steps/StepName';
const STEP_ID = 'nodeType'; const STEP_ID = 'nodeType';
export default function useNodeTypeStep(i18n) { export default function useNodeTypeStep(launchConfig, i18n) {
const [, meta] = useField('nodeType'); const [, meta] = useField('nodeType');
const [approvalNameField] = useField('approvalName'); const [approvalNameField] = useField('approvalName');
const [nodeTypeField, ,] = useField('nodeType'); const [nodeTypeField, ,] = useField('nodeType');
const [nodeResourceField] = useField('nodeResource'); const [nodeResourceField, nodeResourceMeta] = useField({
name: 'nodeResource',
validate: value => {
if (
value?.type === 'job_template' &&
(!value?.project ||
value?.project === null ||
((!value?.inventory || value?.inventory === null) &&
!value?.ask_inventory_on_launch))
) {
return i18n._(
t`Job Templates with a missing inventory or project cannot be selected when creating or editing nodes. Select another template or fix the missing fields to proceed.`
);
}
return undefined;
},
});
const formError = !!meta.error || !!nodeResourceMeta.error;
return { return {
step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField), step: getStep(
i18n,
nodeTypeField,
approvalNameField,
nodeResourceField,
formError
),
initialValues: getInitialValues(), initialValues: getInitialValues(),
isReady: true, isReady: true,
contentError: null, contentError: null,
hasError: !!meta.error, hasError: formError,
setTouched: setFieldTouched => { setTouched: setFieldTouched => {
setFieldTouched('nodeType', true, false); setFieldTouched('nodeType', true, false);
}, },
validate: () => {}, validate: () => {},
}; };
} }
function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) { function getStep(
i18n,
nodeTypeField,
approvalNameField,
nodeResourceField,
formError
) {
const isEnabled = () => { const isEnabled = () => {
if ( if (
(nodeTypeField.value !== 'workflow_approval_template' && (nodeTypeField.value !== 'workflow_approval_template' &&
nodeResourceField.value === null) || nodeResourceField.value === null) ||
(nodeTypeField.value === 'workflow_approval_template' && (nodeTypeField.value === 'workflow_approval_template' &&
approvalNameField.value === undefined) approvalNameField.value === undefined) ||
formError
) { ) {
return false; return false;
} }
@@ -39,7 +70,7 @@ function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {
return { return {
id: STEP_ID, id: STEP_ID,
name: ( name: (
<StepName hasErrors={false} id="node-type-step"> <StepName hasErrors={formError} id="node-type-step">
{i18n._(t`Node type`)} {i18n._(t`Node type`)}
</StepName> </StepName>
), ),

View File

@@ -1,5 +1,6 @@
import { useContext, useState, useEffect } from 'react'; import { useContext, useState, useEffect } from 'react';
import { useFormikContext } from 'formik'; import { useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import useInventoryStep from '../../../../../components/LaunchPrompt/steps/useInventoryStep'; import useInventoryStep from '../../../../../components/LaunchPrompt/steps/useInventoryStep';
import useCredentialsStep from '../../../../../components/LaunchPrompt/steps/useCredentialsStep'; import useCredentialsStep from '../../../../../components/LaunchPrompt/steps/useCredentialsStep';
import useOtherPromptsStep from '../../../../../components/LaunchPrompt/steps/useOtherPromptsStep'; import useOtherPromptsStep from '../../../../../components/LaunchPrompt/steps/useOtherPromptsStep';
@@ -29,7 +30,12 @@ function showPreviewStep(nodeType, launchConfig) {
); );
} }
const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => { const getNodeToEditDefaultValues = (
launchConfig,
surveyConfig,
nodeToEdit,
resourceDefaultCredentials
) => {
const initialValues = { const initialValues = {
nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null, nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null,
nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template', nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template',
@@ -70,35 +76,34 @@ const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => {
} else if (nodeToEdit?.originalNodeCredentials) { } else if (nodeToEdit?.originalNodeCredentials) {
const defaultCredsWithoutOverrides = []; const defaultCredsWithoutOverrides = [];
const credentialHasScheduleOverride = templateDefaultCred => { const credentialHasOverride = templateDefaultCred => {
let credentialHasOverride = false; let hasOverride = false;
nodeToEdit.originalNodeCredentials.forEach(scheduleCred => { nodeToEdit.originalNodeCredentials.forEach(nodeCredential => {
if ( if (
templateDefaultCred.credential_type === scheduleCred.credential_type templateDefaultCred.credential_type ===
nodeCredential.credential_type
) { ) {
if ( if (
(!templateDefaultCred.vault_id && (!templateDefaultCred.vault_id &&
!scheduleCred.inputs.vault_id) || !nodeCredential.inputs.vault_id) ||
(templateDefaultCred.vault_id && (templateDefaultCred.vault_id &&
scheduleCred.inputs.vault_id && nodeCredential.inputs.vault_id &&
templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) templateDefaultCred.vault_id === nodeCredential.inputs.vault_id)
) { ) {
credentialHasOverride = true; hasOverride = true;
} }
} }
}); });
return credentialHasOverride; return hasOverride;
}; };
if (nodeToEdit?.fullUnifiedJobTemplate?.summary_fields?.credentials) { if (resourceDefaultCredentials) {
nodeToEdit.fullUnifiedJobTemplate.summary_fields.credentials.forEach( resourceDefaultCredentials.forEach(defaultCred => {
defaultCred => { if (!credentialHasOverride(defaultCred)) {
if (!credentialHasScheduleOverride(defaultCred)) { defaultCredsWithoutOverrides.push(defaultCred);
defaultCredsWithoutOverrides.push(defaultCred);
}
} }
); });
} }
initialValues.credentials = nodeToEdit.originalNodeCredentials.concat( initialValues.credentials = nodeToEdit.originalNodeCredentials.concat(
@@ -179,17 +184,27 @@ export default function useWorkflowNodeSteps(
surveyConfig, surveyConfig,
i18n, i18n,
resource, resource,
askLinkType askLinkType,
resourceDefaultCredentials
) { ) {
const { nodeToEdit } = useContext(WorkflowStateContext); const { nodeToEdit } = useContext(WorkflowStateContext);
const { resetForm, values: formikValues } = useFormikContext(); const {
resetForm,
values: formikValues,
errors: formikErrors,
} = useFormikContext();
const [visited, setVisited] = useState({}); const [visited, setVisited] = useState({});
const steps = [ const steps = [
useRunTypeStep(i18n, askLinkType), useRunTypeStep(i18n, askLinkType),
useNodeTypeStep(i18n), useNodeTypeStep(launchConfig, i18n),
useInventoryStep(launchConfig, resource, i18n, visited), useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n), useCredentialsStep(
launchConfig,
resource,
resourceDefaultCredentials,
i18n
),
useOtherPromptsStep(launchConfig, resource, i18n), useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited), useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
]; ];
@@ -222,7 +237,8 @@ export default function useWorkflowNodeSteps(
initialValues = getNodeToEditDefaultValues( initialValues = getNodeToEditDefaultValues(
launchConfig, launchConfig,
surveyConfig, surveyConfig,
nodeToEdit nodeToEdit,
resourceDefaultCredentials
); );
} else { } else {
initialValues = steps.reduce((acc, cur) => { initialValues = steps.reduce((acc, cur) => {
@@ -233,7 +249,23 @@ export default function useWorkflowNodeSteps(
}, {}); }, {});
} }
const errors = formikErrors.nodeResource
? {
nodeResource: formikErrors.nodeResource,
}
: {};
if (
!launchConfig?.ask_credential_on_launch &&
launchConfig?.passwords_needed_to_start?.length > 0
) {
errors.nodeResource = i18n._(
t`Job Templates with credentials that prompt for passwords cannot be selected when creating or editing nodes`
);
}
resetForm({ resetForm({
errors,
values: { values: {
...initialValues, ...initialValues,
nodeResource: formikValues.nodeResource, nodeResource: formikValues.nodeResource,