mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-11-01 11:08:10 +00:00
Handle form validation for open api form (#11963)
* Handle form validation for open api form - Added required validator for all the default fields * Fixed field group error and adedd comments * Fixed acceptance tests * Added changelog * Fix validation in edit mode - Handle read only inputs during edit mode * Minor improvements * Restrict validation only for userpass
This commit is contained in:
3
changelog/11963.txt
Normal file
3
changelog/11963.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
```release-note:improvement
|
||||||
|
ui: Add validation support for open api form fields
|
||||||
|
```
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import AdapterError from '@ember-data/adapter/error';
|
import AdapterError from '@ember-data/adapter/error';
|
||||||
import { inject as service } from '@ember/service';
|
import { inject as service } from '@ember/service';
|
||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
import { computed } from '@ember/object';
|
import { computed, set } from '@ember/object';
|
||||||
import { task } from 'ember-concurrency';
|
import { task } from 'ember-concurrency';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +24,8 @@ export default Component.extend({
|
|||||||
itemType: null,
|
itemType: null,
|
||||||
flashMessages: service(),
|
flashMessages: service(),
|
||||||
router: service(),
|
router: service(),
|
||||||
|
validationMessages: null,
|
||||||
|
isFormInvalid: true,
|
||||||
props: computed('model', function() {
|
props: computed('model', function() {
|
||||||
return this.model.serialize();
|
return this.model.serialize();
|
||||||
}),
|
}),
|
||||||
@@ -41,7 +43,41 @@ export default Component.extend({
|
|||||||
this.router.transitionTo('vault.cluster.access.method.item.list').followRedirects();
|
this.router.transitionTo('vault.cluster.access.method.item.list').followRedirects();
|
||||||
this.flashMessages.success(`Successfully saved ${this.itemType} ${this.model.id}.`);
|
this.flashMessages.success(`Successfully saved ${this.itemType} ${this.model.id}.`);
|
||||||
}).withTestWaiter(),
|
}).withTestWaiter(),
|
||||||
|
init() {
|
||||||
|
this._super(...arguments);
|
||||||
|
this.set('validationMessages', {});
|
||||||
|
if (this.mode === 'edit') {
|
||||||
|
// For validation to work in edit mode,
|
||||||
|
// reconstruct the model values from field group
|
||||||
|
this.model.fieldGroups.forEach(element => {
|
||||||
|
if (element.default) {
|
||||||
|
element.default.forEach(attr => {
|
||||||
|
let fieldValue = attr.options && attr.options.fieldValue;
|
||||||
|
if (fieldValue) {
|
||||||
|
this.model[attr.name] = this.model[fieldValue];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
onKeyUp(name, value) {
|
||||||
|
this.model.set(name, value);
|
||||||
|
if (this.model.validations) {
|
||||||
|
// Set validation error message for updated attribute
|
||||||
|
this.model.validations.attrs[name] && this.model.validations.attrs[name].isValid
|
||||||
|
? set(this.validationMessages, name, '')
|
||||||
|
: set(this.validationMessages, name, this.model.validations.attrs[name].message);
|
||||||
|
|
||||||
|
// Set form button state
|
||||||
|
this.model.validate().then(({ validations }) => {
|
||||||
|
this.set('isFormInvalid', !validations.isValid);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.set('isFormInvalid', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteItem() {
|
deleteItem() {
|
||||||
this.model.destroyRecord().then(() => {
|
this.model.destroyRecord().then(() => {
|
||||||
this.router.transitionTo('vault.cluster.access.method.item.list').followRedirects();
|
this.router.transitionTo('vault.cluster.access.method.item.list').followRedirects();
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ export default Component.extend({
|
|||||||
|
|
||||||
showEnable: false,
|
showEnable: false,
|
||||||
|
|
||||||
|
// cp-validation related properties
|
||||||
|
validationMessages: null,
|
||||||
|
isFormInvalid: false,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
const type = this.mountType;
|
const type = this.mountType;
|
||||||
@@ -108,6 +112,10 @@ export default Component.extend({
|
|||||||
this.mountModel.validations.attrs.path.isValid
|
this.mountModel.validations.attrs.path.isValid
|
||||||
? set(this.validationMessages, 'path', '')
|
? set(this.validationMessages, 'path', '')
|
||||||
: set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message);
|
: set(this.validationMessages, 'path', this.mountModel.validations.attrs.path.message);
|
||||||
|
|
||||||
|
this.mountModel.validate().then(({ validations }) => {
|
||||||
|
this.set('isFormInvalid', !validations.isValid);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onTypeChange(path, value) {
|
onTypeChange(path, value) {
|
||||||
if (path === 'type') {
|
if (path === 'type') {
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ import { computed } from '@ember/object';
|
|||||||
import { fragment } from 'ember-data-model-fragments/attributes';
|
import { fragment } from 'ember-data-model-fragments/attributes';
|
||||||
import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
import fieldToAttrs, { expandAttributeMeta } from 'vault/utils/field-to-attrs';
|
||||||
import { memberAction } from 'ember-api-actions';
|
import { memberAction } from 'ember-api-actions';
|
||||||
|
import { validator, buildValidations } from 'ember-cp-validations';
|
||||||
|
|
||||||
import apiPath from 'vault/utils/api-path';
|
import apiPath from 'vault/utils/api-path';
|
||||||
import attachCapabilities from 'vault/lib/attach-capabilities';
|
import attachCapabilities from 'vault/lib/attach-capabilities';
|
||||||
|
|
||||||
let ModelExport = Model.extend({
|
const Validations = buildValidations({
|
||||||
|
path: validator('presence', {
|
||||||
|
presence: true,
|
||||||
|
message: "Path can't be blank.",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let ModelExport = Model.extend(Validations, {
|
||||||
authConfigs: hasMany('auth-config', { polymorphic: true, inverse: 'backend', async: false }),
|
authConfigs: hasMany('auth-config', { polymorphic: true, inverse: 'backend', async: false }),
|
||||||
path: attr('string'),
|
path: attr('string'),
|
||||||
accessor: attr('string'),
|
accessor: attr('string'),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { resolve, reject } from 'rsvp';
|
|||||||
import { debug } from '@ember/debug';
|
import { debug } from '@ember/debug';
|
||||||
import { dasherize, capitalize } from '@ember/string';
|
import { dasherize, capitalize } from '@ember/string';
|
||||||
import { singularize } from 'ember-inflector';
|
import { singularize } from 'ember-inflector';
|
||||||
|
import buildValidations from 'vault/utils/build-api-validators';
|
||||||
|
|
||||||
import generatedItemAdapter from 'vault/adapters/generated-item-list';
|
import generatedItemAdapter from 'vault/adapters/generated-item-list';
|
||||||
export function sanitizePath(path) {
|
export function sanitizePath(path) {
|
||||||
@@ -280,11 +281,18 @@ export default Service.extend({
|
|||||||
// if our newModel doesn't have fieldGroups already
|
// if our newModel doesn't have fieldGroups already
|
||||||
// we need to create them
|
// we need to create them
|
||||||
try {
|
try {
|
||||||
|
// Initialize prototype to access field groups
|
||||||
let fieldGroups = newModel.proto().fieldGroups;
|
let fieldGroups = newModel.proto().fieldGroups;
|
||||||
if (!fieldGroups) {
|
if (!fieldGroups) {
|
||||||
debug(`Constructing fieldGroups for ${backend}`);
|
debug(`Constructing fieldGroups for ${backend}`);
|
||||||
fieldGroups = this.getFieldGroups(newModel);
|
fieldGroups = this.getFieldGroups(newModel);
|
||||||
newModel = newModel.extend({ fieldGroups });
|
newModel = newModel.extend({ fieldGroups });
|
||||||
|
// Build and add validations on model
|
||||||
|
// NOTE: For initial phase, initialize validations only for user pass auth
|
||||||
|
if (backend === 'userpass') {
|
||||||
|
let validations = buildValidations(fieldGroups);
|
||||||
|
newModel = newModel.extend(validations);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eat the error, fieldGroups is computed in the model definition
|
// eat the error, fieldGroups is computed in the model definition
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
|
|
||||||
.message-inline {
|
.message-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin: 0 0 $spacing-l;
|
margin: 0 0 $spacing-l;
|
||||||
|
|
||||||
.hs-icon {
|
.hs-icon {
|
||||||
@@ -131,6 +132,10 @@
|
|||||||
&.is-marginless {
|
&.is-marginless {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
> p::first-letter {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-text-highlight {
|
.has-text-highlight {
|
||||||
|
|||||||
@@ -52,12 +52,12 @@
|
|||||||
<div class="box is-sideless is-fullwidth is-marginless">
|
<div class="box is-sideless is-fullwidth is-marginless">
|
||||||
<NamespaceReminder @mode="save" @noun={{itemType}} />
|
<NamespaceReminder @mode="save" @noun={{itemType}} />
|
||||||
<MessageError @model={{model}} />
|
<MessageError @model={{model}} />
|
||||||
<FormFieldGroups @model={{model}} @mode={{mode}} />
|
<FormFieldGroups @model={{model}} @mode={{mode}} @onKeyUp={{action "onKeyUp"}} @validationMessages={{validationMessages}}/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field is-grouped-split box is-fullwidth is-bottomless">
|
<div class="field is-grouped-split box is-fullwidth is-bottomless">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<button type="submit" data-test-save-config="true"
|
<button type="submit" data-test-save-config="true"
|
||||||
class="button is-primary {{if saveModel.isRunning "loading"}}" disabled={{saveModel.isRunning}}>
|
class="button is-primary {{if saveModel.isRunning "loading"}}" disabled={{or saveModel.isRunning isFormInvalid}}>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
{{#if (eq mode "create")}}
|
{{#if (eq mode "create")}}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
type="submit"
|
type="submit"
|
||||||
data-test-mount-submit="true"
|
data-test-mount-submit="true"
|
||||||
class="button is-primary {{if mountBackend.isRunning "loading"}}"
|
class="button is-primary {{if mountBackend.isRunning "loading"}}"
|
||||||
disabled={{or mountBackend.isRunning validationError}}
|
disabled={{or mountBackend.isRunning isFormInvalid}}
|
||||||
>
|
>
|
||||||
{{#if (eq mountType "auth")}}
|
{{#if (eq mountType "auth")}}
|
||||||
Enable Method
|
Enable Method
|
||||||
|
|||||||
30
ui/app/utils/build-api-validators.js
Normal file
30
ui/app/utils/build-api-validators.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { validator, buildValidations } from 'ember-cp-validations';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add validation on dynamic form fields generated via open api spec
|
||||||
|
* For fields grouped under default category, add the require/presence validator
|
||||||
|
* @param {Array} fieldGroups
|
||||||
|
* fieldGroups param example:
|
||||||
|
* [ { default: [{name: 'username'}, {name: 'password'}] },
|
||||||
|
* { Tokens: [{name: 'tokenBoundCidrs'}] }
|
||||||
|
* ]
|
||||||
|
* @returns ember cp validation class
|
||||||
|
*/
|
||||||
|
export default function initValidations(fieldGroups) {
|
||||||
|
let validators = {};
|
||||||
|
fieldGroups.forEach(element => {
|
||||||
|
if (element.default) {
|
||||||
|
element.default.forEach(v => {
|
||||||
|
validators[v.name] = createPresenceValidator(v.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return buildValidations(validators);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPresenceValidator = function(label) {
|
||||||
|
return validator('presence', {
|
||||||
|
presence: true,
|
||||||
|
message: `${label} can't be blank.`,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -18,12 +18,16 @@ import layout from '../templates/components/form-field-groups';
|
|||||||
* @model={{mountModel}}
|
* @model={{mountModel}}
|
||||||
* @onChange={{action "onTypeChange"}}
|
* @onChange={{action "onTypeChange"}}
|
||||||
* @renderGroup="Method Options"
|
* @renderGroup="Method Options"
|
||||||
|
* @onKeyUp={{action "onKeyUp"}}
|
||||||
|
* @validationMessages={{validationMessages}}
|
||||||
* />
|
* />
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param [renderGroup=null] {String} - An allow list of groups to include in the render.
|
* @param [renderGroup=null] {String} - An allow list of groups to include in the render.
|
||||||
* @param model=null {DS.Model} - Model to be passed down to form-field component. If `fieldGroups` is present on the model then it will be iterated over and groups of `FormField` components will be rendered.
|
* @param model=null {DS.Model} - Model to be passed down to form-field component. If `fieldGroups` is present on the model then it will be iterated over and groups of `FormField` components will be rendered.
|
||||||
* @param onChange=null {Func} - Handler that will get set on the `FormField` component.
|
* @param onChange=null {Func} - Handler that will get set on the `FormField` component.
|
||||||
|
* @param onKeyUp=null {Func} - Handler that will set the value and trigger validation on input changes
|
||||||
|
* @param validationMessages=null {Object} Object containing validation message for each property
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export default Component.extend({
|
|||||||
*/
|
*/
|
||||||
attr: null,
|
attr: null,
|
||||||
|
|
||||||
|
mode: null,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @private
|
* @private
|
||||||
* @param string
|
* @param string
|
||||||
@@ -93,6 +95,11 @@ export default Component.extend({
|
|||||||
*/
|
*/
|
||||||
valuePath: or('attr.options.fieldValue', 'attr.name'),
|
valuePath: or('attr.options.fieldValue', 'attr.name'),
|
||||||
|
|
||||||
|
isReadOnly: computed('attr.options.readOnly', 'mode', function() {
|
||||||
|
let readonly = this.attr.options?.readOnly || false;
|
||||||
|
return readonly && this.mode === 'edit';
|
||||||
|
}),
|
||||||
|
|
||||||
model: null,
|
model: null,
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<FormField
|
<FormField
|
||||||
data-test-field
|
data-test-field
|
||||||
@attr={{attr}}
|
@attr={{attr}}
|
||||||
|
@mode={{mode}}
|
||||||
@model={{model}}
|
@model={{model}}
|
||||||
@onChange={{onChange}}
|
@onChange={{onChange}}
|
||||||
@onKeyUp={{onKeyUp}}
|
@onKeyUp={{onKeyUp}}
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
<FormField
|
<FormField
|
||||||
data-test-field
|
data-test-field
|
||||||
@attr={{attr}}
|
@attr={{attr}}
|
||||||
|
@mode={{mode}}
|
||||||
@model={{model}}
|
@model={{model}}
|
||||||
/>
|
/>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -191,7 +191,18 @@
|
|||||||
@value={{or (get model valuePath) attr.options.defaultValue}}
|
@value={{or (get model valuePath) attr.options.defaultValue}}
|
||||||
@allowCopy="true"
|
@allowCopy="true"
|
||||||
@onChange={{action (action "setAndBroadcast" valuePath)}}
|
@onChange={{action (action "setAndBroadcast" valuePath)}}
|
||||||
|
onkeyup={{action
|
||||||
|
(action "handleKeyUp" attr.name)
|
||||||
|
value="target.value"
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{{#if (get validationMessages attr.name)}}
|
||||||
|
<AlertInline
|
||||||
|
@type="danger"
|
||||||
|
@message={{get validationMessages attr.name}}
|
||||||
|
@paddingTop=true
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
{{else if (or (eq attr.type "number") (eq attr.type "string"))}}
|
{{else if (or (eq attr.type "number") (eq attr.type "string"))}}
|
||||||
<div class="control">
|
<div class="control">
|
||||||
{{#if (eq attr.options.editType "textarea")}}
|
{{#if (eq attr.options.editType "textarea")}}
|
||||||
@@ -251,6 +262,7 @@
|
|||||||
<input
|
<input
|
||||||
data-test-input={{attr.name}}
|
data-test-input={{attr.name}}
|
||||||
id={{attr.name}}
|
id={{attr.name}}
|
||||||
|
readonly={{isReadOnly}}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
value={{or (get model valuePath) attr.options.defaultValue}}
|
value={{or (get model valuePath) attr.options.defaultValue}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { click, fillIn, settled, visit } from '@ember/test-helpers';
|
import { click, fillIn, settled, visit, triggerKeyEvent } from '@ember/test-helpers';
|
||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupApplicationTest } from 'ember-qunit';
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
import authPage from 'vault/tests/pages/auth';
|
import authPage from 'vault/tests/pages/auth';
|
||||||
@@ -31,7 +31,9 @@ module('Acceptance | userpass secret backend', function(hooks) {
|
|||||||
await visit(`/vault/access/${path1}/item/user/create`);
|
await visit(`/vault/access/${path1}/item/user/create`);
|
||||||
await settled();
|
await settled();
|
||||||
await fillIn('[data-test-input="username"]', user1);
|
await fillIn('[data-test-input="username"]', user1);
|
||||||
|
await triggerKeyEvent('[data-test-input="username"]', 'keyup', 65);
|
||||||
await fillIn('[data-test-textarea]', user1);
|
await fillIn('[data-test-textarea]', user1);
|
||||||
|
await triggerKeyEvent('[data-test-textarea]', 'keyup', 65);
|
||||||
await click('[data-test-save-config="true"]');
|
await click('[data-test-save-config="true"]');
|
||||||
await settled();
|
await settled();
|
||||||
|
|
||||||
@@ -53,7 +55,9 @@ module('Acceptance | userpass secret backend', function(hooks) {
|
|||||||
await click('[data-test-create="user"]');
|
await click('[data-test-create="user"]');
|
||||||
await settled();
|
await settled();
|
||||||
await fillIn('[data-test-input="username"]', user2);
|
await fillIn('[data-test-input="username"]', user2);
|
||||||
|
await triggerKeyEvent('[data-test-input="username"]', 'keyup', 65);
|
||||||
await fillIn('[data-test-textarea]', user2);
|
await fillIn('[data-test-textarea]', user2);
|
||||||
|
await triggerKeyEvent('[data-test-textarea]', 'keyup', 65);
|
||||||
await click('[data-test-save-config="true"]');
|
await click('[data-test-save-config="true"]');
|
||||||
await settled();
|
await settled();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user