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 [launchConfig, setLaunchConfig] = useState(null);
const [surveyConfig, setSurveyConfig] = useState(null);
const [resourceCredentials, setResourceCredentials] = useState([]);
const [error, setError] = useState(null);
const handleLaunch = async () => {
const readLaunch =
@ -56,6 +57,17 @@ function LaunchButton({ resource, i18n, children, history }) {
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)) {
launchWithParams({});
} else {
@ -161,6 +173,7 @@ function LaunchButton({ resource, i18n, children, history }) {
resource={resource}
onLaunch={launchWithParams}
onCancel={() => setShowLaunchPrompt(false)}
resourceDefaultCredentials={resourceCredentials}
/>
)}
</Fragment>

View File

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

View File

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

View File

@ -9,17 +9,95 @@ jest.mock('../../../api/models/CredentialTypes');
jest.mock('../../../api/models/Credentials');
const types = [
{ id: 1, kind: 'ssh', name: 'SSH' },
{ id: 2, kind: 'cloud', name: 'Ansible Tower' },
{ id: 3, kind: 'vault', name: 'Vault' },
{ id: 1, kind: 'ssh', name: 'SSH', url: '/api/v2/credential_types/1/' },
{ id: 3, kind: 'vault', name: 'Vault', url: '/api/v2/credential_types/3/' },
{
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 = [
{ id: 1, kind: 'cloud', name: 'Cred 1', url: 'www.google.com' },
{ id: 2, kind: 'ssh', name: 'Cred 2', url: 'www.google.com' },
{ id: 3, kind: 'Ansible', name: 'Cred 3', url: 'www.google.com' },
{ id: 4, kind: 'Machine', name: 'Cred 4', url: 'www.google.com' },
{ id: 5, kind: 'Machine', name: 'Cred 5', url: 'www.google.com' },
{
id: 1,
kind: 'aws',
name: 'Cred 1',
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', () => {
@ -47,7 +125,7 @@ describe('CredentialsStep', () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik>
<CredentialsStep />
<CredentialsStep allowCredentialsWithPasswords />
</Formik>
);
});
@ -62,7 +140,7 @@ describe('CredentialsStep', () => {
await act(async () => {
wrapper = mountWithContexts(
<Formik>
<CredentialsStep />
<CredentialsStep allowCredentialsWithPasswords />
</Formik>
);
});
@ -76,13 +154,173 @@ describe('CredentialsStep', () => {
});
await act(async () => {
wrapper.find('AnsibleSelect').invoke('onChange')({}, 2);
wrapper.find('AnsibleSelect').invoke('onChange')({}, 3);
});
expect(CredentialsAPI.read).toHaveBeenCalledWith({
credential_type: 2,
credential_type: 3,
order_by: 'name',
page: 1,
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 { t } from '@lingui/macro';
import { useField } from 'formik';
import styled from 'styled-components';
import { Alert } from '@patternfly/react-core';
import { InventoriesAPI } from '../../../api';
import { getQSConfig, parseQueryString } from '../../../util/qs';
@ -11,6 +12,10 @@ import OptionsList from '../../OptionsList';
import ContentLoading from '../../ContentLoading';
import ContentError from '../../ContentError';
const InventoryErrorAlert = styled(Alert)`
margin-bottom: 20px;
`;
const QS_CONFIG = getQSConfig('inventory', {
page: 1,
page_size: 5,
@ -68,6 +73,9 @@ function InventoryStep({ i18n, warningMessage = null }) {
return (
<>
{meta.touched && meta.error && (
<InventoryErrorAlert variant="danger" isInline title={meta.error} />
)}
{warningMessage}
<OptionsList
value={field.value ? [field.value] : []}
@ -103,9 +111,6 @@ function InventoryStep({ i18n, warningMessage = null }) {
selectItem={helpers.setValue}
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 { t } from '@lingui/macro';
import { useField } from 'formik';
import CredentialsStep from './CredentialsStep';
import StepName from './StepName';
import credentialsValidator from './credentialsValidator';
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 {
step: getStep(launchConfig, i18n),
initialValues: getInitialValues(launchConfig, resource),
step: getStep(
launchConfig,
i18n,
allowCredentialsWithPasswords,
formError,
resourceDefaultCredentials
),
initialValues: getInitialValues(launchConfig, resourceDefaultCredentials),
isReady: true,
contentError: null,
hasError: false,
hasError: launchConfig.ask_credential_on_launch && formError,
setTouched: setFieldTouched => {
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) {
return null;
}
@ -27,21 +61,27 @@ function getStep(launchConfig, i18n) {
id: STEP_ID,
key: 4,
name: (
<StepName hasErrors={false} id="credentials-step">
<StepName hasErrors={formError} id="credentials-step">
{i18n._(t`Credentials`)}
</StepName>
),
component: <CredentialsStep i18n={i18n} />,
component: (
<CredentialsStep
i18n={i18n}
allowCredentialsWithPasswords={allowCredentialsWithPasswords}
defaultCredentials={resourceDefaultCredentials}
/>
),
enableNext: true,
};
}
function getInitialValues(launchConfig, resource) {
function getInitialValues(launchConfig, resourceDefaultCredentials) {
if (!launchConfig.ask_credential_on_launch) {
return {};
}
return {
credentials: resource?.summary_fields?.credentials || [],
credentials: resourceDefaultCredentials || [],
};
}

View File

@ -43,14 +43,21 @@ export default function useLaunchSteps(
launchConfig,
surveyConfig,
resource,
i18n
i18n,
resourceDefaultCredentials
) {
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),
useCredentialsStep(
launchConfig,
resource,
resourceDefaultCredentials,
i18n,
true
),
useCredentialPasswordsStep(
launchConfig,
i18n,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -204,6 +204,7 @@ function ScheduleForm({
resource,
launchConfig,
surveyConfig,
resourceDefaultCredentials,
...rest
}) {
const [isWizardOpen, setIsWizardOpen] = useState(false);
@ -297,11 +298,84 @@ function ScheduleForm({
return missingValues;
}, [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(() => {
if (isTemplate && (missingRequiredInventory() || hasMissingSurveyValue())) {
if (
isTemplate &&
(missingRequiredInventory() ||
hasMissingSurveyValue() ||
hasCredentialsThatPrompt())
) {
setIsSaveDisabled(true);
}
}, [isTemplate, hasMissingSurveyValue, missingRequiredInventory]);
}, [
isTemplate,
hasMissingSurveyValue,
missingRequiredInventory,
hasCredentialsThatPrompt,
]);
useEffect(() => {
loadScheduleData();
@ -527,14 +601,14 @@ function ScheduleForm({
surveyConfig={surveyConfig}
launchConfig={launchConfig}
resource={resource}
onCloseWizard={hasErrors => {
onCloseWizard={() => {
setIsWizardOpen(false);
setIsSaveDisabled(hasErrors);
}}
onSave={() => {
setIsWizardOpen(false);
setIsSaveDisabled(false);
}}
resourceDefaultCredentials={resourceDefaultCredentials}
/>
)}
<FormSubmitError error={submitError} />

View File

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

View File

@ -13,29 +13,28 @@ export default function useSchedulePromptSteps(
schedule,
resource,
i18n,
scheduleCredentials
scheduleCredentials,
resourceDefaultCredentials
) {
const {
summary_fields: { credentials: resourceCredentials },
} = resource;
const sourceOfValues =
(Object.keys(schedule).length > 0 && schedule) || resource;
sourceOfValues.summary_fields = {
credentials: [...(resourceCredentials || []), ...scheduleCredentials],
...sourceOfValues.summary_fields,
};
const { resetForm, values } = useFormikContext();
const [visited, setVisited] = useState({});
const steps = [
useInventoryStep(launchConfig, sourceOfValues, i18n, visited),
useCredentialsStep(launchConfig, sourceOfValues, i18n),
useCredentialsStep(
launchConfig,
sourceOfValues,
resourceDefaultCredentials,
i18n
),
useOtherPromptsStep(launchConfig, sourceOfValues, i18n),
useSurveyStep(launchConfig, surveyConfig, sourceOfValues, i18n, visited),
];
const hasErrors = steps.some(step => step.hasError);
steps.push(
usePreviewStep(
launchConfig,
@ -52,21 +51,61 @@ export default function useSchedulePromptSteps(
const isReady = !steps.some(s => !s.isReady);
useEffect(() => {
let initialValues = {};
if (launchConfig && surveyConfig && isReady) {
let initialValues = {};
initialValues = steps.reduce((acc, cur) => {
return {
...acc,
...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
}, [launchConfig, surveyConfig, isReady]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,22 +61,15 @@ describe('JobTemplatesList', () => {
);
});
wrapper.update();
// expect(wrapper.debug()).toBe(false);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isSelected
).toBe(true);
expect(
wrapper.find('CheckboxListItem[name="Test Job Template"]').props()
.isDisabled
).toBe(false);
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(false);
wrapper
.find('CheckboxListItem[name="Test Job Template 2"]')
.simulate('click');
@ -89,71 +82,6 @@ describe('JobTemplatesList', () => {
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 () => {
JobTemplatesAPI.read.mockRejectedValue(new Error());
JobTemplatesAPI.readOptions.mockResolvedValue({

View File

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

View File

@ -6,31 +6,62 @@ import StepName from '../../../../../../components/LaunchPrompt/steps/StepName';
const STEP_ID = 'nodeType';
export default function useNodeTypeStep(i18n) {
export default function useNodeTypeStep(launchConfig, i18n) {
const [, meta] = useField('nodeType');
const [approvalNameField] = useField('approvalName');
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 {
step: getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField),
step: getStep(
i18n,
nodeTypeField,
approvalNameField,
nodeResourceField,
formError
),
initialValues: getInitialValues(),
isReady: true,
contentError: null,
hasError: !!meta.error,
hasError: formError,
setTouched: setFieldTouched => {
setFieldTouched('nodeType', true, false);
},
validate: () => {},
};
}
function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {
function getStep(
i18n,
nodeTypeField,
approvalNameField,
nodeResourceField,
formError
) {
const isEnabled = () => {
if (
(nodeTypeField.value !== 'workflow_approval_template' &&
nodeResourceField.value === null) ||
(nodeTypeField.value === 'workflow_approval_template' &&
approvalNameField.value === undefined)
approvalNameField.value === undefined) ||
formError
) {
return false;
}
@ -39,7 +70,7 @@ function getStep(i18n, nodeTypeField, approvalNameField, nodeResourceField) {
return {
id: STEP_ID,
name: (
<StepName hasErrors={false} id="node-type-step">
<StepName hasErrors={formError} id="node-type-step">
{i18n._(t`Node type`)}
</StepName>
),

View File

@ -1,5 +1,6 @@
import { useContext, useState, useEffect } from 'react';
import { useFormikContext } from 'formik';
import { t } from '@lingui/macro';
import useInventoryStep from '../../../../../components/LaunchPrompt/steps/useInventoryStep';
import useCredentialsStep from '../../../../../components/LaunchPrompt/steps/useCredentialsStep';
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 = {
nodeResource: nodeToEdit?.fullUnifiedJobTemplate || null,
nodeType: nodeToEdit?.fullUnifiedJobTemplate?.type || 'job_template',
@ -70,35 +76,34 @@ const getNodeToEditDefaultValues = (launchConfig, surveyConfig, nodeToEdit) => {
} else if (nodeToEdit?.originalNodeCredentials) {
const defaultCredsWithoutOverrides = [];
const credentialHasScheduleOverride = templateDefaultCred => {
let credentialHasOverride = false;
nodeToEdit.originalNodeCredentials.forEach(scheduleCred => {
const credentialHasOverride = templateDefaultCred => {
let hasOverride = false;
nodeToEdit.originalNodeCredentials.forEach(nodeCredential => {
if (
templateDefaultCred.credential_type === scheduleCred.credential_type
templateDefaultCred.credential_type ===
nodeCredential.credential_type
) {
if (
(!templateDefaultCred.vault_id &&
!scheduleCred.inputs.vault_id) ||
!nodeCredential.inputs.vault_id) ||
(templateDefaultCred.vault_id &&
scheduleCred.inputs.vault_id &&
templateDefaultCred.vault_id === scheduleCred.inputs.vault_id)
nodeCredential.inputs.vault_id &&
templateDefaultCred.vault_id === nodeCredential.inputs.vault_id)
) {
credentialHasOverride = true;
hasOverride = true;
}
}
});
return credentialHasOverride;
return hasOverride;
};
if (nodeToEdit?.fullUnifiedJobTemplate?.summary_fields?.credentials) {
nodeToEdit.fullUnifiedJobTemplate.summary_fields.credentials.forEach(
defaultCred => {
if (!credentialHasScheduleOverride(defaultCred)) {
defaultCredsWithoutOverrides.push(defaultCred);
}
if (resourceDefaultCredentials) {
resourceDefaultCredentials.forEach(defaultCred => {
if (!credentialHasOverride(defaultCred)) {
defaultCredsWithoutOverrides.push(defaultCred);
}
);
});
}
initialValues.credentials = nodeToEdit.originalNodeCredentials.concat(
@ -179,17 +184,27 @@ export default function useWorkflowNodeSteps(
surveyConfig,
i18n,
resource,
askLinkType
askLinkType,
resourceDefaultCredentials
) {
const { nodeToEdit } = useContext(WorkflowStateContext);
const { resetForm, values: formikValues } = useFormikContext();
const {
resetForm,
values: formikValues,
errors: formikErrors,
} = useFormikContext();
const [visited, setVisited] = useState({});
const steps = [
useRunTypeStep(i18n, askLinkType),
useNodeTypeStep(i18n),
useNodeTypeStep(launchConfig, i18n),
useInventoryStep(launchConfig, resource, i18n, visited),
useCredentialsStep(launchConfig, resource, i18n),
useCredentialsStep(
launchConfig,
resource,
resourceDefaultCredentials,
i18n
),
useOtherPromptsStep(launchConfig, resource, i18n),
useSurveyStep(launchConfig, surveyConfig, resource, i18n, visited),
];
@ -222,7 +237,8 @@ export default function useWorkflowNodeSteps(
initialValues = getNodeToEditDefaultValues(
launchConfig,
surveyConfig,
nodeToEdit
nodeToEdit,
resourceDefaultCredentials
);
} else {
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({
errors,
values: {
...initialValues,
nodeResource: formikValues.nodeResource,