diff --git a/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx b/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx
index 19ad76c796..b0d27ccc6e 100644
--- a/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx
+++ b/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx
@@ -7,16 +7,11 @@ import { CheckboxField, FieldTooltip } from '../FormField';
const FieldHeader = styled.div`
display: flex;
- justify-content: space-between;
- padding-bottom: var(--pf-c-form__label--PaddingBottom);
-
- label {
- --pf-c-form__label--PaddingBottom: 0px;
- }
`;
const StyledCheckboxField = styled(CheckboxField)`
--pf-c-check__label--FontSize: var(--pf-c-form__label--FontSize);
+ margin-left: auto;
`;
function FieldWithPrompt({
diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx
index c721b56789..3b75634928 100644
--- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx
@@ -25,17 +25,33 @@ function CredentialAdd({ me }) {
result: credentialId,
} = useRequest(
useCallback(
- async values => {
- const { inputs, organization, ...remainingValues } = values;
+ async (values, credentialTypesMap) => {
+ const { inputs: credentialTypeInputs } = credentialTypesMap[
+ values.credential_type
+ ];
+
+ const {
+ inputs,
+ organization,
+ passwordPrompts,
+ ...remainingValues
+ } = values;
+
const nonPluginInputs = {};
const pluginInputs = {};
- Object.entries(inputs).forEach(([key, value]) => {
- if (value.credential && value.inputs) {
- pluginInputs[key] = value;
+ const possibleFields = credentialTypeInputs.fields || [];
+
+ possibleFields.forEach(field => {
+ const input = inputs[field.id];
+ if (input.credential && input.inputs) {
+ pluginInputs[field.id] = input;
+ } else if (passwordPrompts[field.id]) {
+ nonPluginInputs[field.id] = 'ASK';
} else {
- nonPluginInputs[key] = value;
+ nonPluginInputs[field.id] = input;
}
});
+
const {
data: { id: newCredentialId },
} = await CredentialsAPI.create({
@@ -44,18 +60,17 @@ function CredentialAdd({ me }) {
inputs: nonPluginInputs,
...remainingValues,
});
- const inputSourceRequests = [];
- Object.entries(pluginInputs).forEach(([key, value]) => {
- inputSourceRequests.push(
+
+ await Promise.all(
+ Object.entries(pluginInputs).map(([key, value]) =>
CredentialInputSourcesAPI.create({
input_field_name: key,
metadata: value.inputs,
source_credential: value.credential.id,
target_credential: newCredentialId,
})
- );
- });
- await Promise.all(inputSourceRequests);
+ )
+ );
return newCredentialId;
},
@@ -74,10 +89,13 @@ function CredentialAdd({ me }) {
try {
const {
data: { results: loadedCredentialTypes },
- } = await CredentialTypesAPI.read({
- or__namespace: ['gce', 'scm', 'ssh'],
- });
- setCredentialTypes(loadedCredentialTypes);
+ } = await CredentialTypesAPI.read();
+ setCredentialTypes(
+ loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => {
+ credentialTypesMap[credentialType.id] = credentialType;
+ return credentialTypesMap;
+ }, {})
+ );
} catch (err) {
setError(err);
} finally {
@@ -92,7 +110,7 @@ function CredentialAdd({ me }) {
};
const handleSubmit = async values => {
- await submitRequest(values);
+ await submitRequest(values, credentialTypes);
};
if (error) {
diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx
index c348c6e252..24c7e9df0a 100644
--- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.test.jsx
@@ -5,9 +5,12 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
-import { sleep } from '../../../../testUtils/testUtils';
-import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
+import {
+ CredentialsAPI,
+ CredentialInputSourcesAPI,
+ CredentialTypesAPI,
+} from '../../../api';
import CredentialAdd from './CredentialAdd';
jest.mock('../../../api');
@@ -15,58 +18,6 @@ jest.mock('../../../api');
CredentialTypesAPI.read.mockResolvedValue({
data: {
results: [
- {
- id: 2,
- type: 'credential_type',
- url: '/api/v2/credential_types/2/',
- related: {
- credentials: '/api/v2/credential_types/2/credentials/',
- activity_stream: '/api/v2/credential_types/2/activity_stream/',
- },
- summary_fields: {
- user_capabilities: {
- edit: false,
- delete: false,
- },
- },
- created: '2020-02-12T19:42:43.551238Z',
- modified: '2020-02-12T19:43:03.164800Z',
- name: 'Source Control',
- description: '',
- kind: 'scm',
- namespace: 'scm',
- managed_by_tower: true,
- inputs: {
- fields: [
- {
- id: 'username',
- label: 'Username',
- type: 'string',
- },
- {
- id: 'password',
- label: 'Password',
- type: 'string',
- secret: true,
- },
- {
- id: 'ssh_key_data',
- label: 'Source Control Private Key',
- type: 'string',
- format: 'ssh_private_key',
- secret: true,
- multiline: true,
- },
- {
- id: 'ssh_key_unlock',
- label: 'Private Key Passphrase',
- type: 'string',
- secret: true,
- },
- ],
- },
- injectors: {},
- },
{
id: 1,
type: 'credential_type',
@@ -157,48 +108,98 @@ describe('', () => {
let wrapper;
let history;
- beforeEach(async () => {
- history = createMemoryHistory({ initialEntries: ['/credentials'] });
- await act(async () => {
- wrapper = mountWithContexts(, {
- context: { router: { history } },
+ describe('Initial GET request succeeds', () => {
+ beforeEach(async () => {
+ history = createMemoryHistory({ initialEntries: ['/credentials'] });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
});
});
- });
- afterEach(() => {
- wrapper.unmount();
- });
-
- test('Initially renders successfully', () => {
- expect(wrapper.length).toBe(1);
- });
- test('handleSubmit should call the api and redirect to details page', async () => {
- await waitForElement(wrapper, 'isLoading', el => el.length === 0);
-
- wrapper.find('CredentialForm').prop('onSubmit')({
- user: 1,
- organization: null,
- name: 'foo',
- description: 'bar',
- credential_type: '2',
- inputs: {},
+ afterEach(() => {
+ wrapper.unmount();
});
- await sleep(1);
- expect(CredentialsAPI.create).toHaveBeenCalledWith({
- user: 1,
- organization: null,
- name: 'foo',
- description: 'bar',
- credential_type: '2',
- inputs: {},
+
+ test('handleSubmit should call the api and redirect to details page', async () => {
+ await waitForElement(wrapper, 'isLoading', el => el.length === 0);
+ await act(async () => {
+ wrapper.find('CredentialForm').prop('onSubmit')({
+ user: 1,
+ organization: null,
+ name: 'foo',
+ description: 'bar',
+ credential_type: '1',
+ inputs: {
+ username: {
+ credential: {
+ id: 1,
+ name: 'Some cred',
+ },
+ inputs: {
+ foo: 'bar',
+ },
+ },
+ password: 'foo',
+ ssh_key_data: 'bar',
+ ssh_public_key_data: 'baz',
+ ssh_key_unlock: 'foobar',
+ become_method: '',
+ become_username: '',
+ become_password: '',
+ },
+ passwordPrompts: {
+ become_password: true,
+ },
+ });
+ });
+ expect(CredentialsAPI.create).toHaveBeenCalledWith({
+ user: 1,
+ organization: null,
+ name: 'foo',
+ description: 'bar',
+ credential_type: '1',
+ inputs: {
+ password: 'foo',
+ ssh_key_data: 'bar',
+ ssh_public_key_data: 'baz',
+ ssh_key_unlock: 'foobar',
+ become_method: '',
+ become_username: '',
+ become_password: 'ASK',
+ },
+ });
+ expect(CredentialInputSourcesAPI.create).toHaveBeenCalledWith({
+ input_field_name: 'username',
+ metadata: {
+ foo: 'bar',
+ },
+ source_credential: 1,
+ target_credential: 13,
+ });
+ expect(history.location.pathname).toBe('/credentials/13/details');
+ });
+
+ test('handleCancel should return the user back to the inventories list', async () => {
+ await waitForElement(wrapper, 'isLoading', el => el.length === 0);
+ wrapper.find('Button[aria-label="Cancel"]').simulate('click');
+ expect(history.location.pathname).toEqual('/credentials');
});
- expect(history.location.pathname).toBe('/credentials/13/details');
});
- test('handleCancel should return the user back to the inventories list', async () => {
- await waitForElement(wrapper, 'isLoading', el => el.length === 0);
- wrapper.find('Button[aria-label="Cancel"]').simulate('click');
- expect(history.location.pathname).toEqual('/credentials');
+ describe('Initial GET request fails', () => {
+ test('shows error when initial GET request fails', async () => {
+ CredentialTypesAPI.read.mockRejectedValue(new Error());
+ history = createMemoryHistory({ initialEntries: ['/credentials'] });
+ await act(async () => {
+ wrapper = mountWithContexts(, {
+ context: { router: { history } },
+ });
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError').length).toBe(1);
+ wrapper.unmount();
+ });
});
});
diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx
index aef18ea8e9..79fe6c5f7c 100644
--- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx
@@ -1,7 +1,6 @@
import React, { useCallback, useState, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import { object } from 'prop-types';
-
import { CardBody } from '../../../components/Card';
import {
CredentialsAPI,
@@ -22,8 +21,34 @@ function CredentialEdit({ credential, me }) {
const { error: submitError, request: submitRequest, result } = useRequest(
useCallback(
- async (values, inputSourceMap) => {
- const createAndUpdateInputSources = pluginInputs =>
+ async (values, credentialTypesMap, inputSourceMap) => {
+ const { inputs: credentialTypeInputs } = credentialTypesMap[
+ values.credential_type
+ ];
+
+ const {
+ inputs,
+ organization,
+ passwordPrompts,
+ ...remainingValues
+ } = values;
+
+ const nonPluginInputs = {};
+ const pluginInputs = {};
+ const possibleFields = credentialTypeInputs.fields || [];
+
+ possibleFields.forEach(field => {
+ const input = inputs[field.id];
+ if (input.credential && input.inputs) {
+ pluginInputs[field.id] = input;
+ } else if (passwordPrompts[field.id]) {
+ nonPluginInputs[field.id] = 'ASK';
+ } else {
+ nonPluginInputs[field.id] = input;
+ }
+ });
+
+ const createAndUpdateInputSources = () =>
Object.entries(pluginInputs).map(([fieldName, fieldValue]) => {
if (!inputSourceMap[fieldName]) {
return CredentialInputSourcesAPI.create({
@@ -46,27 +71,15 @@ function CredentialEdit({ credential, me }) {
return null;
});
- const destroyInputSources = inputs => {
- const destroyRequests = [];
- Object.values(inputSourceMap).forEach(inputSource => {
+ const destroyInputSources = () =>
+ Object.values(inputSourceMap).map(inputSource => {
const { id, input_field_name } = inputSource;
if (!inputs[input_field_name]?.credential) {
- destroyRequests.push(CredentialInputSourcesAPI.destroy(id));
+ return CredentialInputSourcesAPI.destroy(id);
}
+ return null;
});
- return destroyRequests;
- };
- const { inputs, organization, ...remainingValues } = values;
- const nonPluginInputs = {};
- const pluginInputs = {};
- Object.entries(inputs).forEach(([key, value]) => {
- if (value.credential && value.inputs) {
- pluginInputs[key] = value;
- } else {
- nonPluginInputs[key] = value;
- }
- });
const [{ data }] = await Promise.all([
CredentialsAPI.update(credential.id, {
user: (me && me.id) || null,
@@ -74,12 +87,14 @@ function CredentialEdit({ credential, me }) {
inputs: nonPluginInputs,
...remainingValues,
}),
- ...destroyInputSources(inputs),
+ ...destroyInputSources(),
]);
- await Promise.all(createAndUpdateInputSources(pluginInputs));
+
+ await Promise.all(createAndUpdateInputSources());
+
return data;
},
- [credential.id, me]
+ [me, credential.id]
)
);
@@ -100,12 +115,15 @@ function CredentialEdit({ credential, me }) {
data: { results: loadedInputSources },
},
] = await Promise.all([
- CredentialTypesAPI.read({
- or__namespace: ['gce', 'scm', 'ssh'],
- }),
+ CredentialTypesAPI.read(),
CredentialsAPI.readInputSources(credential.id, { page_size: 200 }),
]);
- setCredentialTypes(loadedCredentialTypes);
+ setCredentialTypes(
+ loadedCredentialTypes.reduce((credentialTypesMap, credentialType) => {
+ credentialTypesMap[credentialType.id] = credentialType;
+ return credentialTypesMap;
+ }, {})
+ );
setInputSources(
loadedInputSources.reduce((inputSourcesMap, inputSource) => {
inputSourcesMap[inputSource.input_field_name] = inputSource;
@@ -127,7 +145,7 @@ function CredentialEdit({ credential, me }) {
};
const handleSubmit = async values => {
- await submitRequest(values, inputSources);
+ await submitRequest(values, credentialTypes, inputSources);
};
if (error) {
diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx
index 3d4ce756cb..daa772cd3b 100644
--- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx
+++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx
@@ -5,9 +5,12 @@ import {
mountWithContexts,
waitForElement,
} from '../../../../testUtils/enzymeHelpers';
-import { sleep } from '../../../../testUtils/testUtils';
-import { CredentialsAPI, CredentialTypesAPI } from '../../../api';
+import {
+ CredentialsAPI,
+ CredentialInputSourcesAPI,
+ CredentialTypesAPI,
+} from '../../../api';
import CredentialEdit from './CredentialEdit';
jest.mock('../../../api');
@@ -108,58 +111,6 @@ const mockCredential = {
CredentialTypesAPI.read.mockResolvedValue({
data: {
results: [
- {
- id: 2,
- type: 'credential_type',
- url: '/api/v2/credential_types/2/',
- related: {
- credentials: '/api/v2/credential_types/2/credentials/',
- activity_stream: '/api/v2/credential_types/2/activity_stream/',
- },
- summary_fields: {
- user_capabilities: {
- edit: false,
- delete: false,
- },
- },
- created: '2020-02-12T19:42:43.551238Z',
- modified: '2020-02-12T19:43:03.164800Z',
- name: 'Source Control',
- description: '',
- kind: 'scm',
- namespace: 'scm',
- managed_by_tower: true,
- inputs: {
- fields: [
- {
- id: 'username',
- label: 'Username',
- type: 'string',
- },
- {
- id: 'password',
- label: 'Password',
- type: 'string',
- secret: true,
- },
- {
- id: 'ssh_key_data',
- label: 'Source Control Private Key',
- type: 'string',
- format: 'ssh_private_key',
- secret: true,
- multiline: true,
- },
- {
- id: 'ssh_key_unlock',
- label: 'Private Key Passphrase',
- type: 'string',
- secret: true,
- },
- ],
- },
- injectors: {},
- },
{
id: 1,
type: 'credential_type',
@@ -245,58 +196,173 @@ CredentialTypesAPI.read.mockResolvedValue({
});
CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } });
-CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } });
+CredentialsAPI.readInputSources.mockResolvedValue({
+ data: {
+ results: [
+ {
+ id: 34,
+ summary_fields: {
+ source_credential: {
+ id: 20,
+ name: 'CyberArk Conjur Secret Lookup',
+ description: '',
+ kind: 'conjur',
+ cloud: false,
+ credential_type_id: 20,
+ },
+ },
+ input_field_name: 'password',
+ metadata: {
+ secret_path: 'a',
+ secret_version: 'b',
+ },
+ source_credential: 20,
+ },
+ {
+ id: 35,
+ summary_fields: {
+ source_credential: {
+ id: 20,
+ name: 'CyberArk Conjur Secret Lookup',
+ description: '',
+ kind: 'conjur',
+ cloud: false,
+ credential_type_id: 20,
+ },
+ },
+ input_field_name: 'become_username',
+ metadata: {
+ secret_path: 'foo',
+ secret_version: 'bar',
+ },
+ source_credential: 20,
+ },
+ ],
+ },
+});
describe('', () => {
let wrapper;
let history;
- beforeEach(async () => {
- history = createMemoryHistory({ initialEntries: ['/credentials'] });
- await act(async () => {
- wrapper = mountWithContexts(
- ,
- {
- context: { router: { history } },
- }
- );
+ describe('Initial GET request succeeds', () => {
+ beforeEach(async () => {
+ history = createMemoryHistory({ initialEntries: ['/credentials'] });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ });
+
+ test('initially renders successfully', async () => {
+ expect(wrapper.find('CredentialEdit').length).toBe(1);
+ });
+
+ test('handleCancel returns the user to credential detail', async () => {
+ await waitForElement(wrapper, 'isLoading', el => el.length === 0);
+ wrapper.find('Button[aria-label="Cancel"]').simulate('click');
+ expect(history.location.pathname).toEqual('/credentials/3/details');
+ });
+
+ test('handleSubmit should post to the api', async () => {
+ await waitForElement(wrapper, 'isLoading', el => el.length === 0);
+ await act(async () => {
+ wrapper.find('CredentialForm').prop('onSubmit')({
+ user: 1,
+ organization: null,
+ name: 'foo',
+ description: 'bar',
+ credential_type: '1',
+ inputs: {
+ username: {
+ credential: {
+ id: 1,
+ name: 'Some cred',
+ },
+ inputs: {
+ foo: 'bar',
+ },
+ },
+ password: 'foo',
+ ssh_key_data: 'bar',
+ ssh_public_key_data: 'baz',
+ ssh_key_unlock: 'foobar',
+ become_method: '',
+ become_username: {
+ credential: {
+ id: 1,
+ name: 'Some cred',
+ },
+ inputs: {
+ secret_path: '/foo/bar',
+ secret_version: '9000',
+ },
+ touched: true,
+ },
+ become_password: '',
+ },
+ passwordPrompts: {
+ become_password: true,
+ },
+ });
+ });
+ expect(CredentialsAPI.update).toHaveBeenCalledWith(3, {
+ user: 1,
+ organization: null,
+ name: 'foo',
+ description: 'bar',
+ credential_type: '1',
+ inputs: {
+ password: 'foo',
+ ssh_key_data: 'bar',
+ ssh_public_key_data: 'baz',
+ ssh_key_unlock: 'foobar',
+ become_method: '',
+ become_password: 'ASK',
+ },
+ });
+ expect(CredentialInputSourcesAPI.create).toHaveBeenCalledWith({
+ input_field_name: 'username',
+ metadata: {
+ foo: 'bar',
+ },
+ source_credential: 1,
+ target_credential: 3,
+ });
+ expect(CredentialInputSourcesAPI.update).toHaveBeenCalledWith(35, {
+ metadata: {
+ secret_path: '/foo/bar',
+ secret_version: '9000',
+ },
+ source_credential: 1,
+ });
+ expect(CredentialInputSourcesAPI.destroy).toHaveBeenCalledWith(34);
+ expect(history.location.pathname).toBe('/credentials/3/details');
});
});
-
- afterEach(() => {
- wrapper.unmount();
- });
-
- test('initially renders successfully', async () => {
- expect(wrapper.find('CredentialEdit').length).toBe(1);
- });
-
- test('handleCancel returns the user to credential detail', async () => {
- await waitForElement(wrapper, 'isLoading', el => el.length === 0);
- wrapper.find('Button[aria-label="Cancel"]').simulate('click');
- expect(history.location.pathname).toEqual('/credentials/3/details');
- });
-
- test('handleSubmit should post to the api', async () => {
- await waitForElement(wrapper, 'isLoading', el => el.length === 0);
-
- wrapper.find('CredentialForm').prop('onSubmit')({
- user: 1,
- organization: null,
- name: 'foo',
- description: 'bar',
- credential_type: '2',
- inputs: {},
+ describe('Initial GET request fails', () => {
+ test('shows error when initial GET request fails', async () => {
+ CredentialTypesAPI.read.mockRejectedValue(new Error());
+ history = createMemoryHistory({ initialEntries: ['/credentials'] });
+ await act(async () => {
+ wrapper = mountWithContexts(
+ ,
+ {
+ context: { router: { history } },
+ }
+ );
+ });
+ wrapper.update();
+ expect(wrapper.find('ContentError').length).toBe(1);
+ wrapper.unmount();
});
- await sleep(1);
- expect(CredentialsAPI.update).toHaveBeenCalledWith(3, {
- user: 1,
- organization: null,
- name: 'foo',
- description: 'bar',
- credential_type: '2',
- inputs: {},
- });
- expect(history.location.pathname).toBe('/credentials/3/details');
});
});
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
index ddd4ccaa5f..de814d2630 100644
--- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx
@@ -3,30 +3,20 @@ import { Formik, useField } from 'formik';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { arrayOf, func, object, shape } from 'prop-types';
-import { Form, FormGroup, Title } from '@patternfly/react-core';
+import { Form, FormGroup } from '@patternfly/react-core';
import FormField, { FormSubmitError } from '../../../components/FormField';
import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup';
import AnsibleSelect from '../../../components/AnsibleSelect';
import { required } from '../../../util/validators';
import OrganizationLookup from '../../../components/Lookup/OrganizationLookup';
-import {
- FormColumnLayout,
- SubFormLayout,
-} from '../../../components/FormLayout';
-import {
- GoogleComputeEngineSubForm,
- ManualSubForm,
- SourceControlSubForm,
-} from './CredentialSubForms';
+import { FormColumnLayout } from '../../../components/FormLayout';
+import TypeInputsSubForm from './TypeInputsSubForm';
function CredentialFormFields({
i18n,
credentialTypes,
formik,
- gceCredentialTypeId,
initialValues,
- scmCredentialTypeId,
- sshCredentialTypeId,
}) {
const [orgField, orgMeta, orgHelpers] = useField('organization');
const [credTypeField, credTypeMeta, credTypeHelpers] = useField({
@@ -34,23 +24,53 @@ function CredentialFormFields({
validate: required(i18n._(t`Select a value for this field`), i18n),
});
- const credentialTypeOptions = Object.keys(credentialTypes).map(key => {
- return {
- value: credentialTypes[key].id,
- key: credentialTypes[key].kind,
- label: credentialTypes[key].name,
- };
- });
+ const credentialTypeOptions = Object.keys(credentialTypes)
+ .map(key => {
+ return {
+ value: credentialTypes[key].id,
+ key: credentialTypes[key].id,
+ label: credentialTypes[key].name,
+ };
+ })
+ .sort((a, b) => (a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1));
- const resetSubFormFields = (value, form) => {
- Object.keys(form.initialValues.inputs).forEach(label => {
- if (parseInt(value, 10) === form.initialValues.credential_type) {
- form.setFieldValue(`inputs.${label}`, initialValues.inputs[label]);
- } else {
- form.setFieldValue(`inputs.${label}`, '');
+ const resetSubFormFields = (newCredentialType, form) => {
+ const fields = credentialTypes[newCredentialType].inputs.fields || [];
+ fields.forEach(
+ ({ ask_at_runtime, type, id, choices, default: defaultValue }) => {
+ if (
+ parseInt(newCredentialType, 10) === form.initialValues.credential_type
+ ) {
+ form.setFieldValue(`inputs.${id}`, initialValues.inputs[id]);
+ if (ask_at_runtime) {
+ form.setFieldValue(
+ `passwordPrompts.${id}`,
+ initialValues.passwordPrompts[id]
+ );
+ }
+ } else {
+ switch (type) {
+ case 'string':
+ form.setFieldValue(`inputs.${id}`, defaultValue || '');
+ break;
+ case 'boolean':
+ form.setFieldValue(`inputs.${id}`, defaultValue || false);
+ break;
+ default:
+ break;
+ }
+
+ if (choices) {
+ form.setFieldValue(`inputs.${id}`, defaultValue);
+ }
+
+ if (ask_at_runtime) {
+ form.setFieldValue(`passwordPrompts.${id}`, false);
+ }
+ }
+ form.setFieldTouched(`inputs.${id}`, false);
}
- form.setFieldTouched(`inputs.${label}`, false);
- });
+ );
};
return (
@@ -105,18 +125,13 @@ function CredentialFormFields({
}}
/>
- {credTypeField.value !== undefined && credTypeField.value !== '' && (
-
- {i18n._(t`Type Details`)}
- {
- {
- [gceCredentialTypeId]: ,
- [sshCredentialTypeId]: ,
- [scmCredentialTypeId]: ,
- }[credTypeField.value]
- }
-
- )}
+ {credTypeField.value !== undefined &&
+ credTypeField.value !== '' &&
+ credentialTypes[credTypeField.value]?.inputs?.fields && (
+
+ )}
>
);
}
@@ -135,19 +150,44 @@ function CredentialForm({
description: credential.description || '',
organization: credential?.summary_fields?.organization || null,
credential_type: credential.credential_type || '',
- inputs: {
- become_method: credential?.inputs?.become_method || '',
- become_password: credential?.inputs?.become_password || '',
- become_username: credential?.inputs?.become_username || '',
- password: credential?.inputs?.password || '',
- project: credential?.inputs?.project || '',
- ssh_key_data: credential?.inputs?.ssh_key_data || '',
- ssh_key_unlock: credential?.inputs?.ssh_key_unlock || '',
- ssh_public_key_data: credential?.inputs?.ssh_public_key_data || '',
- username: credential?.inputs?.username || '',
- },
+ inputs: {},
+ passwordPrompts: {},
};
+ Object.values(credentialTypes).forEach(credentialType => {
+ const fields = credentialType.inputs.fields || [];
+ fields.forEach(
+ ({ ask_at_runtime, type, id, choices, default: defaultValue }) => {
+ if (credential?.inputs && credential.inputs[id]) {
+ if (ask_at_runtime) {
+ initialValues.passwordPrompts[id] =
+ credential.inputs[id] === 'ASK' || false;
+ }
+ initialValues.inputs[id] = credential.inputs[id];
+ } else {
+ switch (type) {
+ case 'string':
+ initialValues.inputs[id] = defaultValue || '';
+ break;
+ case 'boolean':
+ initialValues.inputs[id] = defaultValue || false;
+ break;
+ default:
+ break;
+ }
+
+ if (choices) {
+ initialValues.inputs[id] = defaultValue;
+ }
+
+ if (ask_at_runtime) {
+ initialValues.passwordPrompts[id] = false;
+ }
+ }
+ }
+ );
+ });
+
Object.values(inputSources).forEach(inputSource => {
initialValues.inputs[inputSource.input_field_name] = {
credential: inputSource.summary_fields.source_credential,
@@ -155,60 +195,10 @@ function CredentialForm({
};
});
- const scmCredentialTypeId = Object.keys(credentialTypes)
- .filter(key => credentialTypes[key].namespace === 'scm')
- .map(key => credentialTypes[key].id)[0];
- const sshCredentialTypeId = Object.keys(credentialTypes)
- .filter(key => credentialTypes[key].namespace === 'ssh')
- .map(key => credentialTypes[key].id)[0];
- const gceCredentialTypeId = Object.keys(credentialTypes)
- .filter(key => credentialTypes[key].namespace === 'gce')
- .map(key => credentialTypes[key].id)[0];
-
return (
{
- const scmKeys = [
- 'username',
- 'password',
- 'ssh_key_data',
- 'ssh_key_unlock',
- ];
- const sshKeys = [
- 'username',
- 'password',
- 'ssh_key_data',
- 'ssh_public_key_data',
- 'ssh_key_unlock',
- 'become_method',
- 'become_username',
- 'become_password',
- ];
- const gceKeys = ['username', 'ssh_key_data', 'project'];
- if (parseInt(values.credential_type, 10) === scmCredentialTypeId) {
- Object.keys(values.inputs).forEach(key => {
- if (scmKeys.indexOf(key) < 0) {
- delete values.inputs[key];
- }
- });
- } else if (
- parseInt(values.credential_type, 10) === sshCredentialTypeId
- ) {
- Object.keys(values.inputs).forEach(key => {
- if (sshKeys.indexOf(key) < 0) {
- delete values.inputs[key];
- }
- });
- } else if (
- parseInt(values.credential_type, 10) === gceCredentialTypeId
- ) {
- Object.keys(values.inputs).forEach(key => {
- if (gceKeys.indexOf(key) < 0) {
- delete values.inputs[key];
- }
- });
- }
onSubmit(values);
}}
>
@@ -219,9 +209,6 @@ function CredentialForm({
formik={formik}
initialValues={initialValues}
credentialTypes={credentialTypes}
- gceCredentialTypeId={gceCredentialTypeId}
- scmCredentialTypeId={scmCredentialTypeId}
- sshCredentialTypeId={sshCredentialTypeId}
{...rest}
/>
@@ -239,13 +226,16 @@ function CredentialForm({
CredentialForm.proptype = {
handleSubmit: func.isRequired,
handleCancel: func.isRequired,
+ credentialTypes: shape({}).isRequired,
credential: shape({}),
inputSources: arrayOf(object),
+ submitError: shape({}),
};
CredentialForm.defaultProps = {
credential: {},
inputSources: [],
+ submitError: null,
};
export default withI18n()(CredentialForm);
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx
index c77089b758..2062ed01ee 100644
--- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.test.jsx
@@ -4,11 +4,19 @@ import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import machineCredential from './data.machineCredential.json';
import gceCredential from './data.gceCredential.json';
import scmCredential from './data.scmCredential.json';
-import credentialTypes from './data.credentialTypes.json';
+import credentialTypesArr from './data.credentialTypes.json';
import CredentialForm from './CredentialForm';
jest.mock('../../../api');
+const credentialTypes = credentialTypesArr.reduce(
+ (credentialTypesMap, credentialType) => {
+ credentialTypesMap[credentialType.id] = credentialType;
+ return credentialTypesMap;
+ },
+ {}
+);
+
describe('', () => {
let wrapper;
const onCancel = jest.fn();
@@ -28,23 +36,19 @@ describe('', () => {
expect(wrapper.find('FormGroup[label="Organization"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1);
- expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1);
+ expect(wrapper.find('input#credential-password').length).toBe(1);
expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="Signed SSH Certificate"]').length
).toBe(1);
+ expect(wrapper.find('input#credential-ssh_key_unlock').length).toBe(1);
expect(
- wrapper.find('FormGroup[label="Private Key Passphrase"]').length
- ).toBe(1);
- expect(
- wrapper.find('FormGroup[label="Privelege Escalation Method"]').length
+ wrapper.find('FormGroup[label="Privilege Escalation Method"]').length
).toBe(1);
expect(
wrapper.find('FormGroup[label="Privilege Escalation Username"]').length
).toBe(1);
- expect(
- wrapper.find('FormGroup[label="Privilege Escalation Password"]').length
- ).toBe(1);
+ expect(wrapper.find('input#credential-become_password').length).toBe(1);
};
const sourceFieldExpects = () => {
@@ -55,7 +59,7 @@ describe('', () => {
expect(wrapper.find('FormGroup[label="Credential Type"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Username"]').length).toBe(1);
expect(wrapper.find('FormGroup[label="Password"]').length).toBe(1);
- expect(wrapper.find('FormGroup[label="SSH Private Key"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="SCM Private Key"]').length).toBe(1);
expect(
wrapper.find('FormGroup[label="Private Key Passphrase"]').length
).toBe(1);
@@ -71,10 +75,10 @@ describe('', () => {
wrapper.find('FormGroup[label="Service account JSON file"]').length
).toBe(1);
expect(
- wrapper.find('FormGroup[label="Service account email address"]').length
+ wrapper.find('FormGroup[label="Service Account Email Address"]').length
).toBe(1);
expect(wrapper.find('FormGroup[label="Project"]').length).toBe(1);
- expect(wrapper.find('FormGroup[label="RSA private key"]').length).toBe(1);
+ expect(wrapper.find('FormGroup[label="RSA Private Key"]').length).toBe(1);
};
describe('Add', () => {
@@ -152,9 +156,9 @@ describe('', () => {
gceFieldExpects();
expect(wrapper.find('input#credential-username').prop('value')).toBe('');
expect(wrapper.find('input#credential-project').prop('value')).toBe('');
- expect(wrapper.find('textarea#credential-sshKeyData').prop('value')).toBe(
- ''
- );
+ expect(
+ wrapper.find('textarea#credential-ssh_key_data').prop('value')
+ ).toBe('');
await act(async () => {
wrapper.find('FileUpload').invoke('onChange')({
name: 'foo.json',
@@ -169,7 +173,9 @@ describe('', () => {
expect(wrapper.find('input#credential-project').prop('value')).toBe(
'test123'
);
- expect(wrapper.find('textarea#credential-sshKeyData').prop('value')).toBe(
+ expect(
+ wrapper.find('textarea#credential-ssh_key_data').prop('value')
+ ).toBe(
'-----BEGIN PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n-----END PRIVATE KEY-----\n'
);
});
@@ -180,9 +186,9 @@ describe('', () => {
wrapper.update();
expect(wrapper.find('input#credential-username').prop('value')).toBe('');
expect(wrapper.find('input#credential-project').prop('value')).toBe('');
- expect(wrapper.find('textarea#credential-sshKeyData').prop('value')).toBe(
- ''
- );
+ expect(
+ wrapper.find('textarea#credential-ssh_key_data').prop('value')
+ ).toBe('');
});
test('should show error when error thrown parsing JSON', async () => {
expect(wrapper.find('#credential-gce-file-helper').text()).toBe(
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx
new file mode 100644
index 0000000000..ee97e43d27
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/BecomeMethodField.jsx
@@ -0,0 +1,80 @@
+import React, { useState } from 'react';
+import { useField } from 'formik';
+import { bool, shape, string } from 'prop-types';
+import {
+ FormGroup,
+ Select,
+ SelectOption,
+ SelectVariant,
+} from '@patternfly/react-core';
+import { FieldTooltip } from '../../../../components/FormField';
+
+function BecomeMethodField({ fieldOptions, isRequired }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [options, setOptions] = useState(
+ [
+ 'sudo',
+ 'su',
+ 'pbrun',
+ 'pfexec',
+ 'dzdo',
+ 'pmrun',
+ 'runas',
+ 'enable',
+ 'doas',
+ 'ksu',
+ 'machinectl',
+ 'sesu',
+ ].map(val => ({ value: val }))
+ );
+ const [becomeMethodField, meta, helpers] = useField({
+ name: `inputs.${fieldOptions.id}`,
+ });
+ return (
+
+ {fieldOptions.help_text && (
+
+ )}
+
+
+ );
+}
+BecomeMethodField.propTypes = {
+ fieldOptions: shape({
+ id: string.isRequired,
+ label: string.isRequired,
+ }).isRequired,
+ isRequired: bool,
+};
+BecomeMethodField.defaultProps = {
+ isRequired: false,
+};
+
+export default BecomeMethodField;
diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx
new file mode 100644
index 0000000000..8adcf10190
--- /dev/null
+++ b/awx/ui_next/src/screens/Credential/shared/CredentialFormFields/CredentialField.jsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import { useField, useFormikContext } from 'formik';
+import { shape, string } from 'prop-types';
+import { withI18n } from '@lingui/react';
+import { t } from '@lingui/macro';
+import {
+ FormGroup,
+ InputGroup,
+ TextArea,
+ TextInput,
+} from '@patternfly/react-core';
+import { FieldTooltip, PasswordInput } from '../../../../components/FormField';
+import AnsibleSelect from '../../../../components/AnsibleSelect';
+import { CredentialType } from '../../../../types';
+import { required } from '../../../../util/validators';
+import { CredentialPluginField } from './CredentialPlugins';
+import BecomeMethodField from './BecomeMethodField';
+
+function CredentialInput({ fieldOptions, credentialKind, ...rest }) {
+ const [subFormField, meta] = useField(`inputs.${fieldOptions.id}`);
+ const isValid = !(meta.touched && meta.error);
+ if (fieldOptions.multiline) {
+ return (
+